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