diff --git a/ui-tui/src/__tests__/useQueue.test.ts b/ui-tui/src/__tests__/useQueue.test.ts new file mode 100644 index 0000000000..ada53589da --- /dev/null +++ b/ui-tui/src/__tests__/useQueue.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { removeAtInPlace } from '../hooks/useQueue.js' + +describe('removeAtInPlace', () => { + it('removes the item at the given index in place', () => { + const arr = ['a', 'b', 'c'] + + removeAtInPlace(arr, 1) + expect(arr).toEqual(['a', 'c']) + }) + + it('is a no-op when the index is out of bounds', () => { + const arr = ['a', 'b'] + + removeAtInPlace(arr, -1) + removeAtInPlace(arr, 5) + expect(arr).toEqual(['a', 'b']) + }) + + it('returns the same reference (mutates in place)', () => { + const arr = ['x'] + const same = removeAtInPlace(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..84978b98c4 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -307,6 +307,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return scrollTranscript(key.pageUp ? -step : step) } + // 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() } @@ -357,6 +364,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..f66b6fd314 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -24,7 +24,9 @@ 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} · Ctrl+X delete · Esc cancel` : '' + }`} {q.showLead && ( 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 || diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 50c293acd8..b79d08061b 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..0c79ab4eb4 100644 --- a/ui-tui/src/hooks/useQueue.ts +++ b/ui-tui/src/hooks/useQueue.ts @@ -1,5 +1,17 @@ import { useCallback, useRef, useState } from 'react' +// 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 + } + + arr.splice(i, 1) + + return arr +} + export function useQueue() { const queueRef = useRef([]) const [queuedDisplay, setQueuedDisplay] = useState([]) @@ -36,6 +48,19 @@ export function useQueue() { [syncQueue] ) + const removeQ = useCallback( + (i: number) => { + const before = queueRef.current.length + + removeAtInPlace(queueRef.current, i) + + if (queueRef.current.length !== before) { + syncQueue() + } + }, + [syncQueue] + ) + return { dequeue, enqueue, @@ -43,6 +68,7 @@ export function useQueue() { queueEditRef, queueRef, queuedDisplay, + removeQ, replaceQ, setQueueEdit, syncQueue