diff --git a/cli.py b/cli.py index 8cf25b13f56..5b4bef350be 100644 --- a/cli.py +++ b/cli.py @@ -9986,6 +9986,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Extract prompt after "/queue " or "/q " parts = cmd_original.split(None, 1) payload = parts[1].strip() if len(parts) > 1 else "" + payload = self._expand_paste_references(payload) if not payload: _cprint(" Usage: /queue ") else: diff --git a/tests/cli/test_cli_queue_paste.py b/tests/cli/test_cli_queue_paste.py new file mode 100644 index 00000000000..3e32d6c2d48 --- /dev/null +++ b/tests/cli/test_cli_queue_paste.py @@ -0,0 +1,22 @@ +"""Regression tests for collapsed paste references passed to /queue.""" + +from queue import Queue +from unittest.mock import patch + +from cli import HermesCLI + + +def test_queue_expands_collapsed_paste_reference(tmp_path): + pasted = "first\nmiddle\nlast" + paste_file = tmp_path / "paste.txt" + paste_file.write_text(pasted, encoding="utf-8") + placeholder = f"[Pasted text #1: 3 lines → {paste_file}]" + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj._agent_running = False + cli_obj._pending_input = Queue() + cli_obj._pending_resume_sessions = None + + with patch("cli._cprint"): + assert cli_obj.process_command(f"/queue {placeholder}") is True + + assert cli_obj._pending_input.get_nowait() == pasted diff --git a/ui-tui/src/__tests__/queueSubmission.test.ts b/ui-tui/src/__tests__/queueSubmission.test.ts new file mode 100644 index 00000000000..26193d8d3d7 --- /dev/null +++ b/ui-tui/src/__tests__/queueSubmission.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import type { ComposerToken } from '../app/interfaces.js' +import { expandPasteTokens, queueItemFromSlash } from '../app/useSubmission.js' +import { imageToken } from '../domain/attachments.js' + +describe('/queue collapsed paste submission', () => { + it('keeps the collapsed argument for display and the full multiline payload for execution', () => { + const display = '[[ first.. [3 lines] .. last ]]' + + expect(queueItemFromSlash(`/queue ${display}`, '/queue first\nmiddle\nlast')).toEqual({ + display, + text: 'first\nmiddle\nlast' + }) + }) + + it('supports the /q alias and rejects an empty queue command', () => { + expect(queueItemFromSlash('/q [[ payload ]]', '/q complete payload')).toEqual({ + display: '[[ payload ]]', + text: 'complete payload' + }) + expect(queueItemFromSlash('/queue', '/queue')).toBeUndefined() + }) + + it('expands paste tokens without consuming image tokens', () => { + const paste: ComposerToken = { kind: 'paste', label: '[[ paste [2 lines] ]]', text: 'one\ntwo' } + const image: ComposerToken = { kind: 'image', index: 1, label: imageToken(1), path: '/tmp/image.png' } + + expect(expandPasteTokens([paste, image])(`${paste.label} and ${image.label}`)).toBe( + `one\ntwo and ${image.label}` + ) + }) +}) diff --git a/ui-tui/src/__tests__/useQueue.test.ts b/ui-tui/src/__tests__/useQueue.test.ts index ada53589daa..edb311b1d90 100644 --- a/ui-tui/src/__tests__/useQueue.test.ts +++ b/ui-tui/src/__tests__/useQueue.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { removeAtInPlace } from '../hooks/useQueue.js' +import { prependQueueItem, queueItem, removeAtInPlace, takeQueueItem } from '../hooks/useQueue.js' describe('removeAtInPlace', () => { it('removes the item at the given index in place', () => { @@ -26,3 +26,31 @@ describe('removeAtInPlace', () => { expect(arr).toEqual([]) }) }) + +describe('queue items', () => { + it('keeps execution text and collapsed display together through edit and requeue', () => { + const display = '[[ first.. [3 lines] .. last ]]' + const text = 'first\nmiddle\nlast' + const queue = [queueItem(text, display), queueItem('next')] + + const edited = takeQueueItem(queue, 0, `before ${display} after`) + + expect(edited).toEqual({ + display: `before ${display} after`, + text: `before ${text} after` + }) + expect(queue).toEqual([queueItem('next')]) + + prependQueueItem(queue, edited!) + expect(queue[0]).toEqual({ + display: `before ${display} after`, + text: `before ${text} after` + }) + }) + + it('treats a rewritten collapsed label as literal edited text', () => { + const queue = [queueItem('full payload', '[[ collapsed ]]')] + + expect(takeQueueItem(queue, 0, 'replacement')).toEqual(queueItem('replacement')) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 3802769e933..c448df0259b 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -12,6 +12,7 @@ import type { SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../gatewayTypes.js' +import type { QueueItem } from '../hooks/useQueue.js' import type { ParsedVoiceRecordKey } from '../lib/platform.js' import type { RpcResult } from '../lib/rpc.js' import type { ActiveWidget } from '../sdk/types.js' @@ -369,19 +370,19 @@ export interface ComposerActions { attachImagePath: (path: string) => void clearIn: () => void dequeue: () => string | undefined - enqueue: (text: string) => void + enqueue: (text: string, display?: string) => void handleTextPaste: (event: PasteEvent) => MaybePromise openEditor: () => Promise + prependQueue: (item: QueueItem) => void pushHistory: (text: string) => void removeQueue: (index: number) => void - replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter setComposerTokens: StateSetter setHistoryIdx: StateSetter setInput: StateSetter setInputBuf: StateSetter setQueueEdit: (index: null | number) => void - syncQueue: () => void + takeQueue: (index: number, editedDisplay?: string) => QueueItem | undefined /** Reconcile attached payloads against tokens still present in the text. */ syncTokens: (value: string) => void } @@ -390,7 +391,7 @@ export interface ComposerRefs { historyDraftRef: MutableRefObject historyRef: MutableRefObject queueEditRef: MutableRefObject - queueRef: MutableRefObject + queueRef: MutableRefObject submitRef: MutableRefObject<(value: string) => void> tokensRef: MutableRefObject } @@ -502,10 +503,10 @@ export interface SlashHandlerContext { composer: { attachClipboardImage: () => void attachImagePath: (path: string) => void - enqueue: (text: string) => void + enqueue: (text: string, display?: string) => void hasSelection: boolean openEditor: () => Promise - queueRef: MutableRefObject + queueRef: MutableRefObject selection: SelectionApi setInput: StateSetter } diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 188e3c57270..9ec084463e0 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -135,10 +135,10 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions queueEditIdx, enqueue, dequeue, + prependQ, removeQ, - replaceQ, setQueueEdit, - syncQueue + takeQ } = useQueue() const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() @@ -435,16 +435,16 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions enqueue, handleTextPaste, openEditor, + prependQueue: prependQ, pushHistory, removeQueue: removeQ, - replaceQueue: replaceQ, setCompIdx, setComposerTokens, setHistoryIdx, setInput, setInputBuf, setQueueEdit, - syncQueue, + takeQueue: takeQ, syncTokens }), [ @@ -455,15 +455,15 @@ export function useComposerState({ gw, submitRef, sys }: UseComposerStateOptions enqueue, handleTextPaste, openEditor, + prependQ, pushHistory, removeQ, - replaceQ, setCompIdx, setComposerTokens, setHistoryIdx, setInput, setQueueEdit, - syncQueue, + takeQ, syncTokens ] ) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index ef503a9e67e..97cb704b0b2 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -239,7 +239,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { cActions.setQueueEdit(index) cActions.setHistoryIdx(null) - cActions.setInput(cRefs.queueRef.current[index] ?? '') + cActions.setInput(cRefs.queueRef.current[index]?.display ?? '') return true } diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 28ffaff9ca3..3862a8099b4 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -2,14 +2,15 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' import { TYPING_IDLE_MS } from '../config/timing.js' import { expandTokens } from '../domain/attachments.js' -import { completionToApplyOnSubmit, looksLikeSlashCommand } from '../domain/slash.js' +import { completionToApplyOnSubmit, looksLikeSlashCommand, parseSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { SessionSteerResponse, ShellExecResponse } from '../gatewayTypes.js' +import { queueItem, type QueueItem } from '../hooks/useQueue.js' import { asRpcResult } from '../lib/rpc.js' import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' import type { Msg } from '../types.js' -import type { ComposerActions, ComposerRefs, ComposerState } from './interfaces.js' +import type { ComposerActions, ComposerRefs, ComposerState, ComposerToken } from './interfaces.js' import { submitPrompt } from './submissionCore.js' import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' @@ -19,6 +20,21 @@ const DOUBLE_ENTER_MS = 450 const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text) +export const expandPasteTokens = (tokens: ComposerToken[]) => + expandTokens(tokens.filter(token => token.kind === 'paste')) + +const slashArgument = (command: string) => /^\/\S+\s+([\s\S]+)$/.exec(command)?.[1] ?? '' + +export const queueItemFromSlash = (displayCommand: string, expandedCommand: string): QueueItem | undefined => { + const display = slashArgument(displayCommand) + + if (!display.trim()) { + return undefined + } + + return queueItem(slashArgument(expandedCommand), display) +} + export function useSubmission(opts: UseSubmissionOptions) { const { appendMessage, composerActions, composerRefs, composerState, gw, setLastUserMsg, slashRef, submitRef, sys } = opts @@ -157,16 +173,15 @@ export function useSubmission(opts: UseSubmissionOptions) { // `opts.fallbackToFront` re-inserts at the queue head (queue-edit picks keep // their position); the mainline submit path appends. const handleBusyInput = useCallback( - (full: string, opts: { fallbackToFront?: boolean } = {}) => { + (item: QueueItem, opts: { fallbackToFront?: boolean } = {}) => { const live = getUiState() const mode = live.busyInputMode const enqueueText = () => { if (opts.fallbackToFront) { - composerRefs.queueRef.current.unshift(full) - composerActions.syncQueue() + composerActions.prependQueue(item) } else { - composerActions.enqueue(full) + composerActions.enqueue(item.text, item.display) } } @@ -176,11 +191,11 @@ export function useSubmission(opts: UseSubmissionOptions) { } if (mode === 'queue') { - return composerActions.enqueue(full) + return enqueueText() } if (mode === 'steer' && live.sid) { - gw.request('session.steer', { session_id: live.sid, text: full }) + gw.request('session.steer', { session_id: live.sid, text: item.text }) .then(raw => { const r = asRpcResult(raw) @@ -197,9 +212,9 @@ export function useSubmission(opts: UseSubmissionOptions) { // the agent is in model generation, tool execution, or an older runtime. // Reuse the normal submit pipeline so the correction gets its user bubble // and file-drop interpolation exactly once. - send(full) + send(item.text) }, - [composerActions, composerRefs, gw, send, sys] + [composerActions, gw, send, sys] ) const dispatchSubmission = useCallback( @@ -214,11 +229,24 @@ export function useSubmission(opts: UseSubmissionOptions) { // Idempotent on token-free text, so re-submitting a recalled entry is // stable. const toHistory = expandTokens(composerRefs.tokensRef.current)(full) + const queuePayload = expandPasteTokens(composerRefs.tokensRef.current)(full) if (looksLikeSlashCommand(full)) { appendMessage({ kind: 'slash', role: 'system', text: full }) composerActions.pushHistory(toHistory) - slashRef.current(full) + + const parsed = parseSlashCommand(full) + + const queued = + parsed.name === 'queue' || parsed.name === 'q' ? queueItemFromSlash(full, queuePayload) : undefined + + if (queued) { + composerActions.enqueue(queued.text, queued.display) + sys(`queued: "${queued.display.slice(0, 50)}${queued.display.length > 50 ? '…' : ''}"`) + } else { + slashRef.current(full) + } + composerActions.clearIn() return @@ -244,9 +272,7 @@ export function useSubmission(opts: UseSubmissionOptions) { composerActions.clearIn() if (editIdx !== null) { - composerActions.replaceQueue(editIdx, full) - const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] - composerActions.syncQueue() + const picked = composerActions.takeQueue(editIdx, full) composerActions.setQueueEdit(null) if (!picked || !live.sid) { @@ -258,21 +284,19 @@ export function useSubmission(opts: UseSubmissionOptions) { // silently going back to the queue. handleBusyInput resolves // mode-specific behavior (interrupt-and-send, steer, or queue). if (getUiState().busyInputMode === 'queue') { - composerRefs.queueRef.current.unshift(picked) - - return composerActions.syncQueue() + return composerActions.prependQueue(picked) } return handleBusyInput(picked, { fallbackToFront: true }) } - return sendQueued(picked) + return sendQueued(picked.text) } composerActions.pushHistory(toHistory) if (getUiState().busy) { - return handleBusyInput(full) + return handleBusyInput(queueItem(full)) } if (hasInterpolation(full)) { @@ -292,7 +316,8 @@ export function useSubmission(opts: UseSubmissionOptions) { send, sendQueued, shellExec, - slashRef + slashRef, + sys ] ) @@ -324,8 +349,6 @@ export function useSubmission(opts: UseSubmissionOptions) { if (doubleTap && live.sid && composerRefs.queueRef.current.length) { const next = composerActions.dequeue() - composerActions.syncQueue() - if (next) { composerActions.setQueueEdit(null) dispatchSubmission(next) diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts index 0c79ab4eb4b..6265549e9be 100644 --- a/ui-tui/src/hooks/useQueue.ts +++ b/ui-tui/src/hooks/useQueue.ts @@ -1,5 +1,33 @@ import { useCallback, useRef, useState } from 'react' +export interface QueueItem { + display: string + text: string +} + +export const queueItem = (text: string, display = text): QueueItem => ({ display, text }) + +export function prependQueueItem(queue: QueueItem[], item: QueueItem): void { + queue.unshift(item) +} + +export function takeQueueItem(queue: QueueItem[], index: number, editedDisplay?: string): QueueItem | undefined { + if (index < 0 || index >= queue.length) { + return undefined + } + + const [item] = queue.splice(index, 1) + + if (!item || editedDisplay === undefined) { + return item + } + + return { + display: editedDisplay, + text: editedDisplay.includes(item.display) ? editedDisplay.replace(item.display, item.text) : editedDisplay + } +} + // 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[] { @@ -13,12 +41,12 @@ export function removeAtInPlace(arr: T[], i: number): T[] { } export function useQueue() { - const queueRef = useRef([]) + const queueRef = useRef([]) const [queuedDisplay, setQueuedDisplay] = useState([]) const queueEditRef = useRef(null) const [queueEditIdx, setQueueEditIdx] = useState(null) - const syncQueue = useCallback(() => setQueuedDisplay([...queueRef.current]), []) + const syncQueue = useCallback(() => setQueuedDisplay(queueRef.current.map(item => item.display)), []) const setQueueEdit = useCallback((idx: number | null) => { queueEditRef.current = idx @@ -26,51 +54,58 @@ export function useQueue() { }, []) const enqueue = useCallback( - (text: string) => { - queueRef.current.push(text) + (text: string, display = text) => { + queueRef.current.push(queueItem(text, display)) + syncQueue() + }, + [syncQueue] + ) + + const prependQ = useCallback( + (item: QueueItem) => { + prependQueueItem(queueRef.current, item) syncQueue() }, [syncQueue] ) const dequeue = useCallback(() => { - const head = queueRef.current.shift() + const head = queueRef.current.shift()?.text syncQueue() return head }, [syncQueue]) - const replaceQ = useCallback( - (i: number, text: string) => { - queueRef.current[i] = text - syncQueue() + const takeQ = useCallback( + (i: number, editedDisplay?: string) => { + const item = takeQueueItem(queueRef.current, i, editedDisplay) + + if (item) { + syncQueue() + } + + return item }, [syncQueue] ) const removeQ = useCallback( (i: number) => { - const before = queueRef.current.length - - removeAtInPlace(queueRef.current, i) - - if (queueRef.current.length !== before) { - syncQueue() - } + takeQ(i) }, - [syncQueue] + [takeQ] ) return { dequeue, enqueue, + prependQ, queueEditIdx, queueEditRef, queueRef, queuedDisplay, removeQ, - replaceQ, setQueueEdit, - syncQueue + takeQ } }