From c8d2d853fbef4d03135c275ebbac158d521fa74d Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Tue, 7 Jul 2026 11:48:12 +0700 Subject: [PATCH 1/8] fix(tui): paste protection for Termux char-by-char paste Termux context menu paste injects characters one at a time, including newlines as raw KeyEnter events. This caused every pasted multiline text to be submitted prematurely before the user could review it. The fix adds a timing-based paste detector that works alongside the existing bracketed-paste handler (tea.PasteMsg): - Three new model fields: lastCharTime, lastKeyTime, burstCount - Timing trackers run before any early return in case tea.KeyPressMsg - Enter handler checks burstCount > 1 (2+ chars within 100ms = paste) - Composer check gated with burstCount > 0 to avoid infinite composer lock - burstCount reset at every intentional action (permission, picker, submit, wizard, spec review, etc.) so normal typing is unaffected The 100ms window catches all observed Termux paste speeds while remaining under typical human inter-key intervals. The burstCount resets at modal boundaries prevent false triggers in tests and real usage. --- internal/tui/model.go | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/internal/tui/model.go b/internal/tui/model.go index 617d38ba6..28810fd83 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -192,6 +192,12 @@ type model struct { // renders the live elapsed time from it so a long or stalled turn never looks // like a frozen terminal (for ANY provider, not just slow ones). Zero = idle. turnStartedAt time.Time + // lastCharTime tracks when the last non-Enter key was received, for paste detection. + lastCharTime time.Time + // lastKeyTime tracks every keypress timestamp for burst calculation. + lastKeyTime time.Time + // burstCount counts consecutive keypresses within 100ms (paste mode). + burstCount int queuedMessage string // loops holds the session's active /loop definitions (see loop.go). activeLoopID // tags the in-flight run when it is a loop iteration (empty = a user turn), so the @@ -1175,6 +1181,18 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case tea.KeyPressMsg: + // Paste-detection timing trackers. MUST run before any early return + // so burst counting stays accurate regardless of which branch fires. + now := time.Now() + if !m.lastKeyTime.IsZero() && now.Sub(m.lastKeyTime) < 100*time.Millisecond { + m.burstCount++ + } else { + m.burstCount = 0 + } + m.lastKeyTime = now + if !keyIs(msg, tea.KeyEnter) { + m.lastCharTime = now + } if m.setup.visible { return m.handleSetupKey(msg) } @@ -1202,6 +1220,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') { m.helpOverlay = false } + m.burstCount = 0 return m, nil } switch { @@ -1286,6 +1305,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.escapeAskUser() } if m.pendingSpecReview != nil { + m.burstCount = 0 return m.cancelSpecReview() } if m.pendingPermission != nil && m.pendingPermission.request.ToolName == tools.RequestPermissionsToolName { @@ -1362,12 +1382,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.pendingPermission != nil { // Enter confirms the highlighted option (default: allow once); the // a/y/d hotkeys and a click still resolve directly. + m.burstCount = 0 return m.confirmPermissionCursor() } if m.pendingAskUser != nil { + m.burstCount = 0 return m.confirmAskUser() } if m.pendingSpecReview != nil { + m.burstCount = 0 return m, nil } if m.providerWizard != nil { @@ -1380,6 +1403,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleMCPManagerKey(msg) } if m.picker != nil { + m.burstCount = 0 return m.choosePicker() } if keyAlt(msg) || keyShift(msg) { @@ -1394,6 +1418,27 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.suggestionsActive() { return m.chooseSuggestion() } + // Timing-based paste protection: 2+ rapid keys within 100ms + // (as opposed to 1 or 2 = normal typing + Enter) means we are + // inside a character-by-character paste. Insert newline. + if m.burstCount > 1 { + state := m.currentComposerState() + m = m.insertComposerTextWithPastePreview(state, "\n", "") + m.clearSuggestions() + return m, nil + } + + // Composer-based paste protection: when the composer already has + // multiline text (e.g. pasted via bracketed paste / Ctrl+Shift+V), + // plain Enter inserts a newline instead of submitting so each + // pasted \n does not trigger a premature submit. + if m.composerActive && m.burstCount > 0 && strings.Contains(m.composer.text, "\n") { + state := m.currentComposerState() + m = m.insertComposerTextWithPastePreview(state, "\n", "") + m.clearSuggestions() + return m, nil + } + m.burstCount = 0 return m.handleSubmit() case keyIs(msg, tea.KeyTab) && keyShift(msg): if m.transcriptDetailed { @@ -1730,18 +1775,23 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil // picker mode: non-navigation keys do nothing } if m.pendingSpecReview != nil { + m.burstCount = 0 return m.handleSpecReviewKey(msg) } if m.pendingPermission != nil { + m.burstCount = 0 return m.handlePermissionKey(msg) } if m.providerWizard != nil { + m.burstCount = 0 return m.handleProviderWizardKey(msg) } if m.mcpAddWizard != nil { + m.burstCount = 0 return m.handleMCPAddWizardKey(msg) } if m.mcpManager != nil { + m.burstCount = 0 return m.handleMCPManagerKey(msg) } // An open picker is modal over the input: swallow remaining keys so they From 40c692bdfefeb240563b96512b31789d19a2e2c6 Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Tue, 7 Jul 2026 12:05:38 +0700 Subject: [PATCH 2/8] fix(tui): reset burstCount in Enter handler wizard paths CodeRabbit review found 3 Enter-handler paths (providerWizard, mcpAddWizard, mcpManager) that delegated without resetting burstCount. The equivalent post-switch paths already had the reset but the Enter-specific branches were missing it, creating a window where stale burst state could cause false paste detection. --- internal/tui/model.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/tui/model.go b/internal/tui/model.go index 28810fd83..a049ba476 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1394,12 +1394,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if m.providerWizard != nil { + m.burstCount = 0 return m.handleProviderWizardKey(msg) } if m.mcpAddWizard != nil { + m.burstCount = 0 return m.handleMCPAddWizardKey(msg) } if m.mcpManager != nil { + m.burstCount = 0 return m.handleMCPManagerKey(msg) } if m.picker != nil { @@ -1539,12 +1542,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.moveAskUserTab(1), nil } if m.providerWizard != nil { + m.burstCount = 0 return m.handleProviderWizardKey(msg) } if m.mcpAddWizard != nil { + m.burstCount = 0 return m.handleMCPAddWizardKey(msg) } if m.mcpManager != nil { + m.burstCount = 0 return m.handleMCPManagerKey(msg) } if m.picker == nil && m.suggestionsActive() { @@ -1570,12 +1576,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.moveAskUserCursor(-1), nil } if m.providerWizard != nil { + m.burstCount = 0 return m.handleProviderWizardKey(msg) } if m.mcpAddWizard != nil { + m.burstCount = 0 return m.handleMCPAddWizardKey(msg) } if m.mcpManager != nil { + m.burstCount = 0 return m.handleMCPManagerKey(msg) } if m.picker != nil { @@ -1606,12 +1615,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.moveAskUserCursor(1), nil } if m.providerWizard != nil { + m.burstCount = 0 return m.handleProviderWizardKey(msg) } if m.mcpAddWizard != nil { + m.burstCount = 0 return m.handleMCPAddWizardKey(msg) } if m.mcpManager != nil { + m.burstCount = 0 return m.handleMCPManagerKey(msg) } if m.picker != nil { @@ -1641,12 +1653,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.moveAskUserCursor(1), nil } if m.providerWizard != nil { + m.burstCount = 0 return m.handleProviderWizardKey(msg) } if m.mcpAddWizard != nil { + m.burstCount = 0 return m.handleMCPAddWizardKey(msg) } if m.mcpManager != nil { + m.burstCount = 0 return m.handleMCPManagerKey(msg) } if m.picker != nil { @@ -1684,12 +1699,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.moveAskUserCursor(-1), nil } if m.providerWizard != nil { + m.burstCount = 0 return m.handleProviderWizardKey(msg) } if m.mcpAddWizard != nil { + m.burstCount = 0 return m.handleMCPAddWizardKey(msg) } if m.mcpManager != nil { + m.burstCount = 0 return m.handleMCPManagerKey(msg) } if m.picker != nil { From 8742769883d999a984ac07c06d9aa4a4b0aee737 Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Tue, 7 Jul 2026 14:41:11 +0000 Subject: [PATCH 3/8] fix(tui): raise paste burst threshold to >2, use injectable clock --- internal/tui/model.go | 11 ++++++----- internal/tui/model_test.go | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index a049ba476..ffa34d26b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1183,7 +1183,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: // Paste-detection timing trackers. MUST run before any early return // so burst counting stays accurate regardless of which branch fires. - now := time.Now() + now := m.now() if !m.lastKeyTime.IsZero() && now.Sub(m.lastKeyTime) < 100*time.Millisecond { m.burstCount++ } else { @@ -1421,10 +1421,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.suggestionsActive() { return m.chooseSuggestion() } - // Timing-based paste protection: 2+ rapid keys within 100ms - // (as opposed to 1 or 2 = normal typing + Enter) means we are - // inside a character-by-character paste. Insert newline. - if m.burstCount > 1 { + // Timing-based paste protection: 3+ rapid keys within 100ms + // means we are inside a character-by-character paste (Termux + // context menu). 1-2 fast keys + Enter is normal fast typing + // and should submit, not insert newline. + if m.burstCount > 2 { state := m.currentComposerState() m = m.insertComposerTextWithPastePreview(state, "\n", "") m.clearSuggestions() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 4c4efbfdb..59fd441a6 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -123,6 +123,7 @@ func TestPromptSubmitStoresReasoningSeparatelyFromAnswer(t *testing.T) { }) base := time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC) times := []time.Time{ + base, // burst tracker on Enter keypress (timing-based paste guard) base, // run start: consumed by turnStartedAt (the working-line elapsed clock) base, base.Add(1 * time.Second), From f8a4aa2dcc06e2db646ffcb3f074372125175e9d Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Wed, 8 Jul 2026 07:36:39 +0000 Subject: [PATCH 4/8] fix(tui): gate paste detection to Termux only, add bidirectional tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Paste burst detection (insert newline on rapid Enter) now only activates under Termux (TERMUX_VERSION env var), so fast desktop typing never false-triggers. - Switched burst tracker from time.Now() to m.now() for deterministic testing. - Added 3 tests: • TestTermuxBurstInsertsNewline — Termux + 3 chars + Enter → newline • TestTermuxFastTypingSubmits — Termux + 2 chars + Enter → submit • TestDesktopBurstNotAffected — Desktop + 3 chars + Enter → submit --- internal/tui/model.go | 12 ++-- internal/tui/model_test.go | 114 +++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index ffa34d26b..72e5090d3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1421,11 +1421,13 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.suggestionsActive() { return m.chooseSuggestion() } - // Timing-based paste protection: 3+ rapid keys within 100ms - // means we are inside a character-by-character paste (Termux - // context menu). 1-2 fast keys + Enter is normal fast typing - // and should submit, not insert newline. - if m.burstCount > 2 { + // Timing-based paste protection: under Termux, context-menu paste + // injects characters one at a time (including newlines as raw + // KeyEnter events). A sustained burst of 3+ keys within 100ms + // means we are inside a char-by-char paste — insert newline + // instead of submitting. Gated to Termux so fast desktop typing + // (which can reach similar inter-key intervals) is never affected. + if os.Getenv("TERMUX_VERSION") != "" && m.burstCount > 2 { state := m.currentComposerState() m = m.insertComposerTextWithPastePreview(state, "\n", "") m.clearSuggestions() diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 59fd441a6..8652d735e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2676,3 +2676,117 @@ func TestOverlayViewportLinesCompositesAndPreservesBackdropText(t *testing.T) { t.Fatalf("overlaid row should keep backdrop margin text alongside the panel, got %q", panelRow) } } + +// TestTermuxBurstInsertsNewline: under Termux, 3+ rapid chars + Enter inserts newline. +func TestTermuxBurstInsertsNewline(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("TERMUX_VERSION", "v0.118.0") + provider := &fakeProvider{events: []zeroruntime.StreamEvent{ + {Type: zeroruntime.StreamEventText, Content: "ok"}, + {Type: zeroruntime.StreamEventDone}, + }} + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + ProviderName: "tokenrouter", + ModelName: "MiniMax-M3", + Provider: provider, + Registry: tools.NewRegistry(), + }) + base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + tick := 0 + m.now = func() time.Time { + tick++ + return base.Add(time.Duration(tick) * 60 * time.Millisecond) + } + m.input.SetValue("") + m.width = 100 + m.height = 30 + + for _, ch := range "abc" { + updated, _ := m.Update(testKeyText(string(ch))) + m = updated.(model) + } + + updated, _ := m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if m.pending { + t.Fatal("burst should insert newline, not submit") + } + if !strings.Contains(m.composerValue(), "\n") { + t.Fatalf("burst should insert newline into composer, got %q", m.composerValue()) + } +} + +// TestTermuxFastTypingSubmits: under Termux, 2 fast chars + Enter still submits. +func TestTermuxFastTypingSubmits(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("TERMUX_VERSION", "v0.118.0") + provider := &fakeProvider{events: []zeroruntime.StreamEvent{ + {Type: zeroruntime.StreamEventText, Content: "ok"}, + {Type: zeroruntime.StreamEventDone}, + }} + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + ProviderName: "tokenrouter", + ModelName: "MiniMax-M3", + Provider: provider, + Registry: tools.NewRegistry(), + }) + base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + tick := 0 + m.now = func() time.Time { + tick++ + return base.Add(time.Duration(tick) * 60 * time.Millisecond) + } + m.input.SetValue("") + m.width = 100 + m.height = 30 + + for _, ch := range "ab" { + updated, _ := m.Update(testKeyText(string(ch))) + m = updated.(model) + } + + updated, cmd := m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if cmd == nil { + t.Fatal("fast typing should submit, got nil cmd") + } +} + +// TestDesktopBurstNotAffected: on desktop (no TERMUX_VERSION), 3 fast chars + Enter submits. +func TestDesktopBurstNotAffected(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("TERMUX_VERSION", "") + provider := &fakeProvider{events: []zeroruntime.StreamEvent{ + {Type: zeroruntime.StreamEventText, Content: "ok"}, + {Type: zeroruntime.StreamEventDone}, + }} + m := newModel(context.Background(), Options{ + Cwd: t.TempDir(), + ProviderName: "tokenrouter", + ModelName: "MiniMax-M3", + Provider: provider, + Registry: tools.NewRegistry(), + }) + base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) + tick := 0 + m.now = func() time.Time { + tick++ + return base.Add(time.Duration(tick) * 60 * time.Millisecond) + } + m.input.SetValue("") + m.width = 100 + m.height = 30 + + for _, ch := range "abc" { + updated, _ := m.Update(testKeyText(string(ch))) + m = updated.(model) + } + + updated, cmd := m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if cmd == nil { + t.Fatal("desktop burst should submit, got nil cmd") + } +} From 03a4659d3dd4cfc2494000b0e3d8b1588f781157 Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Wed, 8 Jul 2026 07:46:11 +0000 Subject: [PATCH 5/8] test: extract burst test helper, fix assertions, add reset-after-submit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extracted shared burstTestModel helper + typeKeys helper to eliminate duplication across the 3 tests (CodeRabbit nitpick). - Added m.pending assertion to submit tests (cmd != nil alone is weak). - Added TestBurstResetAfterSubmit verifying burstCount is properly reset after submission — 2 chars + Enter still submits. --- internal/tui/model_test.go | 108 +++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 58 deletions(-) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 8652d735e..2535a5a44 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2677,10 +2677,11 @@ func TestOverlayViewportLinesCompositesAndPreservesBackdropText(t *testing.T) { } } -// TestTermuxBurstInsertsNewline: under Termux, 3+ rapid chars + Enter inserts newline. -func TestTermuxBurstInsertsNewline(t *testing.T) { +// burstTestModel creates a model with fake provider, advancing clock (60ms/tick), +// and empty composer for burst/paste testing. termuxVersion controls the env var. +func burstTestModel(t *testing.T, termuxVersion string) model { t.Setenv("XDG_DATA_HOME", t.TempDir()) - t.Setenv("TERMUX_VERSION", "v0.118.0") + t.Setenv("TERMUX_VERSION", termuxVersion) provider := &fakeProvider{events: []zeroruntime.StreamEvent{ {Type: zeroruntime.StreamEventText, Content: "ok"}, {Type: zeroruntime.StreamEventDone}, @@ -2701,12 +2702,22 @@ func TestTermuxBurstInsertsNewline(t *testing.T) { m.input.SetValue("") m.width = 100 m.height = 30 + return m +} - for _, ch := range "abc" { +// typeKeys simulates rapid keypresses into the model, returning the final state. +func typeKeys(m model, keys string) model { + for _, ch := range keys { updated, _ := m.Update(testKeyText(string(ch))) m = updated.(model) } + return m +} +// TestTermuxBurstInsertsNewline: under Termux, 3+ rapid chars + Enter inserts newline. +func TestTermuxBurstInsertsNewline(t *testing.T) { + m := burstTestModel(t, "v0.118.0") + m = typeKeys(m, "abc") updated, _ := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if m.pending { @@ -2719,36 +2730,13 @@ func TestTermuxBurstInsertsNewline(t *testing.T) { // TestTermuxFastTypingSubmits: under Termux, 2 fast chars + Enter still submits. func TestTermuxFastTypingSubmits(t *testing.T) { - t.Setenv("XDG_DATA_HOME", t.TempDir()) - t.Setenv("TERMUX_VERSION", "v0.118.0") - provider := &fakeProvider{events: []zeroruntime.StreamEvent{ - {Type: zeroruntime.StreamEventText, Content: "ok"}, - {Type: zeroruntime.StreamEventDone}, - }} - m := newModel(context.Background(), Options{ - Cwd: t.TempDir(), - ProviderName: "tokenrouter", - ModelName: "MiniMax-M3", - Provider: provider, - Registry: tools.NewRegistry(), - }) - base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) - tick := 0 - m.now = func() time.Time { - tick++ - return base.Add(time.Duration(tick) * 60 * time.Millisecond) - } - m.input.SetValue("") - m.width = 100 - m.height = 30 - - for _, ch := range "ab" { - updated, _ := m.Update(testKeyText(string(ch))) - m = updated.(model) - } - + m := burstTestModel(t, "v0.118.0") + m = typeKeys(m, "ab") updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) + if !m.pending { + t.Fatal("fast typing should be pending after submit") + } if cmd == nil { t.Fatal("fast typing should submit, got nil cmd") } @@ -2756,37 +2744,41 @@ func TestTermuxFastTypingSubmits(t *testing.T) { // TestDesktopBurstNotAffected: on desktop (no TERMUX_VERSION), 3 fast chars + Enter submits. func TestDesktopBurstNotAffected(t *testing.T) { - t.Setenv("XDG_DATA_HOME", t.TempDir()) - t.Setenv("TERMUX_VERSION", "") - provider := &fakeProvider{events: []zeroruntime.StreamEvent{ - {Type: zeroruntime.StreamEventText, Content: "ok"}, - {Type: zeroruntime.StreamEventDone}, - }} - m := newModel(context.Background(), Options{ - Cwd: t.TempDir(), - ProviderName: "tokenrouter", - ModelName: "MiniMax-M3", - Provider: provider, - Registry: tools.NewRegistry(), - }) - base := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC) - tick := 0 - m.now = func() time.Time { - tick++ - return base.Add(time.Duration(tick) * 60 * time.Millisecond) + m := burstTestModel(t, "") + m = typeKeys(m, "abc") + updated, cmd := m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if !m.pending { + t.Fatal("desktop burst should be pending after submit") } - m.input.SetValue("") - m.width = 100 - m.height = 30 - - for _, ch := range "abc" { - updated, _ := m.Update(testKeyText(string(ch))) - m = updated.(model) + if cmd == nil { + t.Fatal("desktop burst should submit, got nil cmd") } +} +// TestBurstResetAfterSubmit: after submit, burstCount is reset so normal +// typing (2 chars + Enter) still submits without false newline insertion. +func TestBurstResetAfterSubmit(t *testing.T) { + m := burstTestModel(t, "v0.118.0") + // Type and submit normally + m = typeKeys(m, "hi") updated, cmd := m.Update(testKey(tea.KeyEnter)) m = updated.(model) if cmd == nil { - t.Fatal("desktop burst should submit, got nil cmd") + t.Fatal("first submit should start a run") + } + // Process the agent response so the model is no longer pending + resp, _ := m.Update(execCmd(cmd)) + m = resp.(model) + + // After reset, 2 fast chars + Enter should still submit (burstCount < 3) + m = typeKeys(m, "ok") + updated, cmd = m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if !m.pending { + t.Fatal("after burst reset, fast typing should submit") + } + if cmd == nil { + t.Fatal("after burst reset, got nil cmd") } } From d82f26753bfcd6e917586a73a92e77c9554ea2e0 Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Wed, 8 Jul 2026 07:56:57 +0000 Subject: [PATCH 6/8] test: add explicit pending check and lastKeyTime reset in burst reset test --- internal/tui/model_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 2535a5a44..8ed383a24 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2770,6 +2770,13 @@ func TestBurstResetAfterSubmit(t *testing.T) { // Process the agent response so the model is no longer pending resp, _ := m.Update(execCmd(cmd)) m = resp.(model) + if m.pending { + t.Fatal("model should not be pending after agent response") + } + + // Reset lastKeyTime so the burst tracker sees a clean gap before the + // second round of typing — avoids coupling to fake-clock progression. + m.lastKeyTime = time.Time{} // After reset, 2 fast chars + Enter should still submit (burstCount < 3) m = typeKeys(m, "ok") From 380c36120d20ef051f5436691899988b19a3775e Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Wed, 8 Jul 2026 08:47:21 +0000 Subject: [PATCH 7/8] fix(tui): align composer-multiline guard with same burstCount > 2 threshold --- internal/tui/model.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 72e5090d3..f00f0a48a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1437,8 +1437,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { // Composer-based paste protection: when the composer already has // multiline text (e.g. pasted via bracketed paste / Ctrl+Shift+V), // plain Enter inserts a newline instead of submitting so each - // pasted \n does not trigger a premature submit. - if m.composerActive && m.burstCount > 0 && strings.Contains(m.composer.text, "\n") { + // pasted \n does not trigger a premature submit. Uses the same + // burstCount > 2 threshold as the Termux path so a single fast + // key + Enter on a multiline prompt still submits. + if m.composerActive && m.burstCount > 2 && strings.Contains(m.composer.text, "\n") { state := m.currentComposerState() m = m.insertComposerTextWithPastePreview(state, "\n", "") m.clearSuggestions() From 51354d92d6b67edb6e814de5f0d53917ebcf85bd Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Wed, 8 Jul 2026 12:48:06 +0000 Subject: [PATCH 8/8] test: add TestMultilineBurstSubmits for multiline composer + 2-char burst --- internal/tui/model_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 8ed383a24..608c7a37e 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2789,3 +2789,26 @@ func TestBurstResetAfterSubmit(t *testing.T) { t.Fatal("after burst reset, got nil cmd") } } + +// TestMultilineBurstSubmits: with a \n already in the composer, a 2-char +// burst + Enter on desktop should still submit (burstCount < 3), not +// insert another newline. +func TestMultilineBurstSubmits(t *testing.T) { + m := burstTestModel(t, "") + // Seed the composer with multiline text via applyComposerKey + m.composerActive = true + m.composer.text = "hello\nwor" + m.composer.cursor = len([]rune(m.composer.text)) + m.input.SetValue(m.composer.text) + + // Type "ld" fast (2 chars) then Enter + m = typeKeys(m, "ld") + updated, cmd := m.Update(testKey(tea.KeyEnter)) + m = updated.(model) + if !m.pending { + t.Fatal("multiline burst should submit, not insert newline") + } + if cmd == nil { + t.Fatal("multiline burst got nil cmd") + } +}