fix(tui): honor /queue list, clear, edit, rm, and move

The submit path queued every argument, so those verbs were sent to the
model. Route them like the classic CLI and write the change through the
pending-input journal.
This commit is contained in:
xxxigm authored and Austin Pickett committed 2026-11-05 12:34:55 -05:00
1 parent 2b11e33c76
commit bb8100aede
3 files changed
+353 -8

No files matched your search

+309
View File
@@ -0,0 +1,309 @@
import type { QueueItem } from '../hooks/useQueue.js'
import { t } from '../i18n/runtime.js'
import { savePendingInput } from '../lib/pendingInputs.js'
const LIST_VERBS = new Set(['list', 'ls', 'show'])
const REMOVE_VERBS = new Set(['rm', 'remove', 'delete', 'del', 'pop'])
const EDIT_VERBS = new Set(['edit', 'set'])
const USAGE_KEY = {
add: 'slashCmd.core.queue.usageAdd',
edit: 'slashCmd.core.queue.usageEdit',
move: 'slashCmd.core.queue.usageMove',
remove: 'slashCmd.core.queue.usageRemove'
} as const
export type QueuePlan =
| { kind: 'list' }
| { kind: 'clear' }
| { kind: 'remove'; index: number }
| { kind: 'edit'; index: number; text: string; display: string }
| { kind: 'move'; from: number; to: number }
| { kind: 'usage'; key: keyof typeof USAGE_KEY }
| { kind: 'enqueue'; text: string; display: string }
export interface QueueSurface {
items: QueueItem[]
refresh: () => void
removeAt: (index: number) => void
rows: string[]
}
const argumentOf = (command: string) => /^\/\S+\s+([\s\S]+)$/.exec(command)?.[1] ?? ''
const splitHead = (payload: string) => {
const head = payload.split(/\s+/, 1)[0] ?? ''
return { head, rest: payload.slice(head.length).trim(), verb: head.toLowerCase() }
}
// Same bound as the classic CLI: a non-index token is a prompt, not a verb argument.
const indexToken = (token: string): number | null => {
if (!/^\d+$/.test(token)) {
return null
}
const value = Number(token)
return Number.isSafeInteger(value) ? value : null
}
const enqueuePlan = (payload: string, displayPayload: string): QueuePlan => ({
display: displayPayload || payload,
kind: 'enqueue',
text: payload
})
const planEdit = (rest: string, payload: string, displayPayload: string): QueuePlan => {
if (!rest) {
return { key: 'edit', kind: 'usage' }
}
const token = rest.split(/\s+/, 1)[0] ?? ''
const index = indexToken(token)
if (index === null) {
return enqueuePlan(payload, displayPayload)
}
const text = rest.slice(token.length).trim()
if (!text) {
return { key: 'edit', kind: 'usage' }
}
// A collapsed paste keeps its label on the row and the expanded text as the prompt.
const display = displayPayload === payload ? text : displayPayload.replace(/^\S+\s+\S+\s*/, '') || text
return { display, index, kind: 'edit', text }
}
const planRemove = (rest: string, payload: string, displayPayload: string): QueuePlan => {
if (!rest) {
return { key: 'remove', kind: 'usage' }
}
const bits = rest.split(/\s+/)
const index = indexToken(bits[0] ?? '')
if (index === null) {
return enqueuePlan(payload, displayPayload)
}
return bits.length === 1 ? { index, kind: 'remove' } : { key: 'remove', kind: 'usage' }
}
const planMove = (rest: string, payload: string, displayPayload: string): QueuePlan => {
if (!rest) {
return { key: 'move', kind: 'usage' }
}
const bits = rest.split(/\s+/)
const from = indexToken(bits[0] ?? '')
if (from === null) {
return enqueuePlan(payload, displayPayload)
}
const to = bits.length === 2 ? indexToken(bits[1] ?? '') : null
return to === null ? { key: 'move', kind: 'usage' } : { from, kind: 'move', to }
}
/** Classic CLI `/queue` routing. Anything that is not a management verb stays a prompt. */
export function planQueueSlash(displayCommand: string, expandedCommand: string): QueuePlan {
const payload = argumentOf(expandedCommand).trim()
const displayPayload = argumentOf(displayCommand).trim()
if (!payload) {
return { kind: 'list' }
}
const { head, rest, verb } = splitHead(payload)
if (verb === 'add') {
const displayRest = displayPayload.slice(head.length).trim()
return rest ? { display: displayRest || rest, kind: 'enqueue', text: rest } : { key: 'add', kind: 'usage' }
}
if (LIST_VERBS.has(verb)) {
return rest ? enqueuePlan(payload, displayPayload) : { kind: 'list' }
}
if (verb === 'clear') {
return rest ? enqueuePlan(payload, displayPayload) : { kind: 'clear' }
}
if (EDIT_VERBS.has(verb)) {
return planEdit(rest, payload, displayPayload)
}
if (REMOVE_VERBS.has(verb)) {
return planRemove(rest, payload, displayPayload)
}
if (verb === 'move') {
return planMove(rest, payload, displayPayload)
}
return enqueuePlan(payload, displayPayload)
}
const clip = (text: string) => {
const flat = text.replace(/\n/g, ' ')
return `${flat.slice(0, 80)}${flat.length > 80 ? '…' : ''}`
}
const shownRows = (surface: QueueSurface) =>
surface.rows.length >= surface.items.length ? surface.rows : surface.items.map(item => item.display)
const listNotice = (rows: string[]) => {
if (!rows.length) {
return `${t('slashCmd.core.queue.empty')} ${t('slashCmd.core.queue.usage')}`
}
const lines = rows.map((row, index) => ` ${index + 1}. ${clip(row)}`)
return `${t('slashCmd.core.queue.pendingHeader', String(rows.length))}\n${lines.join('\n')}`
}
const outOfRange = (index: number, size: number) => index < 1 || index > size
const isServerRow = (index: number, surface: QueueSurface) =>
index >= surface.items.length && index < shownRows(surface).length
const clearQueue = (surface: QueueSurface) => {
const count = Math.max(shownRows(surface).length, surface.items.length)
for (let index = count - 1; index >= 0; index -= 1) {
surface.removeAt(index)
}
surface.refresh()
return t(count === 1 ? 'slashCmd.core.queue.clearedOne' : 'slashCmd.core.queue.clearedOther', String(count))
}
const removeItem = (index: number, surface: QueueSurface) => {
const rows = shownRows(surface)
if (outOfRange(index, rows.length)) {
return t('slashCmd.core.queue.notFound', String(index), String(rows.length))
}
const item = surface.items[index - 1]
if (item?.inFlight) {
return t('slashCmd.core.queue.alreadyStarted', String(index))
}
const preview = clip(item?.display ?? rows[index - 1] ?? '')
surface.removeAt(index - 1)
surface.refresh()
return t('slashCmd.core.queue.removed', String(index), preview)
}
const editItem = (plan: Extract<QueuePlan, { kind: 'edit' }>, surface: QueueSurface) => {
const size = shownRows(surface).length
const index = plan.index - 1
if (outOfRange(plan.index, size)) {
return t('slashCmd.core.queue.notFound', String(plan.index), String(size))
}
if (isServerRow(index, surface)) {
return t('slashCmd.core.queue.gatewayFifo')
}
const item = surface.items[index]
if (!item || item.inFlight) {
return t('slashCmd.core.queue.alreadyStarted', String(plan.index))
}
const previous = { display: item.display, text: item.text }
item.text = plan.text
item.display = plan.display
try {
savePendingInput(item)
} catch (error) {
item.text = previous.text
item.display = previous.display
return t('canonical.queue.notSavedDraftKept', (error as Error).message)
}
surface.refresh()
return t('slashCmd.core.queue.updated', String(plan.index), clip(plan.text))
}
const restamp = (items: QueueItem[]) => {
const base = Date.now()
items.forEach((item, index) => {
item.createdAt = base + index
savePendingInput(item)
})
}
const moveItem = (from: number, to: number, surface: QueueSurface) => {
const size = shownRows(surface).length
if (outOfRange(from, size) || outOfRange(to, size)) {
return t('slashCmd.core.queue.moveOutOfRange', String(size))
}
if (isServerRow(from - 1, surface) || isServerRow(to - 1, surface)) {
return t('slashCmd.core.queue.gatewayFifo')
}
const source = surface.items[from - 1]
const target = surface.items[to - 1]
if (!source || !target || source.inFlight || target.inFlight) {
return t('slashCmd.core.queue.alreadyStarted', String(from))
}
if (from !== to) {
const [moved] = surface.items.splice(from - 1, 1)
surface.items.splice(to - 1, 0, moved!)
restamp(surface.items)
}
surface.refresh()
return t('slashCmd.core.queue.moved', String(from), String(to))
}
/** Apply a management plan to the live composer queue. Enqueue stays with the submit path. */
export function applyQueuePlan(plan: Exclude<QueuePlan, { kind: 'enqueue' }>, surface: QueueSurface): string {
if (plan.kind === 'usage') {
return t(USAGE_KEY[plan.key])
}
if (plan.kind === 'list') {
return listNotice(shownRows(surface))
}
if (plan.kind === 'clear') {
return clearQueue(surface)
}
if (plan.kind === 'remove') {
return removeItem(plan.index, surface)
}
if (plan.kind === 'edit') {
return editItem(plan, surface)
}
return moveItem(plan.from, plan.to, surface)
}
+26 -7
View File
@@ -15,6 +15,7 @@ import type { Msg } from '../types.js'
import { reportSlashCommand } from './createSlashHandler.js'
import type { BusyInputMode, ComposerActions, ComposerRefs, ComposerState, ComposerToken, SlashHandler } from './interfaces.js'
import { stagePastes } from './pasteStore.js'
import { applyQueuePlan, planQueueSlash } from './queueSlash.js'
import { findSlashCommand } from './slash/registry.js'
import { submitPrompt } from './submissionCore.js'
import { captureDestination, isCurrentDestination, type SubmissionDestination } from './submissionDestination.js'
@@ -376,16 +377,33 @@ export function useSubmission(opts: UseSubmissionOptions) {
const parsed = parseSlashCommand(full)
const queued =
parsed.name === 'queue' || parsed.name === 'q' ? queueItemFromSlash(slash.display, slash.command) : undefined
if (queued) {
// Handled here, before the slash handler, so it is counted here.
if (parsed.name === 'queue' || parsed.name === 'q') {
// The catalog advertises list/edit/rm/move/clear. Enqueueing those words
// is what sent them to the model. Handle them before the slash handler.
reportSlashCommand(gw, parsed.name, getUiState().sid)
const plan = planQueueSlash(slash.display, slash.command)
const saved = journaled(() => composerActions.enqueue(queued.text, queued.display, destination, submission.attachments))
if (plan.kind === 'enqueue') {
const preview = plan.display.slice(0, 50)
if (saved) { sys(`queued: "${queued.display.slice(0, 50)}${queued.display.length > 50 ? '…' : ''}"`) }
const saved = journaled(() =>
composerActions.enqueue(plan.text, plan.display, destination, submission.attachments)
)
if (saved) {
sys(`queued: "${preview}${plan.display.length > 50 ? '…' : ''}"`)
}
} else {
composerActions.clearIn()
sys(
applyQueuePlan(plan, {
items: composerRefs.queueRef.current,
refresh: () => composerActions.setQueueEdit(null),
removeAt: index => composerActions.removeQueue(index),
rows: composerState.queuedDisplay
})
)
}
} else {
// Attachment commands capture the cleared composer's revision.
composerActions.clearIn()
@@ -489,6 +507,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
appendMessage,
composerActions,
composerRefs,
composerState.queuedDisplay,
gw,
handleBusyInput,
interpolate,
+18 -1
View File
@@ -136,7 +136,24 @@ export const slashCmdCoreEn = {
queue: {
countOne: (count: string) => `${count} queued message`,
countOther: (count: string) => `${count} queued messages`,
queued: (preview: string) => `queued: "${preview}"`
queued: (preview: string) => `queued: "${preview}"`,
empty: 'Queue is empty.',
usage:
'Usage: /queue <prompt> | /queue list | /queue edit N <prompt> | /queue rm N | /queue move FROM TO | /queue clear',
pendingHeader: (count: string) => `Queue (${count} pending):`,
clearedOne: (count: string) => `Cleared ${count} queued prompt.`,
clearedOther: (count: string) => `Cleared ${count} queued prompts.`,
removed: (index: string, preview: string) => `Removed queue item ${index}: ${preview}`,
notFound: (index: string, size: string) => `Queue item ${index} not found. Current size: ${size}`,
usageEdit: 'Usage: /queue edit <number> <new prompt>',
usageRemove: 'Usage: /queue rm <number>',
updated: (index: string, preview: string) => `Updated queue item ${index}: ${preview}`,
usageMove: 'Usage: /queue move <from> <to>',
moveOutOfRange: (size: string) => `Queue move out of range. Current size: ${size}`,
moved: (source: string, destination: string) => `Moved queue item ${source} to ${destination}.`,
usageAdd: 'Usage: /queue add <prompt>',
gatewayFifo: 'The gateway runs queued prompts in the order they were sent; edit or remove one instead.',
alreadyStarted: (index: string) => `Queue item ${index} already started; it runs as it was sent.`
},
steer: {
usage: 'usage: /steer <prompt>',