fix(tui): show compaction progress

This commit is contained in:
Kit Langton 2026-07-04 09:52:20 -04:00
parent 9751615651
commit 858916f342
4 changed files with 128 additions and 3 deletions

View file

@ -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) {
</box>
<box width="100%" flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={compaction()}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>
<Spinner>Compacting conversation...</Spinner>
</box>
<Show when={compaction() === "auto"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
</text>
</Show>
</box>
</Match>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}>

View file

@ -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<V2Event, { type: "session.compaction.started" }>["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<string, string[]>
status: Record<string, DataSessionStatus>
compaction: Record<string, CompactionReason>
message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]>
@ -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({

View file

@ -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()
},
},

View file

@ -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,