@llm4ts/runner 2.19.0 → 2.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AgentTree.d.ts +150 -0
- package/dist/AgentTree.d.ts.map +1 -0
- package/dist/AgentTree.js +638 -0
- package/dist/AgentTree.js.map +1 -0
- package/dist/AgentTreeSurface.d.ts +34 -0
- package/dist/AgentTreeSurface.d.ts.map +1 -0
- package/dist/AgentTreeSurface.js +118 -0
- package/dist/AgentTreeSurface.js.map +1 -0
- package/dist/FlowArgs.d.ts +1 -1
- package/dist/FlowArgs.d.ts.map +1 -1
- package/dist/FlowArgs.js +1 -0
- package/dist/FlowArgs.js.map +1 -1
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +33 -4
- package/dist/FlowRunner.js.map +1 -1
- package/dist/Terminal.d.ts.map +1 -1
- package/dist/Terminal.js +15 -3
- package/dist/Terminal.js.map +1 -1
- package/dist/Watch.d.ts +46 -0
- package/dist/Watch.d.ts.map +1 -0
- package/dist/Watch.js +177 -0
- package/dist/Watch.js.map +1 -0
- package/package.json +8 -5
- package/src/AgentTree.ts +954 -0
- package/src/AgentTreeSurface.ts +202 -0
- package/src/FlowArgs.ts +1 -0
- package/src/FlowRunner.ts +51 -4
- package/src/Terminal.ts +15 -2
- package/src/Watch.ts +291 -0
package/src/AgentTree.ts
ADDED
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
import type { TokenUsage } from "@llm4ts/core/Models"
|
|
2
|
+
import * as Option from "effect/Option"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import { FlowEvent } from "@llm4ts/flow/FlowEvents"
|
|
5
|
+
import type { TraceLine } from "@llm4ts/flow/FlowRecorder"
|
|
6
|
+
import { estimateCostUsd } from "@llm4ts/flow/PriceList"
|
|
7
|
+
import { formatCount } from "./Terminal.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The agent tree (ADR 0022): a full-screen view of a running or finished
|
|
11
|
+
* flow — the orchestrator, the judgment layer, the story lanes on the
|
|
12
|
+
* executor roster, the judge seat and a session log.
|
|
13
|
+
*
|
|
14
|
+
* Pure: `reduceTree` folds what a run published into a `TreeState`, and
|
|
15
|
+
* `renderTree` draws that state at a width. Where the inputs come from (the
|
|
16
|
+
* event hub of a live run, or a trace file) is the host's business, so a live
|
|
17
|
+
* view, a finished run and a replay are the same fold.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type TreeInput =
|
|
21
|
+
| { readonly _tag: "Event"; readonly at: number; readonly event: FlowEvent }
|
|
22
|
+
| { readonly _tag: "RunEnded"; readonly at: number; readonly outcome: string }
|
|
23
|
+
|
|
24
|
+
export type LaneStatus = "running" | "done" | "failed"
|
|
25
|
+
|
|
26
|
+
export interface TreeLane {
|
|
27
|
+
readonly id: string
|
|
28
|
+
/** The lane's open stages below `story <id>`, innermost last. */
|
|
29
|
+
readonly stages: ReadonlyArray<string>
|
|
30
|
+
readonly executor: string | undefined
|
|
31
|
+
readonly startedAt: number
|
|
32
|
+
readonly status: LaneStatus
|
|
33
|
+
readonly lastTool: string | undefined
|
|
34
|
+
/** The lane's most recent tool calls, oldest first, for the expanded view. */
|
|
35
|
+
readonly tools: ReadonlyArray<string>
|
|
36
|
+
readonly tokens: number
|
|
37
|
+
readonly costUsd: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type LogSource = "lane" | "run" | "roster" | "judge"
|
|
41
|
+
|
|
42
|
+
export interface TreeLogEntry {
|
|
43
|
+
readonly at: number
|
|
44
|
+
readonly source: LogSource
|
|
45
|
+
/** The lane, executor or `flow` the entry is about. */
|
|
46
|
+
readonly who: string
|
|
47
|
+
readonly what: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A story as the epic's board lists it, before or without a lane of its own. */
|
|
51
|
+
export interface TreeStory {
|
|
52
|
+
readonly id: string
|
|
53
|
+
readonly status: "planned" | "active" | "waiting" | "done" | "failed" | "skipped"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A lease an executor holds now: its role and who it is for (a story id). */
|
|
57
|
+
export interface TreeLease {
|
|
58
|
+
readonly executor: string
|
|
59
|
+
readonly role: string
|
|
60
|
+
readonly label: string | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The judge seat: who sits in it, and how often it was asked. */
|
|
64
|
+
export interface TreeJudge {
|
|
65
|
+
readonly executor: string | undefined
|
|
66
|
+
readonly reviews: number
|
|
67
|
+
readonly verdicts: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The latest story verdict (`StoryJudged`). */
|
|
71
|
+
export interface TreeVerdict {
|
|
72
|
+
readonly lane: string
|
|
73
|
+
readonly round: number
|
|
74
|
+
readonly cleared: boolean
|
|
75
|
+
readonly issues: number
|
|
76
|
+
readonly dimensions: ReadonlyArray<{
|
|
77
|
+
readonly id: string
|
|
78
|
+
readonly score: number
|
|
79
|
+
readonly max: number
|
|
80
|
+
}>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The latest typed judgment per key (`JudgmentObserved`). */
|
|
84
|
+
export interface TreeJudgment {
|
|
85
|
+
readonly key: string
|
|
86
|
+
readonly certainty: number
|
|
87
|
+
readonly decision: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TreeState {
|
|
91
|
+
readonly title: string
|
|
92
|
+
readonly action: string | undefined
|
|
93
|
+
/** The board's stories, in plan order; lanes not on it are added after. */
|
|
94
|
+
readonly stories: ReadonlyArray<TreeStory>
|
|
95
|
+
readonly startedAt: number | undefined
|
|
96
|
+
readonly now: number | undefined
|
|
97
|
+
/** Run-wide open stages, innermost last. */
|
|
98
|
+
readonly stages: ReadonlyArray<string>
|
|
99
|
+
/** Lanes in the order they started. */
|
|
100
|
+
readonly lanes: ReadonlyArray<TreeLane>
|
|
101
|
+
/** Executors in the order the run first named them. */
|
|
102
|
+
readonly executors: ReadonlyArray<string>
|
|
103
|
+
readonly leases: ReadonlyArray<TreeLease>
|
|
104
|
+
/** Executors out of the round, with the roster's reason. */
|
|
105
|
+
readonly exclusions: ReadonlyArray<{ readonly executor: string; readonly reason: string }>
|
|
106
|
+
readonly judge: TreeJudge
|
|
107
|
+
readonly verdict: TreeVerdict | undefined
|
|
108
|
+
readonly judgments: ReadonlyArray<TreeJudgment>
|
|
109
|
+
readonly log: ReadonlyArray<TreeLogEntry>
|
|
110
|
+
readonly tokens: number
|
|
111
|
+
/** Estimated: the backend's own figure when it reports one, else the price list. */
|
|
112
|
+
readonly costUsd: number
|
|
113
|
+
/** The run's outcome once its trace says it ended; a live run has none. */
|
|
114
|
+
readonly ended: string | undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface TreeOptions {
|
|
118
|
+
readonly title?: string
|
|
119
|
+
/** What the run set out to do ("run plan", "round 2"), when the caller knows. */
|
|
120
|
+
readonly action?: string
|
|
121
|
+
readonly stories?: ReadonlyArray<TreeStory>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const emptyTree = (options: TreeOptions = {}): TreeState => ({
|
|
125
|
+
title: options.title ?? "flow",
|
|
126
|
+
action: options.action,
|
|
127
|
+
stories: options.stories ?? [],
|
|
128
|
+
startedAt: undefined,
|
|
129
|
+
now: undefined,
|
|
130
|
+
stages: [],
|
|
131
|
+
lanes: [],
|
|
132
|
+
executors: [],
|
|
133
|
+
leases: [],
|
|
134
|
+
exclusions: [],
|
|
135
|
+
judge: { executor: undefined, reviews: 0, verdicts: 0 },
|
|
136
|
+
verdict: undefined,
|
|
137
|
+
judgments: [],
|
|
138
|
+
log: [],
|
|
139
|
+
tokens: 0,
|
|
140
|
+
costUsd: 0,
|
|
141
|
+
ended: undefined
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const costOf = (model: string | undefined, usage: TokenUsage): number =>
|
|
145
|
+
usage.costUsd ?? (model === undefined ? undefined : estimateCostUsd(model, usage)) ?? 0
|
|
146
|
+
|
|
147
|
+
const logged = (state: TreeState, entry: TreeLogEntry): TreeState => ({
|
|
148
|
+
...state,
|
|
149
|
+
log: [...state.log, entry]
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const storyStage = /^story (\S+)$/u
|
|
153
|
+
const expandedTools = 20
|
|
154
|
+
|
|
155
|
+
const updateLane = (
|
|
156
|
+
state: TreeState,
|
|
157
|
+
id: string,
|
|
158
|
+
update: (lane: TreeLane) => TreeLane
|
|
159
|
+
): TreeState => ({
|
|
160
|
+
...state,
|
|
161
|
+
lanes: state.lanes.map((lane) => (lane.id === id ? update(lane) : lane))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const withoutLast = <A>(items: ReadonlyArray<A>, item: A): ReadonlyArray<A> => {
|
|
165
|
+
const index = items.lastIndexOf(item)
|
|
166
|
+
return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)]
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const withExecutor = (state: TreeState, executor: string): TreeState =>
|
|
170
|
+
state.executors.includes(executor)
|
|
171
|
+
? state
|
|
172
|
+
: { ...state, executors: [...state.executors, executor] }
|
|
173
|
+
|
|
174
|
+
const rosterLog = (state: TreeState, at: number, what: string): TreeState =>
|
|
175
|
+
logged(state, { at, source: "roster", who: "roster", what })
|
|
176
|
+
|
|
177
|
+
const reduceLease = (state: TreeState, at: number, lease: TreeLease): TreeState => {
|
|
178
|
+
const held = { ...withExecutor(state, lease.executor), leases: [...state.leases, lease] }
|
|
179
|
+
switch (lease.role) {
|
|
180
|
+
case "coder": {
|
|
181
|
+
const lane = held.lanes.find((candidate) => candidate.id === lease.label)
|
|
182
|
+
const placed =
|
|
183
|
+
lane === undefined
|
|
184
|
+
? held
|
|
185
|
+
: updateLane(held, lane.id, (open) => ({ ...open, executor: lease.executor }))
|
|
186
|
+
return rosterLog(
|
|
187
|
+
placed,
|
|
188
|
+
at,
|
|
189
|
+
`${lease.executor} → coder${lease.label === undefined ? "" : ` · ${lease.label}`}`
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
case "reviewer":
|
|
193
|
+
return {
|
|
194
|
+
...held,
|
|
195
|
+
judge: {
|
|
196
|
+
...held.judge,
|
|
197
|
+
executor: held.judge.executor ?? lease.executor,
|
|
198
|
+
reviews: held.judge.reviews + 1
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
case "judge":
|
|
202
|
+
return {
|
|
203
|
+
...held,
|
|
204
|
+
judge: { ...held.judge, executor: lease.executor, verdicts: held.judge.verdicts + 1 }
|
|
205
|
+
}
|
|
206
|
+
default:
|
|
207
|
+
return held
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const issueCount = (issues: number): string => `${issues} issue${issues === 1 ? "" : "s"}`
|
|
212
|
+
|
|
213
|
+
const verdictWords = (verdict: TreeVerdict): string =>
|
|
214
|
+
verdict.cleared ? "cleared" : issueCount(verdict.issues)
|
|
215
|
+
|
|
216
|
+
const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState => {
|
|
217
|
+
const lane =
|
|
218
|
+
"lane" in event && event.lane !== undefined
|
|
219
|
+
? state.lanes.find((candidate) => candidate.id === event.lane)
|
|
220
|
+
: undefined
|
|
221
|
+
const executor = "executor" in event ? event.executor : undefined
|
|
222
|
+
const named =
|
|
223
|
+
executor === undefined || state.executors.includes(executor)
|
|
224
|
+
? state
|
|
225
|
+
: { ...state, executors: [...state.executors, executor] }
|
|
226
|
+
const current =
|
|
227
|
+
lane !== undefined && executor !== undefined && lane.executor !== executor
|
|
228
|
+
? updateLane(named, lane.id, (open) => ({ ...open, executor }))
|
|
229
|
+
: named
|
|
230
|
+
switch (event._tag) {
|
|
231
|
+
case "StageStarted": {
|
|
232
|
+
const story = storyStage.exec(event.stage)?.[1]
|
|
233
|
+
if (story !== undefined && event.lane === story) {
|
|
234
|
+
const fresh: TreeLane = {
|
|
235
|
+
id: story,
|
|
236
|
+
stages: [],
|
|
237
|
+
executor: event.executor,
|
|
238
|
+
startedAt: at,
|
|
239
|
+
status: "running",
|
|
240
|
+
lastTool: undefined,
|
|
241
|
+
tools: [],
|
|
242
|
+
tokens: 0,
|
|
243
|
+
costUsd: 0
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
...current,
|
|
247
|
+
lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (lane !== undefined) {
|
|
251
|
+
return updateLane(current, lane.id, (open) => ({
|
|
252
|
+
...open,
|
|
253
|
+
stages: [...open.stages, event.stage]
|
|
254
|
+
}))
|
|
255
|
+
}
|
|
256
|
+
return { ...current, stages: [...current.stages, event.stage] }
|
|
257
|
+
}
|
|
258
|
+
case "StageCompleted":
|
|
259
|
+
case "StageFailed": {
|
|
260
|
+
if (lane !== undefined && event.stage === `story ${lane.id}`) {
|
|
261
|
+
const status: LaneStatus = event._tag === "StageFailed" ? "failed" : "done"
|
|
262
|
+
const closed = updateLane(current, lane.id, (open) => ({ ...open, stages: [], status }))
|
|
263
|
+
return logged(closed, {
|
|
264
|
+
at,
|
|
265
|
+
source: "lane",
|
|
266
|
+
who: lane.id,
|
|
267
|
+
what: event._tag === "StageFailed" ? `failed · ${event.message}` : "done"
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
if (lane !== undefined) {
|
|
271
|
+
return updateLane(current, lane.id, (open) => ({
|
|
272
|
+
...open,
|
|
273
|
+
stages: withoutLast(open.stages, event.stage)
|
|
274
|
+
}))
|
|
275
|
+
}
|
|
276
|
+
return { ...current, stages: withoutLast(current.stages, event.stage) }
|
|
277
|
+
}
|
|
278
|
+
case "Info": {
|
|
279
|
+
// A story's own milestones are `story <id>: …` (resuming, merged,
|
|
280
|
+
// skipped); its other chatter stays in the expanded lane.
|
|
281
|
+
if (event.lane === undefined) {
|
|
282
|
+
// Traces from before the roster's typed events (2.20) carry its notes
|
|
283
|
+
// as prose: leases are chatter, the rest (exclusions, handovers) stays.
|
|
284
|
+
const legacyRoster = /^roster: (.*)$/u.exec(event.message)?.[1]
|
|
285
|
+
if (legacyRoster !== undefined) {
|
|
286
|
+
return / takes \S+/u.test(legacyRoster)
|
|
287
|
+
? current
|
|
288
|
+
: logged(current, { at, source: "roster", who: "roster", what: legacyRoster })
|
|
289
|
+
}
|
|
290
|
+
return logged(current, { at, source: "run", who: "flow", what: event.message })
|
|
291
|
+
}
|
|
292
|
+
const prefix = `story ${event.lane}: `
|
|
293
|
+
return event.message.startsWith(prefix)
|
|
294
|
+
? logged(current, {
|
|
295
|
+
at,
|
|
296
|
+
source: "lane",
|
|
297
|
+
who: event.lane,
|
|
298
|
+
what: event.message.slice(prefix.length)
|
|
299
|
+
})
|
|
300
|
+
: current
|
|
301
|
+
}
|
|
302
|
+
case "StoryJudged": {
|
|
303
|
+
const verdict: TreeVerdict = {
|
|
304
|
+
lane: event.lane,
|
|
305
|
+
round: event.round,
|
|
306
|
+
cleared: event.cleared,
|
|
307
|
+
issues: event.issues,
|
|
308
|
+
dimensions: event.dimensions
|
|
309
|
+
}
|
|
310
|
+
return logged(
|
|
311
|
+
{ ...current, verdict },
|
|
312
|
+
{
|
|
313
|
+
at,
|
|
314
|
+
source: "judge",
|
|
315
|
+
who: "judge",
|
|
316
|
+
what: `${verdict.lane} r${verdict.round} · ${verdictWords(verdict)} → ${verdict.cleared ? "merge" : "coder"}`
|
|
317
|
+
}
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
case "JudgmentObserved":
|
|
321
|
+
return {
|
|
322
|
+
...current,
|
|
323
|
+
judgments: [
|
|
324
|
+
...current.judgments.filter((judgment) => judgment.key !== event.key),
|
|
325
|
+
{ key: event.key, certainty: event.certainty, decision: event.decision }
|
|
326
|
+
]
|
|
327
|
+
}
|
|
328
|
+
case "ExecutorLeased":
|
|
329
|
+
return reduceLease(current, at, {
|
|
330
|
+
executor: event.executor,
|
|
331
|
+
role: event.role,
|
|
332
|
+
label: event.label
|
|
333
|
+
})
|
|
334
|
+
case "ExecutorReleased": {
|
|
335
|
+
const index = current.leases.findIndex(
|
|
336
|
+
(lease) =>
|
|
337
|
+
lease.executor === event.executor &&
|
|
338
|
+
lease.role === event.role &&
|
|
339
|
+
lease.label === event.label
|
|
340
|
+
)
|
|
341
|
+
return index < 0
|
|
342
|
+
? current
|
|
343
|
+
: {
|
|
344
|
+
...current,
|
|
345
|
+
leases: [...current.leases.slice(0, index), ...current.leases.slice(index + 1)]
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
case "ExecutorExcluded":
|
|
349
|
+
return rosterLog(
|
|
350
|
+
{
|
|
351
|
+
...withExecutor(current, event.executor),
|
|
352
|
+
exclusions: [
|
|
353
|
+
...current.exclusions.filter((out) => out.executor !== event.executor),
|
|
354
|
+
{ executor: event.executor, reason: event.reason }
|
|
355
|
+
]
|
|
356
|
+
},
|
|
357
|
+
at,
|
|
358
|
+
`${event.executor} out · ${event.reason}`
|
|
359
|
+
)
|
|
360
|
+
case "ExecutorResumed":
|
|
361
|
+
return rosterLog(
|
|
362
|
+
{
|
|
363
|
+
...current,
|
|
364
|
+
exclusions: current.exclusions.filter((out) => out.executor !== event.executor)
|
|
365
|
+
},
|
|
366
|
+
at,
|
|
367
|
+
`${event.executor} back · ${event.why}`
|
|
368
|
+
)
|
|
369
|
+
case "ExecutorHandedOver":
|
|
370
|
+
return rosterLog(
|
|
371
|
+
current,
|
|
372
|
+
at,
|
|
373
|
+
`${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`
|
|
374
|
+
)
|
|
375
|
+
case "ToolUse":
|
|
376
|
+
return lane === undefined
|
|
377
|
+
? current
|
|
378
|
+
: updateLane(current, lane.id, (open) => ({
|
|
379
|
+
...open,
|
|
380
|
+
lastTool: `${event.tool} ${event.args}`,
|
|
381
|
+
tools: [...open.tools, `${event.tool} ${event.args}`].slice(-expandedTools)
|
|
382
|
+
}))
|
|
383
|
+
case "TokensUsed": {
|
|
384
|
+
const tokens = event.usage.total
|
|
385
|
+
const costUsd = costOf(event.model, event.usage)
|
|
386
|
+
const counted: TreeState = {
|
|
387
|
+
...current,
|
|
388
|
+
tokens: current.tokens + tokens,
|
|
389
|
+
costUsd: current.costUsd + costUsd
|
|
390
|
+
}
|
|
391
|
+
return lane === undefined
|
|
392
|
+
? counted
|
|
393
|
+
: updateLane(counted, lane.id, (open) => ({
|
|
394
|
+
...open,
|
|
395
|
+
tokens: open.tokens + tokens,
|
|
396
|
+
costUsd: open.costUsd + costUsd
|
|
397
|
+
}))
|
|
398
|
+
}
|
|
399
|
+
default:
|
|
400
|
+
return current
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export const reduceTree = (state: TreeState, input: TreeInput): TreeState => {
|
|
405
|
+
const timed: TreeState = {
|
|
406
|
+
...state,
|
|
407
|
+
startedAt: state.startedAt ?? input.at,
|
|
408
|
+
now: Math.max(state.now ?? input.at, input.at)
|
|
409
|
+
}
|
|
410
|
+
switch (input._tag) {
|
|
411
|
+
case "Event":
|
|
412
|
+
return reduceEvent(timed, input.at, input.event)
|
|
413
|
+
case "RunEnded":
|
|
414
|
+
return { ...timed, ended: input.outcome }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const decodeEvent = Schema.decodeUnknownOption(Schema.fromJsonString(FlowEvent))
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* A trace's lines as tree inputs. Kinds this version does not know — raw
|
|
422
|
+
* stream lines, events from a newer llm4ts — are skipped, so any trace renders.
|
|
423
|
+
*/
|
|
424
|
+
export const treeInputsOfTrace = (lines: ReadonlyArray<TraceLine>): ReadonlyArray<TreeInput> =>
|
|
425
|
+
lines.flatMap((line): ReadonlyArray<TreeInput> => {
|
|
426
|
+
if (line.kind === "RunEnded") {
|
|
427
|
+
const outcome = line.fields.outcome
|
|
428
|
+
return [
|
|
429
|
+
{
|
|
430
|
+
_tag: "RunEnded",
|
|
431
|
+
at: line.timestamp,
|
|
432
|
+
outcome: typeof outcome === "string" ? outcome : "completed"
|
|
433
|
+
}
|
|
434
|
+
]
|
|
435
|
+
}
|
|
436
|
+
const raw = line.fields.event
|
|
437
|
+
if (typeof raw !== "string") {
|
|
438
|
+
return []
|
|
439
|
+
}
|
|
440
|
+
return Option.match(decodeEvent(raw), {
|
|
441
|
+
onNone: () => [],
|
|
442
|
+
onSome: (event) => [{ _tag: "Event", at: line.timestamp, event }]
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
// ── drawing ─────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
type Style = "bold" | "dim" | "orchestrator" | "judgment" | "judge" | "log" | "running"
|
|
449
|
+
|
|
450
|
+
interface Span {
|
|
451
|
+
readonly text: string
|
|
452
|
+
readonly style?: Style
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** A frame line before colour: spans, so cutting to width never splits an escape. */
|
|
456
|
+
type Line = ReadonlyArray<Span>
|
|
457
|
+
|
|
458
|
+
const span = (text: string, style?: Style): Span =>
|
|
459
|
+
style === undefined ? { text } : { text, style }
|
|
460
|
+
|
|
461
|
+
const lengthOf = (line: Line): number =>
|
|
462
|
+
line.reduce((total, part) => total + [...part.text].length, 0)
|
|
463
|
+
|
|
464
|
+
/** Pads or cuts a line to exactly `width` characters; a cut ends in `…`. */
|
|
465
|
+
const fit = (line: Line, width: number): Line => {
|
|
466
|
+
const length = lengthOf(line)
|
|
467
|
+
if (length <= width) {
|
|
468
|
+
return length === width ? line : [...line, span(" ".repeat(width - length))]
|
|
469
|
+
}
|
|
470
|
+
const kept: Array<Span> = []
|
|
471
|
+
let room = width - 1
|
|
472
|
+
for (const part of line) {
|
|
473
|
+
if (room <= 0) {
|
|
474
|
+
break
|
|
475
|
+
}
|
|
476
|
+
const characters = [...part.text]
|
|
477
|
+
const taken = characters.slice(0, room).join("")
|
|
478
|
+
kept.push(part.style === undefined ? span(taken) : span(taken, part.style))
|
|
479
|
+
room -= Math.min(room, characters.length)
|
|
480
|
+
}
|
|
481
|
+
return [...kept, span("…")]
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const centre = (line: Line, width: number): Line => {
|
|
485
|
+
const pad = Math.max(0, Math.floor((width - lengthOf(line)) / 2))
|
|
486
|
+
return fit([span(" ".repeat(pad)), ...line], width)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const box = (
|
|
490
|
+
width: number,
|
|
491
|
+
style: Style,
|
|
492
|
+
body: ReadonlyArray<Line>,
|
|
493
|
+
title?: string
|
|
494
|
+
): Array<Line> => {
|
|
495
|
+
const inner = width - 4
|
|
496
|
+
const top: Line =
|
|
497
|
+
title === undefined
|
|
498
|
+
? [span(`┌${"─".repeat(width - 2)}┐`, style)]
|
|
499
|
+
: fit(
|
|
500
|
+
[
|
|
501
|
+
span("┌─ ", style),
|
|
502
|
+
span(title, "bold"),
|
|
503
|
+
span(` ${"─".repeat(Math.max(0, width - 5 - [...title].length))}┐`, style)
|
|
504
|
+
],
|
|
505
|
+
width
|
|
506
|
+
)
|
|
507
|
+
return [
|
|
508
|
+
top,
|
|
509
|
+
...body.map((line): Line => [span("│ ", style), ...fit(line, inner), span(" │", style)]),
|
|
510
|
+
[span(`└${"─".repeat(width - 2)}┘`, style)]
|
|
511
|
+
]
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const beside = (
|
|
515
|
+
columns: ReadonlyArray<ReadonlyArray<Line>>,
|
|
516
|
+
widths: ReadonlyArray<number>,
|
|
517
|
+
gap = 2
|
|
518
|
+
): Array<Line> => {
|
|
519
|
+
const height = Math.max(0, ...columns.map((column) => column.length))
|
|
520
|
+
return Array.from({ length: height }, (_, row) =>
|
|
521
|
+
columns.flatMap((column, index) => [
|
|
522
|
+
...(index === 0 ? [] : [span(" ".repeat(gap))]),
|
|
523
|
+
...fit(column[row] ?? [], widths[index] ?? 0)
|
|
524
|
+
])
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const clock = (from: number | undefined, to: number): string => {
|
|
529
|
+
const seconds = Math.max(0, Math.round((to - (from ?? to)) / 1_000))
|
|
530
|
+
return [Math.floor(seconds / 3_600), Math.floor(seconds / 60) % 60, seconds % 60]
|
|
531
|
+
.map((part) => String(part).padStart(2, "0"))
|
|
532
|
+
.join(":")
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const logStyle: Readonly<Record<LogSource, Style>> = {
|
|
536
|
+
lane: "judgment",
|
|
537
|
+
run: "orchestrator",
|
|
538
|
+
roster: "running",
|
|
539
|
+
judge: "judge"
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const logLines = 5
|
|
543
|
+
const fullLogLines = 30
|
|
544
|
+
|
|
545
|
+
const logOf = (state: TreeState, count: number): Array<Line> => {
|
|
546
|
+
const recent = state.log.slice(-count)
|
|
547
|
+
return recent.length === 0
|
|
548
|
+
? [[span("—", "dim")]]
|
|
549
|
+
: recent.map((entry) => [
|
|
550
|
+
span(clock(state.startedAt, entry.at), "dim"),
|
|
551
|
+
span(" "),
|
|
552
|
+
...fit([span(entry.who, logStyle[entry.source])], 16),
|
|
553
|
+
span(" "),
|
|
554
|
+
span(entry.what)
|
|
555
|
+
])
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const dollars = (usd: number): string => `~$${usd.toFixed(2)}`
|
|
559
|
+
|
|
560
|
+
const elapsed = (from: number | undefined, to: number | undefined): string => {
|
|
561
|
+
const seconds = Math.max(0, Math.round(((to ?? 0) - (from ?? to ?? 0)) / 1_000))
|
|
562
|
+
return seconds < 60
|
|
563
|
+
? `${seconds}s`
|
|
564
|
+
: `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const colours: Readonly<Record<Style, string>> = {
|
|
568
|
+
bold: "\u001b[1m",
|
|
569
|
+
dim: "\u001b[90m",
|
|
570
|
+
orchestrator: "\u001b[38;5;173m",
|
|
571
|
+
judgment: "\u001b[38;5;114m",
|
|
572
|
+
judge: "\u001b[38;5;141m",
|
|
573
|
+
log: "\u001b[38;5;244m",
|
|
574
|
+
running: "\u001b[38;5;110m"
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const paint = (line: Line, colour: boolean): string =>
|
|
578
|
+
line
|
|
579
|
+
.map((part) =>
|
|
580
|
+
colour && part.style !== undefined ? `${colours[part.style]}${part.text}\u001b[0m` : part.text
|
|
581
|
+
)
|
|
582
|
+
.join("")
|
|
583
|
+
|
|
584
|
+
export type TreeMode = "lanes" | "executors"
|
|
585
|
+
|
|
586
|
+
/** What the reader chose to look at; the run's state is separate. */
|
|
587
|
+
export interface TreeView {
|
|
588
|
+
readonly mode: TreeMode
|
|
589
|
+
/** The selected lane's story id. */
|
|
590
|
+
readonly selected: string | undefined
|
|
591
|
+
readonly expanded: boolean
|
|
592
|
+
readonly fullLog: boolean
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export const initialView: TreeView = {
|
|
596
|
+
mode: "lanes",
|
|
597
|
+
selected: undefined,
|
|
598
|
+
expanded: false,
|
|
599
|
+
fullLog: false
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const runningLanes = (state: TreeState): ReadonlyArray<TreeLane> =>
|
|
603
|
+
state.lanes.filter((lane) => lane.status === "running")
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* The view after a key: arrows or 1–9 select a running lane, enter expands
|
|
607
|
+
* it, escape collapses, `e` switches lanes and executors, `l` the full log,
|
|
608
|
+
* `q` quits. Keys it does not know return the same view.
|
|
609
|
+
*/
|
|
610
|
+
export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeView | "quit" => {
|
|
611
|
+
const running = runningLanes(state)
|
|
612
|
+
const index = running.findIndex((lane) => lane.id === view.selected)
|
|
613
|
+
const select = (next: number): TreeView => {
|
|
614
|
+
const lane = running[Math.max(0, Math.min(running.length - 1, next))]
|
|
615
|
+
return lane === undefined ? view : { ...view, selected: lane.id }
|
|
616
|
+
}
|
|
617
|
+
switch (key) {
|
|
618
|
+
case "q":
|
|
619
|
+
return "quit"
|
|
620
|
+
case "down":
|
|
621
|
+
case "right":
|
|
622
|
+
return select(index + 1)
|
|
623
|
+
case "up":
|
|
624
|
+
case "left":
|
|
625
|
+
return select(index < 0 ? 0 : index - 1)
|
|
626
|
+
case "enter":
|
|
627
|
+
return view.selected === undefined ? view : { ...view, expanded: !view.expanded }
|
|
628
|
+
case "escape":
|
|
629
|
+
return { ...view, expanded: false }
|
|
630
|
+
case "e":
|
|
631
|
+
return { ...view, mode: view.mode === "lanes" ? "executors" : "lanes" }
|
|
632
|
+
case "l":
|
|
633
|
+
return { ...view, fullLog: !view.fullLog }
|
|
634
|
+
default:
|
|
635
|
+
return /^[1-9]$/u.test(key) ? select(Number(key) - 1) : view
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export interface TreeRenderOptions {
|
|
640
|
+
readonly width: number
|
|
641
|
+
readonly colour: boolean
|
|
642
|
+
readonly view: TreeView
|
|
643
|
+
/** The terminal's rows; the frame fits them (see `renderTree`). */
|
|
644
|
+
readonly height?: number
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const railWidth = 26
|
|
648
|
+
const maxLaneBoxes = 3
|
|
649
|
+
|
|
650
|
+
const counter = (label: string, value: number): Line => [
|
|
651
|
+
span(`${label.padEnd(15)}${String(value).padStart(5)}`)
|
|
652
|
+
]
|
|
653
|
+
|
|
654
|
+
const railOf = (state: TreeState): Array<Line> =>
|
|
655
|
+
box(railWidth, "judge", [
|
|
656
|
+
centre([span("JUDGE SEAT", "bold")], railWidth - 4),
|
|
657
|
+
centre([span(`${state.judge.executor ?? "—"} · on call`)], railWidth - 4),
|
|
658
|
+
[],
|
|
659
|
+
[span("last verdict:")],
|
|
660
|
+
[
|
|
661
|
+
span(
|
|
662
|
+
state.verdict === undefined
|
|
663
|
+
? "» none yet"
|
|
664
|
+
: `» ${state.verdict.lane} r${state.verdict.round}: ${verdictWords(state.verdict)}`,
|
|
665
|
+
"judge"
|
|
666
|
+
)
|
|
667
|
+
],
|
|
668
|
+
[],
|
|
669
|
+
counter("reviews", state.judge.reviews),
|
|
670
|
+
counter("verdicts", state.judge.verdicts)
|
|
671
|
+
])
|
|
672
|
+
|
|
673
|
+
const laneBox = (
|
|
674
|
+
lane: TreeLane,
|
|
675
|
+
width: number,
|
|
676
|
+
now: number | undefined,
|
|
677
|
+
selected: boolean
|
|
678
|
+
): Array<Line> =>
|
|
679
|
+
box(width, "running", [
|
|
680
|
+
[span(selected ? `▸ ${lane.id}` : lane.id, "bold")],
|
|
681
|
+
[span(lane.executor ?? "(leasing)")],
|
|
682
|
+
[span(lane.stages.at(-1) ?? "starting")],
|
|
683
|
+
[span(lane.lastTool ?? "", "dim")],
|
|
684
|
+
[
|
|
685
|
+
span(
|
|
686
|
+
`${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`
|
|
687
|
+
)
|
|
688
|
+
],
|
|
689
|
+
[span("◐ running", "running")]
|
|
690
|
+
])
|
|
691
|
+
|
|
692
|
+
type ChipStatus = "planned" | "running" | "waiting" | "done" | "failed"
|
|
693
|
+
|
|
694
|
+
/** Every story once: the board's in plan order, then lanes the board does not list. */
|
|
695
|
+
const chipsOf = (
|
|
696
|
+
state: TreeState
|
|
697
|
+
): ReadonlyArray<{ readonly id: string; readonly status: ChipStatus }> => {
|
|
698
|
+
const laneStatus = (id: string): ChipStatus | undefined =>
|
|
699
|
+
state.lanes.find((lane) => lane.id === id)?.status
|
|
700
|
+
const onBoard = new Set(state.stories.map((story) => story.id))
|
|
701
|
+
return [
|
|
702
|
+
...state.stories.map((story) => ({
|
|
703
|
+
id: story.id,
|
|
704
|
+
status:
|
|
705
|
+
laneStatus(story.id) ??
|
|
706
|
+
(story.status === "skipped" ? "done" : story.status === "active" ? "planned" : story.status)
|
|
707
|
+
})),
|
|
708
|
+
...state.lanes
|
|
709
|
+
.filter((lane) => !onBoard.has(lane.id))
|
|
710
|
+
.map((lane) => ({ id: lane.id, status: lane.status }))
|
|
711
|
+
]
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const chipMark: Readonly<Record<ChipStatus, readonly [string, Style | undefined]>> = {
|
|
715
|
+
planned: ["·", "dim"],
|
|
716
|
+
running: ["◐", "running"],
|
|
717
|
+
waiting: ["◌", "judge"],
|
|
718
|
+
done: ["✓", "judgment"],
|
|
719
|
+
failed: ["✗", undefined]
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const chipOf = (chip: { readonly id: string; readonly status: ChipStatus }): Line => {
|
|
723
|
+
const [mark, style] = chipMark[chip.status]
|
|
724
|
+
return [span(`${mark} ${chip.id}`, style)]
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const wrapChips = (chips: ReadonlyArray<Line>, width: number): Array<Line> => {
|
|
728
|
+
const rows: Array<Array<Span>> = []
|
|
729
|
+
let row: Array<Span> = []
|
|
730
|
+
for (const chip of chips) {
|
|
731
|
+
if (row.length > 0 && lengthOf(row) + 2 + lengthOf(chip) > width) {
|
|
732
|
+
rows.push(row)
|
|
733
|
+
row = []
|
|
734
|
+
}
|
|
735
|
+
row = row.length === 0 ? [...chip] : [...row, span(" "), ...chip]
|
|
736
|
+
}
|
|
737
|
+
return row.length === 0 ? rows : [...rows, row]
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* What an executor holds: its leases, or — for a trace from before the
|
|
742
|
+
* roster's events — the running lanes that name it.
|
|
743
|
+
*/
|
|
744
|
+
const heldBy = (state: TreeState, executor: string): ReadonlyArray<string> => {
|
|
745
|
+
const leases = state.leases
|
|
746
|
+
.filter((lease) => lease.executor === executor)
|
|
747
|
+
.map((lease) => `${lease.role}${lease.label === undefined ? "" : ` · ${lease.label}`}`)
|
|
748
|
+
return leases.length > 0
|
|
749
|
+
? leases
|
|
750
|
+
: runningLanes(state)
|
|
751
|
+
.filter((lane) => lane.executor === executor)
|
|
752
|
+
.map((lane) => `coder · ${lane.id}`)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const executorBox = (state: TreeState, executor: string, width: number): Array<Line> => {
|
|
756
|
+
const held = heldBy(state, executor)
|
|
757
|
+
const out = state.exclusions.find((exclusion) => exclusion.executor === executor)
|
|
758
|
+
return box(width, "running", [
|
|
759
|
+
[span(executor, "bold")],
|
|
760
|
+
...(held.length === 0
|
|
761
|
+
? [[span("no lease", "dim")]]
|
|
762
|
+
: held.slice(0, 3).map((lease): Line => [span(lease)])),
|
|
763
|
+
out !== undefined
|
|
764
|
+
? [span(`✗ out: ${out.reason}`)]
|
|
765
|
+
: held.length === 0
|
|
766
|
+
? [span("○ idle", "dim")]
|
|
767
|
+
: [span("◐ busy", "running")]
|
|
768
|
+
])
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** One box per item, side by side, sharing `width` with a two-column gap. */
|
|
772
|
+
const columnsOf = <A>(
|
|
773
|
+
items: ReadonlyArray<A>,
|
|
774
|
+
width: number,
|
|
775
|
+
draw: (item: A, width: number) => Array<Line>
|
|
776
|
+
): Array<Line> => {
|
|
777
|
+
const each = Math.floor((width - 2 * (items.length - 1)) / items.length)
|
|
778
|
+
return beside(
|
|
779
|
+
items.map((item) => draw(item, each)),
|
|
780
|
+
items.map(() => each)
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const expandedLane = (lane: TreeLane, width: number, now: number | undefined): Array<Line> =>
|
|
785
|
+
box(
|
|
786
|
+
width,
|
|
787
|
+
"running",
|
|
788
|
+
[
|
|
789
|
+
[span(`story ${lane.id}`, "bold"), span(` ${elapsed(lane.startedAt, now)}`)],
|
|
790
|
+
...lane.stages.map((stage): Line => [span(stage)]),
|
|
791
|
+
[span(`tools (last ${lane.tools.length})`, "dim")],
|
|
792
|
+
...lane.tools.map((tool): Line => [span(` ${tool}`, "dim")]),
|
|
793
|
+
[span(`${formatCount(lane.tokens)} tok · ${dollars(lane.costUsd)}`)]
|
|
794
|
+
],
|
|
795
|
+
`${lane.id} · ${lane.executor ?? "(leasing)"}`
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
const barWidth = 10
|
|
799
|
+
const judgmentRows = 3
|
|
800
|
+
|
|
801
|
+
const meter = (fraction: number): ReadonlyArray<Span> => {
|
|
802
|
+
const filled = Math.round(Math.max(0, Math.min(1, fraction)) * barWidth)
|
|
803
|
+
return [span("█".repeat(filled), "judgment"), span("░".repeat(barWidth - filled), "dim")]
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const judgmentOf = (state: TreeState, width: number): Array<Line> => {
|
|
807
|
+
const { verdict } = state
|
|
808
|
+
const rows: Array<Line> = [
|
|
809
|
+
...(verdict?.dimensions ?? []).map(
|
|
810
|
+
(dimension): Line => [
|
|
811
|
+
span(dimension.id.padEnd(16)),
|
|
812
|
+
...meter(dimension.max === 0 ? 0 : dimension.score / dimension.max),
|
|
813
|
+
span(` ${dimension.score}/${dimension.max}`)
|
|
814
|
+
]
|
|
815
|
+
),
|
|
816
|
+
...state.judgments
|
|
817
|
+
.slice(-judgmentRows)
|
|
818
|
+
.map(
|
|
819
|
+
(judgment): Line => [
|
|
820
|
+
span(judgment.key.padEnd(16)),
|
|
821
|
+
...meter(judgment.certainty),
|
|
822
|
+
span(` ${judgment.certainty.toFixed(2)} ${judgment.decision}`)
|
|
823
|
+
]
|
|
824
|
+
),
|
|
825
|
+
...(verdict === undefined
|
|
826
|
+
? []
|
|
827
|
+
: [
|
|
828
|
+
[
|
|
829
|
+
verdict.cleared
|
|
830
|
+
? span("cleared → merge", "judgment")
|
|
831
|
+
: span(`${issueCount(verdict.issues)} → coder`, "judge")
|
|
832
|
+
]
|
|
833
|
+
])
|
|
834
|
+
]
|
|
835
|
+
return box(
|
|
836
|
+
width,
|
|
837
|
+
"judgment",
|
|
838
|
+
rows.length === 0 ? [[span("no verdicts yet", "dim")]] : rows,
|
|
839
|
+
verdict === undefined ? "JUDGMENT" : `JUDGMENT · ${verdict.lane} r${verdict.round}`
|
|
840
|
+
)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const mainOf = (state: TreeState, width: number, view: TreeView): Array<Line> => {
|
|
844
|
+
const running = state.lanes.filter((lane) => lane.status === "running")
|
|
845
|
+
const orchestratorWidth = Math.min(46, width)
|
|
846
|
+
const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle")
|
|
847
|
+
const orchestrator = box(
|
|
848
|
+
orchestratorWidth,
|
|
849
|
+
"orchestrator",
|
|
850
|
+
[
|
|
851
|
+
...(state.action === undefined
|
|
852
|
+
? []
|
|
853
|
+
: [centre([span(state.action, "bold")], orchestratorWidth - 4)]),
|
|
854
|
+
[span("stage "), span(stage)],
|
|
855
|
+
[span(`stories ${chipsOf(state).length} elapsed ${elapsed(state.startedAt, state.now)}`)]
|
|
856
|
+
],
|
|
857
|
+
state.title
|
|
858
|
+
)
|
|
859
|
+
const selected = running.find((lane) => lane.id === view.selected)
|
|
860
|
+
const shown = running.slice(0, maxLaneBoxes)
|
|
861
|
+
const executors = state.executors.slice(0, maxLaneBoxes + 1)
|
|
862
|
+
const lanes =
|
|
863
|
+
view.expanded && selected !== undefined
|
|
864
|
+
? expandedLane(selected, width, state.now)
|
|
865
|
+
: view.mode === "executors"
|
|
866
|
+
? executors.length === 0
|
|
867
|
+
? [centre([span("no executors yet", "dim")], width)]
|
|
868
|
+
: columnsOf(executors, width, (executor, each) => executorBox(state, executor, each))
|
|
869
|
+
: shown.length === 0
|
|
870
|
+
? [centre([span("no stories in flight", "dim")], width)]
|
|
871
|
+
: columnsOf(shown, width, (lane, each) =>
|
|
872
|
+
laneBox(lane, each, state.now, lane.id === view.selected)
|
|
873
|
+
)
|
|
874
|
+
return [
|
|
875
|
+
...orchestrator.map((line) => centre(line, width)),
|
|
876
|
+
centre([span("•", "orchestrator")], width),
|
|
877
|
+
...judgmentOf(state, width),
|
|
878
|
+
centre([span(`delegate to roster · ${running.length} running`)], width),
|
|
879
|
+
centre([span("▼", "dim")], width),
|
|
880
|
+
...lanes,
|
|
881
|
+
[],
|
|
882
|
+
...wrapChips(chipsOf(state).map(chipOf), width)
|
|
883
|
+
]
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** A frame with the session log at `logCount` lines, or without it. */
|
|
887
|
+
const frameOf = (
|
|
888
|
+
state: TreeState,
|
|
889
|
+
options: TreeRenderOptions,
|
|
890
|
+
logCount: number | "none"
|
|
891
|
+
): ReadonlyArray<string> => {
|
|
892
|
+
const { width } = options
|
|
893
|
+
const mainWidth = width - railWidth - 2
|
|
894
|
+
const chips = chipsOf(state)
|
|
895
|
+
const busy = state.executors.filter((executor) => heldBy(state, executor).length > 0).length
|
|
896
|
+
const count = (status: ChipStatus): number =>
|
|
897
|
+
chips.filter((chip) => chip.status === status).length
|
|
898
|
+
const lines: Array<Line> = [
|
|
899
|
+
centre(
|
|
900
|
+
[span("LLM4TS AGENT TREE", "bold"), span(" · "), span(state.title, "orchestrator")],
|
|
901
|
+
width
|
|
902
|
+
),
|
|
903
|
+
[span("═".repeat(width), "dim")],
|
|
904
|
+
[],
|
|
905
|
+
...beside([railOf(state), mainOf(state, mainWidth, options.view)], [railWidth, mainWidth]),
|
|
906
|
+
...(logCount === "none"
|
|
907
|
+
? []
|
|
908
|
+
: [[], ...box(width, "log", logOf(state, logCount), "session log")]),
|
|
909
|
+
[
|
|
910
|
+
span(
|
|
911
|
+
`stories [${count("done")}/${chips.length} done · ${count("running")} running · ${count("failed")} failed · ${count("waiting")} waiting] roster [${busy}/${state.executors.length} busy]`
|
|
912
|
+
)
|
|
913
|
+
],
|
|
914
|
+
[
|
|
915
|
+
span(`tokens [${formatCount(state.tokens)}] cost [${dollars(state.costUsd)}] run [`),
|
|
916
|
+
state.ended === undefined ? span("live", "running") : span(state.ended, "bold"),
|
|
917
|
+
span("]")
|
|
918
|
+
]
|
|
919
|
+
]
|
|
920
|
+
return lines.map((line) => paint(fit(line, width), options.colour))
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The tree at a width and, when `height` is given, no taller: the session
|
|
925
|
+
* log gives way first — fewer lines, down to one, then none — and only then
|
|
926
|
+
* is the frame cut, keeping its status lines, so a redraw never scrolls.
|
|
927
|
+
*/
|
|
928
|
+
export const renderTree = (state: TreeState, options: TreeRenderOptions): ReadonlyArray<string> => {
|
|
929
|
+
const wanted = options.view.fullLog ? fullLogLines : logLines
|
|
930
|
+
const full = frameOf(state, options, wanted)
|
|
931
|
+
const { height } = options
|
|
932
|
+
if (height === undefined || full.length <= height) {
|
|
933
|
+
return full
|
|
934
|
+
}
|
|
935
|
+
for (let count = wanted - 1; count >= 1; count -= 1) {
|
|
936
|
+
const shorter = frameOf(state, options, count)
|
|
937
|
+
if (shorter.length <= height) {
|
|
938
|
+
return shorter
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return fitToRows(frameOf(state, options, "none"), height)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* A frame cut to a terminal's height: the top of the tree and its two status
|
|
946
|
+
* lines, so a full-screen redraw never scrolls.
|
|
947
|
+
*/
|
|
948
|
+
const fitToRows = (
|
|
949
|
+
lines: ReadonlyArray<string>,
|
|
950
|
+
rows: number | undefined
|
|
951
|
+
): ReadonlyArray<string> =>
|
|
952
|
+
rows === undefined || rows < 4 || lines.length <= rows
|
|
953
|
+
? lines
|
|
954
|
+
: [...lines.slice(0, rows - 2), ...lines.slice(-2)]
|