Merge pull request #16707 from NousResearch/bb/tui-queue-delete

feat(tui): delete queued message while editing with ctrl-x / cancel with esc
This commit is contained in:
brooklyn!
2026-04-27 15:56:46 -05:00
committed by GitHub
8 changed files with 89 additions and 7 deletions

View File

@@ -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([])
})
})

View File

@@ -125,6 +125,7 @@ export interface ComposerActions {
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
openEditor: () => Promise<void>
pushHistory: (text: string) => void
removeQueue: (index: number) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
setHistoryIdx: StateSetter<null | number>

View File

@@ -110,8 +110,18 @@ export function useComposerState({
const isBlocked = useStore($isBlocked)
const { querier } = useStdin() as { querier: Parameters<typeof readOsc52Clipboard>[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,

View File

@@ -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({

View File

@@ -24,7 +24,9 @@ export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessages
return (
<Box flexDirection="column" marginTop={1}>
<Text color={t.color.dim} dimColor>
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
{`queued (${queued.length})${
queueEditIdx !== null ? ` · editing ${queueEditIdx + 1} · Ctrl+X delete · Esc cancel` : ''
}`}
</Text>
{q.showLead && (

View File

@@ -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 ||

View File

@@ -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 youre editing (Esc cancels edit)'],
[action + '+A/E', 'home / end of line'],
[action + '+Z / ' + action + '+Y', 'undo / redo input edits'],
[action + '+W', 'delete word'],

View File

@@ -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<T>(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<string[]>([])
const [queuedDisplay, setQueuedDisplay] = useState<string[]>([])
@@ -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