mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-20 21:00:29 +08:00
fix(cli): tighten run scrollback separators
This commit is contained in:
@@ -4,7 +4,7 @@ import { useKeyboard } from "@opentui/solid"
|
||||
import "opentui-spinner/solid"
|
||||
import { createMemo, mapArray } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "../tui/component/spinner"
|
||||
import { RunEntryContent, sameEntryGroup } from "./scrollback.writer"
|
||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
|
||||
@@ -13,7 +13,7 @@ export const SUBAGENT_INSPECTOR_ROWS = 8
|
||||
|
||||
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return theme.success
|
||||
return theme.highlight
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
@@ -121,8 +121,8 @@ export function RunFooterSubagentBody(props: {
|
||||
},
|
||||
}))
|
||||
const rows = mapArray(commits, (commit, index) => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
{index() > 0 && !sameEntryGroup(commits()[index() - 1], commit) ? <box height={1} flexShrink={0} /> : null}
|
||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||
{index() > 0 && separatorRows(commits()[index() - 1], commit) > 0 ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent commit={commit} theme={theme()} opts={opts()} width={props.width()} />
|
||||
</box>
|
||||
))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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,
|
||||
// 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 {
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
|
||||
import { withRunSpan } from "./otel"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { entryWriter, sameEntryGroup, spacerWriter } from "./scrollback.writer"
|
||||
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter } from "./scrollback.writer"
|
||||
import { type RunTheme } from "./theme"
|
||||
import type { RunDiffStyle, RunEntryBody, StreamCommit } from "./types"
|
||||
|
||||
@@ -30,6 +30,8 @@ type ActiveEntry = {
|
||||
content: string
|
||||
committedRows: number
|
||||
committedBlocks: number
|
||||
pendingSpacerRows: number
|
||||
rendered: boolean
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
@@ -40,6 +42,7 @@ function commitMarkdownBlocks(input: {
|
||||
startBlock: number
|
||||
endBlockExclusive: number
|
||||
trailingNewline: boolean
|
||||
beforeCommit?: () => void
|
||||
}) {
|
||||
if (input.endBlockExclusive <= input.startBlock) {
|
||||
return false
|
||||
@@ -53,30 +56,19 @@ function commitMarkdownBlocks(input: {
|
||||
|
||||
const next = input.renderable._blockStates[input.endBlockExclusive]
|
||||
const start = first.renderable.y
|
||||
const end = next ? next.renderable.y : last.renderable.y + last.renderable.height + (last.marginBottom ?? 0)
|
||||
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 wantsSpacer(prev: StreamCommit | undefined, next: StreamCommit): boolean {
|
||||
if (!prev) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (sameEntryGroup(prev, next)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !(prev.kind === "tool" && prev.phase === "start")
|
||||
}
|
||||
|
||||
export class RunScrollbackStream {
|
||||
private tail: StreamCommit | undefined
|
||||
private rendered: StreamCommit | undefined
|
||||
private active: ActiveEntry | undefined
|
||||
private wrote: boolean
|
||||
private diffStyle: RunDiffStyle | undefined
|
||||
private sessionID?: () => string | undefined
|
||||
private treeSitterClient: TreeSitterClient | undefined
|
||||
@@ -91,7 +83,6 @@ export class RunScrollbackStream {
|
||||
treeSitterClient?: TreeSitterClient
|
||||
} = {},
|
||||
) {
|
||||
this.wrote = options.wrote ?? true
|
||||
this.diffStyle = options.diffStyle
|
||||
this.sessionID = options.sessionID
|
||||
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient()
|
||||
@@ -148,13 +139,36 @@ export class RunScrollbackStream {
|
||||
content: "",
|
||||
committedRows: 0,
|
||||
committedBlocks: 0,
|
||||
pendingSpacerRows: separatorRows(this.rendered, commit, body),
|
||||
rendered: false,
|
||||
}
|
||||
}
|
||||
|
||||
private async flushActive(done: boolean, trailingNewline: boolean): Promise<void> {
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
return false
|
||||
}
|
||||
|
||||
if (active.body.type === "text") {
|
||||
@@ -162,13 +176,17 @@ export class RunScrollbackStream {
|
||||
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
|
||||
if (targetRows <= active.committedRows) {
|
||||
return false
|
||||
}
|
||||
return
|
||||
|
||||
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") {
|
||||
@@ -177,13 +195,17 @@ export class RunScrollbackStream {
|
||||
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
|
||||
if (targetRows <= active.committedRows) {
|
||||
return false
|
||||
}
|
||||
return
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const renderable = active.renderable as MarkdownRenderable
|
||||
@@ -192,7 +214,7 @@ export class RunScrollbackStream {
|
||||
await active.surface.settle()
|
||||
const targetBlockCount = done ? renderable._blockStates.length : renderable._stableBlockCount
|
||||
if (targetBlockCount <= active.committedBlocks) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -202,13 +224,18 @@ export class RunScrollbackStream {
|
||||
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<void> {
|
||||
private async finishActive(trailingNewline: boolean): Promise<StreamCommit | undefined> {
|
||||
if (!this.active) {
|
||||
return
|
||||
}
|
||||
@@ -226,11 +253,13 @@ export class RunScrollbackStream {
|
||||
active.surface.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
await this.finishActive(false)
|
||||
this.markRendered(await this.finishActive(false))
|
||||
this.active = this.createEntry(commit, body)
|
||||
}
|
||||
|
||||
@@ -238,28 +267,27 @@ export class RunScrollbackStream {
|
||||
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) {
|
||||
await this.finishActive(false)
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
const body = entryBody(commit)
|
||||
if (body.type === "none") {
|
||||
if (entryDone(commit)) {
|
||||
await this.finishActive(false)
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
if (this.wrote && wantsSpacer(this.tail, commit)) {
|
||||
this.renderer.writeToScrollback(spacerWriter())
|
||||
}
|
||||
|
||||
if (
|
||||
body.type !== "structured" &&
|
||||
(entryCanStream(commit, body) ||
|
||||
@@ -267,17 +295,18 @@ export class RunScrollbackStream {
|
||||
) {
|
||||
await this.writeStreaming(commit, body)
|
||||
if (entryDone(commit)) {
|
||||
await this.finishActive(false)
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
this.wrote = true
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
if (same) {
|
||||
await this.finishActive(false)
|
||||
this.markRendered(await this.finishActive(false))
|
||||
}
|
||||
|
||||
this.writeSpacer(separatorRows(this.rendered, commit, body))
|
||||
|
||||
this.renderer.writeToScrollback(
|
||||
entryWriter({
|
||||
commit,
|
||||
@@ -287,7 +316,7 @@ export class RunScrollbackStream {
|
||||
},
|
||||
}),
|
||||
)
|
||||
this.wrote = true
|
||||
this.markRendered(commit)
|
||||
this.tail = commit
|
||||
}
|
||||
|
||||
@@ -312,7 +341,7 @@ export class RunScrollbackStream {
|
||||
"session.id": this.sessionID?.() || undefined,
|
||||
},
|
||||
async () => {
|
||||
await this.finishActive(trailingNewline)
|
||||
this.markRendered(await this.finishActive(trailingNewline))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { entryBody, entryFlags } from "./entry.body"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
||||
import type { ScrollbackOptions, StreamCommit } from "./types"
|
||||
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
function todoText(item: { status: string; content: string }): string {
|
||||
if (item.status === "completed") {
|
||||
@@ -44,11 +44,39 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
|
||||
|
||||
const current = entryGroupKey(left)
|
||||
const next = entryGroupKey(right)
|
||||
if (current && next && current === next) {
|
||||
return true
|
||||
return Boolean(current && next && current === next)
|
||||
}
|
||||
|
||||
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
|
||||
if (commit.kind === "tool") {
|
||||
if (body.type === "structured" || body.type === "markdown") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
return "inline"
|
||||
}
|
||||
|
||||
return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start"
|
||||
if (commit.kind === "reasoning") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
return "block"
|
||||
}
|
||||
|
||||
export function separatorRows(
|
||||
prev: StreamCommit | undefined,
|
||||
next: StreamCommit,
|
||||
body: RunEntryBody = entryBody(next),
|
||||
): number {
|
||||
if (!prev || sameEntryGroup(prev, next)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (entryLayout(prev) === "inline" && entryLayout(next, body) === "inline") {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
export function RunEntryContent(props: {
|
||||
@@ -59,6 +87,39 @@ export function RunEntryContent(props: {
|
||||
}) {
|
||||
const theme = props.theme ?? RUN_THEME_FALLBACK
|
||||
const body = createMemo(() => entryBody(props.commit))
|
||||
const text = () => {
|
||||
const value = body()
|
||||
if (value.type !== "text") {
|
||||
return
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
const code = () => {
|
||||
const value = body()
|
||||
if (value.type !== "code") {
|
||||
return
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
const snapshot = () => {
|
||||
const value = body()
|
||||
if (value.type !== "structured") {
|
||||
return
|
||||
}
|
||||
|
||||
return value.snapshot
|
||||
}
|
||||
const markdown = () => {
|
||||
const value = body()
|
||||
if (value.type !== "markdown") {
|
||||
return
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
if (body().type === "none") {
|
||||
return null
|
||||
}
|
||||
@@ -67,7 +128,7 @@ export function RunEntryContent(props: {
|
||||
const style = entryLook(props.commit, theme.entry)
|
||||
return (
|
||||
<text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
|
||||
{body().content}
|
||||
{text()?.content}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
@@ -77,11 +138,11 @@ export function RunEntryContent(props: {
|
||||
<code
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
filetype={body().filetype}
|
||||
filetype={code()?.filetype}
|
||||
drawUnstyledText={false}
|
||||
streaming={props.commit.phase === "progress"}
|
||||
syntaxStyle={entrySyntax(props.commit, theme)}
|
||||
content={body().content}
|
||||
content={code()?.content}
|
||||
fg={entryColor(props.commit, theme)}
|
||||
/>
|
||||
)
|
||||
@@ -90,48 +151,48 @@ export function RunEntryContent(props: {
|
||||
if (body().type === "structured") {
|
||||
const width = Math.max(1, Math.trunc(props.width ?? 80))
|
||||
|
||||
if (body().snapshot.kind === "code") {
|
||||
if (snapshot()?.kind === "code") {
|
||||
return (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{body().snapshot.title}
|
||||
{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={entrySyntax(props.commit, theme)}
|
||||
content={body().snapshot.content}
|
||||
fg={theme.block.text}
|
||||
/>
|
||||
<code
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
filetype={toolFiletype(snapshot()?.file)}
|
||||
streaming={false}
|
||||
syntaxStyle={entrySyntax(props.commit, theme)}
|
||||
content={snapshot()?.content}
|
||||
fg={theme.block.text}
|
||||
/>
|
||||
</line_number>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
if (body().snapshot.kind === "diff") {
|
||||
if (snapshot()?.kind === "diff") {
|
||||
const view = toolDiffView(width, props.opts?.diffStyle)
|
||||
return (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
{body().snapshot.items.map((item) => (
|
||||
{(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={entrySyntax(props.commit, theme)}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
<diff
|
||||
diff={item.diff}
|
||||
view={view}
|
||||
filetype={toolFiletype(item.file)}
|
||||
syntaxStyle={entrySyntax(props.commit, theme)}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme.block.text}
|
||||
addedBg={theme.block.diffAddedBg}
|
||||
removedBg={theme.block.diffRemovedBg}
|
||||
@@ -155,21 +216,21 @@ export function RunEntryContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
if (body().snapshot.kind === "task") {
|
||||
if (snapshot()?.kind === "task") {
|
||||
return (
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{body().snapshot.title}
|
||||
{snapshot()?.title}
|
||||
</text>
|
||||
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
|
||||
{body().snapshot.rows.map((row) => (
|
||||
{(snapshot()?.rows ?? []).map((row) => (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.text}>
|
||||
{row}
|
||||
</text>
|
||||
))}
|
||||
{body().snapshot.tail ? (
|
||||
{snapshot()?.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{body().snapshot.tail}
|
||||
{snapshot()?.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
@@ -177,21 +238,21 @@ export function RunEntryContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
if (body().snapshot.kind === "todo") {
|
||||
if (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) => (
|
||||
{(snapshot()?.items ?? []).map((item) => (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.text}>
|
||||
{todoText(item)}
|
||||
</text>
|
||||
))}
|
||||
{body().snapshot.tail ? (
|
||||
{snapshot()?.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{body().snapshot.tail}
|
||||
{snapshot()?.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
@@ -200,27 +261,27 @@ export function RunEntryContent(props: {
|
||||
}
|
||||
|
||||
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}
|
||||
<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}>
|
||||
{(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>
|
||||
))}
|
||||
{snapshot()?.tail ? (
|
||||
<text width="100%" wrapMode="word" fg={theme.block.muted}>
|
||||
{snapshot()?.tail}
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -230,7 +291,7 @@ export function RunEntryContent(props: {
|
||||
width="100%"
|
||||
syntaxStyle={entrySyntax(props.commit, theme)}
|
||||
streaming={props.commit.phase === "progress"}
|
||||
content={body().content}
|
||||
content={markdown()?.content}
|
||||
fg={entryColor(props.commit, theme)}
|
||||
tableOptions={{ widthMode: "content" }}
|
||||
/>
|
||||
|
||||
@@ -132,6 +132,8 @@ export type ToolSnapshot =
|
||||
| ToolTodoSnapshot
|
||||
| ToolQuestionSnapshot
|
||||
|
||||
export type EntryLayout = "inline" | "block"
|
||||
|
||||
export type RunEntryBody =
|
||||
| { type: "none" }
|
||||
| { type: "text"; content: string }
|
||||
|
||||
@@ -38,9 +38,9 @@ function createFooter(renderer: TestRenderer) {
|
||||
inputNewline: "shift+enter",
|
||||
},
|
||||
diffStyle: "auto",
|
||||
onPermissionReply: () => {},
|
||||
onQuestionReply: () => {},
|
||||
onQuestionReject: () => {},
|
||||
onPermissionReply: () => { },
|
||||
onQuestionReply: () => { },
|
||||
onQuestionReject: () => { },
|
||||
treeSitterClient,
|
||||
})
|
||||
}
|
||||
@@ -209,16 +209,15 @@ test("run footer keeps tool start rows tight with following reasoning", async ()
|
||||
messageID: "msg-reasoning",
|
||||
partID: "part-reasoning",
|
||||
phase: "progress",
|
||||
text: "Thinking: Found it.",
|
||||
text: "Thinking: Found it.",
|
||||
})
|
||||
|
||||
await footer.idle()
|
||||
|
||||
const rows = payloads
|
||||
.map((item) => item.replace(/ +/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
const rows = payloads.map((item) => item.replace(/ +/g, " ").trim())
|
||||
|
||||
expect(rows).toEqual(['✱ Glob "**/run.ts"', "_Thinking:_ Found it."])
|
||||
expect(payloads).toHaveLength(3)
|
||||
expect(rows).toEqual(['✱ Glob "**/run.ts"', "", "_Thinking:_ Found it."])
|
||||
} finally {
|
||||
lib.commitSplitFooterSnapshot = originalCommitSplitFooterSnapshot
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RunEntryContent } from "@/cli/cmd/run/scrollback.writer"
|
||||
import { RunEntryContent, separatorRows } from "@/cli/cmd/run/scrollback.writer"
|
||||
import { RunFooterView } from "@/cli/cmd/run/footer.view"
|
||||
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
||||
import type { StreamCommit } from "@/cli/cmd/run/types"
|
||||
@@ -51,3 +51,46 @@ test("run entry content updates when live commit text changes", async () => {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("subagent rows use shared separator rules", async () => {
|
||||
const commits: StreamCommit[] = [
|
||||
{
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
messageID: "msg-tool",
|
||||
partID: "part-tool",
|
||||
tool: "glob",
|
||||
phase: "start",
|
||||
text: "running glob",
|
||||
toolState: "running",
|
||||
part: {
|
||||
id: "part-tool",
|
||||
type: "tool",
|
||||
tool: "glob",
|
||||
callID: "call-tool",
|
||||
messageID: "msg-tool",
|
||||
sessionID: "session-1",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
pattern: "**/run.ts",
|
||||
},
|
||||
time: {
|
||||
start: 1,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
{
|
||||
kind: "reasoning",
|
||||
source: "reasoning",
|
||||
messageID: "msg-reasoning",
|
||||
partID: "part-reasoning",
|
||||
phase: "progress",
|
||||
text: "Thinking: Found it.",
|
||||
},
|
||||
]
|
||||
|
||||
expect(separatorRows(undefined, commits[0]!)).toBe(0)
|
||||
expect(separatorRows(commits[0], commits[1]!)).toBe(1)
|
||||
})
|
||||
|
||||
@@ -25,6 +25,10 @@ function claimCommits(renderer: TestRenderer): ClaimedCommit[] {
|
||||
return (renderer as any).externalOutputQueue.claim() as ClaimedCommit[]
|
||||
}
|
||||
|
||||
function renderCommit(commit: ClaimedCommit): string {
|
||||
return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n")
|
||||
}
|
||||
|
||||
function destroyCommits(commits: ClaimedCommit[]) {
|
||||
for (const commit of commits) {
|
||||
commit.snapshot.destroy()
|
||||
@@ -268,6 +272,335 @@ test("preserves blank rows between streamed markdown block commits", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("inserts a spacer between inline tool starts and block tool finals", 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: "start",
|
||||
source: "tool",
|
||||
partID: "tool-1",
|
||||
messageID: "msg-1",
|
||||
tool: "write",
|
||||
toolState: "running",
|
||||
part: {
|
||||
id: "tool-1",
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-1",
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "write",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
filePath: "src/a.ts",
|
||||
content: "const x = 1\n",
|
||||
},
|
||||
time: {
|
||||
start: 1,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
const start = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(start).toHaveLength(1)
|
||||
expect(renderCommit(start[0]!).trim()).toBe("← Write src/a.ts")
|
||||
} finally {
|
||||
destroyCommits(start)
|
||||
}
|
||||
|
||||
await scrollback.append({
|
||||
kind: "tool",
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
partID: "tool-1",
|
||||
messageID: "msg-1",
|
||||
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,
|
||||
})
|
||||
|
||||
const final = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(final).toHaveLength(2)
|
||||
expect(renderCommit(final[0]!).trim()).toBe("")
|
||||
expect(renderCommit(final[1]!)).toContain("# Wrote src/a.ts")
|
||||
} finally {
|
||||
destroyCommits(final)
|
||||
}
|
||||
})
|
||||
|
||||
test("inserts a spacer between block assistant entries and following inline tools", 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: "assistant",
|
||||
text: "hello",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
})
|
||||
await scrollback.complete()
|
||||
|
||||
const first = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(first).toHaveLength(1)
|
||||
expect(renderCommit(first[0]!).trim()).toBe("hello")
|
||||
} finally {
|
||||
destroyCommits(first)
|
||||
}
|
||||
|
||||
await scrollback.append({
|
||||
kind: "tool",
|
||||
source: "tool",
|
||||
messageID: "msg-tool",
|
||||
partID: "part-tool",
|
||||
tool: "glob",
|
||||
phase: "start",
|
||||
text: "running glob",
|
||||
toolState: "running",
|
||||
part: {
|
||||
id: "part-tool",
|
||||
type: "tool",
|
||||
tool: "glob",
|
||||
callID: "call-tool",
|
||||
messageID: "msg-tool",
|
||||
sessionID: "session-1",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
pattern: "**/run.ts",
|
||||
},
|
||||
time: {
|
||||
start: 1,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
const next = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(next).toHaveLength(2)
|
||||
expect(renderCommit(next[0]!).trim()).toBe("")
|
||||
expect(renderCommit(next[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"')
|
||||
} finally {
|
||||
destroyCommits(next)
|
||||
}
|
||||
})
|
||||
|
||||
test("bodyless starts keep the previous rendered item as separator context", 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: "assistant",
|
||||
text: "hello",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
})
|
||||
await scrollback.complete()
|
||||
destroyCommits(claimCommits(out.renderer))
|
||||
|
||||
await scrollback.append({
|
||||
kind: "tool",
|
||||
text: "",
|
||||
phase: "start",
|
||||
source: "tool",
|
||||
partID: "task-1",
|
||||
messageID: "msg-2",
|
||||
tool: "task",
|
||||
toolState: "running",
|
||||
part: {
|
||||
id: "task-1",
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-2",
|
||||
type: "tool",
|
||||
callID: "call-2",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
description: "Explore run.ts",
|
||||
subagent_type: "explore",
|
||||
},
|
||||
time: {
|
||||
start: 1,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
expect(claimCommits(out.renderer)).toHaveLength(0)
|
||||
|
||||
await scrollback.append({
|
||||
kind: "tool",
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
partID: "task-1",
|
||||
messageID: "msg-2",
|
||||
tool: "task",
|
||||
toolState: "error",
|
||||
part: {
|
||||
id: "task-1",
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-2",
|
||||
type: "tool",
|
||||
callID: "call-2",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "error",
|
||||
input: {
|
||||
description: "Explore run.ts",
|
||||
subagent_type: "explore",
|
||||
},
|
||||
error: "boom",
|
||||
time: {
|
||||
start: 1,
|
||||
end: 2,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
const final = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(final).toHaveLength(2)
|
||||
expect(renderCommit(final[0]!).trim()).toBe("")
|
||||
expect(renderCommit(final[1]!)).toContain("Explore task completed")
|
||||
} finally {
|
||||
destroyCommits(final)
|
||||
}
|
||||
})
|
||||
|
||||
test("streamed assistant blocks defer their spacer until first render", 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: "user",
|
||||
text: "use subagent to explore run.ts",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
})
|
||||
destroyCommits(claimCommits(out.renderer))
|
||||
|
||||
for (const chunk of ["Exploring", " run.ts", " via", " a codebase-aware", " subagent next."]) {
|
||||
await scrollback.append({
|
||||
kind: "assistant",
|
||||
text: chunk,
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "msg-1",
|
||||
partID: "part-1",
|
||||
})
|
||||
}
|
||||
|
||||
const progress = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(progress).toHaveLength(0)
|
||||
} finally {
|
||||
destroyCommits(progress)
|
||||
}
|
||||
|
||||
await scrollback.complete()
|
||||
|
||||
const final = claimCommits(out.renderer)
|
||||
try {
|
||||
expect(final).toHaveLength(2)
|
||||
expect(renderCommit(final[0]!).trim()).toBe("")
|
||||
expect(renderCommit(final[1]!).replace(/\n/g, " ")).toContain(
|
||||
"Exploring run.ts via a codebase-aware subagent next.",
|
||||
)
|
||||
} finally {
|
||||
destroyCommits(final)
|
||||
}
|
||||
})
|
||||
|
||||
test("coalesces same-line tool progress into one snapshot", async () => {
|
||||
const out = await createTestRenderer({
|
||||
width: 80,
|
||||
|
||||
Reference in New Issue
Block a user