Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528// Retained streaming append logic for direct-mode scrollback.//// Static text entries are rendered through `scrollback.writer.tsx`. Retained// surfaces keep streaming markdown/code layout stable and wait for image loads// before their snapshots enter scrollback.import { CodeRenderable, ImageRenderable, MarkdownRenderable, TextRenderable, getTreeSitterClient, type TreeSitterClient, type CliRenderer, type ScrollbackSurface,} from "@opentui/core"import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono"import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"import { turnSummaryCommit } from "./turn-summary"import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"import { type RunTheme } from "./theme"import type { RunEntryBody, StreamCommit } from "./types"
type ActiveBody = Exclude<RunEntryBody, { type: "none" | "structured" }>
type ActiveEntry = { body: ActiveBody commit: StreamCommit surface: ScrollbackSurface renderable: TextRenderable | CodeRenderable | MarkdownRenderable content: string committedRows: number committedBlocks: number pendingSpacerRows: number rendered: boolean}
function commitMarkdownBlocks(input: { surface: ScrollbackSurface renderable: MarkdownRenderable startBlock: number endBlockExclusive: number trailingNewline: boolean beforeCommit?: () => void}) { if (input.endBlockExclusive <= input.startBlock) { return false }
const first = input.renderable._blockStates[input.startBlock] const last = input.renderable._blockStates[input.endBlockExclusive - 1] if (!first || !last) { return false }
const next = input.renderable._blockStates[input.endBlockExclusive] const start = first.renderable.y 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 staticBody(commit: StreamCommit, body: RunEntryBody, spaced: number): RunEntryBody { if (spaced === 0 || body.type !== "text") { return body }
if (commit.kind !== "tool" || commit.phase !== "progress" || commit.toolState !== "completed") { return body }
if (!body.content.startsWith("\n")) { return body }
return { ...body, content: body.content.replace(/^\n/, ""), }}
export class RunScrollbackStream { private tail: StreamCommit | undefined private rendered: StreamCommit | undefined private active: ActiveEntry | undefined private imageSurface: ScrollbackSurface | undefined private treeSitterClient: TreeSitterClient | undefined private wrote: boolean private shellOutput: () => boolean private mono: boolean private imagePreview: boolean private destroyed = false private pendingThemes: RunTheme[] = []
constructor( private renderer: CliRenderer, private theme: RunTheme, options: { wrote?: boolean treeSitterClient?: TreeSitterClient onThemeRelease?: (theme: RunTheme) => void shellOutput?: () => boolean mono?: boolean imagePreview?: boolean } = {}, ) { this.treeSitterClient = options.treeSitterClient this.wrote = options.wrote ?? false this.shellOutput = options.shellOutput ?? (() => true) this.mono = options.mono ?? false this.imagePreview = options.imagePreview ?? false this.onThemeRelease = options.onThemeRelease }
private onThemeRelease: ((theme: RunTheme) => void) | undefined
public async setMono(mono: boolean): Promise<void> { if (this.mono === mono) return const active = this.active if (active?.body.type !== "markdown") await this.complete() this.mono = mono if (active?.body.type !== "markdown") return
// Rebuild the Markdown tree, keeping its source and printed block boundary. // Mono hooks are one-way, and ending the entry would lose open fence/list context. const next = this.createEntry(active.commit, active.body) this.active = { ...active, surface: next.surface, renderable: next.renderable } active.surface.destroy() this.releasePendingThemes() }
private releasePendingThemes(): void { if (this.pendingThemes.length === 0) { return }
for (const theme of this.pendingThemes.splice(0)) this.onThemeRelease?.(theme) }
public setTheme(theme: RunTheme): void { if (this.theme === theme) { return }
const previous = this.theme this.theme = theme const active = this.active if (!active) { this.onThemeRelease?.(previous) return }
this.pendingThemes.push(previous)
const style = entryLook(active.commit, theme.entry) if (active.renderable instanceof TextRenderable) { active.renderable.fg = style.fg active.renderable.attributes = style.attrs ?? 0 return }
active.renderable.fg = entryColor(active.commit, theme) active.renderable.syntaxStyle = entrySyntax(theme) }
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry { const surface = this.renderer.createScrollbackSurface({ startOnNewLine: entryFlags(commit).startOnNewLine, }) const style = entryLook(commit, this.theme.entry) const treeSitterClient = body.type === "text" ? undefined : (this.treeSitterClient ??= getTreeSitterClient()) const renderable = body.type === "text" ? new TextRenderable(surface.renderContext, { content: "", width: "100%", wrapMode: "word", fg: style.fg, attributes: style.attrs, }) : body.type === "code" ? new CodeRenderable(surface.renderContext, { content: "", filetype: body.filetype, syntaxStyle: entrySyntax(this.theme), width: "100%", wrapMode: "word", drawUnstyledText: false, streaming: true, fg: entryColor(commit, this.theme), treeSitterClient, }) : new MarkdownRenderable(surface.renderContext, { content: "", syntaxStyle: entrySyntax(this.theme), width: "100%", streaming: true, internalBlockMode: "top-level", tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" }, fg: entryColor(commit, this.theme), treeSitterClient, })
if (this.mono && renderable instanceof MarkdownRenderable) monoMarkdownRenderable(renderable) surface.root.add(renderable)
const rows = separatorRows(this.rendered, commit, body)
return { body, commit, surface, renderable, content: "", committedRows: 0, committedBlocks: 0, pendingSpacerRows: rows || (!this.rendered && this.wrote ? 1 : 0), rendered: false, } }
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()) this.wrote = false }
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 false }
if (active.body.type === "text") { if (!(active.renderable instanceof TextRenderable)) { return false }
const renderable = active.renderable renderable.content = active.content active.surface.render() this.releasePendingThemes() const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1) if (targetRows <= active.committedRows) { return false }
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") { if (!(active.renderable instanceof CodeRenderable)) { return false }
const renderable = active.renderable renderable.content = active.content renderable.streaming = !done await active.surface.settle() this.releasePendingThemes() const targetRows = done ? active.surface.height : Math.max(active.committedRows, active.surface.height - 1) if (targetRows <= active.committedRows) { return false }
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.renderable instanceof MarkdownRenderable)) { return false }
const renderable = active.renderable renderable.content = active.content renderable.streaming = !done await active.surface.settle() this.releasePendingThemes() const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount if (targetBlockCount <= active.committedBlocks) { return false }
if ( commitMarkdownBlocks({ surface: active.surface, renderable, 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<StreamCommit | undefined> { if (!this.active) { return undefined }
const active = this.active
try { await this.flushActive(true, trailingNewline) } finally { if (this.active === active) { this.active = undefined }
if (!active.surface.isDestroyed) { active.surface.destroy() } this.releasePendingThemes() }
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) { this.markRendered(await this.finishActive(false)) this.active = this.createEntry(commit, body) }
this.active.body = body this.active.commit = commit this.active.content += body.content await this.flushActive(false, false) if (this.active.rendered) { this.markRendered(this.active.commit) } }
private async writeImage(commit: StreamCommit): Promise<void> { const surface = this.renderer.createScrollbackSurface(entryFlags(commit)) this.imageSurface = surface
try { const image = new ImageRenderable(surface.renderContext, { source: commit.image, fit: "fit", visible: false, alignSelf: "flex-start", flexShrink: 0, }) surface.root.add(image) // settle() waits for code highlighting, not image decoding. await image.loadPromise if (surface.isDestroyed) return
const body = entryBody(commit, { mono: this.mono }) if (body.type !== "text") return const style = entryLook(commit, this.theme.entry) const caption = new TextRenderable(surface.renderContext, { content: body.content + (image.image ? "" : "\nNo preview"), width: "100%", wrapMode: "word", fg: style.fg, attributes: style.attrs, flexShrink: 0, }) surface.root.add(caption, 0) surface.render()
if (image.image && !this.mono) { const resolution = this.renderer.resolution const fitted = image.getFittedSize( Math.min( surface.width, resolution?.width ? Math.max(1, Math.floor((image.image.width * this.renderer.terminalWidth) / resolution.width)) : Infinity, ), Math.min( Math.max(0, this.renderer.terminalHeight - this.renderer.height - caption.height), resolution?.height ? Math.max(1, Math.floor((image.image.height * this.renderer.terminalHeight) / resolution.height)) : Infinity, ), image.cellAspectRatio, ) // fit centers within its rectangle, so the rectangle itself must be fitted. image.width = fitted.width image.height = fitted.height image.visible = fitted.width > 0 && fitted.height > 0 surface.render() }
this.writeSpacer(separatorRows(this.rendered, commit, body) || (!this.rendered && this.wrote ? 1 : 0)) surface.commitRows(0, surface.height, { trailingNewline: entryFlags(commit).trailingNewline }) this.markRendered(commit) } finally { surface.destroy() this.imageSurface = undefined } }
public async append(commit: StreamCommit): Promise<void> { if (this.destroyed || this.renderer.isDestroyed) return const same = sameEntryGroup(this.tail, commit) if (!same || commit.image) { this.markRendered(await this.finishActive(false)) } if (this.destroyed || this.renderer.isDestroyed) return
if (commit.summary) { this.writeSpacer(1) this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme, mono: this.mono })) this.markRendered(commit) this.tail = commit return }
if (commit.image && this.imagePreview && !this.mono) { await this.writeImage(commit) this.tail = commit return }
const body = entryBody(commit, { shellOutput: this.shellOutput(), mono: this.mono }) if (body.type === "none") { if (entryDone(commit)) { this.markRendered(await this.finishActive(false)) }
this.tail = commit return }
if ( body.type !== "structured" && (entryCanStream(commit, body) || (commit.kind === "tool" && commit.phase === "final" && body.type === "markdown")) ) { await this.writeStreaming(commit, body) if (entryDone(commit)) { this.markRendered(await this.finishActive(entryFlags(commit).trailingNewline)) } this.tail = commit return }
if (same) { this.markRendered(await this.finishActive(false)) }
const rows = separatorRows(this.rendered, commit, body) const spaced = rows || (!this.rendered && this.wrote ? 1 : 0) this.writeSpacer(spaced)
this.renderer.writeToScrollback( entryWriter({ commit, body: staticBody(commit, body, spaced), theme: this.theme, opts: { mono: this.mono }, }), ) this.markRendered(commit) this.tail = commit }
private resetActive(): void { if (!this.active) { return }
if (!this.active.surface.isDestroyed) { this.active.surface.destroy() }
this.active = undefined this.releasePendingThemes() }
public async complete(trailingNewline = false): Promise<void> { this.markRendered(await this.finishActive(trailingNewline)) }
public async writeTurnSummary(input: { agent: string; model: string; duration: string }): Promise<void> { await this.append(turnSummaryCommit(input)) }
public destroy(): void { this.destroyed = true this.imageSurface?.destroy() this.imageSurface = undefined this.resetActive() this.releasePendingThemes() }}