Skip to content
Open
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