| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432 |
- // Retained streaming append logic for direct-mode scrollback.
- //
- // Static entries are rendered through `scrollback.writer.tsx`. This file only
- // 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 {
- CodeRenderable,
- MarkdownRenderable,
- TextRenderable,
- getTreeSitterClient,
- type TreeSitterClient,
- type CliRenderer,
- type ScrollbackSurface,
- } from "@opentui/core"
- import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
- 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 { RunDiffStyle, 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 diffStyle: RunDiffStyle | undefined
- private sessionID?: () => string | undefined
- private treeSitterClient: TreeSitterClient | undefined
- private wrote: boolean
- private pendingThemes: RunTheme[] = []
- constructor(
- private renderer: CliRenderer,
- private theme: RunTheme,
- options: {
- wrote?: boolean
- diffStyle?: RunDiffStyle
- sessionID?: () => string | undefined
- treeSitterClient?: TreeSitterClient
- onThemeRelease?: (theme: RunTheme) => void
- } = {},
- ) {
- this.diffStyle = options.diffStyle
- this.sessionID = options.sessionID
- this.treeSitterClient = options.treeSitterClient
- this.wrote = options.wrote ?? false
- this.onThemeRelease = options.onThemeRelease
- }
- private onThemeRelease: ((theme: RunTheme) => void) | undefined
- 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(active.commit, 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(commit, this.theme),
- width: "100%",
- wrapMode: "word",
- drawUnstyledText: false,
- streaming: true,
- fg: entryColor(commit, this.theme),
- treeSitterClient,
- })
- : new MarkdownRenderable(surface.renderContext, {
- content: "",
- syntaxStyle: entrySyntax(commit, this.theme),
- width: "100%",
- streaming: true,
- internalBlockMode: "top-level",
- tableOptions: { widthMode: "content" },
- fg: entryColor(commit, this.theme),
- treeSitterClient,
- })
- 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)
- }
- }
- public async append(commit: StreamCommit): Promise<void> {
- const same = sameEntryGroup(this.tail, commit)
- if (!same) {
- this.markRendered(await this.finishActive(false))
- }
- if (commit.summary) {
- this.writeSpacer(1)
- this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme }))
- this.markRendered(commit)
- this.tail = commit
- return
- }
- const body = entryBody(commit)
- 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(false))
- }
- 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: {
- diffStyle: this.diffStyle,
- },
- }),
- )
- 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.resetActive()
- this.releasePendingThemes()
- }
- }
|