diff --git a/packages/tui/src/mini/footer.command.tsx b/packages/tui/src/mini/footer.command.tsx index 5b3998413de..4c7e5aef13c 100644 --- a/packages/tui/src/mini/footer.command.tsx +++ b/packages/tui/src/mini/footer.command.tsx @@ -374,7 +374,7 @@ export function RunCommandMenuBody(props: { const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill")) const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length) const entries = createMemo(() => { - const builtins = ["editor", "new", "settings"] + const builtins = ["compact", "editor", "new", "settings"] const session: CommandEntry[] = [ { action: "editor", @@ -404,6 +404,14 @@ export function RunCommandMenuBody(props: { }, ] : []), + { + action: "slash", + category: "Session", + name: "compact", + display: "Compact session", + footer: "/compact", + keywords: "compact summarize session context", + }, { action: "slash", category: "Session", diff --git a/packages/tui/src/mini/footer.prompt.tsx b/packages/tui/src/mini/footer.prompt.tsx index 6d0ece4e14f..544d76007d2 100644 --- a/packages/tui/src/mini/footer.prompt.tsx +++ b/packages/tui/src/mini/footer.prompt.tsx @@ -395,6 +395,12 @@ export function createPromptState(input: PromptInput): PromptState { description: "configure Mini transcript output", } satisfies SlashOption, { kind: "slash", name: "new", display: "/new", description: "start a new session" } satisfies SlashOption, + { + kind: "slash", + name: "compact", + display: "/compact", + description: "summarize the session to reduce context usage", + } satisfies SlashOption, { kind: "slash", name: "exit", display: "/exit", description: "close OpenCode" } satisfies SlashOption, ] const hidden = new Set(builtins.map((item) => item.name)) diff --git a/packages/tui/src/mini/prompt.shared.ts b/packages/tui/src/mini/prompt.shared.ts index 64924e0daed..be1e24db867 100644 --- a/packages/tui/src/mini/prompt.shared.ts +++ b/packages/tui/src/mini/prompt.shared.ts @@ -53,6 +53,11 @@ export function isNewCommand(input: string): boolean { return input.trim().toLowerCase() === "/new" } +export function isCompactCommand(input: string): boolean { + const text = input.trim().toLowerCase() + return text === "/compact" || text === "/summarize" +} + export function createPromptHistory(items?: RunPrompt[]): PromptHistoryState { const list = (items ?? []).filter((item) => item.text.trim().length > 0).map(promptCopy) const next: RunPrompt[] = [] diff --git a/packages/tui/src/mini/runtime.queue.ts b/packages/tui/src/mini/runtime.queue.ts index 8141f135ea3..74a399416e2 100644 --- a/packages/tui/src/mini/runtime.queue.ts +++ b/packages/tui/src/mini/runtime.queue.ts @@ -4,13 +4,13 @@ // operations drain one at a time. Ordinary prompts submitted during an active // ordinary turn are admitted immediately to the server's durable queue. // -// The queue also handles /exit, /quit, and /new commands, empty-prompt rejection, +// The queue also handles local session commands, empty-prompt rejection, // and tracks per-turn wall-clock duration for the footer status line. // // Resolves when the footer closes and all in-flight work finishes. import { SessionMessage } from "@opencode-ai/schema/session-message" import { Locale } from "../util/locale" -import { isExitCommand, isNewCommand } from "./prompt.shared" +import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared" import type { FooterApi, FooterEvent, RunPrompt } from "./types" type Trace = { @@ -24,6 +24,7 @@ export type QueueInput = { onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise onNewSession?: () => void | Promise + onCompact?: () => void | Promise admit: (prompt: RunPrompt, signal: AbortSignal) => Promise settle: () => Promise run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise @@ -126,6 +127,24 @@ export async function runPromptQueue(input: QueueInput): Promise { continue } + if (prompt.mode !== "shell" && isCompactCommand(prompt.text)) { + emit( + { + type: "stream.patch", + patch: { + phase: "running", + status: "compacting session", + }, + }, + { + phase: "running", + status: "compacting session", + }, + ) + await input.onCompact?.() + continue + } + const sent = prompt.mode === "shell" ? prompt @@ -251,7 +270,11 @@ export async function runPromptQueue(input: QueueInput): Promise { active.mode !== "shell" && prompt.mode !== "shell" && prompt.command?.source !== "skill" && - !isNewCommand(prompt.text) + !isNewCommand(prompt.text) && + !isCompactCommand(prompt.text) && + !state.queue.some( + (item) => item.mode !== "shell" && (isNewCommand(item.text) || isCompactCommand(item.text)), + ) ) { const sent = { ...prompt, messageID: SessionMessage.ID.create() } const admission = state.admission @@ -265,7 +288,7 @@ export async function runPromptQueue(input: QueueInput): Promise { } state.queue.push(prompt) - if (prompt.mode !== "shell" && isNewCommand(prompt.text)) { + if (prompt.mode !== "shell" && (isNewCommand(prompt.text) || isCompactCommand(prompt.text))) { drain() return } diff --git a/packages/tui/src/mini/runtime.ts b/packages/tui/src/mini/runtime.ts index 34bd286259f..fac1efda117 100644 --- a/packages/tui/src/mini/runtime.ts +++ b/packages/tui/src/mini/runtime.ts @@ -876,6 +876,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) }, onAdmissionError: renderPromptError, + onCompact: async () => { + await state.sdk.session.compact({ sessionID: state.sessionID }, formRequestOptions(state.location)) + }, settle: async () => { const next = await ensureStream() await next.handle.waitForIdle() diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index adbb4050ad4..a4a5936dba4 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -410,7 +410,8 @@ test("direct command panel renders grouped command palette", async () => { expect(frame).toContain("Open editor") expect(frame).toContain("/editor") expect(frame).toContain("Show status") - expect(frame).toContain("Switch model") + expect(frame).toContain("Compact session") + expect(frame).toContain("/compact") expect(frame).toContain("Skills") expect(frame).toContain("/skills") expect(frame.match(/\bAgent\b/g)?.length).toBe(1) diff --git a/packages/tui/test/mini/runtime.queue.test.ts b/packages/tui/test/mini/runtime.queue.test.ts index 78ce2913c5a..c7fe59fba80 100644 --- a/packages/tui/test/mini/runtime.queue.test.ts +++ b/packages/tui/test/mini/runtime.queue.test.ts @@ -80,6 +80,63 @@ describe("run runtime queue", () => { ]) }) + test.each(["/compact", "/summarize"])("treats %s as a local compaction command", async (command) => { + const ui = createFooterApiFixture() + const seen: string[] = [] + let compacted = 0 + + const task = runPromptQueue({ + footer: ui.api, + onCompact: async () => { + compacted += 1 + }, + run: async (input) => { + seen.push(input.text) + ui.api.close() + }, + }) + + ui.submit(command) + ui.submit("hello") + await task + + expect(compacted).toBe(1) + expect(seen).toEqual(["hello"]) + expect(ui.commits.map((item) => item.text)).toEqual(["hello"]) + }) + + test("keeps prompts submitted after an in-flight /compact behind the compaction barrier", async () => { + const ui = createFooterApiFixture() + const active = Promise.withResolvers() + const order: string[] = [] + + const task = runPromptQueue({ + footer: ui.api, + onCompact: async () => { + order.push("compact") + }, + admit: async (prompt) => { + order.push(`admit:${prompt.text}`) + }, + run: async (prompt) => { + order.push(`run:${prompt.text}`) + if (prompt.text === "first") await active.promise + if (prompt.text === "later") ui.api.close() + }, + }) + + ui.submit("first") + await Promise.resolve() + ui.submit("/compact") + ui.submit("later") + await Promise.resolve() + expect(order).toEqual(["run:first"]) + + active.resolve() + await task + expect(order).toEqual(["run:first", "compact", "run:later"]) + }) + test("shell mode submits /exit as a shell command", async () => { const ui = createFooterApiFixture() const seen: RunPrompt[] = []