From 2b1846d80f9c4eb5bbf3293741199087c028fe73 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Thu, 27 Aug 2026 21:57:25 +0200 Subject: [PATCH 1/3] fix(tui): bound live dictation regions Amp-Thread-ID: https://ampcode.com/threads/T-01a0448f-5860-721c-8a47-5119fc57f685 Co-authored-by: Amp --- internal/tui/dictation_stream.go | 17 +++++++++++++--- internal/tui/dictation_test.go | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/internal/tui/dictation_stream.go b/internal/tui/dictation_stream.go index 5e994eedd..aa062c741 100644 --- a/internal/tui/dictation_stream.go +++ b/internal/tui/dictation_stream.go @@ -92,6 +92,13 @@ func (m model) handleDictationPartial(msg sttPartialMsg) model { func (m *model) applyStreamingText(text string) { state := m.currentComposerState() + stateRunes := []rune(state.text) + if m.dictation.regionActive { + // The composer may have changed between partials. Keep the tracked range + // valid before using regionStart as a slice bound. + m.dictation.regionStart = clamp(m.dictation.regionStart, 0, len(stateRunes)) + m.dictation.regionEnd = clamp(m.dictation.regionEnd, m.dictation.regionStart, len(stateRunes)) + } if !m.dictation.regionActive { m.dictation.regionActive = true m.dictation.regionStart = state.cursor @@ -100,7 +107,7 @@ func (m *model) applyStreamingText(text string) { // 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 + 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 = " " @@ -112,7 +119,7 @@ func (m *model) applyStreamingText(text string) { // 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]) + prefix := string(stateRunes[:m.dictation.regionStart]) anchor := m.dictation.regionAnchor switch { case prefix == anchor: @@ -137,9 +144,13 @@ func (m *model) applyStreamingText(text string) { m.dictation.regionStart = state.cursor m.dictation.regionEnd = state.cursor m.dictation.regionPrefix = "" - m.dictation.regionAnchor = string([]rune(state.text)[:state.cursor]) + m.dictation.regionAnchor = string(stateRunes[:state.cursor]) } } + // A prefix adjustment can move the region outside the current composer. + // Clamp again before deletion and before comparing the cursor to the range. + m.dictation.regionStart = clamp(m.dictation.regionStart, 0, len(stateRunes)) + m.dictation.regionEnd = clamp(m.dictation.regionEnd, m.dictation.regionStart, len(stateRunes)) // Replace [regionStart, regionEnd) with prefix + the new cumulative text. rendered := m.dictation.regionPrefix + text cleared := deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd) diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index b164a826c..0b4e58e4c 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -175,6 +175,39 @@ 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 TestDictationStreamingPartialClampsRegionAfterComposerShrink(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 TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) { // Esc can race an already-buffered realtime event: cancelDictation discards // the live region and resets state synchronously, but the streaming From 058ba802c75ae9fbcdd75453f3090c71a79dcc8b Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Fri, 28 Aug 2026 21:05:12 +0200 Subject: [PATCH 2/3] fix(tui): preserve user edits across dictation partials --- internal/tui/dictation_stream.go | 14 +++++---- internal/tui/dictation_test.go | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/internal/tui/dictation_stream.go b/internal/tui/dictation_stream.go index aa062c741..0b48f2775 100644 --- a/internal/tui/dictation_stream.go +++ b/internal/tui/dictation_stream.go @@ -93,12 +93,6 @@ func (m model) handleDictationPartial(msg sttPartialMsg) model { func (m *model) applyStreamingText(text string) { state := m.currentComposerState() stateRunes := []rune(state.text) - if m.dictation.regionActive { - // The composer may have changed between partials. Keep the tracked range - // valid before using regionStart as a slice bound. - m.dictation.regionStart = clamp(m.dictation.regionStart, 0, len(stateRunes)) - m.dictation.regionEnd = clamp(m.dictation.regionEnd, m.dictation.regionStart, len(stateRunes)) - } if !m.dictation.regionActive { m.dictation.regionActive = true m.dictation.regionStart = state.cursor @@ -112,6 +106,14 @@ func (m *model) applyStreamingText(text string) { // Fold the separator into the region so a cancel removes it too. m.dictation.regionPrefix = " " } + } else if m.dictation.regionStart < 0 || m.dictation.regionStart > len(stateRunes) { + // The user edited across the old region start, so its prefix can no + // longer be compared safely. Preserve their text and insert the next + // partial as a fresh live region at the current cursor. + m.dictation.regionStart = state.cursor + m.dictation.regionEnd = state.cursor + m.dictation.regionPrefix = "" + m.dictation.regionAnchor = string(stateRunes[:state.cursor]) } 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 diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index 0b4e58e4c..c2acf5df7 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -208,6 +208,58 @@ func TestDictationStreamingPartialClampsRegionAfterComposerShrink(t *testing.T) } } +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 TestDictationStreamingInvalidRegionBoundsReanchorOrClamp(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: "apartial 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 From 329dd1237e129544987a1f03f43d19d8c0408a50 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sat, 29 Aug 2026 11:00:15 +0200 Subject: [PATCH 3/3] fix(tui): reanchor stale dictation regions Amp-Thread-ID: https://ampcode.com/threads/T-01a04c92-2d1d-7508-91bc-416341b7e8b0 Co-authored-by: Amp --- internal/tui/dictation.go | 8 +++- internal/tui/dictation_stream.go | 66 +++++++++----------------------- internal/tui/dictation_test.go | 37 ++++++++++++++++-- 3 files changed, 59 insertions(+), 52 deletions(-) diff --git a/internal/tui/dictation.go b/internal/tui/dictation.go index adac424f7..9ab48592d 100644 --- a/internal/tui/dictation.go +++ b/internal/tui/dictation.go @@ -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): @@ -327,6 +330,7 @@ func (d *dictationController) reset() { d.streamStop = nil d.regionActive = false d.regionPrefix = "" + d.regionRendered = "" d.waveBars = nil d.waveTick = 0 } diff --git a/internal/tui/dictation_stream.go b/internal/tui/dictation_stream.go index 0b48f2775..8da00f63a 100644 --- a/internal/tui/dictation_stream.go +++ b/internal/tui/dictation_stream.go @@ -2,7 +2,6 @@ package tui import ( "context" - "strings" tea "charm.land/bubbletea/v2" @@ -98,61 +97,23 @@ func (m *model) applyStreamingText(text string) { 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. + // 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 if m.dictation.regionStart < 0 || m.dictation.regionStart > len(stateRunes) { - // The user edited across the old region start, so its prefix can no - // longer be compared safely. Preserve their text and insert the next - // partial as a fresh live region at the current 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]) - } 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(stateRunes[: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(stateRunes[:state.cursor]) - } } - // A prefix adjustment can move the region outside the current composer. - // Clamp again before deletion and before comparing the cursor to the range. - m.dictation.regionStart = clamp(m.dictation.regionStart, 0, len(stateRunes)) - m.dictation.regionEnd = clamp(m.dictation.regionEnd, m.dictation.regionStart, len(stateRunes)) // Replace [regionStart, regionEnd) with prefix + the new cumulative text. rendered := m.dictation.regionPrefix + text cleared := deleteComposerRange(state, m.dictation.regionStart, m.dictation.regionEnd) @@ -166,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 } @@ -182,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 } diff --git a/internal/tui/dictation_test.go b/internal/tui/dictation_test.go index c2acf5df7..d9950d82e 100644 --- a/internal/tui/dictation_test.go +++ b/internal/tui/dictation_test.go @@ -192,7 +192,7 @@ func TestDictationStreamingPartialWithCaretInsideExistingText(t *testing.T) { } } -func TestDictationStreamingPartialClampsRegionAfterComposerShrink(t *testing.T) { +func TestDictationStreamingPartialReanchorsRegionAfterComposerShrink(t *testing.T) { m := model{} m.setComposerState(composerState{text: "hello world", cursor: 11}) m.dictation.phase = dictRecording @@ -208,6 +208,37 @@ func TestDictationStreamingPartialClampsRegionAfterComposerShrink(t *testing.T) } } +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}) @@ -230,7 +261,7 @@ func TestDictationStreamingBackspaceAcrossRegionPreservesUserTextOnCancel(t *tes } } -func TestDictationStreamingInvalidRegionBoundsReanchorOrClamp(t *testing.T) { +func TestDictationStreamingInvalidRegionBoundsReanchor(t *testing.T) { tests := []struct { name string start int @@ -240,7 +271,7 @@ func TestDictationStreamingInvalidRegionBoundsReanchorOrClamp(t *testing.T) { }{ {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: "apartial text"}, + {name: "end past end", start: 1, end: 40, anchor: "a", want: "abpartial text"}, } for _, tt := range tests {