This commit is contained in:
Simon Klee 2026-04-20 12:22:32 +02:00
parent 9d0cd907f1
commit 30e6543a09
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
3 changed files with 145 additions and 45 deletions

View file

@ -11,20 +11,24 @@ import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from
function todoText(item: { status: string; content: string }): string {
if (item.status === "completed") {
return `[x] ${item.content}`
return `[] ${item.content}`
}
if (item.status === "cancelled") {
return `[ ] ${item.content} (cancelled)`
return `~[ ] ${item.content}~`
}
if (item.status === "in_progress") {
return `[ ] ${item.content} (in progress)`
return `[] ${item.content}`
}
return `[ ] ${item.content}`
}
function todoColor(theme: RunTheme, status: string) {
return status === "in_progress" ? theme.footer.warning : theme.block.muted
}
export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
return
@ -149,23 +153,28 @@ export function RunEntryContent(props: {
}
if (body().type === "structured") {
const snap = snapshot()
if (!snap) {
return null
}
const width = Math.max(1, Math.trunc(props.width ?? 80))
if (snapshot()?.kind === "code") {
if (snap.kind === "code") {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{snapshot()?.title}
{snap.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(snapshot()?.file)}
filetype={toolFiletype(snap.file)}
streaming={false}
syntaxStyle={entrySyntax(props.commit, theme)}
content={snapshot()?.content}
content={snap.content}
fg={theme.block.text}
/>
</line_number>
@ -174,11 +183,11 @@ export function RunEntryContent(props: {
)
}
if (snapshot()?.kind === "diff") {
if (snap.kind === "diff") {
const view = toolDiffView(width, props.opts?.diffStyle)
return (
<box width="100%" flexDirection="column" gap={1}>
{(snapshot()?.items ?? []).map((item) => (
{snap.items.map((item) => (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.title}
@ -216,21 +225,21 @@ export function RunEntryContent(props: {
)
}
if (snapshot()?.kind === "task") {
if (snap.kind === "task") {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{snapshot()?.title}
{snap.title}
</text>
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
{(snapshot()?.rows ?? []).map((row) => (
{snap.rows.map((row) => (
<text width="100%" wrapMode="word" fg={theme.block.text}>
{row}
</text>
))}
{snapshot()?.tail ? (
{snap.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{snapshot()?.tail}
{snap.tail}
</text>
) : null}
</box>
@ -238,21 +247,21 @@ export function RunEntryContent(props: {
)
}
if (snapshot()?.kind === "todo") {
if (snap.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}>
{(snapshot()?.items ?? []).map((item) => (
<text width="100%" wrapMode="word" fg={theme.block.text}>
<box width="100%" flexDirection="column" gap={0}>
{snap.items.map((item) => (
<text width="100%" wrapMode="word" fg={todoColor(theme, item.status)}>
{todoText(item)}
</text>
))}
{snapshot()?.tail ? (
{snap.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{snapshot()?.tail}
{snap.tail}
</text>
) : null}
</box>
@ -260,13 +269,17 @@ export function RunEntryContent(props: {
)
}
if (snap.kind !== "question") {
return null
}
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}>
{(snapshot()?.items ?? []).map((item) => (
{snap.items.map((item) => (
<box width="100%" flexDirection="column" gap={0}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.question}
@ -276,9 +289,9 @@ export function RunEntryContent(props: {
</text>
</box>
))}
{snapshot()?.tail ? (
{snap.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{snapshot()?.tail}
{snap.tail}
</text>
) : null}
</box>

View file

@ -391,7 +391,8 @@ function runTodo(p: ToolProps<typeof TodoWriteTool>): ToolInline {
return []
}
return [`${item.status === "completed" ? "[x]" : "[ ]"} ${body}`]
const mark = item.status === "completed" ? "[✓]" : item.status === "in_progress" ? "[•]" : "[ ]"
return [`${mark} ${body}`]
})
.join("\n"),
}
@ -608,24 +609,11 @@ function snapTodo(p: ToolProps<typeof TodoWriteTool>): ToolSnapshot {
},
]
})
const doneN = items.filter((item) => item.status === "completed").length
const runN = items.filter((item) => item.status === "in_progress").length
const left = items.length - doneN - runN
const tail = [`${items.length} total`]
if (doneN > 0) {
tail.push(`${doneN} done`)
}
if (runN > 0) {
tail.push(`${runN} active`)
}
if (left > 0) {
tail.push(`${left} pending`)
}
return {
kind: "todo",
items,
tail: `${done("todos", span(p.frame.state))} · ${tail.join(" · ")}`,
tail: "",
}
}
@ -816,13 +804,8 @@ function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
return rows.join("\n")
}
function scrollTodoStart(p: ToolProps<typeof TodoWriteTool>): string {
const todos = p.input.todos ?? []
if (todos.length === 0) {
return "⚙ Updating todos..."
}
return `⚙ Updating ${todos.length} todo${todos.length === 1 ? "" : "s"}`
function scrollTodoStart(_: ToolProps<typeof TodoWriteTool>): string {
return ""
}
function scrollTodoFinal(p: ToolProps<typeof TodoWriteTool>): string {

View file

@ -442,6 +442,110 @@ test("inserts a spacer between block assistant entries and following inline tool
}
})
test("renders todos without redundant start or footer lines", async () => {
const out = await createTestRenderer({
width: 80,
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
active.push(out.renderer)
const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
wrote: false,
})
await scrollback.append({
kind: "tool",
text: "",
phase: "start",
source: "tool",
partID: "todo-1",
messageID: "msg-1",
tool: "todowrite",
toolState: "running",
part: {
id: "todo-1",
sessionID: "session-1",
messageID: "msg-1",
type: "tool",
callID: "call-1",
tool: "todowrite",
state: {
status: "running",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
time: {
start: 1,
},
},
} as never,
})
expect(claimCommits(out.renderer)).toHaveLength(0)
await scrollback.append({
kind: "tool",
text: "",
phase: "final",
source: "tool",
partID: "todo-1",
messageID: "msg-1",
tool: "todowrite",
toolState: "completed",
part: {
id: "todo-1",
sessionID: "session-1",
messageID: "msg-1",
type: "tool",
callID: "call-1",
tool: "todowrite",
state: {
status: "completed",
input: {
todos: [
{ status: "completed", content: "List files under `run/`" },
{ status: "in_progress", content: "Count functions in each `run/` file" },
{ status: "pending", content: "Mark each tracking item complete" },
],
},
metadata: {},
time: {
start: 1,
end: 4,
},
},
} as never,
})
const commits = claimCommits(out.renderer)
try {
expect(commits).toHaveLength(1)
const raw = decoder.decode(commits[0]!.snapshot.getRealCharBytes(true))
const rows = Array.from({ length: commits[0]!.snapshot.height }, (_, index) =>
raw.slice(index * 80, (index + 1) * 80).trimEnd(),
)
const rendered = rows.join("\n")
expect(rendered).toContain("# Todos")
expect(rendered).toContain("[✓] List files under `run/`")
expect(rendered).toContain("[•] Count functions in each `run/` file")
expect(rendered).toContain("[ ] Mark each tracking item complete")
expect(rendered).not.toContain("Updating")
expect(rendered).not.toContain("todos completed")
expect(rows).toContain("[✓] List files under `run/`")
expect(rows).toContain("[•] Count functions in each `run/` file")
expect(rows).toContain("[ ] Mark each tracking item complete")
} finally {
destroyCommits(commits)
}
})
test("bodyless starts keep the previous rendered item as separator context", async () => {
const out = await createTestRenderer({
width: 80,