Просмотр исходного кода

feat(tui): add compact command to mini (#38251)

Simon Klee 2 недель назад
Родитель
Сommit
691a7d93c8

+ 9 - 1
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<CommandEntry[]>(() => {
-    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",

+ 6 - 0
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))

+ 5 - 0
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[] = []

+ 27 - 4
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<void>
   onNewSession?: () => void | Promise<void>
+  onCompact?: () => void | Promise<void>
   admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
   settle: () => Promise<void>
   run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
@@ -126,6 +127,24 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
             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<void> {
       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<void> {
     }
 
     state.queue.push(prompt)
-    if (prompt.mode !== "shell" && isNewCommand(prompt.text)) {
+    if (prompt.mode !== "shell" && (isNewCommand(prompt.text) || isCompactCommand(prompt.text))) {
       drain()
       return
     }

+ 3 - 0
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()

+ 2 - 1
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)

+ 57 - 0
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<void>()
+    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[] = []