diff --git a/internal/ui/core/events.go b/internal/ui/core/events.go index 88e09d83..6c80d746 100644 --- a/internal/ui/core/events.go +++ b/internal/ui/core/events.go @@ -25,6 +25,11 @@ type SubmitMsg struct { // presses ESC a second time, the canceling state is reset to false. type CancelTimerExpiredMsg struct{} +// CtrlCResetMsg is sent after a short delay when the user presses Ctrl+C to +// clear input. If the user doesn't press Ctrl+C again within the timeout, +// the ctrlCPressedOnce flag is reset so the next Ctrl+C will clear again. +type CtrlCResetMsg struct{} + // --- Tree session events --- // TreeNodeSelectedMsg is sent when the user selects a node in the tree selector. diff --git a/internal/ui/input.go b/internal/ui/input.go index 88c2932d..b180bd73 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -859,6 +859,21 @@ func (s *InputComponent) PendingImageCount() int { return len(s.pendingImages) } +// Clear clears the textarea content and resets related state. Returns true if +// there was content to clear, false if the input was already empty. +func (s *InputComponent) Clear() bool { + hadContent := s.textarea.Value() != "" + s.textarea.SetValue("") + s.textarea.CursorEnd() + s.lastValue = "" + s.showPopup = false + s.argMode = false + s.fileMode = false + s.browsingHistory = false + s.savedInput = "" + return hadContent +} + // applyFileCompletion replaces the @prefix in the textarea with the selected // file or MCP resource suggestion. For directories, it keeps the popup open // for further drilling. For files and resources, it closes the popup and adds diff --git a/internal/ui/model.go b/internal/ui/model.go index 8eba5a9f..84d682d1 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -720,6 +720,10 @@ type AppModel struct { // disables alt screen to restore the terminal properly. quitting bool + // ctrlCPressedOnce tracks if Ctrl+C was pressed once to clear input. + // A second Ctrl+C (or Ctrl+C when input is empty) will quit the app. + ctrlCPressedOnce bool + // streamingBashOutput holds the current streaming bash output lines. // Lines are accumulated as they arrive and displayed in the stream region. streamingBashOutput []string @@ -869,7 +873,7 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { m.messages = []MessageItem{} // Wire up child components now that we have the concrete implementations. - m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to quit)", appCtrl) + m.input = NewInputComponent(width, "Enter your prompt (Type /help for commands, Ctrl+C to clear input, Ctrl+C again to quit)", appCtrl) // Wire up cwd for @file autocomplete. if ic, ok := m.input.(*InputComponent); ok && opts.Cwd != "" { @@ -1283,6 +1287,19 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.overlayResponseCh = nil m.overlay = nil } + + // Check if we should clear input first (on first Ctrl+C when input has content). + if m.state == stateInput && !m.ctrlCPressedOnce { + if ic, ok := m.input.(*InputComponent); ok { + if hadContent := ic.Clear(); hadContent { + // Input was cleared. Set flag so next Ctrl+C will quit. + m.ctrlCPressedOnce = true + // Start reset timer so the flag clears after 3 seconds. + return m, ctrlCResetCmd() + } + } + } + // Set quitting flag so View() disables alt screen for clean exit. m.quitting = true // Graceful quit: app.Close() is deferred in cmd/root.go. @@ -1564,10 +1581,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uicore.CancelTimerExpiredMsg: m.canceling = false + // ── Ctrl+C reset timer expired ──────────────────────────────────────────── + case uicore.CtrlCResetMsg: + m.ctrlCPressedOnce = false + // ── Input submitted ────────────────────────────────────────────────────── case uicore.SubmitMsg: // Re-enable auto-scroll when user submits a new message. m.scrollList.autoScroll = true + // Reset Ctrl+C flag so next Ctrl+C clears input instead of quitting. + m.ctrlCPressedOnce = false // Handle slash commands locally — they should never reach app.Run(). // Parse once: split on the first space so argument-bearing commands @@ -3422,7 +3445,7 @@ func (m *AppModel) printHelpMessage() { "- `!command`: Run shell command, output included in LLM context\n" + "- `!!command`: Run shell command, output excluded from LLM context\n\n" + "**Keys:**\n" + - "- `Ctrl+C`: Exit at any time\n" + + "- `Ctrl+C`: Clear input (press again to exit)\n" + "- `ESC` (x2): Cancel ongoing LLM generation\n" + "- `Ctrl+X s`: Steer — redirect the agent mid-turn (injected between tool calls)\n" + "- `Ctrl+X e`: Open `$EDITOR` to compose/edit your prompt\n" + @@ -4509,6 +4532,14 @@ func cancelTimerCmd() tea.Cmd { }) } +// ctrlCResetCmd returns a tea.Cmd that fires CtrlCResetMsg after 3s. +// This resets the ctrlCPressedOnce flag so the next Ctrl+C will clear input again. +func ctrlCResetCmd() tea.Cmd { + return tea.Tick(3*time.Second, func(_ time.Time) tea.Msg { + return uicore.CtrlCResetMsg{} + }) +} + // -------------------------------------------------------------------------- // Interactive prompt support // -------------------------------------------------------------------------- diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 52f7faf9..32187c46 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -873,6 +873,123 @@ func TestCtrlC_producesQuit(t *testing.T) { } } +// TestCtrlC_clearsInput_firstPress tests that Ctrl+C clears input on first +// press when there's content, and requires a second press to quit. +func TestCtrlC_clearsInput_firstPress(t *testing.T) { + // Create a real InputComponent to test the clear behavior + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + + // Replace with real InputComponent that has content + input := NewInputComponent(80, "test", ctrl) + input.textarea.SetValue("some text content") + m.input = input + + // First Ctrl+C should clear input, not quit + _, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + + // Should have cleared the input + if input.textarea.Value() != "" { + t.Fatalf("expected input to be cleared, got %q", input.textarea.Value()) + } + + // Should have set ctrlCPressedOnce flag + if !m.ctrlCPressedOnce { + t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C") + } + + // The command should be a ctrlCResetCmd (not tea.Quit) + if cmd == nil { + t.Fatal("expected a command after first Ctrl+C, got nil") + } + msg := cmd() + if _, ok := msg.(core.CtrlCResetMsg); !ok { + t.Fatalf("expected CtrlCResetMsg, got %T", msg) + } + + // Second Ctrl+C should now quit + _, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if cmd == nil { + t.Fatal("expected tea.Quit cmd on second Ctrl+C, got nil") + } + msg = cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("expected QuitMsg on second Ctrl+C, got %T", msg) + } +} + +// TestCtrlC_resetAfterSubmit tests that the Ctrl+C flag is reset after +// submitting a message, so the next Ctrl+C clears input again. +func TestCtrlC_resetAfterSubmit(t *testing.T) { + // Use newTestAppModel but replace the input with a real InputComponent + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + + // Replace with real InputComponent + input := NewInputComponent(80, "test", ctrl) + input.textarea.SetValue("content") + m.input = input + + // First Ctrl+C clears input + updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + m = updated.(*AppModel) + if input.textarea.Value() != "" { + t.Fatal("expected input to be cleared") + } + + // Flag should be set + if !m.ctrlCPressedOnce { + t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C") + } + + // Simulate CtrlCResetMsg being processed (timer expired) + updated, _ = m.Update(core.CtrlCResetMsg{}) + m = updated.(*AppModel) + + // Flag should be reset + if m.ctrlCPressedOnce { + t.Fatal("expected ctrlCPressedOnce to be false after CtrlCResetMsg") + } + + // Add new content to input + input.textarea.SetValue("new content") + + // Next Ctrl+C should clear again (not quit) because flag was reset + _, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if input.textarea.Value() != "" { + t.Fatalf("expected input to be cleared again, got %q", input.textarea.Value()) + } + if cmd == nil { + t.Fatal("expected a command after Ctrl+C, got nil") + } + msg := cmd() + if _, ok := msg.(core.CtrlCResetMsg); !ok { + t.Fatalf("expected CtrlCResetMsg, got %T", msg) + } +} + +// TestCtrlC_emptyInput_quitsImmediately tests that Ctrl+C quits immediately +// when the input is empty (no content to clear). +func TestCtrlC_emptyInput_quitsImmediately(t *testing.T) { + ctrl := &stubAppController{} + m, _, _ := newTestAppModel(ctrl) + + // Replace with real InputComponent (empty by default) + input := NewInputComponent(80, "test", ctrl) + m.input = input + + // Ctrl+C on empty input should quit immediately + _, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + + if cmd == nil { + t.Fatal("expected tea.Quit cmd on empty input, got nil") + } + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Fatalf("expected QuitMsg, got %T", msg) + } +} + // -------------------------------------------------------------------------- // submitMsg during stateWorking (queue path) // --------------------------------------------------------------------------