Skip to content
Merged
73 changes: 73 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
137 changes: 137 additions & 0 deletions internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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")
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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")
}
}
Loading