mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
3 Commits
v2
...
tui-prompt-queue
| Author | SHA1 | Date | |
|---|---|---|---|
| 98622d247a | |||
| 9d348a7f39 | |||
| d4686f247b |
@@ -157,6 +157,7 @@ export function Prompt(props: PromptProps) {
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const queueShortcut = Keymap.useShortcut("prompt.queue")
|
||||
const currentLocation = useLocation()
|
||||
const config = useConfig().data
|
||||
const dialog = useDialog()
|
||||
@@ -361,6 +362,20 @@ export function Prompt(props: PromptProps) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Queue prompt",
|
||||
name: "prompt.queue",
|
||||
category: "Prompt",
|
||||
palette: undefined,
|
||||
run: async (_input: string | undefined, event?: KeyEvent) => {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (!input.focused) return
|
||||
const handled = await submit("queue")
|
||||
if (!handled) return
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Remove editor context",
|
||||
name: "prompt.editor_context.clear",
|
||||
@@ -519,6 +534,11 @@ export function Prompt(props: PromptProps) {
|
||||
commands: promptCommands(),
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
bindings: ["prompt.queue"],
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
bindings: [
|
||||
"prompt.submit",
|
||||
@@ -904,7 +924,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
let submitting = false
|
||||
async function submit() {
|
||||
async function submit(delivery: "steer" | "queue" = "steer") {
|
||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
// input's native onSubmit racing another dispatch). Without this guard,
|
||||
// a second call slips past the empty-input check before the first call
|
||||
@@ -914,13 +934,13 @@ export function Prompt(props: PromptProps) {
|
||||
if (submitting) return false
|
||||
submitting = true
|
||||
try {
|
||||
return await submitInner()
|
||||
return await submitInner(delivery)
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitInner() {
|
||||
async function submitInner(delivery: "steer" | "queue") {
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
@@ -933,12 +953,20 @@ export function Prompt(props: PromptProps) {
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.text) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
if (delivery === "queue" && (store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")) {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
void exit()
|
||||
return true
|
||||
}
|
||||
const slash = argumentSlash(store.prompt.text, keymapCommands())
|
||||
if (slash) {
|
||||
if (delivery === "queue") {
|
||||
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
clearPrompt()
|
||||
await slash.command.run(slash.input)
|
||||
return true
|
||||
@@ -1036,6 +1064,7 @@ export function Prompt(props: PromptProps) {
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
delivery,
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
@@ -1046,6 +1075,10 @@ export function Prompt(props: PromptProps) {
|
||||
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
)
|
||||
) {
|
||||
if (delivery === "queue") {
|
||||
toast.show({ message: "Skills cannot be queued", variant: "warning" })
|
||||
return false
|
||||
}
|
||||
move.startSubmit()
|
||||
void client.api.session.skill({
|
||||
sessionID,
|
||||
@@ -1103,6 +1136,7 @@ export function Prompt(props: PromptProps) {
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
delivery,
|
||||
})
|
||||
.then(
|
||||
() => undefined,
|
||||
@@ -1575,6 +1609,13 @@ export function Prompt(props: PromptProps) {
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
<Show when={queueShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme.text.default} wrapMode="none" flexShrink={0}>
|
||||
{shortcut()} <span style={{ fg: theme.text.subdued }}>queue</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
|
||||
@@ -161,6 +161,7 @@ export const Definitions = {
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
prompt_queue: keybind("alt+return", "Queue prompt"),
|
||||
prompt_editor_context_clear: keybind("none", "Clear editor context"),
|
||||
prompt_skills: keybind("none", "Open skill selector"),
|
||||
prompt_stash: keybind("none", "Stash prompt"),
|
||||
@@ -170,7 +171,7 @@ export const Definitions = {
|
||||
input_clear: keybind("ctrl+c", "Clear input field"),
|
||||
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
||||
input_submit: keybind("return", "Submit input"),
|
||||
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
|
||||
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
|
||||
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
|
||||
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
|
||||
input_move_up: keybind("up", "Move cursor up in input"),
|
||||
@@ -359,6 +360,7 @@ export const CommandMap = {
|
||||
messages_redo: "session.redo",
|
||||
display_thinking: "session.toggle.thinking",
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_queue: "prompt.queue",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
prompt_skills: "prompt.skills",
|
||||
prompt_stash: "prompt.stash",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
displayCharAt,
|
||||
displaySlice,
|
||||
isExitCommand,
|
||||
isCompactCommand,
|
||||
mentionTriggerIndex,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
@@ -980,8 +981,18 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
enabled: input.prompt() && !visible(),
|
||||
commands: [
|
||||
{
|
||||
id: "prompt.queue",
|
||||
title: "Queue prompt",
|
||||
group: "Prompt",
|
||||
run() {
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft), "queue")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "prompt.editor",
|
||||
title: "Open editor",
|
||||
@@ -1116,7 +1127,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
}
|
||||
|
||||
const submitPrompt = (next: RunPrompt) => {
|
||||
const submitPrompt = (next: RunPrompt, delivery: "steer" | "queue" = "steer") => {
|
||||
if (!area || area.isDestroyed) {
|
||||
draft = promptCopy(next)
|
||||
}
|
||||
@@ -1136,6 +1147,13 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
||||
if (
|
||||
delivery === "queue" &&
|
||||
(next.mode === "shell" || command?.source === "skill" || isNewCommand(next.text) || isCompactCommand(next.text))
|
||||
) {
|
||||
input.onStatus("this prompt cannot be queued")
|
||||
return
|
||||
}
|
||||
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
||||
input.onExit()
|
||||
return
|
||||
@@ -1157,10 +1175,10 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const submit = command
|
||||
? { ...next, command }
|
||||
? { ...next, command, delivery }
|
||||
: parsed?.type === "command"
|
||||
? { ...next, command: parsed.command }
|
||||
: next
|
||||
? { ...next, command: parsed.command, delivery }
|
||||
: { ...next, delivery }
|
||||
const shellMode = next.mode === "shell"
|
||||
|
||||
resetDraft()
|
||||
|
||||
@@ -185,6 +185,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const command = () => shortcut("command.palette.show")
|
||||
const subagentShortcut = () => shortcut("session.child.first")
|
||||
const queuedShortcut = () => shortcut("session.queued_prompts")
|
||||
const queueShortcut = () => shortcut("prompt.queue")
|
||||
const backgroundShortcut = () => shortcut("session.background")
|
||||
const subagentInterruptShortcut = () => shortcut("subagent.interrupt")
|
||||
const interrupt = () => shortcut("session.interrupt")
|
||||
@@ -457,6 +458,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
if (activeTabs().length > 0 && subagentShortcut()) {
|
||||
items.push({ key: subagentShortcut(), label: "subagents" })
|
||||
}
|
||||
if (busy() && queueShortcut()) {
|
||||
items.push({ key: queueShortcut(), label: "queue" })
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ export type QueueInput = {
|
||||
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
|
||||
onNewSession?: () => void | Promise<void>
|
||||
onCompact?: () => void | Promise<void>
|
||||
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
|
||||
admit: (prompt: RunPrompt, delivery: "steer" | "queue", signal: AbortSignal) => Promise<void>
|
||||
settle: () => Promise<void>
|
||||
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
input.onSend?.(sent, "steer")
|
||||
input.onSend?.(sent, sent.delivery ?? "steer")
|
||||
|
||||
if (state.closed) {
|
||||
break
|
||||
@@ -276,10 +276,11 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
|
||||
const admission = state.admission
|
||||
admissionVersion += 1
|
||||
input.onSend?.(sent, "queue")
|
||||
const delivery = prompt.delivery ?? "queue"
|
||||
input.onSend?.(sent, delivery)
|
||||
admissions = admissions
|
||||
.then(() => admission)
|
||||
.then(() => input.admit(sent, admissionController.signal))
|
||||
.then(() => input.admit(sent, delivery, admissionController.signal))
|
||||
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -892,7 +892,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
trace: log,
|
||||
onSend: (prompt, delivery) => {
|
||||
state.shown = true
|
||||
state.history.push(prompt)
|
||||
state.history.push({ ...prompt, delivery: undefined })
|
||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
@@ -903,18 +903,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
})
|
||||
}
|
||||
},
|
||||
admit: async (prompt, signal) => {
|
||||
admit: async (prompt, delivery, signal) => {
|
||||
await state.switching?.catch(() => {})
|
||||
const next = await ensureStream()
|
||||
await next.handle.queuePromptTurn({
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
})
|
||||
await next.handle.admitPromptTurn(
|
||||
{
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
prompt,
|
||||
files: input.files,
|
||||
includeFiles: false,
|
||||
signal,
|
||||
},
|
||||
delivery,
|
||||
)
|
||||
},
|
||||
onAdmissionError: renderPromptError,
|
||||
onCompact: async () => {
|
||||
|
||||
@@ -71,7 +71,7 @@ export type SessionResizeReplayInput = {
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput, admitted?: () => void): Promise<void>
|
||||
queuePromptTurn(input: SessionTurnInput): Promise<void>
|
||||
admitPromptTurn(input: SessionTurnInput, delivery: "steer" | "queue"): Promise<void>
|
||||
waitForIdle(): Promise<void>
|
||||
interruptActiveTurn(): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
@@ -1643,14 +1643,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
return {
|
||||
async queuePromptTurn(next) {
|
||||
async admitPromptTurn(next, delivery) {
|
||||
if (next.prompt.mode === "shell" || next.prompt.command?.source === "skill")
|
||||
throw new Error("This prompt cannot be queued")
|
||||
if (!state.connected) throw new Error("Event stream is reconnecting")
|
||||
const client = sdk
|
||||
if (next.agent)
|
||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||
mergePending(await admitPrompt(next, client, "queue"))
|
||||
mergePending(await admitPrompt(next, client, delivery))
|
||||
settlementClient = client
|
||||
},
|
||||
async waitForIdle() {
|
||||
@@ -1688,7 +1688,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1700,7 +1700,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, "steer"), admitted)
|
||||
await runTurnWait(next, messageID, client, () => admitPrompt(next, client, next.prompt.delivery ?? "steer"), admitted)
|
||||
},
|
||||
async interruptActiveTurn() {
|
||||
// A running shell holds no drain, so session.interrupt cannot reach it;
|
||||
|
||||
@@ -75,6 +75,7 @@ export type RunPrompt = {
|
||||
messageID?: string
|
||||
text: string
|
||||
parts: RunPromptPart[]
|
||||
delivery?: "steer" | "queue"
|
||||
mode?: "shell"
|
||||
command?: {
|
||||
name: string
|
||||
|
||||
@@ -175,6 +175,11 @@ export function Session() {
|
||||
.flatMap((sessionID) => data.session.form.list(sessionID) ?? [])
|
||||
.concat(global)
|
||||
})
|
||||
const queuedPrompts = createMemo(() =>
|
||||
data.session.pending.list(route.sessionID).flatMap((item) =>
|
||||
item.type === "user" && item.delivery === "queue" ? [{ id: item.id, text: item.data.text }] : [],
|
||||
),
|
||||
)
|
||||
const [composer, setComposer] = createStore({
|
||||
open: false,
|
||||
tab: undefined as string | undefined,
|
||||
@@ -1006,6 +1011,9 @@ export function Session() {
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
@@ -1849,9 +1857,10 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const mode = themes.mode
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const color = createMemo(() => local.agent.color(data.session.get(ctx.sessionID)?.agent ?? "build"))
|
||||
const queued = createMemo(
|
||||
() => data.session.status(ctx.sessionID) === "running" && data.session.input.has(ctx.sessionID, props.message.id),
|
||||
)
|
||||
const delivery = createMemo(() => {
|
||||
const pending = data.session.pending.list(ctx.sessionID).find((item) => item.id === props.message.id)
|
||||
return pending?.type === "user" ? pending.delivery : undefined
|
||||
})
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const promptRef = usePromptRef()
|
||||
@@ -1860,7 +1869,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
<Show when={props.message.text.trim() || files().length}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={queued() ? theme.border.default : color()}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box
|
||||
@@ -1887,6 +1896,9 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
<Show when={delivery()}>
|
||||
{(value) => <text fg={theme.text.subdued}>{value() === "queue" ? "queued" : "steering"}</text>}
|
||||
</Show>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
@@ -1919,6 +1931,38 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
)
|
||||
}
|
||||
|
||||
function QueuedPromptDock(props: { prompts: { id: string; text: string }[] }) {
|
||||
const theme = useTheme("elevated")
|
||||
const shortcut = Keymap.useShortcut("command.palette.show")
|
||||
const next = createMemo(() => props.prompts[0]?.text)
|
||||
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={theme.border.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme.background.default}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
gap={2}
|
||||
>
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<span style={{ fg: theme.text.default }}>{props.prompts.length} queued</span>
|
||||
<Show when={next()}>{(text) => <> · Next · {text()}</>}</Show>
|
||||
</text>
|
||||
<Show when={shortcut()}>
|
||||
{(key) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" flexShrink={0}>
|
||||
<span style={{ fg: theme.text.default }}>{key()}</span> view all
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const inputs = new Set(data.session.input.list(sessionID()))
|
||||
const pending = data.session.pending.list(sessionID())
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(
|
||||
boundary ? messages.filter((message) => message.id < boundary) : messages,
|
||||
@@ -53,12 +54,17 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
turnTokens(),
|
||||
)
|
||||
partitionPending(rows, pendingPermissions())
|
||||
removeQueuedPrompts(
|
||||
rows,
|
||||
pending
|
||||
.filter((item) => item.type === "user" && item.delivery === "queue")
|
||||
.map((item) => item.id),
|
||||
)
|
||||
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
|
||||
rows.splice(
|
||||
position === -1 ? rows.length : position,
|
||||
0,
|
||||
...data.session.pending
|
||||
.list(sessionID())
|
||||
...pending
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
|
||||
)
|
||||
@@ -112,10 +118,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
data.session.pending
|
||||
.list(sessionID())
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item) => item.id),
|
||||
data.session.pending.list(sessionID()).map((item) => `${item.id}:${"delivery" in item ? item.delivery : item.type}`),
|
||||
() => setRows(reconcile(reduce())),
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -196,7 +199,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
|
||||
const queuedStart = (rows: SessionRow[]) => {
|
||||
const index = rows.findIndex(
|
||||
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
|
||||
(row) =>
|
||||
row.type === "compaction-queued" ||
|
||||
(row.type === "message" && isPending(row.messageID)),
|
||||
)
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
@@ -281,6 +286,12 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
return rows
|
||||
}
|
||||
|
||||
export function removeQueuedPrompts(rows: SessionRow[], messageIDs: string[]) {
|
||||
const queued = new Set(messageIDs)
|
||||
const visible = rows.filter((row) => row.type !== "message" || !queued.has(row.messageID))
|
||||
rows.splice(0, rows.length, ...visible)
|
||||
}
|
||||
|
||||
export function reduceSessionRows(
|
||||
messages: SessionMessageInfo[],
|
||||
inputs = new Set<string>(),
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
messageBoundaryIDs,
|
||||
removeQueuedPrompts,
|
||||
reduceSessionRows,
|
||||
} from "../../../src/routes/session/rows"
|
||||
import type { SessionRow } from "../../../src/routes/session/rows"
|
||||
|
||||
test("removes queued prompts from transcript rows", () => {
|
||||
const rows: SessionRow[] = [
|
||||
{ type: "message" as const, messageID: "active" },
|
||||
{ type: "message" as const, messageID: "queue-1" },
|
||||
{ type: "message" as const, messageID: "steer" },
|
||||
{ type: "message" as const, messageID: "queue-2" },
|
||||
{ type: "message" as const, messageID: "queue-3" },
|
||||
{ type: "message" as const, messageID: "queue-4" },
|
||||
]
|
||||
|
||||
removeQueuedPrompts(rows, ["queue-1", "queue-2", "queue-3", "queue-4"])
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ type: "message", messageID: "active" },
|
||||
{ type: "message", messageID: "steer" },
|
||||
])
|
||||
})
|
||||
|
||||
test("filters OpenAI cache quantization from cache reuse drops", () => {
|
||||
const openai = { id: "gpt", providerID: "openai" }
|
||||
|
||||
@@ -56,9 +56,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
commits,
|
||||
calls,
|
||||
promptReady,
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
submit(text: string, mode?: RunPrompt["mode"], delivery?: RunPrompt["delivery"]) {
|
||||
if (prompts.size === 0) return false
|
||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||
const prompt: RunPrompt = { text, parts: [], ...(mode ? { mode } : {}), ...(delivery ? { delivery } : {}) }
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -1068,11 +1068,11 @@ test("direct footer submits slash autocomplete selections without dispatching sh
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" } },
|
||||
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" } },
|
||||
{ text: "/new ", parts: [] },
|
||||
{ text: "/new ", parts: [] },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
|
||||
{ text: "/review ", parts: [], command: { name: "review", arguments: "" }, delivery: "steer" },
|
||||
{ text: "/review branch", parts: [], command: { name: "review", arguments: "branch" }, delivery: "steer" },
|
||||
{ text: "/new ", parts: [], delivery: "steer" },
|
||||
{ text: "/new ", parts: [], delivery: "steer" },
|
||||
])
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("/settings ")
|
||||
} finally {
|
||||
@@ -1100,7 +1100,9 @@ test("direct footer slash autocomplete keeps a real skills command", async () =>
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" } }])
|
||||
expect(submits).toEqual([
|
||||
{ text: "/skills ", parts: [], command: { name: "skills", arguments: "" }, delivery: "steer" },
|
||||
])
|
||||
expect(app.captureCharFrame()).not.toContain("Apply formatter fixes")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
@@ -1158,7 +1160,12 @@ test("direct footer tags skill slash submissions with their catalog source", asy
|
||||
await app.renderOnce()
|
||||
|
||||
expect(submits).toEqual([
|
||||
{ text: "/formatter src", parts: [], command: { name: "formatter", arguments: "src", source: "skill" } },
|
||||
{
|
||||
text: "/formatter src",
|
||||
parts: [],
|
||||
command: { name: "formatter", arguments: "src", source: "skill" },
|
||||
delivery: "steer",
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
app.cleanup()
|
||||
|
||||
@@ -82,7 +82,8 @@ describe("run runtime boot", () => {
|
||||
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
|
||||
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
|
||||
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
||||
})
|
||||
|
||||
test("preserves disabled leader from resolved tui config", async () => {
|
||||
|
||||
@@ -265,6 +265,33 @@ describe("run runtime queue", () => {
|
||||
await task
|
||||
})
|
||||
|
||||
test("preserves explicit steer and queue delivery for in-flight prompts", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const admitted: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (_input, _signal, onAdmitted) => {
|
||||
onAdmitted()
|
||||
await gate.promise
|
||||
},
|
||||
admit: async (input, delivery) => {
|
||||
admitted.push(`${input.text}:${delivery}`)
|
||||
},
|
||||
settle: async () => ui.api.close(),
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two", undefined, "steer")
|
||||
ui.submit("three", undefined, "queue")
|
||||
while (admitted.length < 2) await Bun.sleep(0)
|
||||
expect(admitted).toEqual(["two:steer", "three:queue"])
|
||||
|
||||
gate.resolve()
|
||||
await task
|
||||
})
|
||||
|
||||
test("continues durable admission after one fails", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const admitted: string[] = []
|
||||
@@ -308,7 +335,7 @@ describe("run runtime queue", () => {
|
||||
admitted()
|
||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
||||
},
|
||||
admit: async (_prompt, signal) => {
|
||||
admit: async (_prompt, _delivery, signal) => {
|
||||
admissionStarted.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("run interactive runtime", () => {
|
||||
turnStarted.resolve()
|
||||
api.close()
|
||||
},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
@@ -209,7 +209,7 @@ describe("run interactive runtime", () => {
|
||||
streamStarted.resolve()
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
@@ -556,7 +556,7 @@ describe("run interactive runtime", () => {
|
||||
setTimeout(() => input.footer.close(), 0)
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
admitPromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
|
||||
@@ -701,14 +701,14 @@ describe("V2 mini transport", () => {
|
||||
const prompt = spyOn(client.session, "prompt").mockImplementation(
|
||||
(request) => ok(promptAdmission(request)) as never,
|
||||
)
|
||||
await transport.queuePromptTurn({
|
||||
await transport.admitPromptTurn({
|
||||
agent: "review",
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_next", text: "another", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
}, "queue")
|
||||
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "review" }, expect.anything())
|
||||
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
|
||||
events.push({
|
||||
@@ -813,14 +813,14 @@ describe("V2 mini transport", () => {
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||
})
|
||||
await transport.queuePromptTurn({
|
||||
await transport.admitPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
}, "queue")
|
||||
events.push({
|
||||
id: "evt_queued_promoted",
|
||||
created: 3,
|
||||
|
||||
Reference in New Issue
Block a user