mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
improve direct scrollback parity
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user