TUI (spec 3.4.0): the spinner line is the progress phase; /team shows the tree with each agent's phase, context and messages; /team open (a subagent's session, read-only), /team findings, /context <subagent>
ci / check (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 08:28:28 +00:00
1 parent 8c5edc0f01
commit ecfd1b0b8a
10 files changed
+117 -15

No files matched your search

+1
View File
@@ -645,6 +645,7 @@ export function createApp(o: AppOptions): App {
if (member) { if (member) {
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it. // A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
job.team = member.name job.team = member.name
member.jobId = job.id
// Its progress and context use, for the tree (spec 3.4.0 §12). // Its progress and context use, for the tree (spec 3.4.0 §12).
childBus.on((e) => { childBus.on((e) => {
if (e.type === "phase") { if (e.type === "phase") {
+40 -2
View File
@@ -18,6 +18,8 @@ import { skillMessage, slug, type Skill } from "../skill/index.ts"
import { CONTINUE_PROMPT } from "./engine.ts" import { CONTINUE_PROMPT } from "./engine.ts"
import { TEAM, Team } from "../team/team.ts" import { TEAM, Team } from "../team/team.ts"
import { SUBAGENTS } from "../team/subagents.ts" import { SUBAGENTS } from "../team/subagents.ts"
import { progressLine } from "./progress.ts"
import { fillPrompt } from "../prompt/fill.ts"
export type CommandKind = "custom" | "skill" | "builtin" export type CommandKind = "custom" | "skill" | "builtin"
@@ -49,7 +51,7 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" }, { name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" }, { name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" }, { name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
{ name: "team", description: "subagents: the team and the room; how many; stop one", hint: "size <min> <max> | stop <name>" }, { name: "team", description: "subagents: the tree and the room; how many; stop one; their findings", hint: "size <min> <max> | stop <name> | findings <name>" },
] ]
/** The status bar's word for a team with members: its size of the most it may have, and how many /** The status bar's word for a team with members: its size of the most it may have, and how many
@@ -60,6 +62,34 @@ export function teamLabel(team: Team): string {
return `agents ${team.size()}/${team.max}${n ? ` · ${n} subagent${n === 1 ? "" : "s"} working` : ""}` return `agents ${team.size()}/${team.max}${n ? ` · ${n} subagent${n === 1 ? "" : "s"} working` : ""}`
} }
/** Spec 3.4.0 (subagents.md §12): the tree as the person sees it — every agent with its position,
* state, model, mode, context use, progress line and messages; open slots; the ended ones. */
export function treeText(team: Team, now = Date.now()): string {
const info = team.info()
const R = TEAM.read
const depth = (a: { parent: string | null }): number => (a.parent ? 1 + depth(info.agents.find((x) => x.name === a.parent) ?? { parent: null }) : 0)
const lines = [`${SUBAGENTS.setting.label} (${info.size} of ${info.max} agents; ${SUBAGENTS.setting.min} ${info.min}, ${SUBAGENTS.setting.max} ${info.max}):`]
for (const a of info.agents) {
const bits = [
`${" ".repeat(depth(a))}- ${a.name}${a.id === a.name ? "" : ` (${a.id})`} — ${a.position}`,
R.states[a.state],
a.model,
a.request.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode,
a.context && a.context.window ? `${Math.floor((a.context.used * 100) / a.context.window)}% context` : "",
a.progress ? progressLine(a.progress, now) : "",
a.messages ? `${a.messages} message${a.messages === 1 ? "" : "s"}` : "",
a.findings ? `${a.findings} finding${a.findings === 1 ? "" : "s"}` : "",
].filter(Boolean)
lines.push(bits.join(" · "))
}
if (info.open_slots) lines.push(fillPrompt(R.slots, { n: String(info.open_slots) }))
if (info.ended.length) {
lines.push(R.ended)
for (const a of info.ended) lines.push(`- ${a.name} (${a.id}) — ${a.position} · ${R.states[a.state]}${a.findings ? ` · ${a.findings} finding${a.findings === 1 ? "" : "s"}` : ""}`)
}
return lines.join("\n")
}
/** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the /** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
* person sees everything, team.md §5); its size for this session; a member stopped by the person, * person sees everything, team.md §5); its size for this session; a member stopped by the person,
* which the lead is told in a note. */ * which the lead is told in a note. */
@@ -83,6 +113,11 @@ export function teamText(app: App, arg: string): string {
} }
return `${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short}. Set for this session; the agent is told with your next message.` return `${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short}. Set for this session; the agent is told with your next message.`
} }
if (verb === "findings") {
const name = rest[0]
if (!name) return "/team findings <name or id>"
return t.readFindings("lead", name)
}
if (verb === "stop") { if (verb === "stop") {
const name = rest[0] const name = rest[0]
if (!name) return "/team stop <name> — a member and everything under it" if (!name) return "/team stop <name> — a member and everything under it"
@@ -95,7 +130,10 @@ export function teamText(app: App, arg: string): string {
: "No subagents in this session: they are switched off (subagents: false)." : "No subagents in this session: they are switched off (subagents: false)."
const since = Math.max(0, t.room.length - TEAM.read_max) const since = Math.max(0, t.room.length - TEAM.read_max)
const own = t.couldServe().own ? "" : `\n${S.own}` const own = t.couldServe().own ? "" : `\n${S.own}`
return `${t.read("lead", since)}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size <min> <max>); /team stop <name> stops a member.${own}` // The tree as the person sees it (spec 3.4.0 §12), then the room as team_read shows it.
const read = t.read("lead", since)
const room = read.slice(read.indexOf("\n\n") + 2)
return `${treeText(t)}\n\n${room}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size <min> <max>); /team stop <name> stops a subagent; /team open <name> shows its session, read-only; /team findings <name>; /context <name>.${own}`
} }
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and /** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
+3
View File
@@ -104,6 +104,8 @@ export interface TeamAgent {
endedAt?: number endedAt?: number
/** The sub-manager it was under before that one ended (team_replace puts it back). */ /** The sub-manager it was under before that one ended (team_replace puts it back). */
movedFrom?: string movedFrom?: string
/** The device's job that runs it (its transcript, read-only). */
jobId?: string
} }
export interface RoomMessage { export interface RoomMessage {
@@ -883,6 +885,7 @@ export class Team {
movedFrom: undefined, movedFrom: undefined,
ended: undefined, ended: undefined,
waitSince: undefined, waitSince: undefined,
jobId: undefined,
} }
this.agents.set(fresh.name, fresh) this.agents.set(fresh.name, fresh)
for (const c of this.agents.values()) for (const c of this.agents.values())
+2
View File
@@ -129,6 +129,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
const sendToJob = (text: string) => { const sendToJob = (text: string) => {
const id = entered()! const id = entered()!
const job = app.registry.get(app.jobsSessionId(), id) const job = app.registry.get(app.jobsSessionId(), id)
// Spec 3.4.0 §11: a subagent of the tree is read-only; the person speaks through the main agent.
if (job?.team) return view.info(`${id} is ${job.team}, a subagent of the main agent: its session is read-only — tell the main agent, or /team stop ${job.team}`, "warn")
if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn") if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn")
if (job.state === "running") { if (job.state === "running") {
job.subagent.send(text) job.subagent.send(text)
+38 -4
View File
@@ -12,6 +12,7 @@ import { instructionFiles } from "../prompt/assemble.ts"
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts" import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
import { contextBreakdown, renderBreakdown } from "../session/context.ts" import { contextBreakdown, renderBreakdown } from "../session/context.ts"
import { SUBAGENTS } from "../team/subagents.ts" import { SUBAGENTS } from "../team/subagents.ts"
import { TEAM } from "../team/team.ts"
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts" import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
import { git } from "../git/run.ts" import { git } from "../git/run.ts"
import { branches, deleteBranch, switchBranch } from "../git/branch.ts" import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
@@ -262,12 +263,29 @@ export const COMMANDS: Command[] = [
}, },
{ {
name: "team", name: "team",
args: "[size [<min> <max>] | stop [<name>]]", args: "[size [<min> <max>] | stop [<name>] | open [<name>] | findings <name>]",
description: "subagents: the team and the room; how many; stop one", description: "subagents: the tree and the room; how many; stop one; their findings",
run(c, arg) { run(c, arg) {
// The tree and the room as the lead sees them — the person sees everything (team.md §5). // The tree and the room as the lead sees them — the person sees everything (team.md §5).
const words = arg.trim().split(/\s+/).filter(Boolean) const words = arg.trim().split(/\s+/).filter(Boolean)
if (words[0] === "size" && words.length === 1) return openTeamSize(c) if (words[0] === "size" && words.length === 1) return openTeamSize(c)
// Spec 3.4.0 §11: a subagent's session, read-only, live or after it ended.
if (words[0] === "open") {
const t = c.app.team.team
const all = [...t.tree().filter((a) => a.manager !== null), ...t.ended]
const open = (key: string) => {
const a = t.find(key)
if (!a?.jobId) return c.view.info(`no subagent ${key} with a session here — /team lists them`, "warn")
c.enterJob(a.jobId)
}
if (words[1]) return open(words[1])
if (!all.length) return c.view.info("no subagents yet")
return c.openDialog({
title: "A subagent's session — read-only",
options: all.map((a) => ({ label: `${a.name} (${a.id})`, value: a.id, description: `${a.role} · ${TEAM.read.states[a.state]}` })),
onSelect: open,
})
}
if (words[0] === "stop" && words.length === 1) { if (words[0] === "stop" && words.length === 1) {
const members = c.app.team.team.tree().filter((a) => a.manager !== null) const members = c.app.team.team.tree().filter((a) => a.manager !== null)
if (!members.length) return c.view.info("the team has no members") if (!members.length) return c.view.info("the team has no members")
@@ -708,8 +726,24 @@ export const COMMANDS: Command[] = [
}, },
{ {
name: "context", name: "context",
description: "where the context window goes", args: "[<subagent>]",
run(c) { description: "where the context window goes — the session's, or a subagent's",
run(c, arg) {
// Spec 3.4.0 §12: a subagent's context, by its name or id.
const who = arg.trim()
if (who) {
const t = c.app.team.team
const a = t.find(who)
if (!a) return c.view.info(`no subagent ${who} — /team lists them`, "warn")
const h = c.app.team.handle(a.name)
if (!h || a.endState) {
const u = a.context
return c.view.info(u ? `${a.name} (${a.id}): ${u.used} of ${u.window} tokens (${Math.floor((u.used * 100) / u.window)}%) when it ${a.endState ? TEAM.read.states[a.state] : "last said"}` : `${a.name} (${a.id}): no context use known yet`)
}
const se = h.engine
const sys = se.systemPrompt()
return c.view.info(`${a.name} (${a.id}) — ${a.role}\n${renderBreakdown(contextBreakdown(se, sys, [], undefined, se.o.tools))}`)
}
const e = c.app.engine const e = c.app.engine
// The prompt the session sends (made now if no request has gone yet). // The prompt the session sends (made now if no request has gone yet).
const system = e.systemPrompt() const system = e.systemPrompt()
+19 -2
View File
@@ -1,11 +1,18 @@
import { createSignal, For, onCleanup } from "solid-js" import { createSignal, For, onCleanup } from "solid-js"
import { duration } from "../../duration.ts" import { duration } from "../../duration.ts"
import { progressLine } from "../../session/progress.ts"
import { useTui } from "../context.ts" import { useTui } from "../context.ts"
import { fmtTokens } from "./status.tsx" import { fmtTokens } from "./status.tsx"
/** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's /** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's
* return_progress). The time left is guessed from the reading so far, once there is enough of it; * return_progress). The time left is guessed from the reading so far, once there is enough of it;
* a long prompt reads slower towards its end, so it is a floor more than a promise. */ * a long prompt reads slower towards its end, so it is a floor more than a promise. */
/** How long the rest of the prompt will take, from the server's pace so far; "" until it is known. */
export function etaOf(p: { total: number; done: number; processed: number; ms: number }): string {
if (p.processed >= 2048 && p.ms >= 2000 && p.done < p.total) return `~${duration(((p.total - p.done) / p.processed) * p.ms, true)} left`
return ""
}
export function reading(p: { total: number; done: number; processed: number; ms: number }): string { export function reading(p: { total: number; done: number; processed: number; ms: number }): string {
const pct = Math.floor((p.done / p.total) * 100) const pct = Math.floor((p.done / p.total) * 100)
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`] const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
@@ -34,6 +41,16 @@ export function Spinner() {
} }
const what = () => { const what = () => {
const s = view.state const s = view.state
// Spec 3.4.0: the phase the engine says, drawn as every client draws it (progress.md §3).
if (s.now) {
tick()
// The CLI's own look around the shared line: how long the rest of the prompt will take.
// The server's counts as they come (the phase event is paced for the link; the screen is not).
const reading = s.now.phase === "reading_prompt" && s.progress
const now = reading ? { ...s.now, done: s.progress!.done, total: s.progress!.total } : s.now
const eta = reading ? etaOf(s.progress!) : ""
return `${progressLine(now)}${eta ? ` · ${eta}` : ""}`
}
if (s.preparing) return `preparing ${s.preparing}` if (s.preparing) return `preparing ${s.preparing}`
if (s.phase === "waiting" && s.progress) return reading(s.progress) if (s.phase === "waiting" && s.progress) return reading(s.progress)
if (s.phase === "thinking") return pick(sp().verbs, 30) if (s.phase === "thinking") return pick(sp().verbs, 30)
@@ -45,9 +62,9 @@ export function Spinner() {
<box paddingLeft={1} height={1}> <box paddingLeft={1} height={1}>
<text wrapMode="none" truncate> <text wrapMode="none" truncate>
<span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span> <span style={{ fg: theme().colors.accent }}>{pick(sp().frames, 1)} </span>
<span style={{ fg: theme().colors.muted }}>{what()}… </span> <span style={{ fg: theme().colors.muted }}>{what()}{view.state.now ? " · " : "… "}</span>
<span style={{ fg: theme().colors.dim }}> <span style={{ fg: theme().colors.dim }}>
{elapsed()} · {tokens()} · esc to stop {view.state.now ? `turn ${elapsed()}` : elapsed()} · {tokens()} · esc to stop
</span> </span>
</text> </text>
</box> </box>
+1 -1
View File
@@ -27,7 +27,7 @@ export function SubagentView(p: { id: string; view: View; scrollRef?: (r: Scroll
const j = job() const j = job()
if (!j) return `subagent · ${p.id} · gone · esc back` if (!j) return `subagent · ${p.id} · gone · esc back`
const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j) const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j)
const message = j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)" const message = j.team ? `read-only: ${j.team} works for the main agent` : j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)"
return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ") return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ")
} }
const what = () => job()?.description ?? "" const what = () => job()?.description ?? ""
+5
View File
@@ -120,6 +120,8 @@ export interface ViewState {
progress?: { total: number; done: number; processed: number; ms: number } progress?: { total: number; done: number; processed: number; ms: number }
/** The session's background jobs (the registry's list, kept from `job` events), running first. */ /** The session's background jobs (the registry's list, kept from `job` events), running first. */
jobs: TrackedJob[] jobs: TrackedJob[]
/** Spec 3.4.0 (harness/progress.md): what the agent is doing now — the spinner line. */
now?: import("../session/progress.ts").Progress | null
} }
// What the model was given beside the words typed — attachments, and a session note // What the model was given beside the words typed — attachments, and a session note
@@ -285,6 +287,9 @@ export function createView(bus: Bus) {
if (e.state === "start") startTask() if (e.state === "start") startTask()
else inTask = false else inTask = false
break break
case "phase":
set("now", e.progress ? { ...e.progress } : null)
break
case "sub_tool": case "sub_tool":
set( set(
"items", "items",
+1 -1
View File
@@ -54,7 +54,7 @@ describe("end to end", () => {
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit") expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares // knowledge_search/get are offered once any knowledge base has documents, and the suite shares
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run). // one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"]) expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "team_whoami", "team_findings", "team_decide", "team_assign", "team_replace", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
const ends = events.filter((e) => e.type === "tool_end") const ends = events.filter((e) => e.type === "tool_end")
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"]) expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
const sid = store.sessions(1)[0]!.id const sid = store.sessions(1)[0]!.id
+7 -5
View File
@@ -372,14 +372,14 @@ test("thinking is one line — its text is not shown; the provider's count of it
expect(await t.until("SECRET-INNER-STEP")).toBe(true) expect(await t.until("SECRET-INNER-STEP")).toBe(true)
}) })
test("while it thinks: the thought says only Thinking…; the line at the bottom has the verb, the task's time and tokens", async () => { test("while it thinks: the thought says only Thinking…; the line at the bottom has the phase (progress.md), the task's time and tokens", async () => {
t = await tui([{ gapMs: 1500, chunks: [delta({ reasoning_content: "Weighing it up: HIDDEN-WHILE-THINKING and more words here." }), delta({ content: "Done thinking." })] }]) t = await tui([{ gapMs: 1500, chunks: [delta({ reasoning_content: "Weighing it up: HIDDEN-WHILE-THINKING and more words here." }), delta({ content: "Done thinking." })] }])
await t.input.typeText("go") await t.input.typeText("go")
t.input.pressEnter() t.input.pressEnter()
expect(await t.until("~15 tokens")).toBe(true) expect(await t.until("~15 tokens")).toBe(true)
const frame = t.frame() const frame = t.frame()
expect(frame).toMatch(/^\s*Thinking\.{1,3}\s*$/m) expect(frame).toMatch(/^\s*Thinking\.{1,3}\s*$/m)
expect(frame).toMatch(/(pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|reasoning|analyzing|synthesizing|formulating)… \d+s · ~15 tokens · esc to stop/) expect(frame).toMatch(/Thinking · \d+s · turn \d+s · ~15 tokens · esc to stop/)
expect(frame).not.toContain("HIDDEN-WHILE-THINKING") expect(frame).not.toContain("HIDDEN-WHILE-THINKING")
expect(await t.until("Done thinking.")).toBe(true) expect(await t.until("Done thinking.")).toBe(true)
expect(t.frame()).toMatch(/Thought for \d\.\ds · ~15 tokens/) expect(t.frame()).toMatch(/Thought for \d\.\ds · ~15 tokens/)
@@ -397,7 +397,7 @@ test("the line at the bottom counts the whole task: the clock and the tokens go
// The spinner line redraws on its own tick, slower than the reply's text: on a slow machine the // The spinner line redraws on its own tick, slower than the reply's text: on a slow machine the
// frame that first shows "Half" can still carry the line from before. The 1.2 s gap holds this // frame that first shows "Half" can still carry the line from before. The 1.2 s gap holds this
// state, so wait for the line within it rather than reading the first frame. // state, so wait for the line within it rather than reading the first frame.
const counted = /writing… \d+s · ~4\d tokens · esc to stop/ const counted = /Writing(: [\d.]+ tok\/s)? · \d+s · turn \d+s · ~4\d tokens · esc to stop/
for (let i = 0; i < 20 && !counted.test(t.frame()); i++) await Bun.sleep(50), await t.settle() for (let i = 0; i < 20 && !counted.test(t.frame()); i++) await Bun.sleep(50), await t.settle()
expect(t.frame()).toMatch(counted) expect(t.frame()).toMatch(counted)
expect(await t.until("way.")).toBe(true) expect(await t.until("way.")).toBe(true)
@@ -922,10 +922,12 @@ test("while the server reads a long prompt: the line at the bottom says how far
t = await tui([{ gapMs: 500, chunks: [pp(0, 0), pp(80000, 160000), delta({ content: "Read it all." }, "stop")] }]) t = await tui([{ gapMs: 500, chunks: [pp(0, 0), pp(80000, 160000), delta({ content: "Read it all." }, "stop")] }])
await t.input.typeText("go") await t.input.typeText("go")
t.input.pressEnter() t.input.pressEnter()
expect(await t.until("reading the prompt 64% · 90k of 140k · ~1m 40s left")).toBe(true) // At 100 columns the line is cut in its middle; its start and its end are what is checked.
expect(await t.until("~1m 40s left")).toBe(true)
expect(t.frame()).toContain("Reading the prompt: 90000 / 140000 tokens (64%")
expect(await t.until("Read it all.")).toBe(true) expect(await t.until("Read it all.")).toBe(true)
await t.settle() await t.settle()
expect(t.frame()).not.toContain("reading the prompt") expect(t.frame()).not.toContain("Reading the prompt")
}) })
test("a mouse wheel that arrives as a burst of ↑ scrolls the transcript; one ↑ still walks history", async () => { test("a mouse wheel that arrives as a burst of ↑ scrolls the transcript; one ↑ still walks history", async () => {