Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9986,6 +9986,7 @@ def process_command(self, command: str) -> bool:
# 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 <prompt>")
else:
Expand Down
22 changes: 22 additions & 0 deletions tests/cli/test_cli_queue_paste.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions ui-tui/src/__tests__/queueSubmission.test.ts
Original file line number Diff line number Diff line change
@@ -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}`
)
})
})
30 changes: 29 additions & 1 deletion ui-tui/src/__tests__/useQueue.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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'))
})
})
13 changes: 7 additions & 6 deletions ui-tui/src/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<ComposerPasteResult | null>
openEditor: () => Promise<void>
prependQueue: (item: QueueItem) => void
pushHistory: (text: string) => void
removeQueue: (index: number) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
setComposerTokens: StateSetter<ComposerToken[]>
setHistoryIdx: StateSetter<null | number>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
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
}
Expand All @@ -390,7 +391,7 @@ export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<null | number>
queueRef: MutableRefObject<string[]>
queueRef: MutableRefObject<QueueItem[]>
submitRef: MutableRefObject<(value: string) => void>
tokensRef: MutableRefObject<ComposerToken[]>
}
Expand Down Expand Up @@ -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<void>
queueRef: MutableRefObject<string[]>
queueRef: MutableRefObject<QueueItem[]>
selection: SelectionApi
setInput: StateSetter<string>
}
Expand Down
12 changes: 6 additions & 6 deletions ui-tui/src/app/useComposerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}),
[
Expand All @@ -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
]
)
Expand Down
2 changes: 1 addition & 1 deletion ui-tui/src/app/useInputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
67 changes: 45 additions & 22 deletions ui-tui/src/app/useSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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<SessionSteerResponse>('session.steer', { session_id: live.sid, text: full })
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: item.text })
.then(raw => {
const r = asRpcResult<SessionSteerResponse>(raw)

Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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)) {
Expand All @@ -292,7 +316,8 @@ export function useSubmission(opts: UseSubmissionOptions) {
send,
sendQueued,
shellExec,
slashRef
slashRef,
sys
]
)

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading