Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions internal/tui/dictation.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,12 @@ type dictationController struct {
regionStart int
regionEnd int
regionPrefix string
// regionRendered is the exact text last inserted into [regionStart,
// regionEnd). A later partial or cancel may remove that range only while it
// still matches, so stale bounds never consume user replacement text.
regionRendered string
// regionAnchor snapshots the text BEFORE the live region, so the next
// partial can detect external edits (typing, paste) and shift [start,end)
// to stay aligned. Updated alongside regionStart on each render.
// partial can detect edits before the tracked range.
regionAnchor string

// waveBars is the recording waveform's recent bar heights (a scrolling ring):
Expand Down Expand Up @@ -327,6 +330,7 @@ func (d *dictationController) reset() {
d.streamStop = nil
d.regionActive = false
d.regionPrefix = ""
d.regionRendered = ""
d.waveBars = nil
d.waveTick = 0
}
Expand Down
65 changes: 25 additions & 40 deletions internal/tui/dictation_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package tui

import (
"context"
"strings"

tea "charm.land/bubbletea/v2"

Expand Down Expand Up @@ -92,53 +91,28 @@ func (m model) handleDictationPartial(msg sttPartialMsg) model {

func (m *model) applyStreamingText(text string) {
state := m.currentComposerState()
stateRunes := []rune(state.text)
if !m.dictation.regionActive {
m.dictation.regionActive = true
m.dictation.regionStart = state.cursor
m.dictation.regionEnd = state.cursor
m.dictation.regionPrefix = ""
// Anchor the prefix text BEFORE the live region. If the user types or
// pastes outside the region, the next partial can detect the change in
// this prefix and shift [start,end) to stay aligned.
m.dictation.regionAnchor = state.text
// Anchor the prefix text before the live region. A later partial removes
// the tracked range only while both this prefix and the rendered region
// still match.
m.dictation.regionAnchor = string(stateRunes[:state.cursor])
if needsLeadingSpace(state) {
// Fold the separator into the region so a cancel removes it too.
m.dictation.regionPrefix = " "
}
} else {
// Compare the prefix before the live region. If it changed (the user
// typed/pasted there) shift [start,end) by the length delta so the
// next partial's slice targets the right span. If the user edited
// INSIDE the region, drop the live region and re-anchor at the
// current cursor — we can't tell the partial from the user's text
// after that point.
prefix := string([]rune(state.text)[:m.dictation.regionStart])
anchor := m.dictation.regionAnchor
switch {
case prefix == anchor:
// External edit at or after the region — no shift needed.
case len(prefix) > len(anchor) && strings.HasPrefix(prefix, anchor):
// External edit inserted text just before the region (i.e. at
// position regionStart, pushing the region right).
delta := len([]rune(prefix)) - len([]rune(anchor))
m.dictation.regionStart += delta
m.dictation.regionEnd += delta
m.dictation.regionAnchor = prefix
case len(anchor) > len(prefix) && strings.HasPrefix(anchor, prefix):
// External delete just before the region.
delta := len([]rune(anchor)) - len([]rune(prefix))
m.dictation.regionStart -= delta
m.dictation.regionEnd -= delta
m.dictation.regionAnchor = prefix
default:
// User edited inside or across the region — can't safely
// overwrite. Drop the live region and re-anchor at the cursor
// so the next partial inserts as a fresh span.
m.dictation.regionStart = state.cursor
m.dictation.regionEnd = state.cursor
m.dictation.regionPrefix = ""
m.dictation.regionAnchor = string([]rune(state.text)[:state.cursor])
}
} else if !m.dictation.liveRegionMatches(stateRunes) {
// The composer changed before or inside the tracked range. Its old
// bounds no longer prove which bytes belong to dictation, so preserve
// everything and insert the next partial as a fresh live region.
m.dictation.regionStart = state.cursor
m.dictation.regionEnd = state.cursor
m.dictation.regionPrefix = ""
m.dictation.regionAnchor = string(stateRunes[:state.cursor])
}
// Replace [regionStart, regionEnd) with prefix + the new cumulative text.
rendered := m.dictation.regionPrefix + text
Expand All @@ -153,14 +127,22 @@ func (m *model) applyStreamingText(text string) {
}
updated := insertComposerText(cleared, rendered)
m.dictation.regionEnd = m.dictation.regionStart + len([]rune(rendered))
m.dictation.regionRendered = rendered
m.setComposerState(updated)
}

func (d dictationController) liveRegionMatches(stateRunes []rune) bool {
return d.regionStart >= 0 && d.regionEnd >= d.regionStart && d.regionEnd <= len(stateRunes) &&
string(stateRunes[:d.regionStart]) == d.regionAnchor &&
string(stateRunes[d.regionStart:d.regionEnd]) == d.regionRendered
}

// commitDictationRegion keeps the streamed text in the composer and stops
// tracking it as a live region (used on successful completion — the final
// transcript equals the last partial already rendered).
func (m model) commitDictationRegion() model {
m.dictation.regionActive = false
m.dictation.regionRendered = ""
return m
}

Expand All @@ -169,8 +151,11 @@ func (m model) commitDictationRegion() model {
func (m model) discardDictationRegion() model {
if m.dictation.regionActive {
state := m.currentComposerState()
m.setComposerState(deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd))
if m.dictation.liveRegionMatches([]rune(state.text)) {
m.setComposerState(deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd))
}
m.dictation.regionActive = false
m.dictation.regionRendered = ""
}
return m
}
116 changes: 116 additions & 0 deletions internal/tui/dictation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,122 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
}
}

func TestDictationStreamingPartialWithCaretInsideExistingText(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "hello world", cursor: 5})
m.dictation.phase = dictRecording
m.dictation.streaming = true

m = m.handleDictationPartial(sttPartialMsg{text: "there"})
if m.composer.text != "hello there world" {
t.Fatalf("after first partial: %q", m.composer.text)
}
m = m.handleDictationPartial(sttPartialMsg{text: "there friend"})
m = m.handleDictationPartial(sttPartialMsg{text: "there friend again"})
if m.composer.text != "hello there friend again world" {
t.Fatalf("after third partial: %q", m.composer.text)
}
}

func TestDictationStreamingPartialReanchorsRegionAfterComposerShrink(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "hello world", cursor: 11})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{text: "there"})

// Simulate the user replacing the composer while a later partial is in
// flight. The old live-region bounds now exceed the new composer length.
m.setComposerState(composerState{text: "hi ", cursor: 3})
m = m.handleDictationPartial(sttPartialMsg{text: "again"})
if m.composer.text != "hi again" {
t.Fatalf("partial after composer shrink: %q", m.composer.text)
}
}

func TestDictationStreamingPartialDoesNotClampStaleRegionIntoUserText(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "aOLDz", cursor: 1})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{text: "first"})

// Replace the composer while another cumulative partial is in flight. The
// old region starts after the same prefix but no longer contains dictation
// text, so its clamped bounds must not consume the user's "b".
m.setComposerState(composerState{text: "ab", cursor: 2})
m = m.handleDictationPartial(sttPartialMsg{text: "next"})
if m.composer.text != "abnext" {
t.Fatalf("stale live region replaced user text: %q", m.composer.text)
}
}

func TestDictationStreamingCancelDoesNotClampStaleRegionIntoUserText(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "aOLDz", cursor: 1})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{text: "first"})

m.setComposerState(composerState{text: "ab", cursor: 2})
m = m.discardDictationRegion()
if m.composer.text != "ab" {
t.Fatalf("discarded stale live region from user text: %q", m.composer.text)
}
}

func TestDictationStreamingBackspaceAcrossRegionPreservesUserTextOnCancel(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "hello world", cursor: 11})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{text: "there"})

// The user backspaces through the live transcript and into their original
// text while newer cumulative partials are still in flight.
m.setComposerState(composerState{text: "hello wor", cursor: 9})
m = m.handleDictationPartial(sttPartialMsg{text: "there friend"})
m = m.handleDictationPartial(sttPartialMsg{text: "there friend again"})
if m.composer.text != "hello worthere friend again" {
t.Fatalf("partials replaced user text after backspace: %q", m.composer.text)
}

m, _ = m.cancelDictation()
if m.composer.text != "hello wor" {
t.Fatalf("cancel did not restore the user's edited text: %q", m.composer.text)
}
}

func TestDictationStreamingInvalidRegionBoundsReanchor(t *testing.T) {
tests := []struct {
name string
start int
end int
anchor string
want string
}{
{name: "start past end", start: 40, end: 60, anchor: "a much longer previous composer", want: "abpartial text"},
{name: "negative start", start: -3, end: 1, anchor: "", want: "abpartial text"},
{name: "end past end", start: 1, end: 40, anchor: "a", want: "abpartial text"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "ab", cursor: 2})
m.dictation.regionActive = true
m.dictation.regionStart = tt.start
m.dictation.regionEnd = tt.end
m.dictation.regionAnchor = tt.anchor

m.applyStreamingText("partial text")
if m.composer.text != tt.want {
t.Fatalf("composer = %q, want %q", m.composer.text, tt.want)
}
})
}
}

func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) {
// Esc can race an already-buffered realtime event: cancelDictation discards
// the live region and resets state synchronously, but the streaming
Expand Down
Loading