diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d5298518700..7d8e62ed22d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -395,6 +395,16 @@ export function Session() { dialog.clear() }, }, + { + title: "Remove queued messages", + value: "session.unqueue", + keybind: "session_unqueue", + category: "Session", + onSelect: (dialog) => { + sdk.client.session.unqueue({ sessionID: route.sessionID }) + dialog.clear() + }, + }, { title: "Unshare session", value: "session.unshare", diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 0a9bfc62030..e784c90c93a 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -37,6 +37,7 @@ export namespace Command { export const Default = { INIT: "init", REVIEW: "review", + UNQUEUE: "unqueue", } as const const state = Instance.state(async () => { @@ -54,6 +55,11 @@ export namespace Command { template: PROMPT_REVIEW.replace("${path}", Instance.worktree), subtask: true, }, + [Default.UNQUEUE]: { + name: Default.UNQUEUE, + description: "remove all queued messages", + template: "", + }, } for (const [name, command] of Object.entries(cfg.command ?? {})) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0132bb91daf..849da0fcfce 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -458,6 +458,7 @@ export namespace Config { session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), + session_unqueue: z.string().optional().default("none").describe("Remove queued messages"), session_compact: z.string().optional().default("c").describe("Compact the session"), messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e25d9ded473..75d19972ac0 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -948,6 +948,34 @@ export namespace Server { return c.json(true) }, ) + .post( + "/session/:sessionID/unqueue", + describeRoute({ + description: "Remove queued messages from session", + operationId: "session.unqueue", + responses: { + 200: { + description: "Removed queued messages", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + }), + ), + async (c) => { + await SessionPrompt.removeQueued(c.req.valid("param").sessionID) + return c.json(true) + }, + ) .post( "/session/:sessionID/share", describeRoute({ @@ -2461,6 +2489,7 @@ export namespace Server { session_new: "session.new", session_share: "session.share", session_interrupt: "session.interrupt", + session_unqueue: "session.unqueue", session_compact: "session.compact", messages_page_up: "session.page.up", messages_page_down: "session.page.down", diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 19dc90b3bcb..5f43ab8061d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -227,6 +227,18 @@ export namespace SessionPrompt { return } + export function clearQueue(sessionID: string) { + log.info("clearQueue", { sessionID }) + const s = state() + const match = s[sessionID] + if (!match) return + + for (const callback of match.callbacks) { + callback.reject() + } + match.callbacks = [] + } + export const loop = fn(Identifier.schema("session"), async (sessionID) => { const abort = start(sessionID) if (!abort) { @@ -1279,8 +1291,57 @@ export namespace SessionPrompt { * Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references) */ + export async function removeQueued(sessionID: string) { + const queued: string[] = [] + + // MessageV2.stream yields newest->oldest, so queued messages appear first + for await (const msg of MessageV2.stream(sessionID)) { + if (msg.info.role === "assistant") { + // Only delete if incomplete assistant found + if (!msg.info.time.completed && queued.length > 0) { + await Promise.all(queued.map((messageID) => Session.removeMessage({ sessionID, messageID }))) + } + break + } + + if (msg.info.role === "user") { + queued.push(msg.info.id) + } + } + + clearQueue(sessionID) + } + + async function handleUnqueue(sessionID: string) { + await removeQueued(sessionID) + + return { + info: { + id: "", + sessionID, + role: "assistant" as const, + parentID: "", + mode: "", + time: { created: 0 }, + agent: "", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + path: { cwd: "", root: "" }, + modelID: "", + providerID: "", + }, + parts: [], + } + } + export async function command(input: CommandInput) { log.info("command", input) + + // Special handling for unqueue command + if (input.command === "unqueue") { + return await handleUnqueue(input.sessionID) + } + const command = await Command.get(input.command) const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 5e3e67e1c03..af6ba2512dd 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -73,6 +73,9 @@ import type { SessionAbortData, SessionAbortResponses, SessionAbortErrors, + SessionUnqueueData, + SessionUnqueueResponses, + SessionUnqueueErrors, SessionUnshareData, SessionUnshareResponses, SessionUnshareErrors, @@ -555,6 +558,16 @@ class Session extends _HeyApiClient { }) } + /** + * Remove queued messages from session + */ + public unqueue(options: Options) { + return (options.client ?? this._client).post({ + url: "/session/{id}/unqueue", + ...options, + }) + } + /** * Unshare the session */ diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 32f33f66219..a2e795fc7e0 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -830,6 +830,10 @@ export type KeybindsConfig = { * Interrupt current session */ session_interrupt?: string + /** + * Remove queued messages + */ + session_unqueue?: string /** * Compact the session */ @@ -2392,6 +2396,39 @@ export type SessionAbortResponses = { export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] +export type SessionUnqueueData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/session/{id}/unqueue" +} + +export type SessionUnqueueErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUnqueueError = SessionUnqueueErrors[keyof SessionUnqueueErrors] + +export type SessionUnqueueResponses = { + /** + * Removed queued messages + */ + 200: boolean +} + +export type SessionUnqueueResponse = SessionUnqueueResponses[keyof SessionUnqueueResponses] + export type SessionUnshareData = { body?: never path: { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 797896ace9a..ec1301fbca0 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -116,6 +116,8 @@ import type { SessionSummarizeResponses, SessionTodoErrors, SessionTodoResponses, + SessionUnqueueErrors, + SessionUnqueueResponses, SessionUnrevertErrors, SessionUnrevertResponses, SessionUnshareErrors, @@ -1043,6 +1045,34 @@ export class Session extends HeyApiClient { }) } + /** + * Remove queued messages from session + */ + public unqueue( + parameters: { + sessionID: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/unqueue", + ...options, + ...params, + }) + } + /** * Unshare session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5c4cc69423d..f06d5ba86ad 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -873,6 +873,10 @@ export type KeybindsConfig = { * Interrupt current session */ session_interrupt?: string + /** + * Remove queued messages + */ + session_unqueue?: string /** * Compact the session */ @@ -2748,6 +2752,39 @@ export type SessionAbortResponses = { export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] +export type SessionUnqueueData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + } + url: "/session/{sessionID}/unqueue" +} + +export type SessionUnqueueErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type SessionUnqueueError = SessionUnqueueErrors[keyof SessionUnqueueErrors] + +export type SessionUnqueueResponses = { + /** + * Removed queued messages + */ + 200: boolean +} + +export type SessionUnqueueResponse = SessionUnqueueResponses[keyof SessionUnqueueResponses] + export type SessionUnshareData = { body?: never path: {