mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user