mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-30 13:51:48 +08:00
markdown rendering pass 1
This commit is contained in:
@@ -27,7 +27,21 @@ import type {
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
const KINDS = ["markdown", "table", "text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"]
|
||||
const KINDS = [
|
||||
"markdown",
|
||||
"table",
|
||||
"text",
|
||||
"reasoning",
|
||||
"bash",
|
||||
"write",
|
||||
"edit",
|
||||
"patch",
|
||||
"task",
|
||||
"todo",
|
||||
"question",
|
||||
"error",
|
||||
"mix",
|
||||
]
|
||||
const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
|
||||
const QUESTIONS = ["multi", "single", "checklist", "custom"] as const
|
||||
|
||||
|
||||
183
packages/opencode/src/cli/cmd/run/entry.body.ts
Normal file
183
packages/opencode/src/cli/cmd/run/entry.body.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { toolEntryBody } from "./tool"
|
||||
import type { RunEntryBody, StreamCommit } from "./types"
|
||||
|
||||
export type EntryFlags = {
|
||||
startOnNewLine: boolean
|
||||
trailingNewline: boolean
|
||||
}
|
||||
|
||||
export const RUN_ENTRY_NONE: RunEntryBody = {
|
||||
type: "none",
|
||||
}
|
||||
|
||||
export function cleanRunText(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
function textBody(content: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function codeBody(content: string, filetype?: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "code",
|
||||
content,
|
||||
filetype,
|
||||
}
|
||||
}
|
||||
|
||||
function markdownBody(content: string): RunEntryBody {
|
||||
if (!content) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return {
|
||||
type: "markdown",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function userBody(raw: string): RunEntryBody {
|
||||
if (!raw.trim()) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
const lead = raw.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? raw.slice(lead.length) : raw
|
||||
return textBody(`${lead}› ${body}`)
|
||||
}
|
||||
|
||||
function reasoningBody(raw: string): RunEntryBody {
|
||||
const clean = raw.replace(/\[REDACTED\]/g, "")
|
||||
if (!clean) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
const lead = clean.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? clean.slice(lead.length) : clean
|
||||
const mark = "Thinking: "
|
||||
if (body.startsWith(mark)) {
|
||||
return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length)}`, "markdown")
|
||||
}
|
||||
|
||||
return codeBody(clean, "markdown")
|
||||
}
|
||||
|
||||
function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
|
||||
return textBody(phase === "progress" ? raw : raw.trim())
|
||||
}
|
||||
|
||||
export function entryFlags(commit: StreamCommit): EntryFlags {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant" || commit.kind === "reasoning") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function entryDone(commit: StreamCommit): boolean {
|
||||
if (commit.kind === "assistant" || commit.kind === "reasoning") {
|
||||
return commit.phase === "final"
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return commit.phase === "final" || (commit.phase === "progress" && commit.toolState === "completed")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function entryCanStream(commit: StreamCommit, body: RunEntryBody): boolean {
|
||||
if (commit.phase !== "progress") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (body.type === "none") {
|
||||
return false
|
||||
}
|
||||
|
||||
return commit.kind === "assistant" || commit.kind === "reasoning" || commit.kind === "tool"
|
||||
}
|
||||
|
||||
export function entryBody(commit: StreamCommit): RunEntryBody {
|
||||
const raw = cleanRunText(commit.text)
|
||||
|
||||
if (commit.kind === "user") {
|
||||
return userBody(raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return toolEntryBody(commit, raw) ?? RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
if (commit.phase === "start") {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return markdownBody(raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
if (commit.phase === "start") {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return reasoningBody(raw)
|
||||
}
|
||||
|
||||
return systemBody(raw, commit.phase)
|
||||
}
|
||||
@@ -292,12 +292,7 @@ export function RunQuestionBody(props: {
|
||||
backgroundColor={props.theme.surface}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<box id="run-direct-footer-question-tabs"
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box id="run-direct-footer-question-tabs" flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const active = () => state().tab === index()
|
||||
|
||||
@@ -35,13 +35,11 @@ function statusIcon(status: FooterSubagentTab["status"]) {
|
||||
return "◔"
|
||||
}
|
||||
|
||||
function tabText(input: {
|
||||
tab: FooterSubagentTab
|
||||
slot: string
|
||||
count: number
|
||||
width: number
|
||||
}) {
|
||||
const perTab = Math.max(1, Math.floor((input.width - 4 - Math.max(0, input.count - 1) * 3) / Math.max(1, input.count)))
|
||||
function tabText(input: { tab: FooterSubagentTab; slot: string; count: number; width: number }) {
|
||||
const perTab = Math.max(
|
||||
1,
|
||||
Math.floor((input.width - 4 - Math.max(0, input.count - 1) * 3) / Math.max(1, input.count)),
|
||||
)
|
||||
if (input.count >= 8 || perTab < 12) {
|
||||
return `[${input.slot}]`
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
// and the footer is the only region that can repaint. RunFooter owns both
|
||||
// sides of that boundary:
|
||||
//
|
||||
// Scrollback: append() queues StreamCommit entries and flush() writes them
|
||||
// to the renderer via writeToScrollback(). Commits coalesce in a microtask
|
||||
// queue -- consecutive progress chunks for the same part merge into one
|
||||
// write to avoid excessive scrollback snapshots.
|
||||
// Scrollback: append() queues StreamCommit entries and flush() drains them
|
||||
// through retained scrollback surfaces. Commits coalesce in a microtask
|
||||
// queue so direct-mode transcript updates still preserve ordering without
|
||||
// rebuilding the session model.
|
||||
//
|
||||
// Footer: event() updates the SolidJS signal-backed FooterState, which
|
||||
// drives the reactive footer view (prompt, status, permission, question).
|
||||
@@ -23,16 +23,14 @@
|
||||
//
|
||||
// Interrupt and exit use a two-press pattern: first press shows a hint,
|
||||
// second press within 5 seconds actually fires the action.
|
||||
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
||||
import { CliRenderEvents, type CliRenderer, type TreeSitterClient } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
|
||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import { RunFooterView } from "./footer.view"
|
||||
import { normalizeEntry } from "./scrollback.format"
|
||||
import { entryWriter } from "./scrollback"
|
||||
import { sameEntryGroup, spacerWriter } from "./scrollback.writer"
|
||||
import { RunScrollbackStream } from "./scrollback.surface"
|
||||
import type { RunTheme } from "./theme"
|
||||
import type {
|
||||
RunAgent,
|
||||
@@ -77,6 +75,7 @@ type RunFooterOptions = {
|
||||
onInterrupt?: () => void
|
||||
onExit?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
treeSitterClient?: TreeSitterClient
|
||||
}
|
||||
|
||||
const PERMISSION_ROWS = 12
|
||||
@@ -96,13 +95,10 @@ export class RunFooter implements FooterApi {
|
||||
private destroyed = false
|
||||
private prompts = new Set<(input: RunPrompt) => void>()
|
||||
private closes = new Set<() => void>()
|
||||
// Most recent visible scrollback commit.
|
||||
private tail: StreamCommit | undefined
|
||||
// The entry splash is already in scrollback before footer output starts.
|
||||
private wrote = true
|
||||
// Microtask-coalesced commit queue. Flushed on next microtask or on close/destroy.
|
||||
private queue: StreamCommit[] = []
|
||||
private pending = false
|
||||
private flushing: Promise<void> = Promise.resolve()
|
||||
// Fixed portion of footer height above the textarea.
|
||||
private base: number
|
||||
private rows = TEXTAREA_MIN_ROWS
|
||||
@@ -121,6 +117,7 @@ export class RunFooter implements FooterApi {
|
||||
private interruptTimeout: NodeJS.Timeout | undefined
|
||||
private exitTimeout: NodeJS.Timeout | undefined
|
||||
private interruptHint: string
|
||||
private scrollback: RunScrollbackStream
|
||||
|
||||
constructor(
|
||||
private renderer: CliRenderer,
|
||||
@@ -153,6 +150,10 @@ export class RunFooter implements FooterApi {
|
||||
this.setSubagent = setSubagent
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
|
||||
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
|
||||
diffStyle: options.diffStyle,
|
||||
treeSitterClient: options.treeSitterClient,
|
||||
})
|
||||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
|
||||
@@ -325,6 +326,7 @@ export class RunFooter implements FooterApi {
|
||||
|
||||
if (prev.phase === "running" && state.phase === "idle") {
|
||||
this.flush()
|
||||
this.flushing = this.flushing.then(() => this.scrollback.complete()).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,18 +340,14 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
// Queues a scrollback commit. Consecutive progress chunks for the same
|
||||
// part coalesce by appending text, reducing the number of renderer writes.
|
||||
// Actual flush happens on the next microtask, so a burst of events from
|
||||
// one reducer pass becomes a single scrollback write.
|
||||
// part coalesce by appending text, reducing the number of retained-surface
|
||||
// updates. Actual flush happens on the next microtask, so a burst of events
|
||||
// from one reducer pass becomes a single ordered drain.
|
||||
public append(commit: StreamCommit): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!normalizeEntry(commit)) {
|
||||
return
|
||||
}
|
||||
|
||||
const last = this.queue.at(-1)
|
||||
if (
|
||||
last &&
|
||||
@@ -381,7 +379,22 @@ export class RunFooter implements FooterApi {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.renderer.idle().catch(() => {})
|
||||
this.flush()
|
||||
if (this.state().phase === "idle") {
|
||||
this.flushing = this.flushing.then(() => this.scrollback.complete()).catch(() => {})
|
||||
}
|
||||
|
||||
return this.flushing.then(async () => {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.queue.length > 0) {
|
||||
return this.idle()
|
||||
}
|
||||
|
||||
await this.renderer.idle().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
@@ -410,8 +423,7 @@ export class RunFooter implements FooterApi {
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.prompts.clear()
|
||||
this.closes.clear()
|
||||
this.tail = undefined
|
||||
this.wrote = false
|
||||
this.scrollback.destroy()
|
||||
}
|
||||
|
||||
private notifyClose(): void {
|
||||
@@ -638,27 +650,25 @@ export class RunFooter implements FooterApi {
|
||||
this.renderer.off(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.prompts.clear()
|
||||
this.closes.clear()
|
||||
this.tail = undefined
|
||||
this.wrote = false
|
||||
this.scrollback.destroy()
|
||||
}
|
||||
|
||||
// Drains the commit queue to scrollback. Visible commits start a new block
|
||||
// whenever their block key changes, and new blocks get a single spacer.
|
||||
// Drains the commit queue to scrollback. The surface manager owns grouping,
|
||||
// spacing, and progressive markdown/code settling so direct mode can append
|
||||
// immutable transcript rows without rewriting history.
|
||||
private flush(): void {
|
||||
if (this.destroyed || this.renderer.isDestroyed || this.queue.length === 0) {
|
||||
this.queue.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of this.queue.splice(0)) {
|
||||
const same = sameEntryGroup(this.tail, item)
|
||||
if (this.wrote && !same) {
|
||||
this.renderer.writeToScrollback(spacerWriter())
|
||||
}
|
||||
|
||||
this.renderer.writeToScrollback(entryWriter(item, this.options.theme, { diffStyle: this.options.diffStyle }))
|
||||
this.wrote = true
|
||||
this.tail = item
|
||||
}
|
||||
const batch = this.queue.splice(0)
|
||||
this.flushing = this.flushing
|
||||
.then(async () => {
|
||||
for (const item of batch) {
|
||||
await this.scrollback.append(item)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
process.off("SIGINT", sigint)
|
||||
|
||||
try {
|
||||
await footer.idle().catch(() => {})
|
||||
|
||||
const show = renderer.isDestroyed ? false : next.showExit
|
||||
if (!renderer.isDestroyed && show) {
|
||||
const sessionID = next.sessionID ?? input.sessionID
|
||||
@@ -250,6 +252,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
}
|
||||
} finally {
|
||||
footer.close()
|
||||
await footer.idle().catch(() => {})
|
||||
footer.destroy()
|
||||
shutdown(renderer)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ type BootContext = Pick<
|
||||
type RunRuntimeInput = {
|
||||
boot: () => Promise<BootContext>
|
||||
afterPaint?: (ctx: BootContext) => Promise<void> | void
|
||||
resolveSession?: (ctx: BootContext) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
|
||||
resolveSession?: (
|
||||
ctx: BootContext,
|
||||
) => Promise<{ sessionID: string; sessionTitle?: string; agent?: string | undefined }>
|
||||
files: RunInput["files"]
|
||||
initialInput?: string
|
||||
thinking: boolean
|
||||
@@ -384,7 +386,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
if (signal.aborted || footer.isClosed) {
|
||||
return
|
||||
}
|
||||
const text = stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error))
|
||||
const text =
|
||||
stream?.mod.formatUnknownError(error) ?? (error instanceof Error ? error.message : String(error))
|
||||
footer.append({ kind: "error", text, phase: "start", source: "system" })
|
||||
}
|
||||
},
|
||||
@@ -393,14 +396,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
await stream?.handle.close()
|
||||
}
|
||||
} finally {
|
||||
const title = shown && hasSession
|
||||
? await ctx.sdk.session
|
||||
.get({
|
||||
sessionID,
|
||||
})
|
||||
.then((x) => x.data?.title)
|
||||
.catch(() => undefined)
|
||||
: undefined
|
||||
const title =
|
||||
shown && hasSession
|
||||
? await ctx.sdk.session
|
||||
.get({
|
||||
sessionID,
|
||||
})
|
||||
.then((x) => x.data?.title)
|
||||
.catch(() => undefined)
|
||||
: undefined
|
||||
|
||||
await shell.close({
|
||||
showExit: shown && hasSession,
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
// Text normalization for scrollback entries.
|
||||
//
|
||||
// Transforms a StreamCommit into the final text that will be appended to
|
||||
// terminal scrollback. Each entry kind has its own formatting:
|
||||
//
|
||||
// user → prefixed with "› "
|
||||
// assistant → raw text (progress), empty (start/final unless interrupted)
|
||||
// reasoning → raw text with [REDACTED] stripped
|
||||
// tool → delegated to tool.ts for per-tool scrollback formatting
|
||||
// error/system → raw trimmed text
|
||||
//
|
||||
// Returns an empty string when the commit should produce no visible output
|
||||
// (e.g., assistant start events, empty final events).
|
||||
import { toolFrame, toolScroll, toolView } from "./tool"
|
||||
import type { StreamCommit } from "./types"
|
||||
|
||||
export function clean(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
function toolText(commit: StreamCommit, raw: string): string {
|
||||
const ctx = toolFrame(commit, raw)
|
||||
const view = toolView(ctx.name)
|
||||
|
||||
if (commit.phase === "progress" && !view.output) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
if (ctx.status === "error") {
|
||||
return toolScroll("final", ctx)
|
||||
}
|
||||
|
||||
if (!view.final) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (ctx.status && ctx.status !== "completed") {
|
||||
return ctx.raw.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return toolScroll(commit.phase, ctx)
|
||||
}
|
||||
|
||||
export function normalizeEntry(commit: StreamCommit): string {
|
||||
const raw = clean(commit.text)
|
||||
|
||||
if (commit.kind === "user") {
|
||||
if (!raw.trim()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const lead = raw.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? raw.slice(lead.length) : raw
|
||||
return `${lead}› ${body}`
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return toolText(commit, raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
if (commit.phase === "start") {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? "assistant interrupted" : ""
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
if (commit.phase === "start") {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
return commit.interrupted ? "reasoning interrupted" : ""
|
||||
}
|
||||
|
||||
return raw.replace(/\[REDACTED\]/g, "")
|
||||
}
|
||||
|
||||
if (commit.phase === "start" || commit.phase === "final") {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
382
packages/opencode/src/cli/cmd/run/scrollback.surface.ts
Normal file
382
packages/opencode/src/cli/cmd/run/scrollback.surface.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
// 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,
|
||||
// reasoning, and tool progress entries that need stable markdown/code layout
|
||||
// while content is still arriving.
|
||||
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 { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer"
|
||||
import { type RunEntryTheme, 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
|
||||
}
|
||||
|
||||
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
|
||||
startBlock: number
|
||||
endBlockExclusive: number
|
||||
trailingNewline: boolean
|
||||
}) {
|
||||
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
|
||||
}
|
||||
|
||||
input.surface.commitRows(first.renderable.y, last.renderable.y + last.renderable.height + (last.marginBottom ?? 0), {
|
||||
trailingNewline: input.trailingNewline,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
export class RunScrollbackStream {
|
||||
private tail: StreamCommit | undefined
|
||||
private active: ActiveEntry | undefined
|
||||
private wrote: boolean
|
||||
private diffStyle: RunDiffStyle | undefined
|
||||
private treeSitterClient: TreeSitterClient | undefined
|
||||
|
||||
constructor(
|
||||
private renderer: CliRenderer,
|
||||
private theme: RunTheme,
|
||||
options: {
|
||||
wrote?: boolean
|
||||
diffStyle?: RunDiffStyle
|
||||
treeSitterClient?: TreeSitterClient
|
||||
} = {},
|
||||
) {
|
||||
this.wrote = options.wrote ?? true
|
||||
this.diffStyle = options.diffStyle
|
||||
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
||||
}
|
||||
|
||||
private createEntry(commit: StreamCommit, body: ActiveBody): ActiveEntry {
|
||||
const surface = this.renderer.createScrollbackSurface({
|
||||
startOnNewLine: entryFlags(commit).startOnNewLine,
|
||||
})
|
||||
const id = `run-scrollback-entry-${nextId++}`
|
||||
const renderable =
|
||||
body.type === "text"
|
||||
? new TextRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
fg: look(commit, this.theme.entry).fg,
|
||||
attributes: look(commit, this.theme.entry).attrs,
|
||||
})
|
||||
: body.type === "code"
|
||||
? new CodeRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
filetype: body.filetype,
|
||||
syntaxStyle: syntaxFor(commit, this.theme),
|
||||
width: "100%",
|
||||
wrapMode: "word",
|
||||
drawUnstyledText: false,
|
||||
streaming: true,
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
})
|
||||
: new MarkdownRenderable(surface.renderContext, {
|
||||
id,
|
||||
content: "",
|
||||
syntaxStyle: syntaxFor(commit, this.theme),
|
||||
width: "100%",
|
||||
streaming: true,
|
||||
internalBlockMode: "top-level",
|
||||
tableOptions: { widthMode: "content" },
|
||||
fg: entryColor(commit, this.theme),
|
||||
treeSitterClient: this.treeSitterClient,
|
||||
})
|
||||
|
||||
surface.root.add(renderable)
|
||||
|
||||
return {
|
||||
body,
|
||||
commit,
|
||||
surface,
|
||||
renderable,
|
||||
content: "",
|
||||
committedRows: 0,
|
||||
committedBlocks: 0,
|
||||
}
|
||||
}
|
||||
|
||||
private async flushActive(done: boolean, trailingNewline: boolean): Promise<void> {
|
||||
const active = this.active
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (active.body.type === "text") {
|
||||
const renderable = active.renderable as TextRenderable
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (active.body.type === "code") {
|
||||
const renderable = active.renderable as CodeRenderable
|
||||
renderable.content = active.content
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const renderable = active.renderable as MarkdownRenderable
|
||||
renderable.content = active.content
|
||||
renderable.streaming = !done
|
||||
await active.surface.settle()
|
||||
const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
|
||||
if (targetBlockCount <= active.committedBlocks) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
commitMarkdownBlocks({
|
||||
surface: active.surface,
|
||||
renderable,
|
||||
startBlock: active.committedBlocks,
|
||||
endBlockExclusive: targetBlockCount,
|
||||
trailingNewline: done && targetBlockCount === renderable._blockStates.length ? trailingNewline : false,
|
||||
})
|
||||
) {
|
||||
active.committedBlocks = targetBlockCount
|
||||
}
|
||||
}
|
||||
|
||||
private async finishActive(trailingNewline: boolean): Promise<void> {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.active = this.createEntry(commit, body)
|
||||
}
|
||||
|
||||
this.active.body = body
|
||||
this.active.commit = commit
|
||||
this.active.content += body.content
|
||||
await this.flushActive(false, false)
|
||||
}
|
||||
|
||||
public async append(commit: StreamCommit): Promise<void> {
|
||||
const same = sameEntryGroup(this.tail, commit)
|
||||
if (!same) {
|
||||
await this.finishActive(false)
|
||||
}
|
||||
|
||||
const body = entryBody(commit)
|
||||
if (body.type === "none") {
|
||||
if (entryDone(commit)) {
|
||||
await this.finishActive(entryFlags(commit).trailingNewline)
|
||||
}
|
||||
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
if (this.wrote && !same) {
|
||||
this.renderer.writeToScrollback(spacerWriter())
|
||||
}
|
||||
|
||||
if (body.type !== "structured" && entryCanStream(commit, body)) {
|
||||
await this.writeStreaming(commit, body)
|
||||
this.wrote = true
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
if (same) {
|
||||
await this.finishActive(false)
|
||||
}
|
||||
|
||||
this.renderer.writeToScrollback(
|
||||
entryWriter({
|
||||
commit,
|
||||
theme: this.theme,
|
||||
opts: {
|
||||
diffStyle: this.diffStyle,
|
||||
},
|
||||
}),
|
||||
)
|
||||
this.wrote = true
|
||||
this.tail = commit
|
||||
}
|
||||
|
||||
private resetActive(): void {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.active.surface.isDestroyed) {
|
||||
this.active.surface.destroy()
|
||||
}
|
||||
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
public async complete(trailingNewline = true): Promise<void> {
|
||||
await this.finishActive(trailingNewline)
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.resetActive()
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// Entry writer routing for scrollback commits.
|
||||
//
|
||||
// Decides whether a commit should render as plain text or as a rich snapshot
|
||||
// (code block, diff view, task card, etc.). Completed tool parts whose tool
|
||||
// rule has a "snap" mode get routed to snapEntryWriter, which produces a
|
||||
// structured JSX snapshot. Everything else goes through textEntryWriter.
|
||||
import type { ScrollbackWriter } from "@opentui/core"
|
||||
import { toolView } from "./tool"
|
||||
import { snapEntryWriter, textEntryWriter } from "./scrollback.writer"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
||||
import type { ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
export function entryWriter(
|
||||
commit: StreamCommit,
|
||||
theme: RunTheme = RUN_THEME_FALLBACK,
|
||||
opts: ScrollbackOptions = {},
|
||||
): ScrollbackWriter {
|
||||
const state = commit.toolState ?? commit.part?.state.status
|
||||
if (commit.kind === "tool" && commit.phase === "final" && state === "completed") {
|
||||
if (toolView(commit.tool).snap) {
|
||||
return snapEntryWriter(commit, theme, opts)
|
||||
}
|
||||
}
|
||||
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
@@ -1,140 +1,12 @@
|
||||
// JSX-based scrollback snapshot writers for rich tool output.
|
||||
//
|
||||
// When a tool commit has a "snap" mode (code, diff, task, todo, question),
|
||||
// snapEntryWriter renders it as a structured JSX tree that OpenTUI converts
|
||||
// into a ScrollbackSnapshot. These snapshots support syntax highlighting,
|
||||
// unified/split diffs, line numbers, and LSP diagnostics.
|
||||
//
|
||||
// The writers use OpenTUI's createScrollbackWriter to produce snapshots.
|
||||
// OpenTUI measures and reflows them when the terminal resizes. The fit()
|
||||
// helper measures actual rendered width so narrow content doesn't claim
|
||||
// the full terminal width.
|
||||
//
|
||||
// Plain text entries (textEntryWriter) also go through here -- they just
|
||||
// produce a simple <text> element with the right color and attributes.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
|
||||
import {
|
||||
SyntaxStyle,
|
||||
TextAttributes,
|
||||
type ColorInput,
|
||||
type ScrollbackRenderContext,
|
||||
type ScrollbackSnapshot,
|
||||
type ScrollbackWriter,
|
||||
} from "@opentui/core"
|
||||
import { createScrollbackWriter, type JSX } from "@opentui/solid"
|
||||
import { For, Show } from "solid-js"
|
||||
import * as Filesystem from "../../../util/filesystem"
|
||||
import { toolDiffView, toolFiletype, toolFrame, toolSnapshot, toolView } from "./tool"
|
||||
import { clean, normalizeEntry } from "./scrollback.format"
|
||||
import { createScrollbackWriter } from "@opentui/solid"
|
||||
import { SyntaxStyle, TextAttributes, TextRenderable, type ColorInput, type ScrollbackWriter } from "@opentui/core"
|
||||
import { entryBody, entryFlags } from "./entry.body"
|
||||
import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme"
|
||||
import type { ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
type ToolDict = Record<string, unknown>
|
||||
|
||||
function dict(v: unknown): ToolDict {
|
||||
if (!v || typeof v !== "object") {
|
||||
return {}
|
||||
}
|
||||
|
||||
return v as ToolDict
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === "string" ? v : ""
|
||||
}
|
||||
|
||||
function arr(v: unknown): unknown[] {
|
||||
return Array.isArray(v) ? v : []
|
||||
}
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||
return
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
function diagnostics(meta: ToolDict, file: string): string[] {
|
||||
const all = dict(meta.diagnostics)
|
||||
const key = Filesystem.normalizePath(file)
|
||||
const list = arr(all[key]).map(dict)
|
||||
return list
|
||||
.filter((item) => item.severity === 1)
|
||||
.slice(0, 3)
|
||||
.map((item) => {
|
||||
const range = dict(item.range)
|
||||
const start = dict(range.start)
|
||||
const line = num(start.line)
|
||||
const char = num(start.character)
|
||||
const msg = text(item.message)
|
||||
if (line === undefined || char === undefined) {
|
||||
return `Error ${msg}`.trim()
|
||||
}
|
||||
|
||||
return `Error [${line + 1}:${char + 1}] ${msg}`.trim()
|
||||
})
|
||||
}
|
||||
|
||||
type Flags = {
|
||||
startOnNewLine: boolean
|
||||
trailingNewline: boolean
|
||||
}
|
||||
|
||||
type Paint = {
|
||||
fg: ColorInput
|
||||
attrs?: number
|
||||
}
|
||||
|
||||
type CodeInput = {
|
||||
title: string
|
||||
content: string
|
||||
filetype?: string
|
||||
diagnostics: string[]
|
||||
}
|
||||
|
||||
type DiffInput = {
|
||||
title: string
|
||||
diff?: string
|
||||
filetype?: string
|
||||
deletions?: number
|
||||
diagnostics: string[]
|
||||
}
|
||||
|
||||
type TaskInput = {
|
||||
title: string
|
||||
rows: string[]
|
||||
tail: string
|
||||
}
|
||||
|
||||
type TodoInput = {
|
||||
items: Array<{
|
||||
status: string
|
||||
content: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
type QuestionInput = {
|
||||
items: Array<{
|
||||
question: string
|
||||
answer: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
type Measure = {
|
||||
widthColsMax: number
|
||||
}
|
||||
|
||||
type MeasureNode = {
|
||||
textBufferView?: {
|
||||
measureForDimensions(width: number, height: number): Measure | null
|
||||
}
|
||||
getChildren?: () => unknown[]
|
||||
}
|
||||
|
||||
let bare: SyntaxStyle | undefined
|
||||
|
||||
function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
@@ -146,11 +18,19 @@ function syntax(style?: SyntaxStyle): SyntaxStyle {
|
||||
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): Paint {
|
||||
function look(commit: StreamCommit, theme: RunEntryTheme): { fg: ColorInput; attrs?: number } {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
fg: theme.user.body,
|
||||
@@ -203,237 +83,55 @@ function look(commit: StreamCommit, theme: RunEntryTheme): Paint {
|
||||
return { fg: theme.system.body }
|
||||
}
|
||||
|
||||
function cols(ctx: ScrollbackRenderContext): number {
|
||||
return Math.max(1, Math.trunc(ctx.width))
|
||||
}
|
||||
|
||||
function leaf(node: unknown): MeasureNode | undefined {
|
||||
if (!node || typeof node !== "object") {
|
||||
return
|
||||
function entryColor(commit: StreamCommit, theme: RunTheme): ColorInput {
|
||||
if (commit.kind === "assistant") {
|
||||
return theme.entry.assistant.body
|
||||
}
|
||||
|
||||
const next = node as MeasureNode
|
||||
if (next.textBufferView) {
|
||||
return next
|
||||
if (commit.kind === "reasoning") {
|
||||
return theme.entry.reasoning.body
|
||||
}
|
||||
|
||||
const list = next.getChildren?.() ?? []
|
||||
for (const child of list) {
|
||||
const out = leaf(child)
|
||||
if (out) {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fit(snapshot: ScrollbackSnapshot, ctx: ScrollbackRenderContext) {
|
||||
const node = leaf(snapshot.root)
|
||||
const width = cols(ctx)
|
||||
const box = node?.textBufferView?.measureForDimensions(width, Math.max(1, snapshot.height ?? 1))
|
||||
const rowColumns = Math.max(1, Math.min(width, box?.widthColsMax ?? 0))
|
||||
|
||||
snapshot.width = width
|
||||
snapshot.rowColumns = rowColumns
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function full(node: () => JSX.Element, ctx: ScrollbackRenderContext, flags: Flags) {
|
||||
return createScrollbackWriter(node, {
|
||||
width: cols(ctx),
|
||||
rowColumns: cols(ctx),
|
||||
startOnNewLine: flags.startOnNewLine,
|
||||
trailingNewline: flags.trailingNewline,
|
||||
})(ctx)
|
||||
}
|
||||
|
||||
function TextEntry(props: { body: string; fg: ColorInput; attrs?: number }) {
|
||||
return (
|
||||
<text width="100%" wrapMode="word" fg={props.fg} attributes={props.attrs}>
|
||||
{props.body}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function thinking(body: string) {
|
||||
const mark = "Thinking: "
|
||||
if (body.startsWith(mark)) {
|
||||
return {
|
||||
head: mark,
|
||||
tail: body.slice(mark.length),
|
||||
}
|
||||
if (failed(commit)) {
|
||||
return theme.entry.error.body
|
||||
}
|
||||
|
||||
return {
|
||||
tail: body,
|
||||
if (commit.kind === "tool") {
|
||||
return theme.block.text
|
||||
}
|
||||
|
||||
return look(commit, theme.entry).fg
|
||||
}
|
||||
|
||||
function ReasoningEntry(props: { body: string; theme: RunEntryTheme }) {
|
||||
const part = thinking(props.body)
|
||||
return (
|
||||
<text
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.reasoning.body}
|
||||
attributes={TextAttributes.DIM | TextAttributes.ITALIC}
|
||||
>
|
||||
<Show when={part.head}>{part.head}</Show>
|
||||
{part.tail}
|
||||
</text>
|
||||
)
|
||||
function blankWriter(): ScrollbackWriter {
|
||||
return (ctx) => ({
|
||||
root: new TextRenderable(ctx.renderContext, {
|
||||
id: "run-scrollback-spacer",
|
||||
width: Math.max(1, Math.trunc(ctx.width)),
|
||||
height: 1,
|
||||
content: "",
|
||||
}),
|
||||
width: Math.max(1, Math.trunc(ctx.width)),
|
||||
height: 1,
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
})
|
||||
}
|
||||
|
||||
function Diagnostics(props: { theme: RunTheme; lines: string[] }) {
|
||||
return (
|
||||
<Show when={props.lines.length > 0}>
|
||||
<box>
|
||||
<For each={props.lines}>{(line) => <text fg={props.theme.entry.error.body}>{line}</text>}</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockTool(props: { theme: RunTheme; title: string; children: JSX.Element }) {
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
|
||||
{props.title}
|
||||
</text>
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeTool(props: { theme: RunTheme; data: CodeInput }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title={props.data.title}>
|
||||
<line_number fg={props.theme.block.muted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
fg={props.theme.block.text}
|
||||
filetype={props.data.filetype}
|
||||
syntaxStyle={syntax(props.theme.block.syntax)}
|
||||
content={props.data.content}
|
||||
drawUnstyledText={true}
|
||||
wrapMode="word"
|
||||
/>
|
||||
</line_number>
|
||||
<Diagnostics theme={props.theme} lines={props.data.diagnostics} />
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffTool(props: { theme: RunTheme; data: DiffInput; view: "unified" | "split" }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title={props.data.title}>
|
||||
<Show
|
||||
when={props.data.diff?.trim()}
|
||||
fallback={
|
||||
<text fg={props.theme.block.diffRemoved}>
|
||||
-{props.data.deletions ?? 0} line{props.data.deletions === 1 ? "" : "s"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box>
|
||||
<diff
|
||||
diff={props.data.diff ?? ""}
|
||||
view={props.view}
|
||||
filetype={props.data.filetype}
|
||||
syntaxStyle={syntax(props.theme.block.syntax)}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={props.theme.block.text}
|
||||
addedBg={props.theme.block.diffAddedBg}
|
||||
removedBg={props.theme.block.diffRemovedBg}
|
||||
contextBg={props.theme.block.diffContextBg}
|
||||
addedSignColor={props.theme.block.diffHighlightAdded}
|
||||
removedSignColor={props.theme.block.diffHighlightRemoved}
|
||||
lineNumberFg={props.theme.block.diffLineNumber}
|
||||
lineNumberBg={props.theme.block.diffContextBg}
|
||||
addedLineNumberBg={props.theme.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={props.theme.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Diagnostics theme={props.theme} lines={props.data.diagnostics} />
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskTool(props: { theme: RunTheme; data: TaskInput }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title={props.data.title}>
|
||||
<box>
|
||||
<For each={props.data.rows}>{(line) => <text fg={props.theme.block.text}>{line}</text>}</For>
|
||||
</box>
|
||||
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
|
||||
{props.data.tail}
|
||||
</text>
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function todoMark(status: string): string {
|
||||
if (status === "completed") {
|
||||
return "[x]"
|
||||
function todoText(item: { status: string; content: string }): string {
|
||||
if (item.status === "completed") {
|
||||
return `[x] ${item.content}`
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return "[>]"
|
||||
|
||||
if (item.status === "cancelled") {
|
||||
return `[ ] ${item.content} (cancelled)`
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
return "[-]"
|
||||
|
||||
if (item.status === "in_progress") {
|
||||
return `[ ] ${item.content} (in progress)`
|
||||
}
|
||||
return "[ ]"
|
||||
}
|
||||
|
||||
function TodoTool(props: { theme: RunTheme; data: TodoInput }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title="# Todos">
|
||||
<box>
|
||||
<For each={props.data.items}>
|
||||
{(item) => (
|
||||
<text fg={props.theme.block.text}>
|
||||
{todoMark(item.status)} {item.content}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
|
||||
{props.data.tail}
|
||||
</text>
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) {
|
||||
return (
|
||||
<BlockTool theme={props.theme} title="# Questions">
|
||||
<text fg={props.theme.block.muted} attributes={TextAttributes.DIM}>
|
||||
{props.data.tail}
|
||||
</text>
|
||||
<box gap={1}>
|
||||
<For each={props.data.items}>
|
||||
{(item) => (
|
||||
<box flexDirection="column">
|
||||
<text fg={props.theme.block.muted}>{item.question}</text>
|
||||
<text fg={props.theme.block.text}>{item.answer}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function snapCommit(commit: StreamCommit) {
|
||||
const state = commit.toolState ?? commit.part?.state.status
|
||||
return (
|
||||
commit.kind === "tool" &&
|
||||
commit.phase === "final" &&
|
||||
state === "completed" &&
|
||||
Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
|
||||
)
|
||||
return `[ ] ${item.content}`
|
||||
}
|
||||
|
||||
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
@@ -441,7 +139,7 @@ export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
return
|
||||
}
|
||||
|
||||
if (snapCommit(commit)) {
|
||||
if (toolStructuredFinal(commit)) {
|
||||
return `tool:${commit.partID}:final`
|
||||
}
|
||||
|
||||
@@ -462,111 +160,6 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
|
||||
return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start"
|
||||
}
|
||||
|
||||
export function RunEntryTextContent(props: { commit: StreamCommit; theme: RunEntryTheme }) {
|
||||
const body = normalizeEntry(props.commit)
|
||||
if (props.commit.kind === "reasoning") {
|
||||
return <ReasoningEntry body={body} theme={props.theme} />
|
||||
}
|
||||
|
||||
const style = look(props.commit, props.theme)
|
||||
return <TextEntry body={body} fg={style.fg} attrs={style.attrs} />
|
||||
}
|
||||
|
||||
export function RunEntrySnapContent(props: {
|
||||
commit: StreamCommit
|
||||
theme: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
width?: number
|
||||
}) {
|
||||
const raw = clean(props.commit.text)
|
||||
const snap = toolSnapshot(props.commit, raw)
|
||||
if (!snap) {
|
||||
return <RunEntryTextContent commit={props.commit} theme={props.theme.entry} />
|
||||
}
|
||||
|
||||
const info = toolFrame(props.commit, raw)
|
||||
if (snap.kind === "code") {
|
||||
return (
|
||||
<CodeTool
|
||||
theme={props.theme}
|
||||
data={{
|
||||
title: snap.title,
|
||||
content: snap.content,
|
||||
filetype: toolFiletype(snap.file),
|
||||
diagnostics: diagnostics(info.meta, snap.file ?? ""),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "diff") {
|
||||
const list = snap.items
|
||||
.map((item) => {
|
||||
if (!item.diff.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
diff: item.diff,
|
||||
filetype: toolFiletype(item.file),
|
||||
deletions: item.deletions,
|
||||
diagnostics: diagnostics(info.meta, item.file ?? ""),
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
|
||||
if (list.length === 0) {
|
||||
return <RunEntryTextContent commit={props.commit} theme={props.theme.entry} />
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<For each={list}>
|
||||
{(item) => (
|
||||
<DiffTool theme={props.theme} data={item} view={toolDiffView(props.width ?? 80, props.opts?.diffStyle)} />
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "task") {
|
||||
return (
|
||||
<TaskTool
|
||||
theme={props.theme}
|
||||
data={{
|
||||
title: snap.title,
|
||||
rows: snap.rows,
|
||||
tail: snap.tail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "todo") {
|
||||
return (
|
||||
<TodoTool
|
||||
theme={props.theme}
|
||||
data={{
|
||||
items: snap.items,
|
||||
tail: snap.tail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QuestionTool
|
||||
theme={props.theme}
|
||||
data={{
|
||||
items: snap.items,
|
||||
tail: snap.tail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunEntryContent(props: {
|
||||
commit: StreamCommit
|
||||
theme?: RunTheme
|
||||
@@ -574,102 +167,195 @@ export function RunEntryContent(props: {
|
||||
width?: number
|
||||
}) {
|
||||
const theme = props.theme ?? RUN_THEME_FALLBACK
|
||||
if (snapCommit(props.commit)) {
|
||||
return <RunEntrySnapContent commit={props.commit} theme={theme} opts={props.opts} width={props.width} />
|
||||
const body = entryBody(props.commit)
|
||||
if (body.type === "none") {
|
||||
return null
|
||||
}
|
||||
|
||||
return <RunEntryTextContent commit={props.commit} theme={theme.entry} />
|
||||
}
|
||||
|
||||
function textWriter(commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
fit(
|
||||
createScrollbackWriter(() => <RunEntryTextContent commit={commit} theme={theme} />, {
|
||||
width: cols(ctx),
|
||||
startOnNewLine: flags.startOnNewLine,
|
||||
trailingNewline: flags.trailingNewline,
|
||||
})(ctx),
|
||||
ctx,
|
||||
if (body.type === "text") {
|
||||
const style = look(props.commit, theme.entry)
|
||||
return (
|
||||
<text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
|
||||
{body.content}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function blankWriter(): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
createScrollbackWriter(() => <text width="100%" />, {
|
||||
width: cols(ctx),
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
})(ctx)
|
||||
}
|
||||
|
||||
function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
full(() => <TextEntry body={body.endsWith("\n") ? body : `${body}\n`} fg={theme.system.body} />, ctx, {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
})
|
||||
}
|
||||
|
||||
function flags(commit: StreamCommit): Flags {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
if (body.type === "code") {
|
||||
return (
|
||||
<code
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
filetype={body.filetype}
|
||||
drawUnstyledText={false}
|
||||
streaming={props.commit.phase === "progress"}
|
||||
syntaxStyle={syntaxFor(props.commit, theme)}
|
||||
content={body.content}
|
||||
fg={entryColor(props.commit, theme)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant" || commit.kind === "reasoning") {
|
||||
if (commit.phase === "progress") {
|
||||
return {
|
||||
startOnNewLine: false,
|
||||
trailingNewline: false,
|
||||
}
|
||||
if (body.type === "structured") {
|
||||
const width = Math.max(1, Math.trunc(props.width ?? 80))
|
||||
|
||||
if (body.snapshot.kind === "code") {
|
||||
return (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{body.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={syntaxFor(props.commit, theme)}
|
||||
content={body.snapshot.content}
|
||||
fg={theme.block.text}
|
||||
/>
|
||||
</line_number>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
if (body.snapshot.kind === "diff") {
|
||||
const view = toolDiffView(width, props.opts?.diffStyle)
|
||||
return (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
{body.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={syntaxFor(props.commit, theme)}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme.block.text}
|
||||
addedBg={theme.block.diffAddedBg}
|
||||
removedBg={theme.block.diffRemovedBg}
|
||||
contextBg={theme.block.diffContextBg}
|
||||
addedSignColor={theme.block.diffHighlightAdded}
|
||||
removedSignColor={theme.block.diffHighlightRemoved}
|
||||
lineNumberFg={theme.block.diffLineNumber}
|
||||
lineNumberBg={theme.block.diffContextBg}
|
||||
addedLineNumberBg={theme.block.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.diffRemoved}>
|
||||
-{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
if (body.snapshot.kind === "task") {
|
||||
return (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{body.snapshot.title}
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
|
||||
{body.snapshot.rows.map((row) => (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.text}>
|
||||
{row}
|
||||
</text>
|
||||
))}
|
||||
{body.snapshot.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{body.snapshot.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
if (body.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) => (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.text}>
|
||||
{todoText(item)}
|
||||
</text>
|
||||
))}
|
||||
{body.snapshot.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{body.snapshot.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
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}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
startOnNewLine: true,
|
||||
trailingNewline: true,
|
||||
}
|
||||
return (
|
||||
<markdown
|
||||
width="100%"
|
||||
syntaxStyle={syntaxFor(props.commit, theme)}
|
||||
streaming={props.commit.phase === "progress"}
|
||||
content={body.content}
|
||||
fg={entryColor(props.commit, theme)}
|
||||
tableOptions={{ widthMode: "content" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function textEntryWriter(commit: StreamCommit, theme: RunEntryTheme): ScrollbackWriter {
|
||||
return textWriter(commit, theme, flags(commit))
|
||||
}
|
||||
|
||||
export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: ScrollbackOptions): ScrollbackWriter {
|
||||
const snap = toolSnapshot(commit, clean(commit.text))
|
||||
if (!snap) {
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
|
||||
const style = flags(commit)
|
||||
|
||||
return (ctx) =>
|
||||
full(() => <RunEntrySnapContent commit={commit} theme={theme} opts={opts} width={cols(ctx)} />, ctx, style)
|
||||
}
|
||||
|
||||
export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {
|
||||
return textBlockWriter(clean(text), theme)
|
||||
export function entryWriter(input: {
|
||||
commit: StreamCommit
|
||||
theme?: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
}): ScrollbackWriter {
|
||||
return createScrollbackWriter(
|
||||
// @ts-expect-error @opentui/solid scrollback helper still exposes solid-js JSX types
|
||||
(ctx) => <RunEntryContent commit={input.commit} theme={input.theme} opts={input.opts} width={ctx.width} />,
|
||||
entryFlags(input.commit),
|
||||
)
|
||||
}
|
||||
|
||||
export function spacerWriter(): ScrollbackWriter {
|
||||
|
||||
@@ -38,6 +38,7 @@ export type RunBlockTheme = {
|
||||
text: ColorInput
|
||||
muted: ColorInput
|
||||
syntax?: SyntaxStyle
|
||||
subtleSyntax?: SyntaxStyle
|
||||
diffAdded: ColorInput
|
||||
diffRemoved: ColorInput
|
||||
diffAddedBg: ColorInput
|
||||
@@ -98,7 +99,7 @@ function fade(color: RGBA, base: RGBA, fallback: number, scale: number, limit: n
|
||||
)
|
||||
}
|
||||
|
||||
function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
|
||||
function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle, subtleSyntax?: SyntaxStyle): RunTheme {
|
||||
const bg = theme.background
|
||||
const pane = theme.backgroundElement
|
||||
const shade = fade(pane, bg, 0.12, 0.56, 0.72)
|
||||
@@ -145,6 +146,7 @@ function map(theme: TuiThemeCurrent, syntax?: SyntaxStyle): RunTheme {
|
||||
text: theme.text,
|
||||
muted: theme.textMuted,
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
diffAdded: theme.diffAdded,
|
||||
diffRemoved: theme.diffRemoved,
|
||||
diffAddedBg: theme.diffAddedBg,
|
||||
@@ -229,7 +231,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
||||
const mod = await import("../tui/context/theme")
|
||||
const theme = mod.resolveTheme(mod.generateSystem(colors, pick), pick) as TuiThemeCurrent
|
||||
try {
|
||||
return map(theme, mod.generateSyntax(theme))
|
||||
return map(theme, mod.generateSyntax(theme), mod.generateSubtleSyntax(theme))
|
||||
} catch {
|
||||
return map(theme)
|
||||
}
|
||||
|
||||
@@ -18,16 +18,14 @@ import os from "os"
|
||||
import path from "path"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { Tool } from "../../../tool/tool"
|
||||
import type * as Tool from "../../../tool/tool"
|
||||
import type { ApplyPatchTool } from "../../../tool/apply_patch"
|
||||
import type { BatchTool } from "../../../tool/batch"
|
||||
import type { BashTool } from "../../../tool/bash"
|
||||
import type { CodeSearchTool } from "../../../tool/codesearch"
|
||||
import type { EditTool } from "../../../tool/edit"
|
||||
import type { GlobTool } from "../../../tool/glob"
|
||||
import type { GrepTool } from "../../../tool/grep"
|
||||
import type { InvalidTool } from "../../../tool/invalid"
|
||||
import type { ListTool } from "../../../tool/ls"
|
||||
import type { LspTool } from "../../../tool/lsp"
|
||||
import type { PlanExitTool } from "../../../tool/plan"
|
||||
import type { QuestionTool } from "../../../tool/question"
|
||||
@@ -40,7 +38,7 @@ import type { WebSearchTool } from "../../../tool/websearch"
|
||||
import type { WriteTool } from "../../../tool/write"
|
||||
import { LANGUAGE_EXTENSIONS } from "../../../lsp/language"
|
||||
import * as Locale from "../../../util/locale"
|
||||
import type { RunDiffStyle, StreamCommit } from "./types"
|
||||
import type { RunDiffStyle, RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||
|
||||
export type ToolView = {
|
||||
output: boolean
|
||||
@@ -78,55 +76,6 @@ export type ToolPermissionInfo = {
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type ToolCodeSnapshot = {
|
||||
kind: "code"
|
||||
title: string
|
||||
content: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type ToolDiffSnapshot = {
|
||||
kind: "diff"
|
||||
items: Array<{
|
||||
title: string
|
||||
diff: string
|
||||
file?: string
|
||||
deletions?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export type ToolTaskSnapshot = {
|
||||
kind: "task"
|
||||
title: string
|
||||
rows: string[]
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolTodoSnapshot = {
|
||||
kind: "todo"
|
||||
items: Array<{
|
||||
status: string
|
||||
content: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolQuestionSnapshot = {
|
||||
kind: "question"
|
||||
items: Array<{
|
||||
question: string
|
||||
answer: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolSnapshot =
|
||||
| ToolCodeSnapshot
|
||||
| ToolDiffSnapshot
|
||||
| ToolTaskSnapshot
|
||||
| ToolTodoSnapshot
|
||||
| ToolQuestionSnapshot
|
||||
|
||||
export type ToolProps<T = Tool.Info> = {
|
||||
input: Partial<Tool.InferParameters<T>>
|
||||
metadata: Partial<Tool.InferMetadata<T>>
|
||||
@@ -151,14 +100,14 @@ type ToolDefs = {
|
||||
write: typeof WriteTool
|
||||
edit: typeof EditTool
|
||||
apply_patch: typeof ApplyPatchTool
|
||||
batch: typeof BatchTool
|
||||
batch: Tool.Info
|
||||
task: typeof TaskTool
|
||||
todowrite: typeof TodoWriteTool
|
||||
question: typeof QuestionTool
|
||||
read: typeof ReadTool
|
||||
glob: typeof GlobTool
|
||||
grep: typeof GrepTool
|
||||
list: typeof ListTool
|
||||
list: Tool.Info
|
||||
lsp: typeof LspTool
|
||||
webfetch: typeof WebFetchTool
|
||||
codesearch: typeof CodeSearchTool
|
||||
@@ -361,8 +310,8 @@ function runGrep(p: ToolProps<typeof GrepTool>): ToolInline {
|
||||
}
|
||||
}
|
||||
|
||||
function runList(p: ToolProps<typeof ListTool>): ToolInline {
|
||||
const dir = p.input.path ?? ""
|
||||
function runList(p: ToolProps): ToolInline {
|
||||
const dir = text(dict(p.input).path)
|
||||
return {
|
||||
icon: "→",
|
||||
title: dir ? `List ${toolPath(dir)}` : "List",
|
||||
@@ -487,8 +436,8 @@ function runInvalid(p: ToolProps<typeof InvalidTool>): ToolInline {
|
||||
}
|
||||
}
|
||||
|
||||
function runBatch(p: ToolProps<typeof BatchTool>): ToolInline {
|
||||
const calls = list(p.input.tool_calls).length
|
||||
function runBatch(p: ToolProps): ToolInline {
|
||||
const calls = list(dict(p.input).tool_calls).length
|
||||
return {
|
||||
icon: "#",
|
||||
title: text(p.frame.state.title) || (calls > 0 ? `Batch ${calls} tool${calls === 1 ? "" : "s"}` : "Batch"),
|
||||
@@ -600,7 +549,7 @@ function snapPatch(p: ToolProps<typeof ApplyPatchTool>): ToolSnapshot | undefine
|
||||
return
|
||||
}
|
||||
|
||||
const diff = typeof file.diff === "string" ? file.diff : ""
|
||||
const diff = typeof file.patch === "string" ? file.patch : ""
|
||||
if (!diff.trim()) {
|
||||
return
|
||||
}
|
||||
@@ -865,15 +814,15 @@ function scrollTodoStart(p: ToolProps<typeof TodoWriteTool>): string {
|
||||
}
|
||||
|
||||
function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {
|
||||
const list = p.input.todos ?? []
|
||||
if (list.length === 0) {
|
||||
const items = list<{ status?: string }>(p.input.todos)
|
||||
if (items.length === 0) {
|
||||
return done("todos", span(p.frame.state))
|
||||
}
|
||||
|
||||
const doneN = list.filter((item) => item.status === "completed").length
|
||||
const runN = list.filter((item) => item.status === "in_progress").length
|
||||
const left = list.length - doneN - runN
|
||||
const tail = [`${list.length} total`]
|
||||
const doneN = items.filter((item) => item.status === "completed").length
|
||||
const runN = items.filter((item) => item.status === "in_progress").length
|
||||
const left = items.length - doneN - runN
|
||||
const tail = [`${items.length} total`]
|
||||
if (doneN > 0) {
|
||||
tail.push(`${doneN} done`)
|
||||
}
|
||||
@@ -944,8 +893,8 @@ function scrollGrepStart(p: ToolProps<typeof GrepTool>): string {
|
||||
return `${head} in ${toolPath(dir)}`
|
||||
}
|
||||
|
||||
function scrollListStart(p: ToolProps<typeof ListTool>): string {
|
||||
const dir = p.input.path ?? ""
|
||||
function scrollListStart(p: ToolProps): string {
|
||||
const dir = text(dict(p.input).path)
|
||||
if (!dir) {
|
||||
return "→ List"
|
||||
}
|
||||
@@ -1019,8 +968,8 @@ function permGrep(p: ToolPermissionProps<typeof GrepTool>): ToolPermissionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function permList(p: ToolPermissionProps<typeof ListTool>): ToolPermissionInfo {
|
||||
const dir = p.input.path || p.patterns[0] || ""
|
||||
function permList(p: ToolPermissionProps): ToolPermissionInfo {
|
||||
const dir = text(dict(p.input).path) || p.patterns[0] || ""
|
||||
return {
|
||||
icon: "→",
|
||||
title: `List ${toolPath(dir, { home: true })}`,
|
||||
@@ -1369,6 +1318,16 @@ export function toolView(name?: string): ToolView {
|
||||
)
|
||||
}
|
||||
|
||||
export function toolStructuredFinal(commit: StreamCommit): boolean {
|
||||
const state = commit.toolState ?? commit.part?.state.status
|
||||
return (
|
||||
commit.kind === "tool" &&
|
||||
commit.phase === "final" &&
|
||||
state === "completed" &&
|
||||
Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
|
||||
)
|
||||
}
|
||||
|
||||
export function toolInlineInfo(part: ToolPart): ToolInline {
|
||||
const ctx = frame(part)
|
||||
const draw = rule(ctx.name)?.run
|
||||
@@ -1442,6 +1401,58 @@ export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot |
|
||||
}
|
||||
}
|
||||
|
||||
function textBody(content: string): RunEntryBody | undefined {
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
type: "text",
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
|
||||
const snap = toolSnapshot(commit, raw)
|
||||
if (!snap) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
type: "structured",
|
||||
snapshot: snap,
|
||||
}
|
||||
}
|
||||
|
||||
export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
|
||||
const ctx = toolFrame(commit, raw)
|
||||
const view = toolView(ctx.name)
|
||||
|
||||
if (commit.phase === "progress" && !view.output) {
|
||||
return
|
||||
}
|
||||
|
||||
if (commit.phase === "final") {
|
||||
if (ctx.status === "error") {
|
||||
return textBody(toolScroll("final", ctx))
|
||||
}
|
||||
|
||||
if (!view.final) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.status && ctx.status !== "completed") {
|
||||
return textBody(ctx.raw.trim())
|
||||
}
|
||||
|
||||
if (toolStructuredFinal(commit)) {
|
||||
return structuredBody(commit, raw) ?? textBody(toolScroll("final", ctx))
|
||||
}
|
||||
}
|
||||
|
||||
return textBody(toolScroll(commit.phase, ctx))
|
||||
}
|
||||
|
||||
export function toolFiletype(input?: string): string | undefined {
|
||||
if (!input) {
|
||||
return
|
||||
|
||||
@@ -83,6 +83,62 @@ export type ScrollbackOptions = {
|
||||
diffStyle?: RunDiffStyle
|
||||
}
|
||||
|
||||
export type ToolCodeSnapshot = {
|
||||
kind: "code"
|
||||
title: string
|
||||
content: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type ToolDiffSnapshot = {
|
||||
kind: "diff"
|
||||
items: Array<{
|
||||
title: string
|
||||
diff: string
|
||||
file?: string
|
||||
deletions?: number
|
||||
}>
|
||||
}
|
||||
|
||||
export type ToolTaskSnapshot = {
|
||||
kind: "task"
|
||||
title: string
|
||||
rows: string[]
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolTodoSnapshot = {
|
||||
kind: "todo"
|
||||
items: Array<{
|
||||
status: string
|
||||
content: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolQuestionSnapshot = {
|
||||
kind: "question"
|
||||
items: Array<{
|
||||
question: string
|
||||
answer: string
|
||||
}>
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolSnapshot =
|
||||
| ToolCodeSnapshot
|
||||
| ToolDiffSnapshot
|
||||
| ToolTaskSnapshot
|
||||
| ToolTodoSnapshot
|
||||
| ToolQuestionSnapshot
|
||||
|
||||
export type RunEntryBody =
|
||||
| { type: "none" }
|
||||
| { type: "text"; content: string }
|
||||
| { type: "code"; content: string; filetype?: string }
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: "structured"; snapshot: ToolSnapshot }
|
||||
|
||||
// Which interactive surface the footer is showing. Only one view is active at
|
||||
// a time. The reducer drives transitions: when a permission arrives the view
|
||||
// switches to "permission", and when the permission resolves it falls back to
|
||||
|
||||
@@ -715,7 +715,7 @@ export function generateSyntax(theme: TuiThemeCurrent) {
|
||||
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
|
||||
}
|
||||
|
||||
function generateSubtleSyntax(theme: TuiThemeCurrent) {
|
||||
export function generateSubtleSyntax(theme: TuiThemeCurrent) {
|
||||
const rules = getSyntaxRules(theme)
|
||||
return SyntaxStyle.fromTheme(
|
||||
rules.map((rule) => {
|
||||
|
||||
153
packages/opencode/test/cli/run/entry.body.test.ts
Normal file
153
packages/opencode/test/cli/run/entry.body.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { entryBody, entryCanStream, entryDone } from "../../../src/cli/cmd/run/entry.body"
|
||||
import type { StreamCommit } from "../../../src/cli/cmd/run/types"
|
||||
|
||||
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
|
||||
return input
|
||||
}
|
||||
|
||||
describe("run entry body", () => {
|
||||
test("renders assistant progress as markdown", () => {
|
||||
expect(
|
||||
entryBody(
|
||||
commit({
|
||||
kind: "assistant",
|
||||
text: "# Title\n\nHello **world**",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
partID: "part-1",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "markdown",
|
||||
content: "# Title\n\nHello **world**",
|
||||
})
|
||||
})
|
||||
|
||||
test("renders reasoning as markdown-highlighted code like the tui", () => {
|
||||
const body = entryBody(
|
||||
commit({
|
||||
kind: "reasoning",
|
||||
text: "Thinking: plan next steps",
|
||||
phase: "progress",
|
||||
source: "reasoning",
|
||||
partID: "reason-1",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(body).toEqual({
|
||||
type: "code",
|
||||
filetype: "markdown",
|
||||
content: "_Thinking:_ plan next steps",
|
||||
})
|
||||
expect(entryCanStream(commit({ kind: "reasoning", text: "Thinking: plan next steps", phase: "progress", source: "reasoning" }), body)).toBe(true)
|
||||
})
|
||||
|
||||
test("prefixes user entries in text mode", () => {
|
||||
expect(
|
||||
entryBody(
|
||||
commit({
|
||||
kind: "user",
|
||||
text: "Inspect footer tabs",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
content: "› Inspect footer tabs",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps completed write tool finals structured", () => {
|
||||
const body = entryBody(
|
||||
commit({
|
||||
kind: "tool",
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
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,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(body.type).toBe("structured")
|
||||
if (body.type !== "structured") {
|
||||
throw new Error("expected structured body")
|
||||
}
|
||||
|
||||
expect(body.snapshot).toEqual({
|
||||
kind: "code",
|
||||
title: "# Wrote src/a.ts",
|
||||
content: "const x = 1\n",
|
||||
file: "src/a.ts",
|
||||
})
|
||||
expect(entryDone(
|
||||
commit({
|
||||
kind: "tool",
|
||||
text: "output",
|
||||
phase: "progress",
|
||||
source: "tool",
|
||||
tool: "bash",
|
||||
toolState: "completed",
|
||||
}),
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
test("streams tool progress text", () => {
|
||||
const body = entryBody(
|
||||
commit({
|
||||
kind: "tool",
|
||||
text: "partial output",
|
||||
phase: "progress",
|
||||
source: "tool",
|
||||
tool: "bash",
|
||||
partID: "tool-2",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(body).toEqual({
|
||||
type: "text",
|
||||
content: "partial output",
|
||||
})
|
||||
expect(entryCanStream(commit({ kind: "tool", text: "partial output", phase: "progress", source: "tool", tool: "bash" }), body)).toBe(true)
|
||||
})
|
||||
|
||||
test("renders interrupted assistant finals as text", () => {
|
||||
expect(
|
||||
entryBody(
|
||||
commit({
|
||||
kind: "assistant",
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: "assistant",
|
||||
interrupted: true,
|
||||
partID: "part-1",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
type: "text",
|
||||
content: "assistant interrupted",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,150 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
||||
import { RunFooter } from "../../../src/cli/cmd/run/footer"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const active: Array<{ footer?: RunFooter; renderer: TestRenderer }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const item of active.splice(0)) {
|
||||
item.footer?.destroy()
|
||||
item.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function createFooter(renderer: TestRenderer) {
|
||||
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
|
||||
treeSitterClient.setMockResult({ highlights: [] })
|
||||
|
||||
return new RunFooter(renderer, {
|
||||
directory: "/tmp",
|
||||
findFiles: async () => [],
|
||||
agents: [],
|
||||
resources: [],
|
||||
agentLabel: "Build",
|
||||
modelLabel: "Model default",
|
||||
first: false,
|
||||
history: [],
|
||||
theme: RUN_THEME_FALLBACK,
|
||||
keybinds: {
|
||||
leader: "",
|
||||
variantCycle: "tab",
|
||||
interrupt: "esc",
|
||||
historyPrevious: "up",
|
||||
historyNext: "down",
|
||||
inputSubmit: "enter",
|
||||
inputNewline: "shift+enter",
|
||||
},
|
||||
diffStyle: "auto",
|
||||
onPermissionReply: () => {},
|
||||
onQuestionReply: () => {},
|
||||
onQuestionReject: () => {},
|
||||
treeSitterClient,
|
||||
})
|
||||
}
|
||||
|
||||
test("run footer class loads", () => {
|
||||
expect(typeof RunFooter).toBe("function")
|
||||
})
|
||||
|
||||
test("run footer finalizes streamed markdown tables when the turn goes idle", async () => {
|
||||
const out = await createTestRenderer({
|
||||
width: 80,
|
||||
height: 24,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 6,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
const footer = createFooter(out.renderer)
|
||||
active.push({ footer, renderer: out.renderer })
|
||||
const lib = Reflect.get(out.renderer, "lib") as {
|
||||
commitSplitFooterSnapshot: (...args: unknown[]) => unknown
|
||||
}
|
||||
const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib)
|
||||
let payload = ""
|
||||
|
||||
lib.commitSplitFooterSnapshot = (...args: unknown[]) => {
|
||||
const snapshot = args[1] as {
|
||||
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
|
||||
}
|
||||
payload += decoder.decode(snapshot.getRealCharBytes(true))
|
||||
return originalCommitSplitFooterSnapshot(...args)
|
||||
}
|
||||
|
||||
try {
|
||||
footer.event({ type: "turn.send", queue: 0 })
|
||||
|
||||
const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
|
||||
for (const chunk of text) {
|
||||
footer.append({
|
||||
kind: "assistant",
|
||||
text: chunk,
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
})
|
||||
}
|
||||
|
||||
footer.event({ type: "turn.idle", queue: 0 })
|
||||
await footer.idle()
|
||||
|
||||
expect(payload).toContain("Column 1")
|
||||
expect(payload).toContain("Row 2")
|
||||
expect(payload).toContain("Value 4")
|
||||
} finally {
|
||||
lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
|
||||
}
|
||||
})
|
||||
|
||||
test("run footer keeps active streamed assistant content across width resize", async () => {
|
||||
const out = await createTestRenderer({
|
||||
width: 40,
|
||||
height: 24,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 6,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
const footer = createFooter(out.renderer)
|
||||
active.push({ footer, renderer: out.renderer })
|
||||
const lib = Reflect.get(out.renderer, "lib") as {
|
||||
commitSplitFooterSnapshot: (...args: unknown[]) => unknown
|
||||
}
|
||||
const originalCommitSplitFooterSnapshot = lib.commitSplitFooterSnapshot.bind(lib)
|
||||
let payload = ""
|
||||
|
||||
lib.commitSplitFooterSnapshot = (...args: unknown[]) => {
|
||||
const snapshot = args[1] as {
|
||||
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
|
||||
}
|
||||
payload += decoder.decode(snapshot.getRealCharBytes(true))
|
||||
return originalCommitSplitFooterSnapshot(...args)
|
||||
}
|
||||
|
||||
try {
|
||||
footer.event({ type: "turn.send", queue: 0 })
|
||||
|
||||
footer.append({
|
||||
kind: "assistant",
|
||||
text: "This paragraph only existed in the active surface until finalization.",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-2",
|
||||
partID: "part-2",
|
||||
})
|
||||
|
||||
out.resize(60, 24)
|
||||
|
||||
footer.event({ type: "turn.idle", queue: 0 })
|
||||
await footer.idle()
|
||||
|
||||
expect(payload.replace(/\s+/g, " ").trim()).toContain(
|
||||
"This paragraph only existed in the active surface until finalization.",
|
||||
)
|
||||
} finally {
|
||||
lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
|
||||
}
|
||||
})
|
||||
|
||||
225
packages/opencode/test/cli/run/scrollback.surface.test.ts
Normal file
225
packages/opencode/test/cli/run/scrollback.surface.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
||||
import { RunScrollbackStream } from "../../../src/cli/cmd/run/scrollback.surface"
|
||||
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
|
||||
|
||||
type ClaimedCommit = {
|
||||
snapshot: {
|
||||
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
|
||||
destroy(): void
|
||||
}
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const active: TestRenderer[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const renderer of active.splice(0)) {
|
||||
renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function claimCommits(renderer: TestRenderer): ClaimedCommit[] {
|
||||
return (renderer as any).externalOutputQueue.claim() as ClaimedCommit[]
|
||||
}
|
||||
|
||||
function destroyCommits(commits: ClaimedCommit[]) {
|
||||
for (const commit of commits) {
|
||||
commit.snapshot.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
test("completes finely streamed markdown tables when the turn goes idle", 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,
|
||||
})
|
||||
|
||||
const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
|
||||
|
||||
for (const chunk of text) {
|
||||
await scrollback.append({
|
||||
kind: "assistant",
|
||||
text: chunk,
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
})
|
||||
}
|
||||
|
||||
await scrollback.complete()
|
||||
|
||||
const commits = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(commits.length).toBeGreaterThan(0)
|
||||
const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("\n")
|
||||
expect(rendered).toContain("Column 1")
|
||||
expect(rendered).toContain("Row 2")
|
||||
expect(rendered).toContain("Value 4")
|
||||
} finally {
|
||||
destroyCommits(commits)
|
||||
}
|
||||
})
|
||||
|
||||
test("completes coalesced markdown tables after one progress append", 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,
|
||||
})
|
||||
|
||||
await scrollback.append({
|
||||
kind: "assistant",
|
||||
text: "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
})
|
||||
|
||||
await scrollback.complete()
|
||||
|
||||
const commits = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(commits.length).toBeGreaterThan(0)
|
||||
const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("\n")
|
||||
expect(rendered).toContain("Column 1")
|
||||
expect(rendered).toContain("Row 2")
|
||||
expect(rendered).toContain("Value 4")
|
||||
} finally {
|
||||
destroyCommits(commits)
|
||||
}
|
||||
})
|
||||
|
||||
test("coalesces same-line tool progress into one snapshot", async () => {
|
||||
const out = await createTestRenderer({
|
||||
width: 80,
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 6,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
active.push(out.renderer)
|
||||
|
||||
const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
|
||||
wrote: false,
|
||||
})
|
||||
|
||||
await scrollback.append({
|
||||
kind: "tool",
|
||||
text: "abc",
|
||||
phase: "progress",
|
||||
source: "tool",
|
||||
partID: "tool-1",
|
||||
messageID: "msg-1",
|
||||
tool: "bash",
|
||||
})
|
||||
await scrollback.append({
|
||||
kind: "tool",
|
||||
text: "def",
|
||||
phase: "progress",
|
||||
source: "tool",
|
||||
partID: "tool-1",
|
||||
messageID: "msg-1",
|
||||
tool: "bash",
|
||||
})
|
||||
await scrollback.append({
|
||||
kind: "tool",
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
partID: "tool-1",
|
||||
messageID: "msg-1",
|
||||
tool: "bash",
|
||||
toolState: "completed",
|
||||
})
|
||||
|
||||
const commits = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(commits).toHaveLength(1)
|
||||
expect(decoder.decode(commits[0]!.snapshot.getRealCharBytes(true))).toContain("abcdef")
|
||||
} finally {
|
||||
destroyCommits(commits)
|
||||
}
|
||||
})
|
||||
|
||||
test("renders structured write finals as native code blocks", 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: "final",
|
||||
source: "tool",
|
||||
partID: "tool-2",
|
||||
messageID: "msg-2",
|
||||
tool: "write",
|
||||
toolState: "completed",
|
||||
part: {
|
||||
id: "tool-2",
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-2",
|
||||
type: "tool",
|
||||
callID: "call-2",
|
||||
tool: "write",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "src/a.ts",
|
||||
content: "const x = 1\nconst y = 2\n",
|
||||
},
|
||||
metadata: {},
|
||||
time: {
|
||||
start: 1,
|
||||
end: 2,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
const commits = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(commits).toHaveLength(1)
|
||||
const rendered = decoder.decode(commits[0]!.snapshot.getRealCharBytes(true)).replace(/ +/g, " ")
|
||||
expect(rendered).toContain("# Wrote src/a.ts")
|
||||
expect(rendered).toMatch(/1\s+const x = 1/)
|
||||
expect(rendered).toMatch(/2\s+const y = 2/)
|
||||
} finally {
|
||||
destroyCommits(commits)
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeEntry } from "../../../src/cli/cmd/run/scrollback.format"
|
||||
import { entryBody } from "../../../src/cli/cmd/run/entry.body"
|
||||
import {
|
||||
bootstrapSubagentData,
|
||||
clearFinishedSubagents,
|
||||
@@ -9,6 +9,33 @@ import {
|
||||
snapshotSubagentData,
|
||||
} from "../../../src/cli/cmd/run/subagent-data"
|
||||
|
||||
function visible(commits: Array<Parameters<typeof entryBody>[0]>) {
|
||||
return commits.flatMap((item) => {
|
||||
const body = entryBody(item)
|
||||
if (body.type === "none") {
|
||||
return []
|
||||
}
|
||||
|
||||
if (body.type === "structured") {
|
||||
if (body.snapshot.kind === "code" || body.snapshot.kind === "task") {
|
||||
return [body.snapshot.title]
|
||||
}
|
||||
|
||||
if (body.snapshot.kind === "diff") {
|
||||
return body.snapshot.items.map((item) => item.title)
|
||||
}
|
||||
|
||||
if (body.snapshot.kind === "todo") {
|
||||
return ["# Todos"]
|
||||
}
|
||||
|
||||
return ["# Questions"]
|
||||
}
|
||||
|
||||
return [body.content]
|
||||
})
|
||||
}
|
||||
|
||||
function taskMessage(sessionID: string, status: "running" | "completed" | "error" = "completed") {
|
||||
return {
|
||||
info: {
|
||||
@@ -243,9 +270,9 @@ describe("run subagent data", () => {
|
||||
sessionID: "child-1",
|
||||
commits: expect.any(Array),
|
||||
})
|
||||
expect(snapshot.details["child-1"]?.commits.map((item) => normalizeEntry(item))).toEqual([
|
||||
expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
|
||||
"› Inspect footer tabs",
|
||||
"Thinking: planning next steps",
|
||||
"_Thinking:_ planning next steps",
|
||||
"# Shell\n$ git status --short",
|
||||
])
|
||||
expect(snapshot.permissions).toEqual([
|
||||
@@ -343,9 +370,9 @@ describe("run subagent data", () => {
|
||||
} as never,
|
||||
})
|
||||
|
||||
expect(
|
||||
snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits.map((item) => normalizeEntry(item)),
|
||||
).toEqual(["hello world"])
|
||||
expect(visible(snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits ?? [])).toEqual([
|
||||
"hello world",
|
||||
])
|
||||
})
|
||||
|
||||
test("clears finished tabs on the next parent prompt", () => {
|
||||
|
||||
Reference in New Issue
Block a user