diff --git a/README.md b/README.md index a6374e3..bab0597 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ do this next /queue front /queue front /review /review /queue front +/queue /compact +/queue front /compact + /queue !ls /queue front !pwd @@ -57,6 +60,8 @@ do this next /queue front | `/review /queue` | Queue a slash command using trailing syntax. | | `/queue front /review` | Queue a slash command before existing queued entries. | | `/review /queue front` | Queue a slash command before existing queued entries using trailing syntax. | +| `/queue /compact` | Queue OpenCode's built-in TUI `/compact` command. | +| `/queue front /compact` | Queue OpenCode's built-in TUI `/compact` command before existing queued entries. | | `/queue !ls` | Queue an OpenCode shell block. | | `/queue front !ls` | Queue an OpenCode shell block before existing queued entries. | | `/queue` | Show the current queue. | @@ -87,6 +92,7 @@ When the session is idle: - `message /queue` sends `message` immediately. - `/queue /review` runs `/review` immediately. - `/review /queue` runs `/review` immediately. +- `/queue /compact` runs OpenCode's built-in TUI `/compact` command immediately. - `/queue !ls` runs `ls` immediately as an OpenCode shell block. - `/queue` and `/queue list` show the current queue. - `/queue stop` pauses automatic replay, and `/queue start` resumes it. diff --git a/index.ts b/index.ts index 65456e3..5deedb0 100644 --- a/index.ts +++ b/index.ts @@ -6,6 +6,7 @@ const SUFFIX = /^([\s\S]*?)\s+\/queue(?:\s+(front))?\s*$/ const CMD = /^\/(\S+)(?:\s+([\s\S]*))?$/ const ITEM_NUMBER = /^[1-9]\d*$/ const HANDLED = "__QUEUE_HANDLED__" +const TUI_COMPACT = "session_compact" type InputPart = TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput type Model = { providerID: string; modelID: string } @@ -20,11 +21,13 @@ type QueueInput = { body: string; front: boolean } type Item = | { kind: "prompt"; info: Info; label: string; body: string; parts: InputPart[] } | { kind: "command"; info: Info; source: string; cmd: string; args: string; files: FilePartInput[] } + | { kind: "compact"; info: Info; source: string } | { kind: "shell"; info: Info; source: string; shell: string } type EntryOp = | { kind: "prompt"; label: string; body: string } | { kind: "command"; source: string; cmd: string; args: string } + | { kind: "compact"; source: string } | { kind: "shell"; source: string; shell: string } type Activity = { kind: "idle" } | { kind: "busy" } | { kind: "sending"; idle: boolean } @@ -85,7 +88,16 @@ const parse = (input: QueueInput, files = 0): Op => { } const match = text.match(CMD) - if (match) return { kind: "command", source: text, cmd: match[1], args: match[2] ?? "", front } + if (match) { + const cmd = match[1] + const args = match[2] ?? "" + if (cmd === "compact") { + if (args.trim()) return { kind: "invalid", message: "Queue compact does not accept arguments" } + if (files) return { kind: "invalid", message: "Queue compact does not support attachments" } + return { kind: "compact", source: text, front } + } + return { kind: "command", source: text, cmd, args, front } + } return { kind: "prompt", label: brief(input.body, files), body: input.body, front } } @@ -114,6 +126,9 @@ const control = (op: Op): op is ControlOp => { return false } } +const shouldQueue = (state?: State) => Boolean(state && (state.activity.kind !== "idle" || state.stopped)) +const shouldDeclinePlan = (state?: State) => Boolean(state && (state.activity.kind === "sending" || (!state.stopped && state.items.length))) +const itemText = (item: Item) => (item.kind === "prompt" ? item.body.trim() || item.label : item.source) const plan = (event: unknown): event is Ask => { if (typeof event !== "object" || !event || !("type" in event) || event.type !== "question.asked") return false const question = (event as Ask).properties?.questions?.[0] @@ -204,30 +219,42 @@ export const QueuePlugin: Plugin = async ({ client }) => { const opts = (info: Info) => ({ agent: info.agent, model: info.model, variant: info.variant, controls: info.controls, fast: info.fast }) - const prompt = (sid: string, info: Info, parts: InputPart[], noReply?: boolean) => client.session.prompt({ path: { id: sid }, body: { ...opts(info), noReply, parts } as any }) const shell = (sid: string, command: string, info: Run) => client.session.shell({ path: { id: sid }, body: { agent: info.agent, model: info.model, command } }) + // TUI command events target the focused session; queued replay must target the original session. + const compact = (sid: string, info: Info) => + client.session.summarize({ + path: { id: sid }, + body: { providerID: info.model.providerID, modelID: info.model.modelID }, + throwOnError: true, + }) const replay = async (sid: string, item: Item) => { - if (item.kind === "shell") return shell(sid, item.shell, item.info) - - if (item.kind === "command") { - await client.session.command({ - path: { id: sid }, - body: { - ...opts(item.info), - model: `${item.info.model.providerID}/${item.info.model.modelID}`, - command: item.cmd, - arguments: item.args, - parts: item.files, - } as any, - }) - return - } + switch (item.kind) { + case "shell": + return shell(sid, item.shell, item.info) + case "compact": + return compact(sid, item.info) + case "command": + return client.session.command({ + path: { id: sid }, + body: { + ...opts(item.info), + model: `${item.info.model.providerID}/${item.info.model.modelID}`, + command: item.cmd, + arguments: item.args, + parts: item.files, + } as any, + }) + case "prompt": { + if (!item.parts.length) { + console.warn("QueuePlugin skipped queued item without replayable content") + return + } - if (item.parts.length) { - return prompt(sid, item.info, item.parts.map((part) => ({ ...part, id: undefined }))) + const parts = item.parts.map((part) => ({ ...part, id: undefined })) + return client.session.prompt({ path: { id: sid }, body: { ...opts(item.info), parts } as any }) + } } - console.warn("QueuePlugin skipped queued item without replayable content") } const advance = (sid: string) => { @@ -289,10 +316,7 @@ export const QueuePlugin: Plugin = async ({ client }) => { switch (op.kind) { case "list": { - const list = - current.items - .map((item, i) => `${i + 1}. ${item.kind === "prompt" ? (item.body.trim() ? item.body : item.label) : item.source}`) - .join("\n") || "Queue is empty" + const list = current.items.map((item, i) => `${i + 1}. ${itemText(item)}`).join("\n") || "Queue is empty" return current.stopped ? `${list}\nQueue is stopped` : list } case "clear": @@ -323,8 +347,7 @@ export const QueuePlugin: Plugin = async ({ client }) => { event: async ({ event }) => { if (plan(event)) { const sid = event.properties.sessionID - const current = sessions.get(sid) - if (!current || (current.activity.kind !== "sending" && (current.stopped || !current.items.length))) return + if (!shouldDeclinePlan(sessions.get(sid))) return await no(event.properties.id) await toast("Declined plan approval to continue queued work", "info") return @@ -366,9 +389,7 @@ export const QueuePlugin: Plugin = async ({ client }) => { const queued = parseSuffix(body) if (!queued) return - const current = sessions.get(sid) - const shouldQueue = Boolean(current && (current.activity.kind !== "idle" || current.stopped)) - if (!shouldQueue) { + if (!shouldQueue(sessions.get(sid))) { for (const part of output.parts) if (part.type === "text") part.text = stripSuffix(part.text) return } @@ -377,19 +398,22 @@ export const QueuePlugin: Plugin = async ({ client }) => { return } - const current = sessions.get(sid) - const shouldQueue = Boolean(current && (current.activity.kind !== "idle" || current.stopped)) const op = parse(parsePrefix(body), parts.length) if (control(op)) return stop(await manage(sid, op)) if (op.kind === "invalid") return stop(op.message, "error") - if (!shouldQueue) { + if (!shouldQueue(sessions.get(sid))) { if (op.kind === "shell") { await shell(sid, op.shell, await run(sid)) throw new Error(HANDLED) } + if (op.kind === "compact") { + await client.tui.executeCommand({ body: { command: TUI_COMPACT }, throwOnError: true }) + throw new Error(HANDLED) + } + if (op.kind === "command") { await client.session.command({ path: { id: sid }, body: { command: op.cmd, arguments: op.args, parts } as any }) throw new Error(HANDLED) @@ -412,6 +436,7 @@ export const QueuePlugin: Plugin = async ({ client }) => { const current = state(sid) const parts = files(output.parts) const op = parse(request, parts.length) + const meta = input as Meta if (control(op)) { hide(output.message.id, text) @@ -427,6 +452,11 @@ export const QueuePlugin: Plugin = async ({ client }) => { if (current.activity.kind === "idle" && !current.stopped) { if (op.kind === "command") return + if (op.kind === "compact") { + hide(output.message.id, text) + await compact(sid, { agent: output.message.agent, model: output.message.model, variant: meta.variant, controls: meta.controls, fast: meta.fast }) + return + } if (op.kind === "shell") { hide(output.message.id, text) await shell(sid, op.shell, { agent: output.message.agent, model: output.message.model }) @@ -436,13 +466,13 @@ export const QueuePlugin: Plugin = async ({ client }) => { return } - const meta = input as Meta const info = { agent: output.message.agent, model: { ...output.message.model }, variant: meta.variant, controls: meta.controls, fast: meta.fast } const prior = await latest(sid) if (prior) Object.assign(output.message, opts(prior)) else console.warn("QueuePlugin could not neutralize queued placeholder metadata because the session has no previous message context") let item: Item if (op.kind === "shell") item = { kind: "shell", info, source: op.source, shell: op.shell } + else if (op.kind === "compact") item = { kind: "compact", info, source: op.source } else if (op.kind === "command") item = { kind: "command", info, source: op.source, cmd: op.cmd, args: op.args, files: parts } else { item = { @@ -462,7 +492,7 @@ export const QueuePlugin: Plugin = async ({ client }) => { if (op.front) current.items.unshift(item) else current.items.push(item) hide(output.message.id, text) - await toast(`${op.front ? "Queued first" : "Queued"}: ${item.kind === "prompt" ? item.label : item.source}`, "info") + await toast(`${op.front ? "Queued first" : "Queued"}: ${itemText(item)}`, "info") }, "experimental.chat.messages.transform": async (_, output) => { output.messages = output.messages.filter((msg) => !hidden.has(msg.info.id))