Browse Source

share style/body decisions

Simon Klee 1 tuần trước cách đây
mục cha
commit
bcf3703217

+ 92 - 0
packages/opencode/src/cli/cmd/run/scrollback.shared.ts

@@ -0,0 +1,92 @@
+import { SyntaxStyle, TextAttributes, type ColorInput } from "@opentui/core"
+import { type RunEntryTheme, type RunTheme } from "./theme"
+import type { StreamCommit } from "./types"
+
+function syntax(style?: SyntaxStyle): SyntaxStyle {
+  return style ?? SyntaxStyle.fromTheme([])
+}
+
+export function entrySyntax(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
+  if (commit.kind === "reasoning") {
+    return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
+  }
+
+  return syntax(theme.block.syntax)
+}
+
+export function entryFailed(commit: StreamCommit): boolean {
+  return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
+}
+
+export function entryLook(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } {
+  if (commit.kind === "user") {
+    return {
+      fg: theme.user.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (entryFailed(commit)) {
+    return {
+      fg: theme.error.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (commit.phase === "final") {
+    return {
+      fg: theme.system.body,
+      attrs: TextAttributes.DIM,
+    }
+  }
+
+  if (commit.kind === "tool" && commit.phase === "start") {
+    return {
+      fg: theme.tool.start ?? theme.tool.body,
+    }
+  }
+
+  if (commit.kind === "assistant") {
+    return { fg: theme.assistant.body }
+  }
+
+  if (commit.kind === "reasoning") {
+    return {
+      fg: theme.reasoning.body,
+      attrs: TextAttributes.DIM,
+    }
+  }
+
+  if (commit.kind === "error") {
+    return {
+      fg: theme.error.body,
+      attrs: TextAttributes.BOLD,
+    }
+  }
+
+  if (commit.kind === "tool") {
+    return { fg: theme.tool.body }
+  }
+
+  return { fg: theme.system.body }
+}
+
+export function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput {
+  if (commit.kind === "assistant") {
+    return theme.entry.assistant.body
+  }
+
+  if (commit.kind === "reasoning") {
+    return theme.entry.reasoning.body
+  }
+
+  if (entryFailed(commit)) {
+    return theme.entry.error.body
+  }
+
+  if (commit.kind === "tool") {
+    return theme.block.text
+  }
+
+  return entryLook(commit, theme.entry).fg
+}

+ 14 - 105
packages/opencode/src/cli/cmd/run/scrollback.surface.ts

@@ -7,18 +7,16 @@
 import {
   CodeRenderable,
   MarkdownRenderable,
-  SyntaxStyle,
-  TextAttributes,
   TextRenderable,
   getTreeSitterClient,
   type TreeSitterClient,
   type CliRenderer,
-  type ColorInput,
   type ScrollbackSurface,
 } from "@opentui/core"
 import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
+import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
 import { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer"
-import { type RunEntryTheme, type RunTheme } from "./theme"
+import { type RunTheme } from "./theme"
 import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
 
 type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
@@ -33,103 +31,8 @@ type ActiveEntry = {
   committedBlocks: number
 }
 
-let bare: SyntaxStyle | undefined
 let nextId = 0
 
-function syntax(style?: SyntaxStyle): SyntaxStyle {
-  if (style) {
-    return style
-  }
-
-  bare ??= SyntaxStyle.fromTheme([])
-  return bare
-}
-
-function syntaxFor(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
-  if (commit.kind === "reasoning") {
-    return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
-  }
-
-  return syntax(theme.block.syntax)
-}
-
-function failed(commit: StreamCommit): boolean {
-  return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
-}
-
-function look(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } {
-  if (commit.kind === "user") {
-    return {
-      fg: theme.user.body,
-      attrs: TextAttributes.BOLD,
-    }
-  }
-
-  if (failed(commit)) {
-    return {
-      fg: theme.error.body,
-      attrs: TextAttributes.BOLD,
-    }
-  }
-
-  if (commit.phase === "final") {
-    return {
-      fg: theme.system.body,
-      attrs: TextAttributes.DIM,
-    }
-  }
-
-  if (commit.kind === "tool" && commit.phase === "start") {
-    return {
-      fg: theme.tool.start ?? theme.tool.body,
-    }
-  }
-
-  if (commit.kind === "assistant") {
-    return { fg: theme.assistant.body }
-  }
-
-  if (commit.kind === "reasoning") {
-    return {
-      fg: theme.reasoning.body,
-      attrs: TextAttributes.DIM,
-    }
-  }
-
-  if (commit.kind === "error") {
-    return {
-      fg: theme.error.body,
-      attrs: TextAttributes.BOLD,
-    }
-  }
-
-  if (commit.kind === "tool") {
-    return { fg: theme.tool.body }
-  }
-
-  return { fg: theme.system.body }
-}
-
-function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput {
-  if (commit.kind === "assistant") {
-    return theme.entry.assistant.body
-  }
-
-  if (commit.kind === "reasoning") {
-    return theme.entry.reasoning.body
-  }
-
-  if (failed(commit)) {
-    return theme.entry.error.body
-  }
-
-  if (commit.kind === "tool") {
-    return theme.block.text
-  }
-
-  return look(commit, theme.entry).fg
-}
-
 function commitMarkdownBlocks(input: {
   surface: ScrollbackSurface
   renderable: MarkdownRenderable
@@ -147,7 +50,12 @@ function commitMarkdownBlocks(input: {
     return false
   }
 
-  input.surface.commitRows(first.renderable.y, last.renderable.y + last.renderable.height + (last.marginBottom ?? 0), {
+  const prev = input.renderable._blockStates[input.startBlock - 1]
+  const next = input.renderable._blockStates[input.endBlockExclusive]
+  const start = Math.max(0, first.renderable.y - (prev?.marginBottom ?? 0))
+  const end = last.renderable.y + last.renderable.height + (next ? 0 : (last.marginBottom ?? 0))
+
+  input.surface.commitRows(start, end, {
     trailingNewline: input.trailingNewline,
   })
   return true
@@ -179,6 +87,7 @@ export class RunScrollbackStream {
       startOnNewLine: entryFlags(commit).startOnNewLine,
     })
     const id = `run-scrollback-entry-${nextId++}`
+    const style = entryLook(commit, this.theme.entry)
     const renderable =
       body.type === "text"
         ? new TextRenderable(surface.renderContext, {
@@ -186,15 +95,15 @@ export class RunScrollbackStream {
           content: "",
           width: "100%",
           wrapMode: "word",
-          fg: look(commit, this.theme.entry).fg,
-          attributes: look(commit, this.theme.entry).attrs,
+          fg: style.fg,
+          attributes: style.attrs,
         })
         : body.type === "code"
           ? new CodeRenderable(surface.renderContext, {
             id,
             content: "",
             filetype: body.filetype,
-            syntaxStyle: syntaxFor(commit, this.theme),
+            syntaxStyle: entrySyntax(commit, this.theme),
             width: "100%",
             wrapMode: "word",
             drawUnstyledText: false,
@@ -205,7 +114,7 @@ export class RunScrollbackStream {
           : new MarkdownRenderable(surface.renderContext, {
             id,
             content: "",
-            syntaxStyle: syntaxFor(commit, this.theme),
+            syntaxStyle: entrySyntax(commit, this.theme),
             width: "100%",
             streaming: true,
             internalBlockMode: "top-level",
@@ -372,7 +281,7 @@ export class RunScrollbackStream {
     this.active = undefined
   }
 
-  public async complete(trailingNewline = true): Promise<void> {
+  public async complete(trailingNewline = false): Promise<void> {
     await this.finishActive(trailingNewline)
   }
 

+ 23 - 118
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx

@@ -1,108 +1,13 @@
 /** @jsxImportSource @opentui/solid */
 
 import { createScrollbackWriter } from "@opentui/solid"
-import { SyntaxStyle, TextAttributes, TextRenderable, type ColorInput, type ScrollbackWriter } from "@opentui/core"
+import { TextRenderable, type ScrollbackWriter } from "@opentui/core"
 import { entryBody, entryFlags } from "./entry.body"
+import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
 import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
-import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme"
+import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
 import type { ScrollbackOptions, StreamCommit } from "./types"
 
-let bare: SyntaxStyle | undefined
-
-function syntax(style?: SyntaxStyle): SyntaxStyle {
-  if (style) {
-    return style
-  }
-
-  bare ??= SyntaxStyle.fromTheme([])
-  return bare
-}
-
-function syntaxFor(commit: StreamCommit, theme: RunTheme): SyntaxStyle {
-  if (commit.kind === "reasoning") {
-    return syntax(theme.block.subtleSyntax ?? theme.block.syntax)
-  }
-
-  return syntax(theme.block.syntax)
-}
-
-function failed(commit: StreamCommit): boolean {
-  return commit.kind === "tool" && (commit.toolState === "error" || commit.part?.state.status === "error")
-}
-
-function look(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } {
-  if (commit.kind === "user") {
-    return {
-      fg: theme.user.body,
-      attrs: TextAttributes.BOLD,
-    }
-  }
-
-  if (failed(commit)) {
-    return {
-      fg: theme.error.body,
-      attrs: TextAttributes.BOLD,
-    }
-  }
-
-  if (commit.phase === "final") {
-    return {
-      fg: theme.system.body,
-      attrs: TextAttributes.DIM,
-    }
-  }
-
-  if (commit.kind === "tool" && commit.phase === "start") {
-    return {
-      fg: theme.tool.start ?? theme.tool.body,
-    }
-  }
-
-  if (commit.kind === "assistant") {
-    return { fg: theme.assistant.body }
-  }
-
-  if (commit.kind === "reasoning") {
-    return {
-      fg: theme.reasoning.body,
-      attrs: TextAttributes.DIM,
-    }
-  }
-
-  if (commit.kind === "error") {
-    return {
-      fg: theme.error.body,
-      attrs: TextAttributes.BOLD,
-    }
-  }
-
-  if (commit.kind === "tool") {
-    return { fg: theme.tool.body }
-  }
-
-  return { fg: theme.system.body }
-}
-
-function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput {
-  if (commit.kind === "assistant") {
-    return theme.entry.assistant.body
-  }
-
-  if (commit.kind === "reasoning") {
-    return theme.entry.reasoning.body
-  }
-
-  if (failed(commit)) {
-    return theme.entry.error.body
-  }
-
-  if (commit.kind === "tool") {
-    return theme.block.text
-  }
-
-  return look(commit, theme.entry).fg
-}
-
 function todoText(item: { status: string; content: string }): string {
   if (item.status === "completed") {
     return `[x] ${item.content}`
@@ -158,7 +63,7 @@ export function RunEntryContent(props: {
   }
 
   if (body.type === "text") {
-    const style = look(props.commit, theme.entry)
+    const style = entryLook(props.commit, theme.entry)
     return (
       <text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
         {body.content}
@@ -174,7 +79,7 @@ export function RunEntryContent(props: {
         filetype={body.filetype}
         drawUnstyledText={false}
         streaming={props.commit.phase === "progress"}
-        syntaxStyle={syntaxFor(props.commit, theme)}
+        syntaxStyle={entrySyntax(props.commit, theme)}
         content={body.content}
         fg={entryColor(props.commit, theme)}
       />
@@ -192,15 +97,15 @@ export function RunEntryContent(props: {
           </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={syntaxFor(props.commit, theme)}
-                content={body.snapshot.content}
-                fg={theme.block.text}
-              />
+                <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}
+                />
             </line_number>
           </box>
         </box>
@@ -218,14 +123,14 @@ export function RunEntryContent(props: {
               </text>
               {item.diff.trim() ? (
                 <box width="100%" paddingLeft={1}>
-                  <diff
-                    diff={item.diff}
-                    view={view}
-                    filetype={toolFiletype(item.file)}
-                    syntaxStyle={syntaxFor(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}
@@ -322,7 +227,7 @@ export function RunEntryContent(props: {
   return (
     <markdown
       width="100%"
-      syntaxStyle={syntaxFor(props.commit, theme)}
+      syntaxStyle={entrySyntax(props.commit, theme)}
       streaming={props.commit.phase === "progress"}
       content={body.content}
       fg={entryColor(props.commit, theme)}

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

@@ -5,9 +5,11 @@ import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
 
 type ClaimedCommit = {
   snapshot: {
+    height: number
     getRealCharBytes(addLineBreaks?: boolean): Uint8Array
     destroy(): void
   }
+  trailingNewline: boolean
 }
 
 const decoder = new TextDecoder()
@@ -111,6 +113,58 @@ test("completes coalesced markdown tables after one progress append", async () =
   }
 })
 
+test("completes markdown replies without adding a second blank line above the footer", async () => {
+  const out = await createTestRenderer({
+    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: "# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = \"Hello, markdown\"\nconsole.log(message)\n```",
+    phase: "progress",
+    source: "assistant",
+    messageID: "msg-1",
+    partID: "part-1",
+  })
+
+  const progress = claimCommits(out.renderer)
+  try {
+    expect(progress).toHaveLength(1)
+    expect(progress[0]!.snapshot.height).toBe(4)
+    const rendered = decoder.decode(progress[0]!.snapshot.getRealCharBytes(true))
+    expect(rendered).toContain("Markdown Sample")
+    expect(rendered).toContain("Item 2")
+    expect(rendered).not.toContain("console.log(message)")
+  } finally {
+    destroyCommits(progress)
+  }
+
+  await scrollback.complete()
+
+  const final = claimCommits(out.renderer)
+  try {
+    expect(final).toHaveLength(1)
+    expect(final[0]!.trailingNewline).toBe(false)
+    const rendered = decoder.decode(final[0]!.snapshot.getRealCharBytes(true))
+    expect(rendered).toContain('const message = "Hello, markdown"')
+    expect(rendered).toContain("console.log(message)")
+  } finally {
+    destroyCommits(final)
+  }
+})
+
 test("coalesces same-line tool progress into one snapshot", async () => {
   const out = await createTestRenderer({
     width: 80,