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

fix(cli): tighten run scrollback separators

Simon Klee 13 часов назад
Родитель
Сommit
fef2f8119c

+ 4 - 4
packages/opencode/src/cli/cmd/run/footer.subagent.tsx

@@ -4,7 +4,7 @@ import { useKeyboard } from "@opentui/solid"
 import "opentui-spinner/solid"
 import { createMemo, mapArray } from "solid-js"
 import { SPINNER_FRAMES } from "../tui/component/spinner"
-import { RunEntryContent, sameEntryGroup } from "./scrollback.writer"
+import { RunEntryContent, separatorRows } from "./scrollback.writer"
 import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
 import type { RunFooterTheme, RunTheme } from "./theme"
 
@@ -13,7 +13,7 @@ export const SUBAGENT_INSPECTOR_ROWS = 8
 
 function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
   if (status === "completed") {
-    return theme.success
+    return theme.highlight
   }
 
   if (status === "error") {
@@ -121,8 +121,8 @@ export function RunFooterSubagentBody(props: {
     },
   }))
   const rows = mapArray(commits, (commit, index) => (
-    <box flexDirection="column" gap={0}>
-      {index() > 0 && !sameEntryGroup(commits()[index() - 1], commit) ? <box height={1} flexShrink={0} /> : null}
+    <box flexDirection="column" gap={0} flexShrink={0}>
+      {index() > 0 && separatorRows(commits()[index() - 1], commit) > 0 ? <box height={1} flexShrink={0} /> : null}
       <RunEntryContent commit={commit} theme={theme()} opts={opts()} width={props.width()} />
     </box>
   ))

+ 74 - 45
packages/opencode/src/cli/cmd/run/scrollback.surface.ts

@@ -1,7 +1,7 @@
 // Retained streaming append logic for direct-mode scrollback.
 //
 // Static entries are rendered through `scrollback.writer.tsx`. This file only
-// keeps the minimum retained-surface machinery needed for streaming assistant,
+// keeps the retained-surface machinery needed for streaming assistant,
 // reasoning, and tool progress entries that need stable markdown/code layout
 // while content is still arriving.
 import {
@@ -16,7 +16,7 @@ import {
 import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
 import { withRunSpan } from "./otel"
 import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
-import { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer"
+import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer"
 import { type RunTheme } from "./theme"
 import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
 
@@ -30,6 +30,8 @@ type ActiveEntry = {
   content: string
   committedRows: number
   committedBlocks: number
+  pendingSpacerRows: number
+  rendered: boolean
 }
 
 let nextId = 0
@@ -40,6 +42,7 @@ function commitMarkdownBlocks(input: {
   startBlock: number
   endBlockExclusive: number
   trailingNewline: boolean
+  beforeCommit?: () => void
 }) {
   if (input.endBlockExclusive <= input.startBlock) {
     return false
@@ -53,30 +56,19 @@ function commitMarkdownBlocks(input: {
 
   const next = input.renderable._blockStates[input.endBlockExclusive]
   const start = first.renderable.y
-  const end = next ? next.renderable.y : last.renderable.y + last.renderable.height + (last.marginBottom ?? 0)
+  const end = next ? next.renderable.y : last.renderable.y + last.renderable.height
 
+  input.beforeCommit?.()
   input.surface.commitRows(start, end, {
     trailingNewline: input.trailingNewline,
   })
   return true
 }
 
-function wantsSpacer(prev: StreamCommit | undefined, next: StreamCommit): boolean {
-  if (!prev) {
-    return false
-  }
-
-  if (sameEntryGroup(prev, next)) {
-    return false
-  }
-
-  return !(prev.kind === "tool" && prev.phase === "start")
-}
-
 export class RunScrollbackStream {
   private tail: StreamCommit | undefined
+  private rendered: StreamCommit | undefined
   private active: ActiveEntry | undefined
-  private wrote: boolean
   private diffStyle: RunDiffStyle | undefined
   private sessionID?: () => string | undefined
   private treeSitterClient: TreeSitterClient | undefined
@@ -91,7 +83,6 @@ export class RunScrollbackStream {
       treeSitterClient?: TreeSitterClient
     } = {},
   ) {
-    this.wrote = options.wrote ?? true
     this.diffStyle = options.diffStyle
     this.sessionID = options.sessionID
     this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
@@ -148,13 +139,36 @@ export class RunScrollbackStream {
       content: "",
       committedRows: 0,
       committedBlocks: 0,
+      pendingSpacerRows: separatorRows(this.rendered, commit, body),
+      rendered: false,
     }
   }
 
-  private async flushActive(done: boolean, trailingNewline: boolean): Promise<void> {
+  private markRendered(commit: StreamCommit | undefined): void {
+    if (!commit) {
+      return
+    }
+
+    this.rendered = commit
+  }
+
+  private writeSpacer(rows: number): void {
+    if (rows === 0) {
+      return
+    }
+
+    this.renderer.writeToScrollback(spacerWriter())
+  }
+
+  private flushPendingSpacer(active: ActiveEntry): void {
+    this.writeSpacer(active.pendingSpacerRows)
+    active.pendingSpacerRows = 0
+  }
+
+  private async flushActive(done: boolean, trailingNewline: boolean): Promise<boolean> {
     const active = this.active
     if (!active) {
-      return
+      return false
     }
 
     if (active.body.type === "text") {
@@ -162,13 +176,17 @@ export class RunScrollbackStream {
       renderable.content = active.content
       active.surface.render()
       const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
-      if (targetRows > active.committedRows) {
-        active.surface.commitRows(active.committedRows, targetRows, {
-          trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
-        })
-        active.committedRows = targetRows
+      if (targetRows <= active.committedRows) {
+        return false
       }
-      return
+
+      this.flushPendingSpacer(active)
+      active.surface.commitRows(active.committedRows, targetRows, {
+        trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
+      })
+      active.committedRows = targetRows
+      active.rendered = true
+      return true
     }
 
     if (active.body.type === "code") {
@@ -177,13 +195,17 @@ export class RunScrollbackStream {
       renderable.streaming = !done
       await active.surface.settle()
       const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1)
-      if (targetRows > active.committedRows) {
-        active.surface.commitRows(active.committedRows, targetRows, {
-          trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
-        })
-        active.committedRows = targetRows
+      if (targetRows <= active.committedRows) {
+        return false
       }
-      return
+
+      this.flushPendingSpacer(active)
+      active.surface.commitRows(active.committedRows, targetRows, {
+        trailingNewline: done && targetRows === active.surface.height ? trailingNewline : false,
+      })
+      active.committedRows = targetRows
+      active.rendered = true
+      return true
     }
 
     const renderable = active.renderable as MarkdownRenderable
@@ -192,7 +214,7 @@ export class RunScrollbackStream {
     await active.surface.settle()
     const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
     if (targetBlockCount <= active.committedBlocks) {
-      return
+      return false
     }
 
     if (
@@ -202,13 +224,18 @@ export class RunScrollbackStream {
         startBlock: active.committedBlocks,
         endBlockExclusive: targetBlockCount,
         trailingNewline: done && targetBlockCount === renderable._blockStates.length ? trailingNewline : false,
+        beforeCommit: () => this.flushPendingSpacer(active),
       })
     ) {
       active.committedBlocks = targetBlockCount
+      active.rendered = true
+      return true
     }
+
+    return false
   }
 
-  private async finishActive(trailingNewline: boolean): Promise<void> {
+  private async finishActive(trailingNewline: boolean): Promise<StreamCommit | undefined> {
     if (!this.active) {
       return
     }
@@ -226,11 +253,13 @@ export class RunScrollbackStream {
         active.surface.destroy()
       }
     }
+
+    return active.rendered ? active.commit : undefined
   }
 
   private async writeStreaming(commit: StreamCommit, body: ActiveBody): Promise<void> {
     if (!this.active || !sameEntryGroup(this.active.commit, commit) || this.active.body.type !== body.type) {
-      await this.finishActive(false)
+      this.markRendered(await this.finishActive(false))
       this.active = this.createEntry(commit, body)
     }
 
@@ -238,28 +267,27 @@ export class RunScrollbackStream {
     this.active.commit = commit
     this.active.content += body.content
     await this.flushActive(false, false)
+    if (this.active.rendered) {
+      this.markRendered(this.active.commit)
+    }
   }
 
   public async append(commit: StreamCommit): Promise<void> {
     const same = sameEntryGroup(this.tail, commit)
     if (!same) {
-      await this.finishActive(false)
+      this.markRendered(await this.finishActive(false))
     }
 
     const body = entryBody(commit)
     if (body.type === "none") {
       if (entryDone(commit)) {
-        await this.finishActive(false)
+        this.markRendered(await this.finishActive(false))
       }
 
       this.tail = commit
       return
     }
 
-    if (this.wrote && wantsSpacer(this.tail, commit)) {
-      this.renderer.writeToScrollback(spacerWriter())
-    }
-
     if (
       body.type !== "structured" &&
       (entryCanStream(commit, body) ||
@@ -267,17 +295,18 @@ export class RunScrollbackStream {
     ) {
       await this.writeStreaming(commit, body)
       if (entryDone(commit)) {
-        await this.finishActive(false)
+        this.markRendered(await this.finishActive(false))
       }
-      this.wrote = true
       this.tail = commit
       return
     }
 
     if (same) {
-      await this.finishActive(false)
+      this.markRendered(await this.finishActive(false))
     }
 
+    this.writeSpacer(separatorRows(this.rendered, commit, body))
+
     this.renderer.writeToScrollback(
       entryWriter({
         commit,
@@ -287,7 +316,7 @@ export class RunScrollbackStream {
         },
       }),
     )
-    this.wrote = true
+    this.markRendered(commit)
     this.tail = commit
   }
 
@@ -312,7 +341,7 @@ export class RunScrollbackStream {
         "session.id": this.sessionID?.() || undefined,
       },
       async () => {
-        await this.finishActive(trailingNewline)
+        this.markRendered(await this.finishActive(trailingNewline))
       },
     )
   }

+ 116 - 55
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx

@@ -7,7 +7,7 @@ import { entryBody, entryFlags } from "./entry.body"
 import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
 import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
 import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
-import type { ScrollbackOptions, StreamCommit } from "./types"
+import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
 
 function todoText(item: { status: string; content: string }): string {
   if (item.status === "completed") {
@@ -44,11 +44,39 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
 
   const current = entryGroupKey(left)
   const next = entryGroupKey(right)
-  if (current && next && current === next) {
-    return true
+  return Boolean(current && next && current === next)
+}
+
+export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
+  if (commit.kind === "tool") {
+    if (body.type === "structured" || body.type === "markdown") {
+      return "block"
+    }
+
+    return "inline"
+  }
+
+  if (commit.kind === "reasoning") {
+    return "block"
   }
 
-  return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start"
+  return "block"
+}
+
+export function separatorRows(
+  prev: StreamCommit | undefined,
+  next: StreamCommit,
+  body: RunEntryBody = entryBody(next),
+): number {
+  if (!prev || sameEntryGroup(prev, next)) {
+    return 0
+  }
+
+  if (entryLayout(prev) === "inline" && entryLayout(next, body) === "inline") {
+    return 0
+  }
+
+  return 1
 }
 
 export function RunEntryContent(props: {
@@ -59,6 +87,39 @@ export function RunEntryContent(props: {
 }) {
   const theme = props.theme ?? RUN_THEME_FALLBACK
   const body = createMemo(() => entryBody(props.commit))
+  const text = () => {
+    const value = body()
+    if (value.type !== "text") {
+      return
+    }
+
+    return value
+  }
+  const code = () => {
+    const value = body()
+    if (value.type !== "code") {
+      return
+    }
+
+    return value
+  }
+  const snapshot = () => {
+    const value = body()
+    if (value.type !== "structured") {
+      return
+    }
+
+    return value.snapshot
+  }
+  const markdown = () => {
+    const value = body()
+    if (value.type !== "markdown") {
+      return
+    }
+
+    return value
+  }
+
   if (body().type === "none") {
     return null
   }
@@ -67,7 +128,7 @@ export function RunEntryContent(props: {
     const style = entryLook(props.commit, theme.entry)
     return (
       <text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
-        {body().content}
+        {text()?.content}
       </text>
     )
   }
@@ -77,11 +138,11 @@ export function RunEntryContent(props: {
       <code
         width="100%"
         wrapMode="word"
-        filetype={body().filetype}
+        filetype={code()?.filetype}
         drawUnstyledText={false}
         streaming={props.commit.phase === "progress"}
         syntaxStyle={entrySyntax(props.commit, theme)}
-        content={body().content}
+        content={code()?.content}
         fg={entryColor(props.commit, theme)}
       />
     )
@@ -90,48 +151,48 @@ export function RunEntryContent(props: {
   if (body().type === "structured") {
     const width = Math.max(1, Math.trunc(props.width ?? 80))
 
-    if (body().snapshot.kind === "code") {
+    if (snapshot()?.kind === "code") {
       return (
         <box width="100%" flexDirection="column" gap={1}>
           <text width="100%" wrapMode="word" fg={theme.block.muted}>
-            {body().snapshot.title}
+            {snapshot()?.title}
           </text>
           <box width="100%" paddingLeft={1}>
             <line_number width="100%" fg={theme.block.muted} minWidth={3} paddingRight={1}>
-                <code
-                  width="100%"
-                  wrapMode="char"
-                  filetype={toolFiletype(body().snapshot.file)}
-                  streaming={false}
-                  syntaxStyle={entrySyntax(props.commit, theme)}
-                  content={body().snapshot.content}
-                  fg={theme.block.text}
-                />
+              <code
+                width="100%"
+                wrapMode="char"
+                filetype={toolFiletype(snapshot()?.file)}
+                streaming={false}
+                syntaxStyle={entrySyntax(props.commit, theme)}
+                content={snapshot()?.content}
+                fg={theme.block.text}
+              />
             </line_number>
           </box>
         </box>
       )
     }
 
-    if (body().snapshot.kind === "diff") {
+    if (snapshot()?.kind === "diff") {
       const view = toolDiffView(width, props.opts?.diffStyle)
       return (
         <box width="100%" flexDirection="column" gap={1}>
-          {body().snapshot.items.map((item) => (
+          {(snapshot()?.items ?? []).map((item) => (
             <box width="100%" flexDirection="column" gap={1}>
               <text width="100%" wrapMode="word" fg={theme.block.muted}>
                 {item.title}
               </text>
               {item.diff.trim() ? (
                 <box width="100%" paddingLeft={1}>
-                    <diff
-                      diff={item.diff}
-                      view={view}
-                      filetype={toolFiletype(item.file)}
-                      syntaxStyle={entrySyntax(props.commit, theme)}
-                      showLineNumbers={true}
-                      width="100%"
-                      wrapMode="word"
+                  <diff
+                    diff={item.diff}
+                    view={view}
+                    filetype={toolFiletype(item.file)}
+                    syntaxStyle={entrySyntax(props.commit, theme)}
+                    showLineNumbers={true}
+                    width="100%"
+                    wrapMode="word"
                     fg={theme.block.text}
                     addedBg={theme.block.diffAddedBg}
                     removedBg={theme.block.diffRemovedBg}
@@ -155,21 +216,21 @@ export function RunEntryContent(props: {
       )
     }
 
-    if (body().snapshot.kind === "task") {
+    if (snapshot()?.kind === "task") {
       return (
         <box width="100%" flexDirection="column" gap={1}>
           <text width="100%" wrapMode="word" fg={theme.block.muted}>
-            {body().snapshot.title}
+            {snapshot()?.title}
           </text>
           <box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
-            {body().snapshot.rows.map((row) => (
+            {(snapshot()?.rows ?? []).map((row) => (
               <text width="100%" wrapMode="word" fg={theme.block.text}>
                 {row}
               </text>
             ))}
-            {body().snapshot.tail ? (
+            {snapshot()?.tail ? (
               <text width="100%" wrapMode="word" fg={theme.block.muted}>
-                {body().snapshot.tail}
+                {snapshot()?.tail}
               </text>
             ) : null}
           </box>
@@ -177,21 +238,21 @@ export function RunEntryContent(props: {
       )
     }
 
-    if (body().snapshot.kind === "todo") {
+    if (snapshot()?.kind === "todo") {
       return (
         <box width="100%" flexDirection="column" gap={1}>
           <text width="100%" wrapMode="word" fg={theme.block.muted}>
             # Todos
           </text>
           <box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
-            {body().snapshot.items.map((item) => (
+            {(snapshot()?.items ?? []).map((item) => (
               <text width="100%" wrapMode="word" fg={theme.block.text}>
                 {todoText(item)}
               </text>
             ))}
-            {body().snapshot.tail ? (
+            {snapshot()?.tail ? (
               <text width="100%" wrapMode="word" fg={theme.block.muted}>
-                {body().snapshot.tail}
+                {snapshot()?.tail}
               </text>
             ) : null}
           </box>
@@ -200,27 +261,27 @@ export function RunEntryContent(props: {
     }
 
     return (
-        <box width="100%" flexDirection="column" gap={1}>
-          <text width="100%" wrapMode="word" fg={theme.block.muted}>
-            # Questions
-          </text>
-          <box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
-            {body().snapshot.items.map((item) => (
-              <box width="100%" flexDirection="column" gap={0}>
-                <text width="100%" wrapMode="word" fg={theme.block.muted}>
-                  {item.question}
+      <box width="100%" flexDirection="column" gap={1}>
+        <text width="100%" wrapMode="word" fg={theme.block.muted}>
+          # Questions
+        </text>
+        <box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
+          {(snapshot()?.items ?? []).map((item) => (
+            <box width="100%" flexDirection="column" gap={0}>
+              <text width="100%" wrapMode="word" fg={theme.block.muted}>
+                {item.question}
               </text>
               <text width="100%" wrapMode="word" fg={theme.block.text}>
                 {item.answer}
               </text>
-              </box>
-            ))}
-            {body().snapshot.tail ? (
-              <text width="100%" wrapMode="word" fg={theme.block.muted}>
-                {body().snapshot.tail}
-              </text>
-            ) : null}
-          </box>
+            </box>
+          ))}
+          {snapshot()?.tail ? (
+            <text width="100%" wrapMode="word" fg={theme.block.muted}>
+              {snapshot()?.tail}
+            </text>
+          ) : null}
+        </box>
       </box>
     )
   }
@@ -230,7 +291,7 @@ export function RunEntryContent(props: {
       width="100%"
       syntaxStyle={entrySyntax(props.commit, theme)}
       streaming={props.commit.phase === "progress"}
-      content={body().content}
+      content={markdown()?.content}
       fg={entryColor(props.commit, theme)}
       tableOptions={{ widthMode: "content" }}
     />

+ 2 - 0
packages/opencode/src/cli/cmd/run/types.ts

@@ -132,6 +132,8 @@ export type ToolSnapshot =
   | ToolTodoSnapshot
   | ToolQuestionSnapshot
 
+export type EntryLayout = "inline" | "block"
+
 export type RunEntryBody =
   | { type: "none" }
   | { type: "text"; content: string }

+ 7 - 8
packages/opencode/test/cli/run/footer.test.ts

@@ -38,9 +38,9 @@ function createFooter(renderer: TestRenderer) {
       inputNewline: "shift+enter",
     },
     diffStyle: "auto",
-    onPermissionReply: () => {},
-    onQuestionReply: () => {},
-    onQuestionReject: () => {},
+    onPermissionReply: () => { },
+    onQuestionReply: () => { },
+    onQuestionReject: () => { },
     treeSitterClient,
   })
 }
@@ -209,16 +209,15 @@ test("run footer keeps tool start rows tight with following reasoning", async ()
       messageID: "msg-reasoning",
       partID: "part-reasoning",
       phase: "progress",
-      text: "Thinking:  Found it.",
+      text: "Thinking:    Found it.",
     })
 
     await footer.idle()
 
-    const rows = payloads
-      .map((item) => item.replace(/ +/g, " ").trim())
-      .filter(Boolean)
+    const rows = payloads.map((item) => item.replace(/ +/g, " ").trim())
 
-    expect(rows).toEqual(['✱ Glob "**/run.ts"', "_Thinking:_ Found it."])
+    expect(payloads).toHaveLength(3)
+    expect(rows).toEqual(['✱ Glob "**/run.ts"', "", "_Thinking:_ Found it."])
   } finally {
     lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
   }

+ 44 - 1
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -2,7 +2,7 @@
 import { expect, test } from "bun:test"
 import { testRender } from "@opentui/solid"
 import { createSignal } from "solid-js"
-import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
+import { RunEntryContent, separatorRows } from "@/cli/cmd/run/scrollback.writer"
 import { RunFooterView } from "@/cli/cmd/run/footer.view"
 import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
 import type { StreamCommit } from "@/cli/cmd/run/types"
@@ -51,3 +51,46 @@ test("run entry content updates when live commit text changes", async () => {
     app.renderer.destroy()
   }
 })
+
+test("subagent rows use shared separator rules", async () => {
+  const commits: StreamCommit[] = [
+    {
+      kind: "tool",
+      source: "tool",
+      messageID: "msg-tool",
+      partID: "part-tool",
+      tool: "glob",
+      phase: "start",
+      text: "running glob",
+      toolState: "running",
+      part: {
+        id: "part-tool",
+        type: "tool",
+        tool: "glob",
+        callID: "call-tool",
+        messageID: "msg-tool",
+        sessionID: "session-1",
+        state: {
+          status: "running",
+          input: {
+            pattern: "**/run.ts",
+          },
+          time: {
+            start: 1,
+          },
+        },
+      } as never,
+    },
+    {
+      kind: "reasoning",
+      source: "reasoning",
+      messageID: "msg-reasoning",
+      partID: "part-reasoning",
+      phase: "progress",
+      text: "Thinking:  Found it.",
+    },
+  ]
+
+  expect(separatorRows(undefined, commits[0]!)).toBe(0)
+  expect(separatorRows(commits[0], commits[1]!)).toBe(1)
+})

+ 333 - 0
packages/opencode/test/cli/run/scrollback.surface.test.ts

@@ -25,6 +25,10 @@ function claimCommits(renderer: TestRenderer): ClaimedCommit[] {
   return (renderer as any).externalOutputQueue.claim() as ClaimedCommit[]
 }
 
+function renderCommit(commit: ClaimedCommit): string {
+  return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n")
+}
+
 function destroyCommits(commits: ClaimedCommit[]) {
   for (const commit of commits) {
     commit.snapshot.destroy()
@@ -268,6 +272,335 @@ test("preserves blank rows between streamed markdown block commits", async () =>
   }
 })
 
+test("inserts a spacer between inline tool starts and block tool finals", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "tool-1",
+    messageID: "msg-1",
+    tool: "write",
+    toolState: "running",
+    part: {
+      id: "tool-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "write",
+      state: {
+        status: "running",
+        input: {
+          filePath: "src/a.ts",
+          content: "const x = 1\n",
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  const start = claimCommits(out.renderer)
+  try {
+    expect(start).toHaveLength(1)
+    expect(renderCommit(start[0]!).trim()).toBe("← Write src/a.ts")
+  } finally {
+    destroyCommits(start)
+  }
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "tool-1",
+    messageID: "msg-1",
+    tool: "write",
+    toolState: "completed",
+    part: {
+      id: "tool-1",
+      sessionID: "session-1",
+      messageID: "msg-1",
+      type: "tool",
+      callID: "call-1",
+      tool: "write",
+      state: {
+        status: "completed",
+        input: {
+          filePath: "src/a.ts",
+          content: "const x = 1\n",
+        },
+        metadata: {},
+        time: {
+          start: 1,
+          end: 2,
+        },
+      },
+    } as never,
+  })
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(2)
+    expect(renderCommit(final[0]!).trim()).toBe("")
+    expect(renderCommit(final[1]!)).toContain("# Wrote src/a.ts")
+  } finally {
+    destroyCommits(final)
+  }
+})
+
+test("inserts a spacer between block assistant entries and following inline tools", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "hello",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+  await scrollback.complete()
+
+  const first = claimCommits(out.renderer)
+  try {
+    expect(first).toHaveLength(1)
+    expect(renderCommit(first[0]!).trim()).toBe("hello")
+  } finally {
+    destroyCommits(first)
+  }
+
+  await scrollback.append({
+    kind: "tool",
+    source: "tool",
+    messageID: "msg-tool",
+    partID: "part-tool",
+    tool: "glob",
+    phase: "start",
+    text: "running glob",
+    toolState: "running",
+    part: {
+      id: "part-tool",
+      type: "tool",
+      tool: "glob",
+      callID: "call-tool",
+      messageID: "msg-tool",
+      sessionID: "session-1",
+      state: {
+        status: "running",
+        input: {
+          pattern: "**/run.ts",
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  const next = claimCommits(out.renderer)
+  try {
+    expect(next).toHaveLength(2)
+    expect(renderCommit(next[0]!).trim()).toBe("")
+    expect(renderCommit(next[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"')
+  } finally {
+    destroyCommits(next)
+  }
+})
+
+test("bodyless starts keep the previous rendered item as separator context", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "assistant",
+    text: "hello",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+  await scrollback.complete()
+  destroyCommits(claimCommits(out.renderer))
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "start",
+    source: "tool",
+    partID: "task-1",
+    messageID: "msg-2",
+    tool: "task",
+    toolState: "running",
+    part: {
+      id: "task-1",
+      sessionID: "session-1",
+      messageID: "msg-2",
+      type: "tool",
+      callID: "call-2",
+      tool: "task",
+      state: {
+        status: "running",
+        input: {
+          description: "Explore run.ts",
+          subagent_type: "explore",
+        },
+        time: {
+          start: 1,
+        },
+      },
+    } as never,
+  })
+
+  expect(claimCommits(out.renderer)).toHaveLength(0)
+
+  await scrollback.append({
+    kind: "tool",
+    text: "",
+    phase: "final",
+    source: "tool",
+    partID: "task-1",
+    messageID: "msg-2",
+    tool: "task",
+    toolState: "error",
+    part: {
+      id: "task-1",
+      sessionID: "session-1",
+      messageID: "msg-2",
+      type: "tool",
+      callID: "call-2",
+      tool: "task",
+      state: {
+        status: "error",
+        input: {
+          description: "Explore run.ts",
+          subagent_type: "explore",
+        },
+        error: "boom",
+        time: {
+          start: 1,
+          end: 2,
+        },
+      },
+    } as never,
+  })
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(2)
+    expect(renderCommit(final[0]!).trim()).toBe("")
+    expect(renderCommit(final[1]!)).toContain("Explore task completed")
+  } finally {
+    destroyCommits(final)
+  }
+})
+
+test("streamed assistant blocks defer their spacer until first render", async () => {
+  const out = await createTestRenderer({
+    width: 80,
+    screenMode: "split-footer",
+    footerHeight: 6,
+    externalOutputMode: "capture-stdout",
+    consoleMode: "disabled",
+  })
+  active.push(out.renderer)
+
+  const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
+  treeSitterClient.setMockResult({ highlights: [] })
+
+  const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
+    treeSitterClient,
+    wrote: false,
+  })
+
+  await scrollback.append({
+    kind: "user",
+    text: "use subagent to explore run.ts",
+    phase: "start",
+    source: "system",
+  })
+  destroyCommits(claimCommits(out.renderer))
+
+  for (const chunk of ["Exploring", " run.ts", " via", " a codebase-aware", " subagent next."]) {
+    await scrollback.append({
+      kind: "assistant",
+      text: chunk,
+      phase: "progress",
+      source: "assistant",
+      messageID: "msg-1",
+      partID: "part-1",
+    })
+  }
+
+  const progress = claimCommits(out.renderer)
+  try {
+    expect(progress).toHaveLength(0)
+  } finally {
+    destroyCommits(progress)
+  }
+
+  await scrollback.complete()
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(2)
+    expect(renderCommit(final[0]!).trim()).toBe("")
+    expect(renderCommit(final[1]!).replace(/\n/g, " ")).toContain(
+      "Exploring run.ts via a codebase-aware subagent next.",
+    )
+  } finally {
+    destroyCommits(final)
+  }
+})
+
 test("coalesces same-line tool progress into one snapshot", async () => {
   const out = await createTestRenderer({
     width: 80,