From ea1012f59fd86c69bb8be3992942513d1e7d23a3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 15:24:14 -0500 Subject: [PATCH 1/3] feat(tui): delete queued message while editing with ctrl-x / cancel with esc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today there's no way to remove a queued message — ↑ loads it for edit, ctrl-K dispatches the head, but a draft you no longer want stays put forever. ctrl-C just clears the composer and exits edit mode without touching the queue. Two new bindings, both gated on queueEditIdx !== null so they're inert when the user isn't pointing at a queue item: - ctrl-X — delete the queue item being edited, clear composer, exit edit mode. "cut" matches the mental model and doesn't collide with any existing binding. - esc — cancel the edit (composer clears, item stays in queue). Mirrors ctrl-C's existing behavior so muscle memory has two paths. Header line now reads `queued (3) · editing 2 · ⌃X delete · esc cancel` when in edit mode, so the affordance is discoverable without /help. The /help hotkey table also gets a Ctrl+X entry. ctrl-C is intentionally unchanged: it should never destroy queued content. Cancel is non-destructive (esc / ctrl-C); only ctrl-X removes the item. --- ui-tui/src/__tests__/useQueue.test.ts | 28 ++++++++++++++++++++++++ ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/useComposerState.ts | 16 ++++++++++++-- ui-tui/src/app/useInputHandlers.ts | 9 ++++++++ ui-tui/src/components/queuedMessages.tsx | 3 ++- ui-tui/src/content/hotkeys.ts | 1 + ui-tui/src/hooks/useQueue.ts | 24 ++++++++++++++++++++ 7 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 ui-tui/src/__tests__/useQueue.test.ts diff --git a/ui-tui/src/__tests__/useQueue.test.ts b/ui-tui/src/__tests__/useQueue.test.ts new file mode 100644 index 0000000000..78cd358585 --- /dev/null +++ b/ui-tui/src/__tests__/useQueue.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { removeAt } from '../hooks/useQueue.js' + +describe('removeAt', () => { + it('removes the item at the given index in place', () => { + const arr = ['a', 'b', 'c'] + + removeAt(arr, 1) + expect(arr).toEqual(['a', 'c']) + }) + + it('is a no-op when the index is out of bounds', () => { + const arr = ['a', 'b'] + + removeAt(arr, -1) + removeAt(arr, 5) + expect(arr).toEqual(['a', 'b']) + }) + + it('returns the same reference (mutates in place)', () => { + const arr = ['x'] + const same = removeAt(arr, 0) + + expect(same).toBe(arr) + expect(arr).toEqual([]) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1904277c98..1d7cdaead0 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -125,6 +125,7 @@ export interface ComposerActions { handleTextPaste: (event: PasteEvent) => MaybePromise openEditor: () => Promise pushHistory: (text: string) => void + removeQueue: (index: number) => void replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter setHistoryIdx: StateSetter diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 26dbc9796f..859506db94 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -110,8 +110,18 @@ export function useComposerState({ const isBlocked = useStore($isBlocked) const { querier } = useStdin() as { querier: Parameters[0] } - const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = - useQueue() + const { + queueRef, + queueEditRef, + queuedDisplay, + queueEditIdx, + enqueue, + dequeue, + removeQ, + replaceQ, + setQueueEdit, + syncQueue + } = useQueue() const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw) @@ -294,6 +304,7 @@ export function useComposerState({ handleTextPaste, openEditor, pushHistory, + removeQueue: removeQ, replaceQueue: replaceQ, setCompIdx, setHistoryIdx, @@ -310,6 +321,7 @@ export function useComposerState({ handleTextPaste, openEditor, pushHistory, + removeQ, replaceQ, setCompIdx, setHistoryIdx, diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 5aab1d1bf8..538b5561b7 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -311,6 +311,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return clearSelection() } + if (key.escape && cState.queueEditIdx !== null) { + return cActions.clearIn() + } + if (key.upArrow && !cState.inputBuf.length) { const inputSel = getInputSelection() const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null @@ -357,6 +361,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } } + if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) { + cActions.removeQueue(cState.queueEditIdx) + return cActions.clearIn() + } + if (key.ctrl && ch.toLowerCase() === 'c') { if (live.busy && live.sid) { return turnController.interruptTurn({ diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index ab9c42c551..6e954c8345 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -24,7 +24,8 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages return ( - queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} + queued ({queued.length}) + {queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · ⌃X delete · esc cancel` : ''} {q.showLead && ( diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 50c293acd8..c5e32cd2a2 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -23,6 +23,7 @@ export const HOTKEYS: [string, string][] = [ [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], + ['Ctrl+X', 'delete the queued message you’re editing (esc cancels edit)'], [action + '+A/E', 'home / end of line'], [action + '+Z / ' + action + '+Y', 'undo / redo input edits'], [action + '+W', 'delete word'], diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts index 7546d64e74..18e9a6c55d 100644 --- a/ui-tui/src/hooks/useQueue.ts +++ b/ui-tui/src/hooks/useQueue.ts @@ -1,5 +1,15 @@ import { useCallback, useRef, useState } from 'react' +export function removeAt(arr: T[], i: number): T[] { + if (i < 0 || i >= arr.length) { + return arr + } + + arr.splice(i, 1) + + return arr +} + export function useQueue() { const queueRef = useRef([]) const [queuedDisplay, setQueuedDisplay] = useState([]) @@ -36,6 +46,19 @@ export function useQueue() { [syncQueue] ) + const removeQ = useCallback( + (i: number) => { + const before = queueRef.current.length + + removeAt(queueRef.current, i) + + if (queueRef.current.length !== before) { + syncQueue() + } + }, + [syncQueue] + ) + return { dequeue, enqueue, @@ -43,6 +66,7 @@ export function useQueue() { queueEditRef, queueRef, queuedDisplay, + removeQ, replaceQ, setQueueEdit, syncQueue From 32b068560dd8ae309f34ba6c750f687003ea442b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 15:32:16 -0500 Subject: [PATCH 2/3] fix(tui): stop ctrl+x from leaking a literal 'x' into the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text input's ctrl-passthrough whitelist only listed Ctrl+C and Ctrl+B. Ctrl+X fell through to the printable-char branch and got inserted as 'x' alongside the queue-delete action firing in useInputHandlers. Add Ctrl+X to the same whitelist so it bypasses the readline-style fallback and reaches the app-level handler unchanged. When not in queue-edit mode it's a no-op, which is fine — typing 'x' on Ctrl+X was the wrong default anyway. --- ui-tui/src/components/textInput.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 3b916d3d8d..1bff1d6756 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -684,13 +684,13 @@ export function TextInput({ return } - // Ctrl+B is the documented voice-recording toggle (see platform.ts → - // isVoiceToggleKey). Pass it through so the app-level handler in - // useInputHandlers receives it instead of being swallowed here as - // either backward-word nav (line below) or a literal 'b' insertion. + // Ctrl chords claimed by useInputHandlers — pass through instead of + // letting them fall into readline-style nav or a literal char insert. + // Ctrl+B = voice toggle, Ctrl+X = delete queued message while editing. if ( (k.ctrl && inp === 'c') || (k.ctrl && inp === 'b') || + (k.ctrl && inp === 'x') || k.tab || (k.shift && k.tab) || k.pageUp || From 718088c382b09014322782e9e0fad641922efa8a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Apr 2026 15:37:54 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(tui):=20copilot=20review=20on=20#16707?= =?UTF-8?q?=20=E2=80=94=20naming,=20label=20consistency,=20esc=20priority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `removeAt` → `removeAtInPlace` and document the mutation contract; the old name read like a non-mutating helper. - Hotkey table + queue header: use `Ctrl+X` / `Esc` to match the rest of the UI (was `⌃X` / `esc`). - Render the queued header as a single template literal so JSX text-node whitespace can't sneak into the rendered line. - Make `Esc` while editing beat the `terminal.hasSelection` clear: the header promises 'Esc cancel', so an active selection shouldn't silently consume the keystroke. --- ui-tui/src/__tests__/useQueue.test.ts | 12 ++++++------ ui-tui/src/app/useInputHandlers.ts | 11 +++++++---- ui-tui/src/components/queuedMessages.tsx | 5 +++-- ui-tui/src/content/hotkeys.ts | 2 +- ui-tui/src/hooks/useQueue.ts | 6 ++++-- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/ui-tui/src/__tests__/useQueue.test.ts b/ui-tui/src/__tests__/useQueue.test.ts index 78cd358585..ada53589da 100644 --- a/ui-tui/src/__tests__/useQueue.test.ts +++ b/ui-tui/src/__tests__/useQueue.test.ts @@ -1,26 +1,26 @@ import { describe, expect, it } from 'vitest' -import { removeAt } from '../hooks/useQueue.js' +import { removeAtInPlace } from '../hooks/useQueue.js' -describe('removeAt', () => { +describe('removeAtInPlace', () => { it('removes the item at the given index in place', () => { const arr = ['a', 'b', 'c'] - removeAt(arr, 1) + removeAtInPlace(arr, 1) expect(arr).toEqual(['a', 'c']) }) it('is a no-op when the index is out of bounds', () => { const arr = ['a', 'b'] - removeAt(arr, -1) - removeAt(arr, 5) + removeAtInPlace(arr, -1) + removeAtInPlace(arr, 5) expect(arr).toEqual(['a', 'b']) }) it('returns the same reference (mutates in place)', () => { const arr = ['x'] - const same = removeAt(arr, 0) + const same = removeAtInPlace(arr, 0) expect(same).toBe(arr) expect(arr).toEqual([]) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 538b5561b7..84978b98c4 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -307,14 +307,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return scrollTranscript(key.pageUp ? -step : step) } - if (key.escape && terminal.hasSelection) { - return clearSelection() - } - + // Queue-edit cancel beats selection-clear: the queue header explicitly + // promises "Esc cancel", so honoring it takes priority over the implicit + // selection-dismissal convention. Without an active edit, fall through. if (key.escape && cState.queueEditIdx !== null) { return cActions.clearIn() } + if (key.escape && terminal.hasSelection) { + return clearSelection() + } + if (key.upArrow && !cState.inputBuf.length) { const inputSel = getInputSelection() const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index 6e954c8345..f66b6fd314 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -24,8 +24,9 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages return ( - queued ({queued.length}) - {queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · ⌃X delete · esc cancel` : ''} + {`queued (${queued.length})${ + queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : '' + }`} {q.showLead && ( diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index c5e32cd2a2..b79d08061b 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -23,7 +23,7 @@ export const HOTKEYS: [string, string][] = [ [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], - ['Ctrl+X', 'delete the queued message you’re editing (esc cancels edit)'], + ['Ctrl+X', 'delete the queued message you’re editing (Esc cancels edit)'], [action + '+A/E', 'home / end of line'], [action + '+Z / ' + action + '+Y', 'undo / redo input edits'], [action + '+W', 'delete word'], diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts index 18e9a6c55d..0c79ab4eb4 100644 --- a/ui-tui/src/hooks/useQueue.ts +++ b/ui-tui/src/hooks/useQueue.ts @@ -1,6 +1,8 @@ import { useCallback, useRef, useState } from 'react' -export function removeAt(arr: T[], i: number): T[] { +// Mutates `arr` in place; returned reference is the same input array, kept +// so callers can chain. Use `Array.prototype.toSpliced` if you need a copy. +export function removeAtInPlace(arr: T[], i: number): T[] { if (i < 0 || i >= arr.length) { return arr } @@ -50,7 +52,7 @@ export function useQueue() { (i: number) => { const before = queueRef.current.length - removeAt(queueRef.current, i) + removeAtInPlace(queueRef.current, i) if (queueRef.current.length !== before) { syncQueue()