fix(ui): make ctrl+c clear input before quitting

Change Ctrl+C behavior to match other terminal AI tools (claude, codex, pi):
- First Ctrl+C clears the current input when text is present
- Second Ctrl+C (within 3 seconds) quits the application
- Ctrl+C on empty input quits immediately
- 3-second auto-reset timer clears the 'pressed once' state
- Flag also resets after message submission

Updates placeholder text and help message to reflect new behavior.

Fixes #13
This commit is contained in:
Ed Zynda
2026-04-21 19:32:48 +03:00
parent 88dd216e15
commit 2d9783a44d
4 changed files with 170 additions and 2 deletions
+5
View File
@@ -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.
+15
View File
@@ -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
+33 -2
View File
@@ -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
// --------------------------------------------------------------------------
+117
View File
@@ -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)
// --------------------------------------------------------------------------