Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 858916f342 fix(tui): show compaction progress 2026-07-04 09:52:20 -04:00
4 changed files with 128 additions and 3 deletions
@@ -160,6 +160,7 @@ export function Prompt(props: PromptProps) {
const dialog = useDialog() const dialog = useDialog()
const toast = useToast() const toast = useToast()
const status = createMemo(() => data.session.status(props.sessionID ?? "")) const status = createMemo(() => data.session.status(props.sessionID ?? ""))
const compaction = createMemo(() => data.session.compaction.get(props.sessionID ?? ""))
const activeSubagents = createMemo(() => { const activeSubagents = createMemo(() => {
if (!props.sessionID) return 0 if (!props.sessionID) return 0
return data.session.family(props.sessionID).filter( return data.session.family(props.sessionID).filter(
@@ -1637,6 +1638,18 @@ export function Prompt(props: PromptProps) {
</box> </box>
<box width="100%" flexDirection="row" justifyContent="space-between"> <box width="100%" flexDirection="row" justifyContent="space-between">
<Switch> <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"}> <Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start"> <box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
<box marginLeft={1}> <box marginLeft={1}>
+41 -2
View File
@@ -20,12 +20,13 @@ import type {
SkillV2Info, SkillV2Info,
V2Event, V2Event,
} from "@opencode-ai/sdk/v2" } 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 { createSimpleContext } from "./helper"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { createSignal, onCleanup } from "solid-js" import { createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
type CompactionReason = Extract<V2Event, { type: "session.compaction.started" }>["data"]["reason"]
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") 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. // session ID in that family, including the key itself once its info arrives.
family: Record<string, string[]> family: Record<string, string[]>
status: Record<string, DataSessionStatus> status: Record<string, DataSessionStatus>
compaction: Record<string, CompactionReason>
message: Record<string, SessionMessage[]> message: Record<string, SessionMessage[]>
permission: Record<string, PermissionV2Request[]> permission: Record<string, PermissionV2Request[]>
question: Record<string, QuestionV2Request[]> question: Record<string, QuestionV2Request[]>
@@ -85,6 +87,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
info: {}, info: {},
family: {}, family: {},
status: {}, status: {},
compaction: {},
message: {}, message: {},
permission: {}, permission: {},
question: {}, 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) { function handleEvent(event: V2Event) {
switch (event.type) { switch (event.type) {
case "session.created": case "session.created":
@@ -321,6 +343,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.step.started": case "session.step.started":
clearCompaction(event.data.sessionID, false)
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
if (index.has(event.data.assistantMessageID)) return if (index.has(event.data.assistantMessageID)) return
@@ -517,11 +540,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}) })
break break
case "session.retried": case "session.retried":
case "session.compaction.started":
setStore("session", "status", event.data.sessionID, "running") setStore("session", "status", event.data.sessionID, "running")
break break
case "session.compaction.started":
startCompaction(event.data.sessionID, event.data.reason)
break
case "session.execution.settled": case "session.execution.settled":
setStore("session", "status", event.data.sessionID, "idle") setStore("session", "status", event.data.sessionID, "idle")
clearCompaction(event.data.sessionID, false)
break break
case "session.revert.staged": case "session.revert.staged":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
@@ -535,6 +561,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "session.compaction.delta": case "session.compaction.delta":
break break
case "session.compaction.ended": case "session.compaction.ended":
clearCompaction(event.data.sessionID)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, { message.append(draft, index, {
id: messageIDFromEvent(event.id), id: messageIDFromEvent(event.id),
@@ -655,6 +682,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
status(sessionID: string) { status(sessionID: string) {
return store.session.status[sessionID] ?? "idle" 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) { async refresh(sessionID: string) {
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
registerSession(sessionID) registerSession(sessionID)
@@ -840,6 +878,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() { async function bootstrap() {
if (bootstrapping) return bootstrapping if (bootstrapping) return bootstrapping
setStore("session", "compaction", reconcile({}))
bootstrapping = Promise.allSettled([ bootstrapping = Promise.allSettled([
sdk.api.session sdk.api.session
.list({ .list({
+19 -1
View File
@@ -382,7 +382,25 @@ export function Session() {
aliases: ["summarize"], aliases: ["summarize"],
}, },
run: () => { 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() dialog.clear()
}, },
}, },
+55
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") 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, { emitEvent(events, {
id: "evt_failed_step_started", id: "evt_failed_step_started",
created: 0, created: 0,