فهرست منبع

tui: render mini compaction boundaries (#39103)

Simon Klee 2 هفته پیش
والد
کامیت
9a55d125f6

+ 9 - 0
packages/tui/src/mini/scrollback.writer.tsx

@@ -123,6 +123,15 @@ export function RunEntryContent(props: {
 
   return (
     <Switch fallback={null}>
+      <Match when={props.commit.compaction}>
+        <box width="100%" flexDirection="row" alignItems="center">
+          <box border={["top"]} borderColor={theme().block.muted} flexGrow={1} />
+          <box paddingLeft={1} paddingRight={1}>
+            <text fg={theme().block.muted}>{props.commit.text}</text>
+          </box>
+          <box border={["top"]} borderColor={theme().block.muted} flexGrow={1} />
+        </box>
+      </Match>
       <Match when={text()}>
         <text width="100%" wrapMode="word" fg={style().fg} attributes={style().attrs}>
           {text()!.content}

+ 100 - 0
packages/tui/src/mini/stream-v2.transport.ts

@@ -146,6 +146,7 @@ type State = {
   pending: Map<string, FooterQueuedPrompt>
   admitted: Set<string>
   stepModel: RunInput["model"]
+  activeCompaction?: string
 }
 
 const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
@@ -368,6 +369,40 @@ function skillCommit(messageID: string, name: string): StreamCommit {
   }
 }
 
+function compactionCommit(messageID: string): StreamCommit {
+  return {
+    kind: "system",
+    source: "system",
+    messageID,
+    partID: "compaction:header",
+    text: "Compaction",
+    phase: "start",
+    compaction: true,
+  }
+}
+
+function compactionSummary(messageID: string, text: string, phase: "progress" | "final"): StreamCommit {
+  return {
+    kind: "assistant",
+    source: "assistant",
+    messageID,
+    partID: "compaction:summary",
+    text,
+    phase,
+  }
+}
+
+function compactionError(messageID: string, text: string): StreamCommit {
+  return {
+    kind: "error",
+    source: "system",
+    messageID,
+    partID: "compaction:error",
+    text,
+    phase: "start",
+  }
+}
+
 async function resolveSelectedModel(
   input: StreamInput,
   sdk: OpenCodeClient,
@@ -642,6 +677,28 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       if (completed && state.shellWait?.callID === message.shellID) state.shellWait.resolve()
       return
     }
+    if (message.type === "compaction") {
+      const visible = state.messageIDs.has(message.id)
+      state.messageIDs.add(message.id)
+      if (message.status === "running") state.activeCompaction = message.id
+      if (message.status !== "running" && state.activeCompaction === message.id) state.activeCompaction = undefined
+      if (visible) return
+      if (message.status === "failed") {
+        if (render && message.error.type !== "aborted")
+          write([compactionCommit(message.id), compactionError(message.id, message.error.message)])
+        return
+      }
+      const fragment = { messageID: message.id, partID: "compaction:summary" }
+      const show = render || message.status === "running"
+      state.fragments.project(fragment, message.summary, show)
+      if (!show) return
+      write([
+        compactionCommit(message.id),
+        ...(message.summary ? [compactionSummary(message.id, message.summary, "progress")] : []),
+        ...(message.status === "completed" ? [compactionSummary(message.id, "", "final")] : []),
+      ])
+      return
+    }
     if (message.type !== "assistant") return
     state.messageIDs.add(message.id)
     let textOrdinal = 0
@@ -892,6 +949,48 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       write([skillCommit(messageID, event.data.name)])
       return
     }
+    if (event.type === "session.compaction.started") {
+      const messageID = event.data.inputID ?? messageIDFromEvent(event.id)
+      state.activeCompaction = messageID
+      if (state.messageIDs.has(messageID)) return
+      state.messageIDs.add(messageID)
+      write([compactionCommit(messageID)], { phase: "running", status: "compacting session" })
+      return
+    }
+    if (event.type === "session.compaction.delta") {
+      if (!state.activeCompaction) return
+      const fragment = { messageID: state.activeCompaction, partID: "compaction:summary" }
+      if (!state.fragments.delta(fragment, event.data.text)) return
+      write([compactionSummary(state.activeCompaction, event.data.text, "progress")])
+      return
+    }
+    if (event.type === "session.compaction.ended") {
+      if (!state.activeCompaction) return
+      const messageID = state.activeCompaction
+      state.activeCompaction = undefined
+      const update = state.fragments.end({ messageID, partID: "compaction:summary" }, event.data.text)
+      write([
+        ...(event.data.text.length > update.previous.length
+          ? [compactionSummary(messageID, event.data.text.slice(update.previous.length), "progress")]
+          : []),
+        compactionSummary(messageID, "", "final"),
+      ])
+      return
+    }
+    if (event.type === "session.compaction.failed") {
+      if (!state.activeCompaction) return
+      const messageID = state.activeCompaction
+      state.activeCompaction = undefined
+      if (event.data.error.type === "aborted") {
+        write([compactionSummary(messageID, "", "final")])
+        return
+      }
+      write([
+        compactionSummary(messageID, "", "final"),
+        compactionError(messageID, event.data.error.message),
+      ])
+      return
+    }
     if (event.type === "session.shell.started") {
       state.shellCommands.set(event.data.shell.id, event.data.shell.command)
       const wait = state.shellWait
@@ -1427,6 +1526,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
       state.skillMessages.clear()
       state.shellCommands.clear()
       state.shellStarted.clear()
+      state.activeCompaction = undefined
       state.shellEnded.clear()
       state.errors.clear()
       await hydrate(attempt, { render: true, reuseVisibleWait: false })

+ 1 - 0
packages/tui/src/mini/types.ts

@@ -418,6 +418,7 @@ export type StreamCommit = {
   text: string
   phase: StreamPhase
   source: StreamSource
+  compaction?: true
   summary?: TurnSummary
   messageID?: string
   partID?: string

+ 78 - 0
packages/tui/test/mini/stream-v2.transport.test.ts

@@ -98,6 +98,19 @@ function footer() {
 
 type SessionMessages = MessageListOutput["data"]
 
+function compaction(status: "running" | "completed", summary: string): SessionMessages[number] {
+  const message = {
+    id: "msg_compaction",
+    type: "compaction" as const,
+    reason: "auto" as const,
+    summary,
+    recent: "",
+    time: { created: 1 },
+  }
+  if (status === "running") return { ...message, status }
+  return { ...message, status }
+}
+
 function form(id: string, sessionID: string, title = id): FormInfo {
   return {
     id,
@@ -200,6 +213,71 @@ afterEach(() => {
 })
 
 describe("V2 mini transport", () => {
+  test("renders projected compactions as labeled transcript boundaries", async () => {
+    const events = feed()
+    events.push(connected())
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: sdk({
+        streams: [events],
+        messages: {
+          ses_1: [compaction("completed", "## Transport")],
+        },
+      }),
+      sessionID: "ses_1",
+      thinking: false,
+      replay: true,
+      footer: ui.api,
+    })
+
+    expect(ui.commits).toMatchObject([
+      { text: "Compaction", compaction: true, messageID: "msg_compaction" },
+      { text: "## Transport", phase: "progress", messageID: "msg_compaction" },
+      { text: "", phase: "final", messageID: "msg_compaction" },
+    ])
+    await transport.close()
+  })
+
+  test("shows an active compaction boundary before live summary output without history replay", async () => {
+    const events = feed()
+    events.push(connected())
+    const ui = footer()
+    const transport = await createSessionTransport({
+      sdk: sdk({
+        streams: [events],
+        active: () => ({ ses_1: { type: "running" } }),
+        messages: {
+          ses_1: [compaction("running", "")],
+        },
+      }),
+      sessionID: "ses_1",
+      thinking: false,
+      footer: ui.api,
+    })
+
+    events.push({
+      id: "evt_compaction_delta",
+      created: 2,
+      type: "session.compaction.delta",
+      data: { sessionID: "ses_1", text: "Transport" },
+    })
+    events.push({
+      id: "evt_compaction_ended",
+      created: 3,
+      type: "session.compaction.ended",
+      durable: durable("ses_1", 3),
+      data: { sessionID: "ses_1", reason: "auto", text: "Transport", recent: "" },
+    })
+
+    while (!ui.commits.some((commit) => commit.phase === "final")) await Bun.sleep(0)
+    expect(ui.commits).toMatchObject([
+      { text: "Compaction", compaction: true, messageID: "msg_compaction" },
+      { text: "Transport", phase: "progress", messageID: "msg_compaction" },
+      { text: "", phase: "final", messageID: "msg_compaction" },
+    ])
+    await transport.close()
+  })
+
   test("reports session title changes", async () => {
     const events = feed()
     events.push(connected())