mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-14 00:10:03 +00:00
share style/body decisions
This commit is contained in:
parent
c7532b20d7
commit
bcf3703217
4 changed files with 183 additions and 223 deletions
92
packages/opencode/src/cli/cmd/run/scrollback.shared.ts
Normal file
92
packages/opencode/src/cli/cmd/run/scrollback.shared.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue