diff --git a/internal/tui/model.go b/internal/tui/model.go index 617d38ba6..f00f0a48a 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 := m.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,24 +1382,31 @@ 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 { + 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.burstCount = 0 return m.choosePicker() } if keyAlt(msg) || keyShift(msg) { @@ -1394,6 +1421,32 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.suggestionsActive() { return m.chooseSuggestion() } + // 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() + 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. 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() + return m, nil + } + m.burstCount = 0 return m.handleSubmit() case keyIs(msg, tea.KeyTab) && keyShift(msg): if m.transcriptDetailed { @@ -1494,12 +1547,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() { @@ -1525,12 +1581,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 { @@ -1561,12 +1620,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 { @@ -1596,12 +1658,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 { @@ -1639,12 +1704,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 { @@ -1730,18 +1798,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 diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 4c4efbfdb..608c7a37e 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), @@ -2675,3 +2676,139 @@ func TestOverlayViewportLinesCompositesAndPreservesBackdropText(t *testing.T) { t.Fatalf("overlaid row should keep backdrop margin text alongside the panel, got %q", panelRow) } } + +// 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", termuxVersion) + 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 + return m +} + +// 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 { + 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) { + 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") + } +} + +// TestDesktopBurstNotAffected: on desktop (no TERMUX_VERSION), 3 fast chars + Enter submits. +func TestDesktopBurstNotAffected(t *testing.T) { + 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") + } + 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("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) + 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") + 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") + } +} + +// 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") + } +}