improve direct scrollback parity

This commit is contained in:
Simon Klee
2026-04-02 20:29:12 +02:00
parent eb85552738
commit 3680602856
11 changed files with 1667 additions and 853 deletions

View File

@@ -29,11 +29,13 @@ export class RunFooter implements FooterApi {
private destroyed = false
private prompts = new Set<(text: string) => void>()
private closes = new Set<() => void>()
private queue: StreamCommit[] = []
private pending = false
private tail = true
private base: number
private rows = TEXTAREA_MIN_ROWS
private state: Accessor<FooterState>
private setState: Setter<FooterState>
private settle = false
private interruptTimeout: NodeJS.Timeout | undefined
private exitTimeout: NodeJS.Timeout | undefined
private interruptHint: string
@@ -134,6 +136,25 @@ export class RunFooter implements FooterApi {
}
this.setState(state)
if (prev.phase === "running" && state.phase === "idle") {
this.flush()
if (!this.tail) {
this.renderer.writeToScrollback(
entryWriter(
{
kind: "assistant",
text: "",
phase: "final",
source: "assistant",
gap: true,
},
this.options.theme.entry,
),
)
this.tail = true
}
}
}
public append(commit: StreamCommit): void {
@@ -141,12 +162,42 @@ export class RunFooter implements FooterApi {
return
}
if (!normalizeEntry(commit)) {
if (!normalizeEntry(commit) && !commit.gap) {
return
}
this.renderer.writeToScrollback(entryWriter(commit, this.options.theme.entry))
this.scheduleSettleRender()
const last = this.queue.at(-1)
if (
last &&
last.phase === "progress" &&
commit.phase === "progress" &&
last.kind === commit.kind &&
last.source === commit.source &&
last.partID === commit.partID &&
last.tool === commit.tool
) {
last.text += commit.text
} else {
this.queue.push(commit)
}
if (this.pending) {
return
}
this.pending = true
queueMicrotask(() => {
this.pending = false
this.flush()
})
}
public idle(): Promise<void> {
if (this.destroyed || this.renderer.isDestroyed) {
return Promise.resolve()
}
return this.renderer.idle().catch(() => {})
}
public close(): void {
@@ -154,6 +205,7 @@ export class RunFooter implements FooterApi {
return
}
this.flush()
this.notifyClose()
}
@@ -166,6 +218,7 @@ export class RunFooter implements FooterApi {
return
}
this.flush()
this.destroyed = true
this.notifyClose()
this.clearInterruptTimer()
@@ -355,6 +408,7 @@ export class RunFooter implements FooterApi {
return
}
this.flush()
this.destroyed = true
this.notifyClose()
this.clearInterruptTimer()
@@ -364,24 +418,31 @@ export class RunFooter implements FooterApi {
this.closes.clear()
}
private scheduleSettleRender(): void {
if (this.settle || this.destroyed || this.renderer.isDestroyed) {
private flush(): void {
if (this.destroyed || this.renderer.isDestroyed || this.queue.length === 0) {
this.queue.length = 0
return
}
this.settle = true
void this.renderer
.idle()
.then(() => {
if (this.destroyed || this.renderer.isDestroyed || this.closed) {
return
}
for (const commit of this.queue.splice(0)) {
this.renderer.writeToScrollback(entryWriter(commit, this.options.theme.entry))
this.tail = this.endsWithNewline(commit)
}
}
this.renderer.requestRender()
})
.catch(() => {})
.finally(() => {
this.settle = false
})
private endsWithNewline(commit: StreamCommit): boolean {
if (commit.gap) {
return true
}
if (commit.kind === "user") {
return true
}
if (commit.phase === "start" || commit.phase === "final") {
return true
}
return commit.text.endsWith("\n")
}
}

View File

@@ -406,14 +406,24 @@ export function RunFooterView(props: RunFooterViewProps) {
return
}
if (!props.onSubmit(text)) {
return
}
push(text)
area.setText("")
scheduleRows()
area.focus()
queueMicrotask(() => {
if (props.onSubmit(text)) {
push(text)
return
}
if (!area || area.isDestroyed) {
return
}
area.setText(text)
area.cursorOffset = area.plainText.length
syncRows()
area.focus()
})
}
onMount(() => {

View File

@@ -383,6 +383,7 @@ function splashTitle(title: string | undefined, history: string[]): string | und
/** @internal Exported for testing */
export async function runPromptQueue(input: QueueInput): Promise<void> {
const q: string[] = []
let turn = 0
let run = false
let closed = input.footer.isClosed
let ctrl: AbortController | undefined
@@ -432,7 +433,6 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
status: "sending prompt",
queue: q.length,
})
input.footer.append({ kind: "user", text: prompt, phase: "start", source: "system" })
const start = Date.now()
const next = new AbortController()
ctrl = next
@@ -441,6 +441,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
() => ({ type: "done" as const }),
(error) => ({ type: "error" as const, error }),
)
await input.footer.idle()
const text = turn === 0 ? prompt : `\n${prompt}`
turn += 1
input.footer.append({ kind: "user", text, phase: "start", source: "system" })
const out = await Promise.race([task, until.then(() => ({ type: "closed" as const }))])
if (out.type === "closed") {
next.abort()

View File

@@ -1,3 +1,4 @@
import path from "path"
import {
TextAttributes,
TextRenderable,
@@ -6,6 +7,8 @@ import {
type ScrollbackSnapshot,
type ScrollbackWriter,
} from "@opentui/core"
import stripAnsi from "strip-ansi"
import { Locale } from "../../../util/locale"
import { RUN_THEME_FALLBACK, type RunEntryTheme } from "./theme"
import type { StreamCommit } from "./types"
@@ -16,6 +19,22 @@ type Paint = {
let id = 0
type Dict = Record<string, unknown>
type Measure = {
widthColsMax: number
}
type MeasureNode = {
textBufferView?: {
measureForDimensions(width: number, height: number): Measure | null
}
}
function clean(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
function look(commit: StreamCommit, theme: RunEntryTheme): Paint {
const kind = commit.kind
if (kind === "user") {
@@ -63,8 +82,421 @@ function look(commit: StreamCommit, theme: RunEntryTheme): Paint {
}
}
function dict(v: unknown): Dict {
if (!v || typeof v !== "object") {
return {}
}
return v as Dict
}
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 view(pathLike: string): string {
if (!pathLike) {
return ""
}
const cwd = process.cwd()
const abs = path.isAbsolute(pathLike) ? pathLike : path.resolve(cwd, pathLike)
const rel = path.relative(cwd, abs)
if (!rel) {
return "."
}
if (!rel.startsWith("..")) {
return rel
}
return abs
}
function details(data: Dict, skip: string[] = []): string {
const list = Object.entries(data).filter(([key, val]) => {
if (skip.includes(key)) {
return false
}
return typeof val === "string" || typeof val === "number" || typeof val === "boolean"
})
if (list.length === 0) {
return ""
}
return `[${list.map(([key, val]) => `${key}=${val}`).join(", ")}]`
}
function tool(commit: StreamCommit): string {
return commit.tool || commit.part?.tool || "tool"
}
function input(commit: StreamCommit): Dict {
return dict(commit.part?.state.input)
}
function meta(commit: StreamCommit): Dict {
return dict(dict(commit.part?.state).metadata)
}
function state(commit: StreamCommit): Dict {
return dict(commit.part?.state)
}
function span(commit: StreamCommit): string {
const time = dict(state(commit).time)
const start = num(time.start)
const end = num(time.end)
if (start === undefined || end === undefined || end <= start) {
return ""
}
return Locale.duration(end - start)
}
function done(name: string, time: string): string {
if (!time) {
return `${name} completed`
}
return `${name} completed · ${time}`
}
function bashStart(data: Dict): string {
const cmd = text(data.command)
const desc = text(data.description) || "Shell"
const wd = text(data.workdir)
const dir = wd && wd !== "." ? view(wd) : ""
const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc
if (!cmd) {
return `[tool:bash] ${title}`
}
return `[tool:bash] ${title}\n$ ${cmd}`
}
function readStart(data: Dict): string {
const file = view(text(data.filePath))
const extra = details(data, ["filePath"])
return `[tool:read] Read ${file}${extra ? ` ${extra}` : ""}`.trim()
}
function writeStart(data: Dict): string {
return `[tool:write] Write ${view(text(data.filePath))}`.trim()
}
function editStart(data: Dict): string {
const flag = details({ replaceAll: data.replaceAll })
return `[tool:edit] Edit ${view(text(data.filePath))}${flag ? ` ${flag}` : ""}`.trim()
}
function patchStart(commit: StreamCommit): string {
const files = arr(meta(commit).files)
if (files.length === 0) {
return "[tool:apply_patch] Patch"
}
return `[tool:apply_patch] Patch ${files.length} file${files.length === 1 ? "" : "s"}`
}
function taskStart(data: Dict, raw: string): string {
const desc = text(data.description)
if (!desc) {
return raw.trim()
}
const kind = Locale.titlecase(text(data.subagent_type) || "general")
return `[tool:task] ${kind} Task - ${desc}`
}
function todoStart(data: Dict): string {
const todos = arr(data.todos)
if (todos.length === 0) {
return "[tool:todowrite] Updating todos..."
}
return `[tool:todowrite] Updating ${todos.length} todo${todos.length === 1 ? "" : "s"}`
}
function questionStart(data: Dict): string {
const count = arr(data.questions).length
return `[tool:question] Asked ${count} question${count === 1 ? "" : "s"}`
}
function bashProgress(raw: string, data: Dict): string {
const out = stripAnsi(raw)
const cmd = text(data.command).trim()
if (!cmd) {
return out
}
const wdRaw = text(data.workdir).trim()
const wd = wdRaw ? view(wdRaw) : ""
const lines = out.split("\n")
const first = (lines[0] || "").trim()
const second = (lines[1] || "").trim()
if (wd && (first === wd || first === wdRaw) && second === cmd) {
const body = lines.slice(2).join("\n")
return body.length > 0 ? body : out
}
if (first === cmd || first === `$ ${cmd}`) {
const body = lines.slice(1).join("\n")
return body.length > 0 ? body : out
}
if (wd && (first === `${wd} ${cmd}` || first === `${wdRaw} ${cmd}`)) {
const body = lines.slice(1).join("\n")
return body.length > 0 ? body : out
}
return out
}
function bashFinal(commit: StreamCommit): string {
const code = num(meta(commit).exitCode) ?? num(meta(commit).exit_code)
const time = span(commit)
const head = code === undefined ? done("bash", time) : `└ bash completed (exit ${code})${time ? ` · ${time}` : ""}`
return head
}
function readFinal(commit: StreamCommit): string {
const list = arr(meta(commit).loaded).filter((v): v is string => typeof v === "string")
const head = done("read", span(commit))
if (list.length === 0) {
return head
}
const lines = [head, ...list.slice(0, 5).map((item) => `↳ Loaded ${view(item)}`)]
if (list.length > 5) {
lines.push(`↳ ... and ${list.length - 5} more`)
}
return lines.join("\n")
}
function patchLine(v: Dict): string {
const type = text(v.type)
const rel = text(v.relativePath)
const file = text(v.filePath)
if (type === "add") {
return `+ Created ${rel || view(file)}`
}
if (type === "delete") {
return `- Deleted ${rel || view(file)}`
}
if (type === "move") {
const from = view(file)
const to = rel || view(text(v.movePath))
return `→ Moved ${from} -> ${to}`
}
return `~ Patched ${rel || view(file)}`
}
function patchFinal(commit: StreamCommit): string {
const files = arr(meta(commit).files).map(dict)
const head = done("patch", span(commit))
if (files.length === 0) {
return head
}
const lines = [head, ...files.slice(0, 6).map(patchLine)]
if (files.length > 6) {
lines.push(`... and ${files.length - 6} more`)
}
return lines.join("\n")
}
function taskFinal(commit: StreamCommit): string {
const data = input(commit)
const kind = Locale.titlecase(text(data.subagent_type) || "general")
const head = done(`${kind} task`, span(commit))
const row: string[] = [head]
const title = text(state(commit).title)
if (title) {
row.push(`${title}`)
}
const calls = num(meta(commit).toolcalls) ?? num(meta(commit).toolCalls) ?? num(meta(commit).calls)
if (calls !== undefined) {
row.push(`${Locale.number(calls)} toolcall${calls === 1 ? "" : "s"}`)
}
const sid = text(meta(commit).sessionId) || text(meta(commit).sessionID)
if (sid) {
row.push(`↳ session ${sid}`)
}
return row.join("\n")
}
function todoFinal(commit: StreamCommit): string {
const list = arr(input(commit).todos).map(dict)
if (list.length === 0) {
return done("todos", span(commit))
}
const doneCount = list.filter((item) => text(item.status) === "completed").length
const runCount = list.filter((item) => text(item.status) === "in_progress").length
const left = list.length - doneCount - runCount
const tail = [`${list.length} total`]
if (doneCount > 0) {
tail.push(`${doneCount} done`)
}
if (runCount > 0) {
tail.push(`${runCount} active`)
}
if (left > 0) {
tail.push(`${left} pending`)
}
return `${done("todos", span(commit))} · ${tail.join(" · ")}`
}
function questionFinal(commit: StreamCommit): string {
const q = arr(input(commit).questions).map(dict)
const a = arr(meta(commit).answers)
if (q.length === 0) {
return done("questions", span(commit))
}
const lines = [done("questions", span(commit))]
for (const [i, item] of q.slice(0, 4).entries()) {
const prompt = text(item.question)
const reply = arr(a[i]).filter((v): v is string => typeof v === "string")
lines.push(`? ${prompt || `Question ${i + 1}`}`)
lines.push(` ${reply.length > 0 ? reply.join(", ") : "(no answer)"}`)
}
if (q.length > 4) {
lines.push(`... and ${q.length - 4} more`)
}
return lines.join("\n")
}
function final(commit: StreamCommit, raw: string): string {
const name = tool(commit)
const status = text(state(commit).status)
if (status === "error") {
return raw.trim()
}
if (status !== "completed") {
return raw.trim()
}
if (name === "bash") {
return bashFinal(commit)
}
if (name === "read") {
return readFinal(commit)
}
if (name === "apply_patch") {
return patchFinal(commit)
}
if (name === "task") {
return taskFinal(commit)
}
if (name === "todowrite") {
return todoFinal(commit)
}
if (name === "question") {
return questionFinal(commit)
}
return done(name, span(commit))
}
// TODO: Copied/adopted from tui session tool renderers; evaluate shared layer later.
function formatToolEntry(commit: StreamCommit): string {
const raw = clean(commit.text)
if (commit.phase === "progress") {
if (tool(commit) === "bash") {
return bashProgress(raw, input(commit))
}
return raw
}
if (commit.phase === "final") {
return final(commit, raw)
}
const data = input(commit)
const name = tool(commit)
if (name === "bash") {
return bashStart(data)
}
if (name === "read") {
return readStart(data)
}
if (name === "write") {
return writeStart(data)
}
if (name === "edit") {
return editStart(data)
}
if (name === "apply_patch") {
return patchStart(commit)
}
if (name === "task") {
return taskStart(data, raw)
}
if (name === "todowrite") {
return todoStart(data)
}
if (name === "question") {
return questionStart(data)
}
if (name === "skill") {
return `[tool:skill] Skill "${text(data.name)}"`
}
const extra = details(data)
return extra ? `[tool:${name}] ${extra}` : raw.trim()
}
export function normalizeEntry(commit: StreamCommit): string {
const raw = commit.text.replace(/\r/g, "")
const raw = clean(commit.text)
const kind = commit.kind
if (kind === "user") {
@@ -72,24 +504,45 @@ export function normalizeEntry(commit: StreamCommit): string {
return ""
}
return ` ${raw}`
const lead = raw.match(/^\n+/)?.[0] ?? ""
const body = lead ? raw.slice(lead.length) : raw
return `${lead} ${body}`
}
if (commit.phase === "start" || commit.phase === "final") {
return raw.trim()
if (kind === "tool") {
return formatToolEntry(commit)
}
if (kind === "assistant") {
if (commit.phase === "start") {
return ""
}
if (commit.phase === "final") {
return raw.trim() === "[assistant:interrupted]" ? "assistant interrupted" : ""
}
// Preserve body formatting for progress
return raw
}
if (kind === "reasoning") {
if (commit.phase === "start") {
return ""
}
if (commit.phase === "final") {
return raw.trim() === "[reasoning:interrupted]" ? "reasoning interrupted" : ""
}
const body = raw.replace(/\[REDACTED\]/g, "")
// Keep reasoning raw unless we need special block formatting, but for now we preserve
return body
}
if (commit.phase === "start" || commit.phase === "final") {
return raw.trim()
}
if (kind === "error") {
return raw
}
@@ -101,9 +554,10 @@ function build(commit: StreamCommit, ctx: ScrollbackRenderContext, theme: RunEnt
const body = normalizeEntry(commit)
const width = Math.max(1, ctx.width)
const style = look(commit, theme)
const gap = commit.gap === true
const startOnNewLine = commit.phase === "start" || commit.phase === "final" || commit.kind === "user"
const trailingNewline = commit.phase === "start" || commit.phase === "final" || commit.kind === "user"
const startOnNewLine = gap ? false : commit.phase === "start" || commit.phase === "final" || commit.kind === "user"
const trailingNewline = gap ? true : commit.phase === "start" || commit.phase === "final" || commit.kind === "user"
const root = new TextRenderable(ctx.renderContext, {
id: `run-direct-entry-${id++}`,
@@ -119,19 +573,23 @@ function build(commit: StreamCommit, ctx: ScrollbackRenderContext, theme: RunEnt
})
const height = Math.max(1, root.scrollHeight)
root.height = height
const node = root as unknown as MeasureNode
const box = node.textBufferView?.measureForDimensions(width, height)
const cols = Math.max(0, Math.min(width, box?.widthColsMax ?? 0))
const snap = gap ? 0 : Math.max(1, cols)
return {
root,
width,
width: snap,
height,
rowColumns: width,
rowColumns: cols,
startOnNewLine,
trailingNewline,
}
}
function normalizeBlock(text: string): string {
return text.replace(/\r/g, "")
return clean(text)
}
function buildBlock(text: string, ctx: ScrollbackRenderContext, theme: RunEntryTheme): ScrollbackSnapshot {

View File

@@ -18,6 +18,7 @@ type Tokens = {
}
type PartKind = "assistant" | "reasoning"
type MessageRole = "assistant" | "user"
export type SessionCommit = StreamCommit
@@ -25,6 +26,9 @@ export type SessionData = {
ids: Set<string>
tools: Set<string>
announced: boolean
role: Map<string, MessageRole>
msg: Map<string, string>
end: Set<string>
text: Map<string, string>
sent: Map<string, number>
part: Map<string, PartKind>
@@ -50,6 +54,9 @@ export function createSessionData(): SessionData {
ids: new Set(),
tools: new Set(),
announced: false,
role: new Map(),
msg: new Map(),
end: new Set(),
text: new Map(),
sent: new Map(),
part: new Map(),
@@ -130,7 +137,7 @@ export function flushPart(
data: SessionData,
commits: SessionCommit[],
partID: string,
end: boolean,
_end: boolean,
interrupted: boolean = false,
) {
const kind = data.part.get(partID)
@@ -138,9 +145,20 @@ export function flushPart(
const text = data.text.get(partID) ?? ""
const sent = data.sent.get(partID) ?? 0
const chunk = text.slice(sent)
if (chunk) {
const raw = text.slice(sent)
let chunk = raw
if (sent === 0 && (kind === "assistant" || kind === "reasoning")) {
chunk = chunk.replace(/^\n+/, "")
if (chunk) {
chunk = `\n${chunk}`
}
}
if (raw) {
data.sent.set(partID, text.length)
}
if (chunk) {
commits.push({
kind: kind === "assistant" ? "assistant" : "reasoning",
text: chunk,
@@ -150,20 +168,69 @@ export function flushPart(
})
}
if (!end && !interrupted) return
if (!interrupted) {
return
}
commits.push({
kind: kind === "assistant" ? "assistant" : "reasoning",
text: interrupted ? `[${kind}:interrupted]` : `[${kind}:end]`,
text: `[${kind}:interrupted]`,
phase: "final",
source: kind,
partID,
})
}
function drop(data: SessionData, partID: string) {
data.part.delete(partID)
data.text.delete(partID)
data.sent.delete(partID)
data.msg.delete(partID)
data.end.delete(partID)
}
function replay(data: SessionData, commits: SessionCommit[], messageID: string, role: MessageRole, thinking: boolean) {
for (const [partID, msg] of [...data.msg.entries()]) {
if (msg !== messageID || data.ids.has(partID)) {
continue
}
if (role === "user") {
data.ids.add(partID)
drop(data, partID)
continue
}
const kind = data.part.get(partID)
if (!kind) {
continue
}
if (kind === "reasoning" && !thinking) {
if (data.end.has(partID)) {
data.ids.add(partID)
}
drop(data, partID)
continue
}
flushPart(data, commits, partID, false)
if (data.end.has(partID)) {
data.ids.add(partID)
drop(data, partID)
}
}
}
export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
for (const partID of data.part.keys()) {
if (!data.ids.has(partID)) {
const msg = data.msg.get(partID)
if (msg && data.role.get(msg) !== "assistant") {
continue
}
flushPart(data, commits, partID, false, true)
}
}
@@ -197,6 +264,10 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
}
const info = event.properties.info
if (typeof info.id === "string") {
data.role.set(info.id, info.role)
replay(data, commits, info.id, info.role, input.thinking)
}
if (info.role !== "assistant") {
return out(data, commits)
}
@@ -208,6 +279,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
input.limits[modelKey(info.providerID, info.modelID)],
typeof info.cost === "number" ? info.cost : undefined,
)
return out(data, commits, status, usage)
}
@@ -267,16 +339,30 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
source: "tool",
partID: part.id,
tool: part.tool,
part,
})
return out(data, commits, toolStatus(part))
}
if (part.type === "tool" && part.state.status === "completed") {
const seen = data.tools.has(part.id)
data.tools.delete(part.id)
if (data.ids.has(part.id)) {
return out(data, commits)
}
if (!seen) {
commits.push({
kind: "tool",
text: `[tool:${part.tool}] ${toolStatus(part)}`,
phase: "start",
source: "tool",
partID: part.id,
tool: part.tool,
part,
})
}
data.ids.add(part.id)
const output = part.state.output
@@ -288,6 +374,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
source: "tool",
partID: part.id,
tool: part.tool,
part,
})
}
@@ -298,6 +385,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
source: "tool",
partID: part.id,
tool: part.tool,
part,
})
return out(data, commits)
@@ -318,6 +406,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
source: "tool",
partID: part.id,
tool: part.tool,
part,
})
return out(data, commits)
@@ -328,25 +417,48 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
return out(data, commits)
}
if (part.type === "reasoning" && !input.thinking) {
const kind = part.type === "text" ? "assistant" : "reasoning"
const msg = part.messageID
if (typeof msg === "string") {
data.msg.set(part.id, msg)
const role = data.role.get(msg)
if (role === "user") {
data.ids.add(part.id)
drop(data, part.id)
return out(data, commits)
}
if (!role) {
if (kind === "reasoning" && !input.thinking) {
if (part.time?.end) {
data.ids.add(part.id)
}
return out(data, commits)
}
if (!data.part.has(part.id)) {
data.part.set(part.id, kind)
}
data.text.set(part.id, part.text)
if (part.time?.end) {
data.end.add(part.id)
}
return out(data, commits)
}
}
if (kind === "reasoning" && !input.thinking) {
if (part.time?.end) {
data.ids.add(part.id)
data.text.delete(part.id)
drop(data, part.id)
}
return out(data, commits)
}
const kind = part.type === "text" ? "assistant" : "reasoning"
const wasKnown = data.part.has(part.id)
if (!wasKnown) {
data.part.set(part.id, kind)
commits.push({
kind: kind === "assistant" ? "assistant" : "reasoning",
text: `[${kind}]`,
phase: "start",
source: kind,
partID: part.id,
})
}
data.text.set(part.id, part.text)
@@ -354,9 +466,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
if (part.time?.end) {
data.ids.add(part.id)
data.part.delete(part.id)
data.text.delete(part.id)
data.sent.delete(part.id)
drop(data, part.id)
}
return out(data, commits)

View File

@@ -1,4 +1,4 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import type { OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"
export type RunFilePart = {
type: "file"
@@ -61,6 +61,8 @@ export type StreamCommit = {
source: StreamSource
partID?: string
tool?: string
part?: ToolPart
gap?: boolean
}
export type FooterApi = {
@@ -69,6 +71,7 @@ export type FooterApi = {
onClose(fn: () => void): () => void
patch(next: FooterPatch): void
append(commit: StreamCommit): void
idle(): Promise<void>
close(): void
destroy(): void
}

View File

@@ -11,6 +11,7 @@ async function create() {
setup.renderer.screenMode = "split-footer"
setup.renderer.footerHeight = 6
setup.renderer.externalOutputMode = "capture-stdout"
let interrupts = 0
let exits = 0
@@ -118,4 +119,28 @@ describe("run footer", () => {
ctx.destroy()
}
})
test("inserts spacer line after assistant turn", async () => {
const ctx = await create()
const writes: unknown[] = []
const write = ctx.setup.renderer.writeToScrollback.bind(ctx.setup.renderer)
;(ctx.setup.renderer as any).writeToScrollback = (entry: unknown) => {
writes.push(entry)
return write(entry as any)
}
try {
ctx.footer.append({ kind: "assistant", text: "hello", phase: "progress", source: "assistant" })
;(ctx.footer as any).flush()
expect(writes.length).toBe(1)
ctx.footer.patch({ phase: "running" })
ctx.footer.patch({ phase: "idle" })
expect(writes.length).toBe(2)
} finally {
ctx.destroy()
}
})
})

View File

@@ -47,6 +47,9 @@ function createFooter() {
append(commit) {
appended.push(commit)
},
idle() {
return Promise.resolve()
},
close,
destroy() {
close()
@@ -295,7 +298,7 @@ describe("run runtime", () => {
expect(prompts).toEqual(["one", "two"])
expect(ui.appended).toEqual([
{ kind: "user", text: "one", phase: "start", source: "system" },
{ kind: "user", text: "two", phase: "start", source: "system" },
{ kind: "user", text: "\ntwo", phase: "start", source: "system" },
])
})

File diff suppressed because it is too large Load Diff

View File

@@ -98,10 +98,70 @@ describe("run footer view", () => {
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
await Promise.resolve()
expect(sent).toEqual(["hello"])
})
test("failed submit restores text without recording history", async () => {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "",
usage: "",
first: true,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
keybinds={{
leader: "ctrl+x",
variantCycle: "ctrl+t,<leader>t",
interrupt: "escape",
historyPrevious: "up",
historyNext: "down",
inputSubmit: "return",
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
}}
agent="Build"
onSubmit={() => false}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={(text) => {
setState((state) => ({
...state,
status: text,
}))
}}
/>
),
{
width: 110,
height: 12,
},
)
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
await Promise.resolve()
const area = composer(setup) as any
expect(area.plainText).toBe("hello")
area.setText("")
area.cursorOffset = 0
setup.mockInput.pressArrow("up")
expect(area.plainText).toBe("")
})
test("history up down keeps edge behavior", async () => {
const sent: string[] = []
const [state, setState] = createSignal<FooterState>({
@@ -156,6 +216,7 @@ describe("run footer view", () => {
setup.mockInput.pressEnter()
await setup.mockInput.typeText("two")
setup.mockInput.pressEnter()
await Promise.resolve()
const area = composer(setup)
@@ -295,60 +356,6 @@ describe("run footer view", () => {
})
})
test("placeholder switches after first prompt", async () => {
const [state, setState] = createSignal<FooterState>({
phase: "idle",
status: "",
queue: 0,
model: "model",
duration: "",
usage: "",
first: true,
interrupt: 0,
exit: 0,
})
setup = await testRender(
() => (
<RunFooterView
state={state}
keybinds={{
leader: "ctrl+x",
variantCycle: "ctrl+t,<leader>t",
interrupt: "escape",
historyPrevious: "up",
historyNext: "down",
inputSubmit: "return",
inputNewline: "shift+return,ctrl+return,alt+return,ctrl+j",
}}
agent="Build"
onSubmit={() => true}
onCycle={() => {}}
onInterrupt={() => false}
onExit={() => {}}
onRows={() => {}}
onStatus={() => {}}
/>
),
{
width: 120,
height: 12,
},
)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain('Ask anything... "Fix a TODO in the codebase"')
setState((state) => ({
...state,
first: false,
}))
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Ask anything...")
expect(setup.captureCharFrame()).not.toContain("Fix a TODO in the codebase")
})
test("baseline scaffold follows 6-line layout", async () => {
const [state] = createSignal<FooterState>({
phase: "idle",

View File

@@ -15,6 +15,35 @@ function make(kind: StreamCommit["kind"], text: string, phase: StreamCommit["pha
}
}
function makeTool(
text: string,
phase: StreamCommit["phase"],
tool: string,
data: Record<string, unknown>,
extra: Record<string, unknown> = {},
): StreamCommit {
return {
kind: "tool",
text,
phase,
source: "tool",
tool,
part: {
id: "tool-1",
callID: "call-1",
sessionID: "session-1",
messageID: "message-1",
type: "tool",
tool,
state: {
status: "running",
input: data,
...extra,
},
} as unknown as StreamCommit["part"],
}
}
async function draw(commit: StreamCommit) {
const setup = await testRender(() => null, {
width: 80,
@@ -88,29 +117,33 @@ describe("run scrollback", () => {
expect(out.text).not.toContain("ASSISTANT")
expect(out.text).not.toMatch(/\b\d{2}:\d{2}:\d{2}\b/)
expect(out.text).not.toMatch(/[│┃┆┇┊┋╹╻╺╸]/)
expect(out.snap.width).toBe(80)
expect(out.snap.rowColumns).toBe(80)
expect(out.snap.width).toBe(15)
expect(out.snap.rowColumns).toBe(15)
expect(out.snap.startOnNewLine).toBe(false)
expect(out.snap.trailingNewline).toBe(false)
})
test("renders marker entries without extra blank rows", async () => {
const out = await draw(make("assistant", "[assistant]", "start"))
expect(out.text).toBe("[assistant]")
expect(out.snap.height).toBe(1)
expect(out.snap.startOnNewLine).toBe(true)
expect(out.snap.trailingNewline).toBe(true)
test("hides assistant marker entries", () => {
expect(normalizeEntry(make("assistant", "[assistant]", "start"))).toBe("")
expect(normalizeEntry(make("assistant", "[assistant:end]", "final"))).toBe("")
expect(normalizeEntry(make("assistant", "[assistant:interrupted]", "final"))).toBe("assistant interrupted")
})
test("adds user marker and keeps whitespace", async () => {
const out = await draw(make("user", " one \r\n\t two\t\r\n", "start"))
expect(out.text).toBe(" one \n\t two\t\n")
expect(out.snap.width).toBe(9)
expect(out.snap.rowColumns).toBe(9)
expect(out.snap.startOnNewLine).toBe(true)
expect(out.snap.trailingNewline).toBe(true)
})
test("renders spaced user follow-up prompt", () => {
const out = normalizeEntry(make("user", "\nITS MISSING A SPACE", "start"))
expect(out).toBe("\n ITS MISSING A SPACE")
})
test("normalizes blank user input to empty", () => {
expect(normalizeEntry(make("user", " \r\n\t", "start"))).toBe("")
})
@@ -131,6 +164,8 @@ describe("run scrollback", () => {
const out = await draw(make("assistant", " "))
expect(out.text).toBe(" ")
expect(out.snap.width).toBe(3)
expect(out.snap.rowColumns).toBe(3)
expect(out.snap.startOnNewLine).toBe(false)
expect(out.snap.trailingNewline).toBe(false)
})
@@ -143,6 +178,167 @@ describe("run scrollback", () => {
expect(prefixed.text).toBe("Thinking: keep\ngoing")
})
test("formats tool starts using adopted tui text", () => {
const bash = normalizeEntry(
makeTool("[tool:bash] running", "start", "bash", {
description: "Run typecheck",
command: "bun typecheck",
workdir: "packages/opencode",
}),
)
expect(bash).toContain("[tool:bash] Run typecheck in packages/opencode")
expect(bash).toContain("$ bun typecheck")
const task = normalizeEntry(
makeTool("[tool:task] running", "start", "task", {
subagent_type: "general",
description: "investigate stream",
}),
)
expect(task).toBe("[tool:task] General Task - investigate stream")
const question = normalizeEntry(
makeTool("[tool:question] running", "start", "question", {
questions: [{ question: "Pick one", options: [{ label: "A", description: "a" }] }],
}),
)
expect(question).toBe("[tool:question] Asked 1 question")
})
test("strips ansi from bash progress output", () => {
const text = normalizeEntry(makeTool("\u001b[31merror\u001b[39m", "progress", "bash", {}))
expect(text).toBe("error")
})
test("normalizes carriage returns in tool output", () => {
const text = normalizeEntry(makeTool("cli/cmd\r./run.sh info session\r\nline-2", "progress", "bash", {}))
expect(text).toBe("cli/cmd\n./run.sh info session\nline-2")
})
test("drops echoed bash invocation header from output", () => {
const text = normalizeEntry(
makeTool(
"cli/cmd ./run.sh info session\nScript session info for 'session':\nRecorded: 11.1s, 20K",
"progress",
"bash",
{
workdir: "cli/cmd",
command: "./run.sh info session",
},
),
)
expect(text).toBe("Script session info for 'session':\nRecorded: 11.1s, 20K")
})
test("formats richer read and patch completion summaries", () => {
const read = normalizeEntry(
makeTool(
"[tool:read:end]",
"final",
"read",
{
filePath: "/tmp/a.txt",
},
{
status: "completed",
metadata: {
loaded: ["packages/opencode/src/index.ts", "README.md"],
},
time: { start: 0, end: 1500 },
},
),
)
expect(read).toContain("└ read completed")
expect(read).toContain("↳ Loaded packages/opencode/src/index.ts")
const patch = normalizeEntry(
makeTool(
"[tool:apply_patch:end]",
"final",
"apply_patch",
{},
{
status: "completed",
metadata: {
files: [
{ type: "add", relativePath: "src/new.ts", filePath: "src/new.ts" },
{ type: "delete", relativePath: "src/old.ts", filePath: "src/old.ts" },
],
},
},
),
)
expect(patch).toContain("└ patch completed")
expect(patch).toContain("+ Created src/new.ts")
expect(patch).toContain("- Deleted src/old.ts")
})
test("formats richer task, todo, and question completion summaries", () => {
const task = normalizeEntry(
makeTool(
"[tool:task:end]",
"final",
"task",
{
subagent_type: "general",
description: "investigate",
},
{
status: "completed",
title: "collecting logs",
metadata: {
toolCalls: 3,
sessionId: "sess-123",
},
time: { start: 0, end: 1000 },
},
),
)
expect(task).toContain("└ General task completed")
expect(task).toContain("↳ collecting logs")
expect(task).toContain("↳ 3 toolcalls")
const todo = normalizeEntry(
makeTool(
"[tool:todowrite:end]",
"final",
"todowrite",
{
todos: [
{ content: "a", status: "completed" },
{ content: "b", status: "in_progress" },
{ content: "c", status: "pending" },
],
},
{
status: "completed",
},
),
)
expect(todo).toContain("└ todos completed")
expect(todo).toContain("3 total · 1 done · 1 active · 1 pending")
const question = normalizeEntry(
makeTool(
"[tool:question:end]",
"final",
"question",
{
questions: [{ question: "Pick one", options: [{ label: "A", description: "a" }] }],
},
{
status: "completed",
metadata: {
answers: [["A"]],
},
},
),
)
expect(question).toContain("└ questions completed")
expect(question).toContain("? Pick one")
expect(question).toContain(" A")
})
test("wraps long assistant lines without clipping content", async () => {
const text =
"The sky was a deep shade of indigo as the stars began to emerge. A gentle breeze rustled through the trees, carrying whispers of rain."
@@ -157,7 +353,7 @@ describe("run scrollback", () => {
const assistant = await draw(make("assistant", "a"))
const reasoning = await draw(make("reasoning", "r"))
const error = await draw(make("error", "e", "start"))
const final = await draw(make("assistant", "[assistant:end]", "final"))
const final = await draw(make("system", "[tool:end]", "final"))
expect(same(user.fg, RUN_THEME_FALLBACK.entry.user.body)).toBe(true)
expect(Boolean(user.attrs & TextAttributes.BOLD)).toBe(true)