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
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+117
-15
No files matched your search
@@ -645,6 +645,7 @@ export function createApp(o: AppOptions): App {
|
||||
if (member) {
|
||||
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
|
||||
job.team = member.name
|
||||
member.jobId = job.id
|
||||
// Its progress and context use, for the tree (spec 3.4.0 §12).
|
||||
childBus.on((e) => {
|
||||
if (e.type === "phase") {
|
||||
|
||||
+40
-2
@@ -18,6 +18,8 @@ import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
||||
import { CONTINUE_PROMPT } from "./engine.ts"
|
||||
import { TEAM, Team } from "../team/team.ts"
|
||||
import { SUBAGENTS } from "../team/subagents.ts"
|
||||
import { progressLine } from "./progress.ts"
|
||||
import { fillPrompt } from "../prompt/fill.ts"
|
||||
|
||||
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: "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: "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
|
||||
@@ -60,6 +62,34 @@ export function teamLabel(team: Team): string {
|
||||
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
|
||||
* 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. */
|
||||
@@ -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.`
|
||||
}
|
||||
if (verb === "findings") {
|
||||
const name = rest[0]
|
||||
if (!name) return "/team findings <name or id>"
|
||||
return t.readFindings("lead", name)
|
||||
}
|
||||
if (verb === "stop") {
|
||||
const name = rest[0]
|
||||
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)."
|
||||
const since = Math.max(0, t.room.length - TEAM.read_max)
|
||||
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
|
||||
|
||||
@@ -104,6 +104,8 @@ export interface TeamAgent {
|
||||
endedAt?: number
|
||||
/** The sub-manager it was under before that one ended (team_replace puts it back). */
|
||||
movedFrom?: string
|
||||
/** The device's job that runs it (its transcript, read-only). */
|
||||
jobId?: string
|
||||
}
|
||||
|
||||
export interface RoomMessage {
|
||||
@@ -883,6 +885,7 @@ export class Team {
|
||||
movedFrom: undefined,
|
||||
ended: undefined,
|
||||
waitSince: undefined,
|
||||
jobId: undefined,
|
||||
}
|
||||
this.agents.set(fresh.name, fresh)
|
||||
for (const c of this.agents.values())
|
||||
|
||||
@@ -129,6 +129,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
||||
const sendToJob = (text: string) => {
|
||||
const id = entered()!
|
||||
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.state === "running") {
|
||||
job.subagent.send(text)
|
||||
|
||||
+38
-4
@@ -12,6 +12,7 @@ import { instructionFiles } from "../prompt/assemble.ts"
|
||||
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
||||
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
||||
import { SUBAGENTS } from "../team/subagents.ts"
|
||||
import { TEAM } from "../team/team.ts"
|
||||
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
|
||||
import { git } from "../git/run.ts"
|
||||
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
||||
@@ -262,12 +263,29 @@ export const COMMANDS: Command[] = [
|
||||
},
|
||||
{
|
||||
name: "team",
|
||||
args: "[size [<min> <max>] | stop [<name>]]",
|
||||
description: "subagents: the team and the room; how many; stop one",
|
||||
args: "[size [<min> <max>] | stop [<name>] | open [<name>] | findings <name>]",
|
||||
description: "subagents: the tree and the room; how many; stop one; their findings",
|
||||
run(c, arg) {
|
||||
// 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)
|
||||
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) {
|
||||
const members = c.app.team.team.tree().filter((a) => a.manager !== null)
|
||||
if (!members.length) return c.view.info("the team has no members")
|
||||
@@ -708,8 +726,24 @@ export const COMMANDS: Command[] = [
|
||||
},
|
||||
{
|
||||
name: "context",
|
||||
description: "where the context window goes",
|
||||
run(c) {
|
||||
args: "[<subagent>]",
|
||||
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
|
||||
// The prompt the session sends (made now if no request has gone yet).
|
||||
const system = e.systemPrompt()
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { createSignal, For, onCleanup } from "solid-js"
|
||||
import { duration } from "../../duration.ts"
|
||||
import { progressLine } from "../../session/progress.ts"
|
||||
import { useTui } from "../context.ts"
|
||||
import { fmtTokens } from "./status.tsx"
|
||||
|
||||
/** "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;
|
||||
* 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 {
|
||||
const pct = Math.floor((p.done / p.total) * 100)
|
||||
const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`]
|
||||
@@ -34,6 +41,16 @@ export function Spinner() {
|
||||
}
|
||||
const what = () => {
|
||||
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.phase === "waiting" && s.progress) return reading(s.progress)
|
||||
if (s.phase === "thinking") return pick(sp().verbs, 30)
|
||||
@@ -45,9 +62,9 @@ export function Spinner() {
|
||||
<box paddingLeft={1} height={1}>
|
||||
<text wrapMode="none" truncate>
|
||||
<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 }}>
|
||||
{elapsed()} · {tokens()} · esc to stop
|
||||
{view.state.now ? `turn ${elapsed()}` : elapsed()} · {tokens()} · esc to stop
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function SubagentView(p: { id: string; view: View; scrollRef?: (r: Scroll
|
||||
const j = job()
|
||||
if (!j) return `subagent · ${p.id} · gone · esc back`
|
||||
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(" · ")
|
||||
}
|
||||
const what = () => job()?.description ?? ""
|
||||
|
||||
@@ -120,6 +120,8 @@ export interface ViewState {
|
||||
progress?: { total: number; done: number; processed: number; ms: number }
|
||||
/** The session's background jobs (the registry's list, kept from `job` events), running first. */
|
||||
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
|
||||
@@ -285,6 +287,9 @@ export function createView(bus: Bus) {
|
||||
if (e.state === "start") startTask()
|
||||
else inTask = false
|
||||
break
|
||||
case "phase":
|
||||
set("now", e.progress ? { ...e.progress } : null)
|
||||
break
|
||||
case "sub_tool":
|
||||
set(
|
||||
"items",
|
||||
|
||||
Reference in new issue
Block a user