diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 9fad2eb21c81..6021d06b37e5 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -160,6 +160,7 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => data.session.status(props.sessionID ?? "")) + const compaction = createMemo(() => data.session.compaction.get(props.sessionID ?? "")) const activeSubagents = createMemo(() => { if (!props.sessionID) return 0 return data.session.family(props.sessionID).filter( @@ -1637,6 +1638,18 @@ export function Prompt(props: PromptProps) { + + + + Compacting conversation... + + + + esc interrupt + + + + diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 0ed464e561a6..8db1e2f6b9aa 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -20,12 +20,13 @@ import type { SkillV2Info, V2Event, } from "@opencode-ai/sdk/v2" -import { createStore, produce } from "solid-js/store" +import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" import { createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" +type CompactionReason = Extract["data"]["reason"] const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") @@ -51,6 +52,7 @@ type Data = { // session ID in that family, including the key itself once its info arrives. family: Record status: Record + compaction: Record message: Record permission: Record question: Record @@ -85,6 +87,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ info: {}, family: {}, status: {}, + compaction: {}, message: {}, permission: {}, question: {}, @@ -200,6 +203,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) } + const startCompaction = (sessionID: string, reason: CompactionReason) => { + if (store.session.status[sessionID] !== "running") setStore("session", "status", sessionID, "running") + if (store.session.compaction[sessionID] !== reason) + setStore("session", "compaction", sessionID, reason) + } + + const clearCompaction = (sessionID: string, settleManual = true) => { + const reason = store.session.compaction[sessionID] + if (!reason) return + setStore( + "session", + "compaction", + produce((draft) => { + delete draft[sessionID] + }), + ) + if (settleManual && reason === "manual") setStore("session", "status", sessionID, "idle") + } + function handleEvent(event: V2Event) { switch (event.type) { case "session.created": @@ -321,6 +343,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.step.started": + clearCompaction(event.data.sessionID, false) setStore("session", "status", event.data.sessionID, "running") message.update(event.data.sessionID, (draft, index) => { if (index.has(event.data.assistantMessageID)) return @@ -517,11 +540,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.retried": - case "session.compaction.started": setStore("session", "status", event.data.sessionID, "running") break + case "session.compaction.started": + startCompaction(event.data.sessionID, event.data.reason) + break case "session.execution.settled": setStore("session", "status", event.data.sessionID, "idle") + clearCompaction(event.data.sessionID, false) break case "session.revert.staged": if (store.session.info[event.data.sessionID]) @@ -535,6 +561,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.compaction.delta": break case "session.compaction.ended": + clearCompaction(event.data.sessionID) message.update(event.data.sessionID, (draft, index) => { message.append(draft, index, { id: messageIDFromEvent(event.id), @@ -655,6 +682,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ status(sessionID: string) { return store.session.status[sessionID] ?? "idle" }, + compaction: { + get(sessionID: string) { + return store.session.compaction[sessionID] + }, + startManual(sessionID: string) { + startCompaction(sessionID, "manual") + }, + clearManual(sessionID: string) { + if (store.session.compaction[sessionID] === "manual") clearCompaction(sessionID) + }, + }, async refresh(sessionID: string) { setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) @@ -840,6 +878,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async function bootstrap() { if (bootstrapping) return bootstrapping + setStore("session", "compaction", reconcile({})) bootstrapping = Promise.allSettled([ sdk.api.session .list({ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b968f13cd338..c7f757316def 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -382,7 +382,25 @@ export function Session() { aliases: ["summarize"], }, run: () => { - void sdk.api.session.compact({ sessionID: route.sessionID }) + const sessionID = route.sessionID + if (data.session.status(sessionID) === "running") { + toast.show({ + variant: "warning", + title: "Compaction unavailable", + message: "Wait for the current session activity to finish.", + }) + dialog.clear() + return + } + data.session.compaction.startManual(sessionID) + void sdk.api.session.compact({ sessionID }).catch((error) => { + data.session.compaction.clearManual(sessionID) + toast.show({ + variant: "error", + title: "Compaction failed", + message: errorMessage(error), + }) + }) dialog.clear() }, }, diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 0587662dc367..66b845530aa6 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -310,6 +310,61 @@ test("tracks session status from active sessions and execution events", async () }) await wait(() => data.session.status("session-live") === "idle") + emitEvent(events, { + id: "evt_compaction_started", + created: 0, + type: "session.compaction.started", + durable: durable("session-compact"), + data: { + sessionID: "session-compact", + reason: "manual", + }, + }) + await wait(() => data.session.compaction.get("session-compact") === "manual") + expect(data.session.status("session-compact")).toBe("running") + + emitEvent(events, { + id: "evt_compaction_ended", + created: 0, + type: "session.compaction.ended", + durable: durable("session-compact", 1, 2), + data: { + sessionID: "session-compact", + reason: "manual", + text: "Summary", + recent: "", + }, + }) + await wait(() => data.session.compaction.get("session-compact") === undefined) + expect(data.session.status("session-compact")).toBe("idle") + + emitEvent(events, { + id: "evt_auto_compaction_started", + created: 0, + type: "session.compaction.started", + durable: durable("session-auto-compact"), + data: { + sessionID: "session-auto-compact", + reason: "auto", + }, + }) + await wait(() => data.session.compaction.get("session-auto-compact") === "auto") + + emitEvent(events, { + id: "evt_post_compaction_step", + created: 0, + type: "session.step.started", + durable: durable("session-auto-compact", 1, 2), + data: { + sessionID: "session-auto-compact", + assistantMessageID: "message-after-compaction", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + await wait(() => data.session.compaction.get("session-auto-compact") === undefined) + expect(data.session.status("session-auto-compact")).toBe("running") + emitEvent(events, { id: "evt_failed_step_started", created: 0,