Skip to content

Commit 86f4dd4

Browse files
committed
feat(input): ctrl-c at idle with typed text clears the input
Previously: half-typed prompt + idle + ctrl-c showed a "press again to exit" hint, and a careless second tap killed the session along with your half-finished sentence. Now: ctrl-c wipes the input first; the exit hint only kicks in once the prompt is empty. Lets you bail out of a partially-written thought without backspacing or risking an exit. The priority order in handleAbort becomes overlay > busy > clear-input > exit-hint. Implemented via an imperative handle on Input so App can ask "does the buffer have text, and if so clear it" without lifting the entire input state up.
1 parent e61547d commit 86f4dd4

3 files changed

Lines changed: 54 additions & 30 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.44",
3+
"version": "2.0.0-pre.45",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/ui/App.tsx

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Box, Text, useApp, useInput } from "ink";
2-
import { useEffect, useMemo, useReducer, useState } from "react";
2+
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
33
import { type AgentBundle, createAgent } from "../agent/agent.js";
44
import { ConfigError } from "../agent/config.js";
55
import { initialState, reducer } from "../agent/events.js";
@@ -15,7 +15,7 @@ import { buildAttachmentPrompt, collectAttachments } from "./attachments.js";
1515
import { CompactionBanner } from "./CompactionBanner.js";
1616
import { FirstRunSetup } from "./FirstRunSetup.js";
1717
import { HistoryStore } from "./history-store.js";
18-
import { Input } from "./Input.js";
18+
import { Input, type InputHandle } from "./Input.js";
1919
import { MessageList } from "./MessageList.js";
2020
import { Permission } from "./Permission.js";
2121
import { Status } from "./Status.js";
@@ -97,6 +97,7 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
9797
return next.length > 50 ? next.slice(next.length - 50) : next;
9898
});
9999
const [tasks, setTasks] = useState<readonly Task[]>(() => bundle.toolContext.tasks.list());
100+
const inputRef = useRef<InputHandle | null>(null);
100101

101102
const registry = useMemo(() => {
102103
const reg = new CommandRegistry();
@@ -220,19 +221,21 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
220221
});
221222
};
222223

223-
// Ctrl-C semantics:
224-
// • Any open overlay (Permission, UserQuery) gets dismissed first
225-
// so the user is never trapped behind a prompt with no escape.
226-
// • While the agent is busy: abort the turn. Stays in the app.
227-
// • A second Ctrl-C within DOUBLE_TAP_MS exits, regardless of
228-
// whether the previous press aborted or just landed a hint.
229-
// "Twice real fast" is universally understood as "I want out."
230-
// • While idle at the prompt: first press posts a hint + arms the
231-
// exit window so the second tap exits.
224+
// Ctrl-C semantics, in priority order:
225+
// 1. Open overlay (Permission, UserQuery) → dismiss it. Never trap
226+
// the user behind a prompt with no escape.
227+
// 2. Agent busy → abort the turn. Stay in the app.
228+
// 3. Idle, input has typed text → wipe the input. Lets the user
229+
// back out of a half-written prompt without having to backspace.
230+
// 4. Idle, input empty → post a "Press Ctrl-C again to exit" hint
231+
// and arm the double-tap exit window.
232+
//
233+
// At any state, a second Ctrl-C within DOUBLE_TAP_MS exits cleanly —
234+
// "twice real fast" is universally understood as "I want out."
232235
//
233236
// 1000ms is "real fast" — wide enough that intentional double-taps
234237
// register, tight enough that mashing Ctrl-C twice while flustered
235-
// doesn't accidentally exit. Tunable here if testers want it longer.
238+
// doesn't accidentally exit.
236239
const exitTimerRef = useMemo(() => ({ deadline: 0 }), []);
237240
const handleAbort = () => {
238241
const DOUBLE_TAP_MS = 1000;
@@ -243,11 +246,7 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
243246
}
244247
exitTimerRef.deadline = now + DOUBLE_TAP_MS;
245248

246-
// Overlays first: dismissing them brings the user back to a usable
247-
// input row without exiting the agent loop. Permission denies the
248-
// pending request (and any tool waiting on it surfaces the denial
249-
// to the model as a normal tool error); UserQuery cancels the
250-
// pending question.
249+
// Overlays first.
251250
if (permRequest) {
252251
bundle.permissions.respond(permRequest.id, "deny");
253252
if (busy) {
@@ -273,6 +272,11 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
273272
// gets the user out without confirmation theater.
274273
return;
275274
}
275+
276+
// Idle with typed input → clear it, don't fall through to the
277+
// exit-hint branch. The user is bailing on a prompt, not the app.
278+
if (inputRef.current?.clearIfHasText()) return;
279+
276280
appendStatus("Press Ctrl-C again to exit.");
277281
};
278282

@@ -328,6 +332,7 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
328332
/>
329333
) : (
330334
<Input
335+
ref={inputRef}
331336
disabled={busy}
332337
onSubmit={handleSubmit}
333338
onAbort={handleAbort}

src/ui/Input.tsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Box, Text, useInput, useStdin } from "ink";
2-
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
2+
import { forwardRef, type ReactNode, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
33
import { logInputEvent } from "./debug-input.js";
44
import {
55
backspace,
@@ -26,6 +26,15 @@ export interface SlashCommandSuggestion {
2626
description?: string;
2727
}
2828

29+
/** Imperative handle exposed through Input's ref. App uses this to wipe the
30+
* buffer on a stand-alone Ctrl-C (input has text, agent is idle) without
31+
* having to lift the entire input state. */
32+
export interface InputHandle {
33+
/** If the buffer has non-whitespace text, clear it and return true.
34+
* Otherwise no-op and return false. */
35+
clearIfHasText: () => boolean;
36+
}
37+
2938
interface InputProps {
3039
disabled?: boolean;
3140
onSubmit: (text: string) => void;
@@ -95,18 +104,28 @@ function pickPlaceholder(hasHistory: boolean): string {
95104
* Ctrl-Z undo
96105
* Ctrl-C busy → cancel turn (stay in app); double-tap → exit
97106
*/
98-
export function Input({
99-
disabled,
100-
onSubmit,
101-
onAbort,
102-
commands,
103-
history,
104-
cwd,
105-
suggestion,
106-
onSuggestionDismiss,
107-
}: InputProps) {
107+
export const Input = forwardRef<InputHandle, InputProps>(function Input(
108+
{ disabled, onSubmit, onAbort, commands, history, cwd, suggestion, onSuggestionDismiss }: InputProps,
109+
ref,
110+
) {
108111
const [state, setState] = useState(initialInputState());
109112
const [suggestionIdx, setSuggestionIdx] = useState(0);
113+
114+
// Keep the latest state in a ref so the imperative handle below can
115+
// read it without re-creating the handle on every keystroke.
116+
const stateRef = useRef(state);
117+
stateRef.current = state;
118+
useImperativeHandle(
119+
ref,
120+
(): InputHandle => ({
121+
clearIfHasText: () => {
122+
if (stateRef.current.buffer.trim().length === 0) return false;
123+
setState(initialInputState());
124+
return true;
125+
},
126+
}),
127+
[],
128+
);
110129
/**
111130
* History cursor:
112131
* -1 → live buffer (no history navigation in progress)
@@ -403,7 +422,7 @@ export function Input({
403422
</Box>
404423
</Box>
405424
);
406-
}
425+
});
407426

408427
function SlashSuggestions({
409428
suggestions,

0 commit comments

Comments
 (0)