@llm4ts/runner 2.18.0 → 2.20.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.
@@ -0,0 +1,638 @@
1
+ import * as Option from "effect/Option";
2
+ import * as Schema from "effect/Schema";
3
+ import { FlowEvent } from "@llm4ts/flow/FlowEvents";
4
+ import { estimateCostUsd } from "@llm4ts/flow/PriceList";
5
+ import { formatCount } from "./Terminal.js";
6
+ export const emptyTree = (options = {}) => ({
7
+ title: options.title ?? "flow",
8
+ action: options.action,
9
+ stories: options.stories ?? [],
10
+ startedAt: undefined,
11
+ now: undefined,
12
+ stages: [],
13
+ lanes: [],
14
+ executors: [],
15
+ leases: [],
16
+ exclusions: [],
17
+ judge: { executor: undefined, reviews: 0, verdicts: 0 },
18
+ verdict: undefined,
19
+ judgments: [],
20
+ log: [],
21
+ tokens: 0,
22
+ costUsd: 0,
23
+ ended: undefined
24
+ });
25
+ const costOf = (model, usage) => usage.costUsd ?? (model === undefined ? undefined : estimateCostUsd(model, usage)) ?? 0;
26
+ const logged = (state, entry) => ({
27
+ ...state,
28
+ log: [...state.log, entry]
29
+ });
30
+ const storyStage = /^story (\S+)$/u;
31
+ const expandedTools = 20;
32
+ const updateLane = (state, id, update) => ({
33
+ ...state,
34
+ lanes: state.lanes.map((lane) => (lane.id === id ? update(lane) : lane))
35
+ });
36
+ const withoutLast = (items, item) => {
37
+ const index = items.lastIndexOf(item);
38
+ return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)];
39
+ };
40
+ const withExecutor = (state, executor) => state.executors.includes(executor)
41
+ ? state
42
+ : { ...state, executors: [...state.executors, executor] };
43
+ const rosterLog = (state, at, what) => logged(state, { at, source: "roster", who: "roster", what });
44
+ const reduceLease = (state, at, lease) => {
45
+ const held = { ...withExecutor(state, lease.executor), leases: [...state.leases, lease] };
46
+ switch (lease.role) {
47
+ case "coder": {
48
+ const lane = held.lanes.find((candidate) => candidate.id === lease.label);
49
+ const placed = lane === undefined
50
+ ? held
51
+ : updateLane(held, lane.id, (open) => ({ ...open, executor: lease.executor }));
52
+ return rosterLog(placed, at, `${lease.executor} → coder${lease.label === undefined ? "" : ` · ${lease.label}`}`);
53
+ }
54
+ case "reviewer":
55
+ return {
56
+ ...held,
57
+ judge: {
58
+ ...held.judge,
59
+ executor: held.judge.executor ?? lease.executor,
60
+ reviews: held.judge.reviews + 1
61
+ }
62
+ };
63
+ case "judge":
64
+ return {
65
+ ...held,
66
+ judge: { ...held.judge, executor: lease.executor, verdicts: held.judge.verdicts + 1 }
67
+ };
68
+ default:
69
+ return held;
70
+ }
71
+ };
72
+ const issueCount = (issues) => `${issues} issue${issues === 1 ? "" : "s"}`;
73
+ const verdictWords = (verdict) => verdict.cleared ? "cleared" : issueCount(verdict.issues);
74
+ const reduceEvent = (state, at, event) => {
75
+ const lane = "lane" in event && event.lane !== undefined
76
+ ? state.lanes.find((candidate) => candidate.id === event.lane)
77
+ : undefined;
78
+ const executor = "executor" in event ? event.executor : undefined;
79
+ const named = executor === undefined || state.executors.includes(executor)
80
+ ? state
81
+ : { ...state, executors: [...state.executors, executor] };
82
+ const current = lane !== undefined && executor !== undefined && lane.executor !== executor
83
+ ? updateLane(named, lane.id, (open) => ({ ...open, executor }))
84
+ : named;
85
+ switch (event._tag) {
86
+ case "StageStarted": {
87
+ const story = storyStage.exec(event.stage)?.[1];
88
+ if (story !== undefined && event.lane === story) {
89
+ const fresh = {
90
+ id: story,
91
+ stages: [],
92
+ executor: event.executor,
93
+ startedAt: at,
94
+ status: "running",
95
+ lastTool: undefined,
96
+ tools: [],
97
+ tokens: 0,
98
+ costUsd: 0
99
+ };
100
+ return {
101
+ ...current,
102
+ lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
103
+ };
104
+ }
105
+ if (lane !== undefined) {
106
+ return updateLane(current, lane.id, (open) => ({
107
+ ...open,
108
+ stages: [...open.stages, event.stage]
109
+ }));
110
+ }
111
+ return { ...current, stages: [...current.stages, event.stage] };
112
+ }
113
+ case "StageCompleted":
114
+ case "StageFailed": {
115
+ if (lane !== undefined && event.stage === `story ${lane.id}`) {
116
+ const status = event._tag === "StageFailed" ? "failed" : "done";
117
+ const closed = updateLane(current, lane.id, (open) => ({ ...open, stages: [], status }));
118
+ return logged(closed, {
119
+ at,
120
+ source: "lane",
121
+ who: lane.id,
122
+ what: event._tag === "StageFailed" ? `failed · ${event.message}` : "done"
123
+ });
124
+ }
125
+ if (lane !== undefined) {
126
+ return updateLane(current, lane.id, (open) => ({
127
+ ...open,
128
+ stages: withoutLast(open.stages, event.stage)
129
+ }));
130
+ }
131
+ return { ...current, stages: withoutLast(current.stages, event.stage) };
132
+ }
133
+ case "Info": {
134
+ // A story's own milestones are `story <id>: …` (resuming, merged,
135
+ // skipped); its other chatter stays in the expanded lane.
136
+ if (event.lane === undefined) {
137
+ // Traces from before the roster's typed events (2.20) carry its notes
138
+ // as prose: leases are chatter, the rest (exclusions, handovers) stays.
139
+ const legacyRoster = /^roster: (.*)$/u.exec(event.message)?.[1];
140
+ if (legacyRoster !== undefined) {
141
+ return / takes \S+/u.test(legacyRoster)
142
+ ? current
143
+ : logged(current, { at, source: "roster", who: "roster", what: legacyRoster });
144
+ }
145
+ return logged(current, { at, source: "run", who: "flow", what: event.message });
146
+ }
147
+ const prefix = `story ${event.lane}: `;
148
+ return event.message.startsWith(prefix)
149
+ ? logged(current, {
150
+ at,
151
+ source: "lane",
152
+ who: event.lane,
153
+ what: event.message.slice(prefix.length)
154
+ })
155
+ : current;
156
+ }
157
+ case "StoryJudged": {
158
+ const verdict = {
159
+ lane: event.lane,
160
+ round: event.round,
161
+ cleared: event.cleared,
162
+ issues: event.issues,
163
+ dimensions: event.dimensions
164
+ };
165
+ return logged({ ...current, verdict }, {
166
+ at,
167
+ source: "judge",
168
+ who: "judge",
169
+ what: `${verdict.lane} r${verdict.round} · ${verdictWords(verdict)} → ${verdict.cleared ? "merge" : "coder"}`
170
+ });
171
+ }
172
+ case "JudgmentObserved":
173
+ return {
174
+ ...current,
175
+ judgments: [
176
+ ...current.judgments.filter((judgment) => judgment.key !== event.key),
177
+ { key: event.key, certainty: event.certainty, decision: event.decision }
178
+ ]
179
+ };
180
+ case "ExecutorLeased":
181
+ return reduceLease(current, at, {
182
+ executor: event.executor,
183
+ role: event.role,
184
+ label: event.label
185
+ });
186
+ case "ExecutorReleased": {
187
+ const index = current.leases.findIndex((lease) => lease.executor === event.executor &&
188
+ lease.role === event.role &&
189
+ lease.label === event.label);
190
+ return index < 0
191
+ ? current
192
+ : {
193
+ ...current,
194
+ leases: [...current.leases.slice(0, index), ...current.leases.slice(index + 1)]
195
+ };
196
+ }
197
+ case "ExecutorExcluded":
198
+ return rosterLog({
199
+ ...withExecutor(current, event.executor),
200
+ exclusions: [
201
+ ...current.exclusions.filter((out) => out.executor !== event.executor),
202
+ { executor: event.executor, reason: event.reason }
203
+ ]
204
+ }, at, `${event.executor} out · ${event.reason}`);
205
+ case "ExecutorResumed":
206
+ return rosterLog({
207
+ ...current,
208
+ exclusions: current.exclusions.filter((out) => out.executor !== event.executor)
209
+ }, at, `${event.executor} back · ${event.why}`);
210
+ case "ExecutorHandedOver":
211
+ return rosterLog(current, at, `${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`);
212
+ case "ToolUse":
213
+ return lane === undefined
214
+ ? current
215
+ : updateLane(current, lane.id, (open) => ({
216
+ ...open,
217
+ lastTool: `${event.tool} ${event.args}`,
218
+ tools: [...open.tools, `${event.tool} ${event.args}`].slice(-expandedTools)
219
+ }));
220
+ case "TokensUsed": {
221
+ const tokens = event.usage.total;
222
+ const costUsd = costOf(event.model, event.usage);
223
+ const counted = {
224
+ ...current,
225
+ tokens: current.tokens + tokens,
226
+ costUsd: current.costUsd + costUsd
227
+ };
228
+ return lane === undefined
229
+ ? counted
230
+ : updateLane(counted, lane.id, (open) => ({
231
+ ...open,
232
+ tokens: open.tokens + tokens,
233
+ costUsd: open.costUsd + costUsd
234
+ }));
235
+ }
236
+ default:
237
+ return current;
238
+ }
239
+ };
240
+ export const reduceTree = (state, input) => {
241
+ const timed = {
242
+ ...state,
243
+ startedAt: state.startedAt ?? input.at,
244
+ now: Math.max(state.now ?? input.at, input.at)
245
+ };
246
+ switch (input._tag) {
247
+ case "Event":
248
+ return reduceEvent(timed, input.at, input.event);
249
+ case "RunEnded":
250
+ return { ...timed, ended: input.outcome };
251
+ }
252
+ };
253
+ const decodeEvent = Schema.decodeUnknownOption(Schema.fromJsonString(FlowEvent));
254
+ /**
255
+ * A trace's lines as tree inputs. Kinds this version does not know — raw
256
+ * stream lines, events from a newer llm4ts — are skipped, so any trace renders.
257
+ */
258
+ export const treeInputsOfTrace = (lines) => lines.flatMap((line) => {
259
+ if (line.kind === "RunEnded") {
260
+ const outcome = line.fields.outcome;
261
+ return [
262
+ {
263
+ _tag: "RunEnded",
264
+ at: line.timestamp,
265
+ outcome: typeof outcome === "string" ? outcome : "completed"
266
+ }
267
+ ];
268
+ }
269
+ const raw = line.fields.event;
270
+ if (typeof raw !== "string") {
271
+ return [];
272
+ }
273
+ return Option.match(decodeEvent(raw), {
274
+ onNone: () => [],
275
+ onSome: (event) => [{ _tag: "Event", at: line.timestamp, event }]
276
+ });
277
+ });
278
+ const span = (text, style) => style === undefined ? { text } : { text, style };
279
+ const lengthOf = (line) => line.reduce((total, part) => total + [...part.text].length, 0);
280
+ /** Pads or cuts a line to exactly `width` characters; a cut ends in `…`. */
281
+ const fit = (line, width) => {
282
+ const length = lengthOf(line);
283
+ if (length <= width) {
284
+ return length === width ? line : [...line, span(" ".repeat(width - length))];
285
+ }
286
+ const kept = [];
287
+ let room = width - 1;
288
+ for (const part of line) {
289
+ if (room <= 0) {
290
+ break;
291
+ }
292
+ const characters = [...part.text];
293
+ const taken = characters.slice(0, room).join("");
294
+ kept.push(part.style === undefined ? span(taken) : span(taken, part.style));
295
+ room -= Math.min(room, characters.length);
296
+ }
297
+ return [...kept, span("…")];
298
+ };
299
+ const centre = (line, width) => {
300
+ const pad = Math.max(0, Math.floor((width - lengthOf(line)) / 2));
301
+ return fit([span(" ".repeat(pad)), ...line], width);
302
+ };
303
+ const box = (width, style, body, title) => {
304
+ const inner = width - 4;
305
+ const top = title === undefined
306
+ ? [span(`┌${"─".repeat(width - 2)}┐`, style)]
307
+ : fit([
308
+ span("┌─ ", style),
309
+ span(title, "bold"),
310
+ span(` ${"─".repeat(Math.max(0, width - 5 - [...title].length))}┐`, style)
311
+ ], width);
312
+ return [
313
+ top,
314
+ ...body.map((line) => [span("│ ", style), ...fit(line, inner), span(" │", style)]),
315
+ [span(`└${"─".repeat(width - 2)}┘`, style)]
316
+ ];
317
+ };
318
+ const beside = (columns, widths, gap = 2) => {
319
+ const height = Math.max(0, ...columns.map((column) => column.length));
320
+ return Array.from({ length: height }, (_, row) => columns.flatMap((column, index) => [
321
+ ...(index === 0 ? [] : [span(" ".repeat(gap))]),
322
+ ...fit(column[row] ?? [], widths[index] ?? 0)
323
+ ]));
324
+ };
325
+ const clock = (from, to) => {
326
+ const seconds = Math.max(0, Math.round((to - (from ?? to)) / 1_000));
327
+ return [Math.floor(seconds / 3_600), Math.floor(seconds / 60) % 60, seconds % 60]
328
+ .map((part) => String(part).padStart(2, "0"))
329
+ .join(":");
330
+ };
331
+ const logStyle = {
332
+ lane: "judgment",
333
+ run: "orchestrator",
334
+ roster: "running",
335
+ judge: "judge"
336
+ };
337
+ const logLines = 5;
338
+ const fullLogLines = 30;
339
+ const logOf = (state, count) => {
340
+ const recent = state.log.slice(-count);
341
+ return recent.length === 0
342
+ ? [[span("—", "dim")]]
343
+ : recent.map((entry) => [
344
+ span(clock(state.startedAt, entry.at), "dim"),
345
+ span(" "),
346
+ ...fit([span(entry.who, logStyle[entry.source])], 16),
347
+ span(" "),
348
+ span(entry.what)
349
+ ]);
350
+ };
351
+ const dollars = (usd) => `~$${usd.toFixed(2)}`;
352
+ const elapsed = (from, to) => {
353
+ const seconds = Math.max(0, Math.round(((to ?? 0) - (from ?? to ?? 0)) / 1_000));
354
+ return seconds < 60
355
+ ? `${seconds}s`
356
+ : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
357
+ };
358
+ const colours = {
359
+ bold: "\u001b[1m",
360
+ dim: "\u001b[90m",
361
+ orchestrator: "\u001b[38;5;173m",
362
+ judgment: "\u001b[38;5;114m",
363
+ judge: "\u001b[38;5;141m",
364
+ log: "\u001b[38;5;244m",
365
+ running: "\u001b[38;5;110m"
366
+ };
367
+ const paint = (line, colour) => line
368
+ .map((part) => colour && part.style !== undefined ? `${colours[part.style]}${part.text}\u001b[0m` : part.text)
369
+ .join("");
370
+ export const initialView = {
371
+ mode: "lanes",
372
+ selected: undefined,
373
+ expanded: false,
374
+ fullLog: false
375
+ };
376
+ const runningLanes = (state) => state.lanes.filter((lane) => lane.status === "running");
377
+ /**
378
+ * The view after a key: arrows or 1–9 select a running lane, enter expands
379
+ * it, escape collapses, `e` switches lanes and executors, `l` the full log,
380
+ * `q` quits. Keys it does not know return the same view.
381
+ */
382
+ export const onTreeKey = (view, key, state) => {
383
+ const running = runningLanes(state);
384
+ const index = running.findIndex((lane) => lane.id === view.selected);
385
+ const select = (next) => {
386
+ const lane = running[Math.max(0, Math.min(running.length - 1, next))];
387
+ return lane === undefined ? view : { ...view, selected: lane.id };
388
+ };
389
+ switch (key) {
390
+ case "q":
391
+ return "quit";
392
+ case "down":
393
+ case "right":
394
+ return select(index + 1);
395
+ case "up":
396
+ case "left":
397
+ return select(index < 0 ? 0 : index - 1);
398
+ case "enter":
399
+ return view.selected === undefined ? view : { ...view, expanded: !view.expanded };
400
+ case "escape":
401
+ return { ...view, expanded: false };
402
+ case "e":
403
+ return { ...view, mode: view.mode === "lanes" ? "executors" : "lanes" };
404
+ case "l":
405
+ return { ...view, fullLog: !view.fullLog };
406
+ default:
407
+ return /^[1-9]$/u.test(key) ? select(Number(key) - 1) : view;
408
+ }
409
+ };
410
+ const railWidth = 26;
411
+ const maxLaneBoxes = 3;
412
+ const counter = (label, value) => [
413
+ span(`${label.padEnd(15)}${String(value).padStart(5)}`)
414
+ ];
415
+ const railOf = (state) => box(railWidth, "judge", [
416
+ centre([span("JUDGE SEAT", "bold")], railWidth - 4),
417
+ centre([span(`${state.judge.executor ?? "—"} · on call`)], railWidth - 4),
418
+ [],
419
+ [span("last verdict:")],
420
+ [
421
+ span(state.verdict === undefined
422
+ ? "» none yet"
423
+ : `» ${state.verdict.lane} r${state.verdict.round}: ${verdictWords(state.verdict)}`, "judge")
424
+ ],
425
+ [],
426
+ counter("reviews", state.judge.reviews),
427
+ counter("verdicts", state.judge.verdicts)
428
+ ]);
429
+ const laneBox = (lane, width, now, selected) => box(width, "running", [
430
+ [span(selected ? `▸ ${lane.id}` : lane.id, "bold")],
431
+ [span(lane.executor ?? "(leasing)")],
432
+ [span(lane.stages.at(-1) ?? "starting")],
433
+ [span(lane.lastTool ?? "", "dim")],
434
+ [
435
+ span(`${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`)
436
+ ],
437
+ [span("◐ running", "running")]
438
+ ]);
439
+ /** Every story once: the board's in plan order, then lanes the board does not list. */
440
+ const chipsOf = (state) => {
441
+ const laneStatus = (id) => state.lanes.find((lane) => lane.id === id)?.status;
442
+ const onBoard = new Set(state.stories.map((story) => story.id));
443
+ return [
444
+ ...state.stories.map((story) => ({
445
+ id: story.id,
446
+ status: laneStatus(story.id) ??
447
+ (story.status === "skipped" ? "done" : story.status === "active" ? "planned" : story.status)
448
+ })),
449
+ ...state.lanes
450
+ .filter((lane) => !onBoard.has(lane.id))
451
+ .map((lane) => ({ id: lane.id, status: lane.status }))
452
+ ];
453
+ };
454
+ const chipMark = {
455
+ planned: ["·", "dim"],
456
+ running: ["◐", "running"],
457
+ waiting: ["◌", "judge"],
458
+ done: ["✓", "judgment"],
459
+ failed: ["✗", undefined]
460
+ };
461
+ const chipOf = (chip) => {
462
+ const [mark, style] = chipMark[chip.status];
463
+ return [span(`${mark} ${chip.id}`, style)];
464
+ };
465
+ const wrapChips = (chips, width) => {
466
+ const rows = [];
467
+ let row = [];
468
+ for (const chip of chips) {
469
+ if (row.length > 0 && lengthOf(row) + 2 + lengthOf(chip) > width) {
470
+ rows.push(row);
471
+ row = [];
472
+ }
473
+ row = row.length === 0 ? [...chip] : [...row, span(" "), ...chip];
474
+ }
475
+ return row.length === 0 ? rows : [...rows, row];
476
+ };
477
+ /**
478
+ * What an executor holds: its leases, or — for a trace from before the
479
+ * roster's events — the running lanes that name it.
480
+ */
481
+ const heldBy = (state, executor) => {
482
+ const leases = state.leases
483
+ .filter((lease) => lease.executor === executor)
484
+ .map((lease) => `${lease.role}${lease.label === undefined ? "" : ` · ${lease.label}`}`);
485
+ return leases.length > 0
486
+ ? leases
487
+ : runningLanes(state)
488
+ .filter((lane) => lane.executor === executor)
489
+ .map((lane) => `coder · ${lane.id}`);
490
+ };
491
+ const executorBox = (state, executor, width) => {
492
+ const held = heldBy(state, executor);
493
+ const out = state.exclusions.find((exclusion) => exclusion.executor === executor);
494
+ return box(width, "running", [
495
+ [span(executor, "bold")],
496
+ ...(held.length === 0
497
+ ? [[span("no lease", "dim")]]
498
+ : held.slice(0, 3).map((lease) => [span(lease)])),
499
+ out !== undefined
500
+ ? [span(`✗ out: ${out.reason}`)]
501
+ : held.length === 0
502
+ ? [span("○ idle", "dim")]
503
+ : [span("◐ busy", "running")]
504
+ ]);
505
+ };
506
+ /** One box per item, side by side, sharing `width` with a two-column gap. */
507
+ const columnsOf = (items, width, draw) => {
508
+ const each = Math.floor((width - 2 * (items.length - 1)) / items.length);
509
+ return beside(items.map((item) => draw(item, each)), items.map(() => each));
510
+ };
511
+ const expandedLane = (lane, width, now) => box(width, "running", [
512
+ [span(`story ${lane.id}`, "bold"), span(` ${elapsed(lane.startedAt, now)}`)],
513
+ ...lane.stages.map((stage) => [span(stage)]),
514
+ [span(`tools (last ${lane.tools.length})`, "dim")],
515
+ ...lane.tools.map((tool) => [span(` ${tool}`, "dim")]),
516
+ [span(`${formatCount(lane.tokens)} tok · ${dollars(lane.costUsd)}`)]
517
+ ], `${lane.id} · ${lane.executor ?? "(leasing)"}`);
518
+ const barWidth = 10;
519
+ const judgmentRows = 3;
520
+ const meter = (fraction) => {
521
+ const filled = Math.round(Math.max(0, Math.min(1, fraction)) * barWidth);
522
+ return [span("█".repeat(filled), "judgment"), span("░".repeat(barWidth - filled), "dim")];
523
+ };
524
+ const judgmentOf = (state, width) => {
525
+ const { verdict } = state;
526
+ const rows = [
527
+ ...(verdict?.dimensions ?? []).map((dimension) => [
528
+ span(dimension.id.padEnd(16)),
529
+ ...meter(dimension.max === 0 ? 0 : dimension.score / dimension.max),
530
+ span(` ${dimension.score}/${dimension.max}`)
531
+ ]),
532
+ ...state.judgments
533
+ .slice(-judgmentRows)
534
+ .map((judgment) => [
535
+ span(judgment.key.padEnd(16)),
536
+ ...meter(judgment.certainty),
537
+ span(` ${judgment.certainty.toFixed(2)} ${judgment.decision}`)
538
+ ]),
539
+ ...(verdict === undefined
540
+ ? []
541
+ : [
542
+ [
543
+ verdict.cleared
544
+ ? span("cleared → merge", "judgment")
545
+ : span(`${issueCount(verdict.issues)} → coder`, "judge")
546
+ ]
547
+ ])
548
+ ];
549
+ return box(width, "judgment", rows.length === 0 ? [[span("no verdicts yet", "dim")]] : rows, verdict === undefined ? "JUDGMENT" : `JUDGMENT · ${verdict.lane} r${verdict.round}`);
550
+ };
551
+ const mainOf = (state, width, view) => {
552
+ const running = state.lanes.filter((lane) => lane.status === "running");
553
+ const orchestratorWidth = Math.min(46, width);
554
+ const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle");
555
+ const orchestrator = box(orchestratorWidth, "orchestrator", [
556
+ ...(state.action === undefined
557
+ ? []
558
+ : [centre([span(state.action, "bold")], orchestratorWidth - 4)]),
559
+ [span("stage "), span(stage)],
560
+ [span(`stories ${chipsOf(state).length} elapsed ${elapsed(state.startedAt, state.now)}`)]
561
+ ], state.title);
562
+ const selected = running.find((lane) => lane.id === view.selected);
563
+ const shown = running.slice(0, maxLaneBoxes);
564
+ const executors = state.executors.slice(0, maxLaneBoxes + 1);
565
+ const lanes = view.expanded && selected !== undefined
566
+ ? expandedLane(selected, width, state.now)
567
+ : view.mode === "executors"
568
+ ? executors.length === 0
569
+ ? [centre([span("no executors yet", "dim")], width)]
570
+ : columnsOf(executors, width, (executor, each) => executorBox(state, executor, each))
571
+ : shown.length === 0
572
+ ? [centre([span("no stories in flight", "dim")], width)]
573
+ : columnsOf(shown, width, (lane, each) => laneBox(lane, each, state.now, lane.id === view.selected));
574
+ return [
575
+ ...orchestrator.map((line) => centre(line, width)),
576
+ centre([span("•", "orchestrator")], width),
577
+ ...judgmentOf(state, width),
578
+ centre([span(`delegate to roster · ${running.length} running`)], width),
579
+ centre([span("▼", "dim")], width),
580
+ ...lanes,
581
+ [],
582
+ ...wrapChips(chipsOf(state).map(chipOf), width)
583
+ ];
584
+ };
585
+ /** A frame with the session log at `logCount` lines, or without it. */
586
+ const frameOf = (state, options, logCount) => {
587
+ const { width } = options;
588
+ const mainWidth = width - railWidth - 2;
589
+ const chips = chipsOf(state);
590
+ const busy = state.executors.filter((executor) => heldBy(state, executor).length > 0).length;
591
+ const count = (status) => chips.filter((chip) => chip.status === status).length;
592
+ const lines = [
593
+ centre([span("LLM4TS AGENT TREE", "bold"), span(" · "), span(state.title, "orchestrator")], width),
594
+ [span("═".repeat(width), "dim")],
595
+ [],
596
+ ...beside([railOf(state), mainOf(state, mainWidth, options.view)], [railWidth, mainWidth]),
597
+ ...(logCount === "none"
598
+ ? []
599
+ : [[], ...box(width, "log", logOf(state, logCount), "session log")]),
600
+ [
601
+ span(`stories [${count("done")}/${chips.length} done · ${count("running")} running · ${count("failed")} failed · ${count("waiting")} waiting] roster [${busy}/${state.executors.length} busy]`)
602
+ ],
603
+ [
604
+ span(`tokens [${formatCount(state.tokens)}] cost [${dollars(state.costUsd)}] run [`),
605
+ state.ended === undefined ? span("live", "running") : span(state.ended, "bold"),
606
+ span("]")
607
+ ]
608
+ ];
609
+ return lines.map((line) => paint(fit(line, width), options.colour));
610
+ };
611
+ /**
612
+ * The tree at a width and, when `height` is given, no taller: the session
613
+ * log gives way first — fewer lines, down to one, then none — and only then
614
+ * is the frame cut, keeping its status lines, so a redraw never scrolls.
615
+ */
616
+ export const renderTree = (state, options) => {
617
+ const wanted = options.view.fullLog ? fullLogLines : logLines;
618
+ const full = frameOf(state, options, wanted);
619
+ const { height } = options;
620
+ if (height === undefined || full.length <= height) {
621
+ return full;
622
+ }
623
+ for (let count = wanted - 1; count >= 1; count -= 1) {
624
+ const shorter = frameOf(state, options, count);
625
+ if (shorter.length <= height) {
626
+ return shorter;
627
+ }
628
+ }
629
+ return fitToRows(frameOf(state, options, "none"), height);
630
+ };
631
+ /**
632
+ * A frame cut to a terminal's height: the top of the tree and its two status
633
+ * lines, so a full-screen redraw never scrolls.
634
+ */
635
+ const fitToRows = (lines, rows) => rows === undefined || rows < 4 || lines.length <= rows
636
+ ? lines
637
+ : [...lines.slice(0, rows - 2), ...lines.slice(-2)];
638
+ //# sourceMappingURL=AgentTree.js.map