@llm4ts/runner 2.40.1 → 2.42.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/src/AgentTree.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { TokenUsage } from "@llm4ts/core/Models"
2
2
  import * as Option from "effect/Option"
3
3
  import * as Schema from "effect/Schema"
4
- import { FlowEvent } from "@llm4ts/flow/FlowEvents"
4
+ import { toolCategory } from "@llm4ts/flow/Activity"
5
+ import { FlowEvent, cloneName } from "@llm4ts/flow/FlowEvents"
5
6
  import type { TraceLine } from "@llm4ts/flow/FlowRecorder"
6
7
  import { estimateCostUsd } from "@llm4ts/flow/PriceList"
7
8
  import { formatCount } from "./Terminal.ts"
@@ -48,6 +49,53 @@ export interface TreeLane {
48
49
  readonly gatesMs: number
49
50
  /** Work it said `Began` and no `Timed` has ended yet, innermost last. */
50
51
  readonly running: ReadonlyArray<TreeWork>
52
+ /** The coder's clone number, when its lease carried one (ADR 0033). */
53
+ readonly clone: number | undefined
54
+ /** The plan's tasks in order, as `TasksPlanned` and the task events reported them. */
55
+ readonly tasks: ReadonlyArray<TreeTask>
56
+ /** The running task: its index from 1 and the plan's count. */
57
+ readonly task:
58
+ | {
59
+ readonly index: number
60
+ readonly count: number
61
+ readonly title: string
62
+ readonly satisfies?: ReadonlyArray<number>
63
+ }
64
+ | undefined
65
+ /** Sub-agents the harness spawned on this lane (a delegate tool call), oldest first. */
66
+ readonly children: ReadonlyArray<TreeChild>
67
+ /** A harness pause under way: `pi retry`, `pi compaction`. */
68
+ readonly pause: { readonly label: string; readonly since: number } | undefined
69
+ /** Tasks running beside the lane's coder, each on a coder of its own (ADR 0034). */
70
+ readonly taskCoders: ReadonlyArray<TreeTaskCoder>
71
+ }
72
+
73
+ /** A task running beside a story's coder, in its own worktree (ADR 0034). */
74
+ export interface TreeTaskCoder {
75
+ readonly index: number
76
+ readonly count: number
77
+ readonly title: string
78
+ readonly executor: string | undefined
79
+ readonly clone: number | undefined
80
+ readonly since: number
81
+ }
82
+
83
+ /** One task of a lane's plan, as the checklist shows it. */
84
+ export interface TreeTask {
85
+ readonly index: number
86
+ readonly title: string
87
+ readonly done: boolean
88
+ readonly satisfies?: ReadonlyArray<number>
89
+ }
90
+
91
+ /** A sub-agent a harness spawned inside a lane, observed through its tool calls (ADR 0033). */
92
+ export interface TreeChild {
93
+ readonly id: string
94
+ readonly tool: string
95
+ readonly args: string
96
+ readonly since: number
97
+ readonly lastTool: string | undefined
98
+ readonly ended: boolean
51
99
  }
52
100
 
53
101
  /** Work under way: a model call, a gate or setup command, git, a merge, a wait. */
@@ -87,6 +135,8 @@ export interface TreeLease {
87
135
  readonly executor: string
88
136
  readonly role: string
89
137
  readonly label: string | undefined
138
+ /** Which clone took the slot, when the lease carried one (ADR 0033). */
139
+ readonly clone: number | undefined
90
140
  }
91
141
 
92
142
  /** The judge seat: who sits in it, and how often it was asked. */
@@ -94,6 +144,8 @@ export interface TreeJudge {
94
144
  readonly executor: string | undefined
95
145
  readonly reviews: number
96
146
  readonly verdicts: number
147
+ /** Reviews and verdicts that ran on the story's own executor: not independent (ADR 0033). */
148
+ readonly borrowed: number
97
149
  }
98
150
 
99
151
  /** The latest story verdict (`StoryJudged`). */
@@ -146,6 +198,8 @@ export interface TreeState {
146
198
  readonly running: ReadonlyArray<TreeWork>
147
199
  /** A lane with no event for this long, and no tool running, is marked idle. */
148
200
  readonly idleAfterMs: number
201
+ /** The lane that published last, which the detail box follows by default (ADR 0033). */
202
+ readonly lastChanged: string | undefined
149
203
  }
150
204
 
151
205
  export interface TreeOptions {
@@ -182,7 +236,7 @@ export const emptyTree = (options: TreeOptions = {}): TreeState => ({
182
236
  executors: [],
183
237
  leases: [],
184
238
  exclusions: [],
185
- judge: { executor: undefined, reviews: 0, verdicts: 0 },
239
+ judge: { executor: undefined, reviews: 0, verdicts: 0, borrowed: 0 },
186
240
  verdict: undefined,
187
241
  judgments: [],
188
242
  log: [],
@@ -191,7 +245,8 @@ export const emptyTree = (options: TreeOptions = {}): TreeState => ({
191
245
  ended: undefined,
192
246
  time: { model: 0, tools: 0, gates: 0, merge: 0, wait: 0 },
193
247
  running: [],
194
- idleAfterMs: options.idleAfterMs ?? defaultIdleAfterMs
248
+ idleAfterMs: options.idleAfterMs ?? defaultIdleAfterMs,
249
+ lastChanged: undefined
195
250
  })
196
251
 
197
252
  const costOf = (model: string | undefined, usage: TokenUsage): number =>
@@ -214,6 +269,40 @@ const updateLane = (
214
269
  lanes: state.lanes.map((lane) => (lane.id === id ? update(lane) : lane))
215
270
  })
216
271
 
272
+ /** A wait label that is a harness pausing itself, not the roster or a gate. */
273
+ const isPause = (label: string): boolean => /^pi (retry|compaction)$/u.test(label)
274
+
275
+ /**
276
+ * A delegate tool that starts a sub-agent: Claude's Agent (Task before
277
+ * 2.1.63), Codex's spawn_agent, Gemini's agents. Codex's wait, send_input,
278
+ * resume_agent and close_agent act on one that exists.
279
+ */
280
+ const spawningTools =
281
+ /^(agent|task|spawn_agent|codebase_investigator|generalist|cli_help|browser_agent)$/iu
282
+
283
+ /**
284
+ * A delegate call whose end ends the sub-agent: a synchronous one (Agent,
285
+ * a Gemini agent) or Codex's wait and close; spawn_agent returns at once
286
+ * with the sub-agent still running (ADR 0033).
287
+ */
288
+ const endsChild = (tool: string): boolean => !/^(spawn_agent|send_input|resume_agent)$/iu.test(tool)
289
+
290
+ const newestOpenChild = (children: ReadonlyArray<TreeChild>): number => {
291
+ for (let index = children.length - 1; index >= 0; index -= 1) {
292
+ if (children[index]?.ended === false) {
293
+ return index
294
+ }
295
+ }
296
+ return -1
297
+ }
298
+
299
+ const endNewestChild = (children: ReadonlyArray<TreeChild>): ReadonlyArray<TreeChild> => {
300
+ const index = newestOpenChild(children)
301
+ return index < 0
302
+ ? children
303
+ : children.map((child, position) => (position === index ? { ...child, ended: true } : child))
304
+ }
305
+
217
306
  const withoutLast = <A>(items: ReadonlyArray<A>, item: A): ReadonlyArray<A> => {
218
307
  const index = items.lastIndexOf(item)
219
308
  return index < 0 ? items : [...items.slice(0, index), ...items.slice(index + 1)]
@@ -240,35 +329,45 @@ const withExecutor = (state: TreeState, executor: string): TreeState =>
240
329
  const rosterLog = (state: TreeState, at: number, what: string): TreeState =>
241
330
  logged(state, { at, source: "roster", who: "roster", what })
242
331
 
243
- const reduceLease = (state: TreeState, at: number, lease: TreeLease): TreeState => {
332
+ const reduceLease = (
333
+ state: TreeState,
334
+ at: number,
335
+ lease: TreeLease,
336
+ borrowed: boolean
337
+ ): TreeState => {
244
338
  const held = { ...withExecutor(state, lease.executor), leases: [...state.leases, lease] }
339
+ const who = cloneName(lease.executor, lease.clone)
340
+ const forLabel = lease.label === undefined ? "" : ` · ${lease.label}`
245
341
  switch (lease.role) {
246
342
  case "coder": {
247
343
  const lane = held.lanes.find((candidate) => candidate.id === lease.label)
248
344
  const placed =
249
345
  lane === undefined
250
346
  ? held
251
- : updateLane(held, lane.id, (open) => ({ ...open, executor: lease.executor }))
252
- return rosterLog(
253
- placed,
254
- at,
255
- `${lease.executor} → coder${lease.label === undefined ? "" : ` · ${lease.label}`}`
256
- )
347
+ : updateLane(held, lane.id, (open) => ({
348
+ ...open,
349
+ executor: lease.executor,
350
+ clone: lease.clone
351
+ }))
352
+ return rosterLog(placed, at, `${who} → coder${forLabel}`)
257
353
  }
258
354
  case "reviewer":
259
- return {
355
+ case "judge": {
356
+ const seat = {
260
357
  ...held,
261
358
  judge: {
262
359
  ...held.judge,
263
- executor: held.judge.executor ?? lease.executor,
264
- reviews: held.judge.reviews + 1
360
+ executor:
361
+ lease.role === "judge" ? lease.executor : (held.judge.executor ?? lease.executor),
362
+ reviews: held.judge.reviews + (lease.role === "reviewer" ? 1 : 0),
363
+ verdicts: held.judge.verdicts + (lease.role === "judge" ? 1 : 0),
364
+ borrowed: held.judge.borrowed + (borrowed ? 1 : 0)
265
365
  }
266
366
  }
267
- case "judge":
268
- return {
269
- ...held,
270
- judge: { ...held.judge, executor: lease.executor, verdicts: held.judge.verdicts + 1 }
271
- }
367
+ return borrowed
368
+ ? rosterLog(seat, at, `${lease.role} borrowed from own executor ${who}${forLabel}`)
369
+ : seat
370
+ }
272
371
  default:
273
372
  return held
274
373
  }
@@ -292,11 +391,14 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
292
391
  const current =
293
392
  lane === undefined
294
393
  ? named
295
- : updateLane(named, lane.id, (open) => ({
296
- ...open,
297
- lastEventAt: at,
298
- ...(executor !== undefined && open.executor !== executor ? { executor } : {})
299
- }))
394
+ : {
395
+ ...updateLane(named, lane.id, (open) => ({
396
+ ...open,
397
+ lastEventAt: at,
398
+ ...(executor !== undefined && open.executor !== executor ? { executor } : {})
399
+ })),
400
+ lastChanged: lane.id
401
+ }
300
402
  switch (event._tag) {
301
403
  case "StageStarted": {
302
404
  const story = storyStage.exec(event.stage)?.[1]
@@ -315,11 +417,18 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
315
417
  activity: undefined,
316
418
  turns: [],
317
419
  gatesMs: 0,
318
- running: []
420
+ running: [],
421
+ clone: undefined,
422
+ tasks: [],
423
+ task: undefined,
424
+ children: [],
425
+ pause: undefined,
426
+ taskCoders: []
319
427
  }
320
428
  return {
321
429
  ...current,
322
- lanes: [...current.lanes.filter((open) => open.id !== story), fresh]
430
+ lanes: [...current.lanes.filter((open) => open.id !== story), fresh],
431
+ lastChanged: story
323
432
  }
324
433
  }
325
434
  if (lane !== undefined) {
@@ -328,7 +437,10 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
328
437
  stages: [...open.stages, event.stage]
329
438
  }))
330
439
  }
331
- return { ...current, stages: [...current.stages, event.stage] }
440
+ // A lane that is not a story's (a task run aside, ADR 0034) is not the run's either.
441
+ return event.lane === undefined
442
+ ? { ...current, stages: [...current.stages, event.stage] }
443
+ : current
332
444
  }
333
445
  case "StageCompleted":
334
446
  case "StageFailed": {
@@ -353,7 +465,9 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
353
465
  stages: withoutLast(open.stages, event.stage)
354
466
  }))
355
467
  }
356
- return { ...current, stages: withoutLast(current.stages, event.stage) }
468
+ return event.lane === undefined
469
+ ? { ...current, stages: withoutLast(current.stages, event.stage) }
470
+ : current
357
471
  }
358
472
  case "Info": {
359
473
  // A story's own milestones are `story <id>: …` (resuming, merged,
@@ -402,7 +516,15 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
402
516
  : event.kind === "model" && event.label === "coder"
403
517
  ? { activity: undefined, turns: [...open.turns, event.ms] }
404
518
  : {}),
405
- gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0)
519
+ gatesMs: open.gatesMs + (event.kind === "gate" ? event.ms : 0),
520
+ // The delegate call ended: its sub-agent is done — unless it was
521
+ // only spawned, and runs on until a wait or close.
522
+ ...(event.kind === "tool" && event.category === "delegate" && endsChild(event.label)
523
+ ? { children: endNewestChild(open.children) }
524
+ : {}),
525
+ ...(event.kind === "wait" && open.pause?.label === event.label
526
+ ? { pause: undefined }
527
+ : {})
406
528
  }))
407
529
  }
408
530
  case "Began": {
@@ -411,7 +533,14 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
411
533
  ? event.lane === undefined
412
534
  ? { ...current, running: [...current.running, work] }
413
535
  : current
414
- : updateLane(current, lane.id, (open) => ({ ...open, running: [...open.running, work] }))
536
+ : updateLane(current, lane.id, (open) => ({
537
+ ...open,
538
+ running: [...open.running, work],
539
+ // A harness pause (`pi retry`, `pi compaction`) is the lane's state while it lasts.
540
+ ...(event.kind === "wait" && isPause(event.label)
541
+ ? { pause: { label: event.label, since: at } }
542
+ : {})
543
+ }))
415
544
  }
416
545
  case "StoryJudged": {
417
546
  const verdict: TreeVerdict = {
@@ -440,11 +569,12 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
440
569
  ]
441
570
  }
442
571
  case "ExecutorLeased":
443
- return reduceLease(current, at, {
444
- executor: event.executor,
445
- role: event.role,
446
- label: event.label
447
- })
572
+ return reduceLease(
573
+ current,
574
+ at,
575
+ { executor: event.executor, role: event.role, label: event.label, clone: event.clone },
576
+ event.borrowed === true
577
+ )
448
578
  case "ExecutorReleased": {
449
579
  const index = current.leases.findIndex(
450
580
  (lease) =>
@@ -486,15 +616,115 @@ const reduceEvent = (state: TreeState, at: number, event: FlowEvent): TreeState
486
616
  at,
487
617
  `${event.label ?? "the run"}: ${event.role} leaves ${event.from} · ${event.reason}`
488
618
  )
489
- case "ToolUse":
619
+ case "ToolUse": {
620
+ if (lane === undefined) {
621
+ return current
622
+ }
623
+ const text = `${event.tool} ${event.args}`
624
+ return updateLane(current, lane.id, (open) => {
625
+ // A call inside a sub-agent lands on the newest open child; without
626
+ // one (an older harness, a lost parent) it is the lane's as before.
627
+ const child = event.parent === undefined ? -1 : newestOpenChild(open.children)
628
+ if (child >= 0) {
629
+ return {
630
+ ...open,
631
+ children: open.children.map((candidate, index) =>
632
+ index === child ? { ...candidate, lastTool: text } : candidate
633
+ )
634
+ }
635
+ }
636
+ const delegated =
637
+ toolCategory(event.tool, event.args) === "delegate" && spawningTools.test(event.tool)
638
+ ? [
639
+ ...open.children,
640
+ {
641
+ id: `${open.id}:${open.children.length + 1}`,
642
+ tool: event.tool,
643
+ args: event.args,
644
+ since: at,
645
+ lastTool: undefined,
646
+ ended: false
647
+ }
648
+ ]
649
+ : open.children
650
+ return {
651
+ ...open,
652
+ activity: { text, since: at, tool: true },
653
+ lastTool: text,
654
+ tools: [...open.tools, text].slice(-expandedTools),
655
+ children: delegated
656
+ }
657
+ })
658
+ }
659
+ case "TasksPlanned":
490
660
  return lane === undefined
491
661
  ? current
492
662
  : updateLane(current, lane.id, (open) => ({
493
663
  ...open,
494
- activity: { text: `${event.tool} ${event.args}`, since: at, tool: true },
495
- lastTool: `${event.tool} ${event.args}`,
496
- tools: [...open.tools, `${event.tool} ${event.args}`].slice(-expandedTools)
664
+ tasks: event.tasks.map((task, position) => ({
665
+ index: position + 1,
666
+ title: task.title,
667
+ done: task.completed,
668
+ ...(task.satisfies === undefined ? {} : { satisfies: task.satisfies })
669
+ }))
497
670
  }))
671
+ case "TaskStarted": {
672
+ if (lane === undefined) {
673
+ return current
674
+ }
675
+ const satisfies = event.satisfies === undefined ? {} : { satisfies: event.satisfies }
676
+ const listed = (open: TreeLane): ReadonlyArray<TreeTask> =>
677
+ open.tasks.some((task) => task.index === event.index)
678
+ ? open.tasks
679
+ : [
680
+ ...open.tasks,
681
+ { index: event.index, title: event.title, done: false, ...satisfies }
682
+ ].sort((left, right) => left.index - right.index)
683
+ if (event.parallel === true) {
684
+ return updateLane(current, lane.id, (open) => ({
685
+ ...open,
686
+ tasks: listed(open),
687
+ taskCoders: [
688
+ ...open.taskCoders.filter((coder) => coder.index !== event.index),
689
+ {
690
+ index: event.index,
691
+ count: event.count,
692
+ title: event.title,
693
+ executor: event.executor,
694
+ clone: event.clone,
695
+ since: at
696
+ }
697
+ ]
698
+ }))
699
+ }
700
+ return updateLane(current, lane.id, (open) => ({
701
+ ...open,
702
+ task: { index: event.index, count: event.count, title: event.title, ...satisfies },
703
+ tasks: listed(open),
704
+ // Back with the story's coder: no longer beside it.
705
+ taskCoders: open.taskCoders.filter((coder) => coder.index !== event.index)
706
+ }))
707
+ }
708
+ case "TaskCompleted":
709
+ return lane === undefined
710
+ ? current
711
+ : updateLane(current, lane.id, (open) => ({
712
+ ...open,
713
+ task: open.task?.index === event.index ? undefined : open.task,
714
+ taskCoders: open.taskCoders.filter((coder) => coder.index !== event.index),
715
+ tasks: open.tasks.map((task) =>
716
+ task.index === event.index ? { ...task, done: true } : task
717
+ )
718
+ }))
719
+ case "StoryStatusChanged":
720
+ return {
721
+ ...current,
722
+ stories: current.stories.some((story) => story.id === event.id)
723
+ ? current.stories.map((story) =>
724
+ story.id === event.id ? { ...story, status: event.status } : story
725
+ )
726
+ : [...current.stories, { id: event.id, status: event.status }]
727
+ }
498
728
  case "TokensUsed": {
499
729
  const tokens = event.usage.total
500
730
  const costUsd = costOf(event.model, event.usage)
@@ -698,12 +928,12 @@ const paint = (line: Line, colour: boolean): string =>
698
928
  )
699
929
  .join("")
700
930
 
701
- export type TreeMode = "lanes" | "executors"
931
+ export type TreeMode = "lanes" | "executors" | "boards"
702
932
 
703
933
  /** What the reader chose to look at; the run's state is separate. */
704
934
  export interface TreeView {
705
935
  readonly mode: TreeMode
706
- /** The selected lane's story id, or in the executors view the executor. */
936
+ /** The selected lane's story id, or in the executors view the executor; unset follows the lane that moved last. */
707
937
  readonly selected: string | undefined
708
938
  readonly expanded: boolean
709
939
  readonly fullLog: boolean
@@ -713,6 +943,8 @@ export interface TreeView {
713
943
  readonly tailRole: string | undefined
714
944
  /** How many lines the tail is scrolled back from its end (page up/down). */
715
945
  readonly tailBack: number
946
+ /** The first row of the agent list shown, when the list is taller than its room. */
947
+ readonly scroll: number
716
948
  }
717
949
 
718
950
  export const initialView: TreeView = {
@@ -722,12 +954,27 @@ export const initialView: TreeView = {
722
954
  fullLog: false,
723
955
  tail: false,
724
956
  tailRole: undefined,
725
- tailBack: 0
957
+ tailBack: 0,
958
+ scroll: 0
726
959
  }
727
960
 
728
961
  const runningLanes = (state: TreeState): ReadonlyArray<TreeLane> =>
729
962
  state.lanes.filter((lane) => lane.status === "running")
730
963
 
964
+ /**
965
+ * The lane the detail box is about: the selection, else the lane that moved
966
+ * last, else the first running one — so an unattended screen still shows
967
+ * what is happening (ADR 0033).
968
+ */
969
+ export const selectedLane = (state: TreeState, view: TreeView): TreeLane | undefined => {
970
+ const running = runningLanes(state)
971
+ return (
972
+ running.find((lane) => lane.id === view.selected) ??
973
+ running.find((lane) => lane.id === state.lastChanged) ??
974
+ running[0]
975
+ )
976
+ }
977
+
731
978
  const tailRoles: ReadonlyArray<string | undefined> = [undefined, "coder", "reviewer", "judge"]
732
979
  const tailPage = 10
733
980
 
@@ -737,24 +984,31 @@ export const tailTargetOf = (
737
984
  ): { readonly lane: string } | { readonly executor: string } | undefined =>
738
985
  view.selected === undefined
739
986
  ? undefined
740
- : view.mode === "lanes"
741
- ? { lane: view.selected }
742
- : { executor: view.selected }
987
+ : view.mode === "executors"
988
+ ? { executor: view.selected }
989
+ : { lane: view.selected }
743
990
 
744
991
  /**
745
992
  * The view after a key: arrows or 1–9 select a running lane (an executor in
746
- * the executors view), enter expands it, `t` tails its transcript, `r`
747
- * cycles the tail's role, escape closes, `e` switches lanes and executors,
748
- * `l` the full log, `q` quits. Keys it does not know return the same view.
993
+ * the executors view), enter expands its detail, `t` tails its transcript,
994
+ * `r` cycles the tail's role, escape closes, `e` switches lanes and
995
+ * executors, `b` the boards, `l` the full log, `q` quits. Keys it does not
996
+ * know return the same view.
749
997
  */
750
998
  export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeView | "quit" => {
751
999
  const choices =
752
- view.mode === "lanes" ? runningLanes(state).map((lane) => lane.id) : state.executors
753
- const index = choices.findIndex((id) => id === view.selected)
1000
+ view.mode === "executors" ? state.executors : runningLanes(state).map((lane) => lane.id)
1001
+ const current = view.mode === "executors" ? view.selected : selectedLane(state, view)?.id
1002
+ const index = choices.findIndex((id) => id === current)
754
1003
  const select = (next: number): TreeView => {
755
- const id = choices[Math.max(0, Math.min(choices.length - 1, next))]
756
- return id === undefined ? view : { ...view, selected: id }
1004
+ const row = Math.max(0, Math.min(choices.length - 1, next))
1005
+ const id = choices[row]
1006
+ return id === undefined
1007
+ ? view
1008
+ : { ...view, selected: id, scroll: row < view.scroll ? row : view.scroll }
757
1009
  }
1010
+ const chosen = (): string | undefined =>
1011
+ view.mode === "executors" ? view.selected : selectedLane(state, view)?.id
758
1012
  switch (key) {
759
1013
  case "q":
760
1014
  return "quit"
@@ -765,13 +1019,15 @@ export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeVi
765
1019
  case "left":
766
1020
  return select(index < 0 ? 0 : index - 1)
767
1021
  case "enter":
768
- return view.selected === undefined || view.mode !== "lanes"
1022
+ return view.mode === "executors" || chosen() === undefined
769
1023
  ? view
770
1024
  : { ...view, expanded: !view.expanded }
771
- case "t":
772
- return view.selected === undefined
1025
+ case "t": {
1026
+ const id = chosen()
1027
+ return id === undefined
773
1028
  ? view
774
- : { ...view, tail: !view.tail, expanded: false, tailBack: 0 }
1029
+ : { ...view, selected: id, tail: !view.tail, expanded: false, tailBack: 0 }
1030
+ }
775
1031
  case "pageup":
776
1032
  return view.tail ? { ...view, tailBack: view.tailBack + tailPage } : view
777
1033
  case "pagedown":
@@ -788,9 +1044,17 @@ export const onTreeKey = (view: TreeView, key: string, state: TreeState): TreeVi
788
1044
  case "e":
789
1045
  return {
790
1046
  ...view,
791
- mode: view.mode === "lanes" ? "executors" : "lanes",
1047
+ mode: view.mode === "executors" ? "lanes" : "executors",
792
1048
  selected: undefined,
793
1049
  expanded: false,
1050
+ tail: false,
1051
+ scroll: 0
1052
+ }
1053
+ case "b":
1054
+ return {
1055
+ ...view,
1056
+ mode: view.mode === "boards" ? "lanes" : "boards",
1057
+ expanded: false,
794
1058
  tail: false
795
1059
  }
796
1060
  case "l":
@@ -811,7 +1075,8 @@ export interface TreeRenderOptions {
811
1075
  }
812
1076
 
813
1077
  const railWidth = 26
814
- const maxLaneBoxes = 3
1078
+ /** The agent list never shrinks below this many rows for the log's sake. */
1079
+ const minListRows = 3
815
1080
 
816
1081
  const counter = (label: string, value: number): Line => [
817
1082
  span(`${label.padEnd(15)}${String(value).padStart(5)}`)
@@ -833,40 +1098,8 @@ const railOf = (state: TreeState): Array<Line> =>
833
1098
  ],
834
1099
  [],
835
1100
  counter("reviews", state.judge.reviews),
836
- counter("verdicts", state.judge.verdicts)
837
- ])
838
-
839
- const laneBox = (
840
- lane: TreeLane,
841
- width: number,
842
- now: number | undefined,
843
- selected: boolean,
844
- idleAfterMs: number
845
- ): Array<Line> =>
846
- box(width, "running", [
847
- [span(selected ? `▸ ${lane.id}` : lane.id, "bold")],
848
- [span(lane.executor ?? "(leasing)")],
849
- [span(lane.stages.at(-1) ?? "starting")],
850
- doingLine(lane, now),
851
- [
852
- span(
853
- `${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`
854
- )
855
- ],
856
- ...(lane.turns.length === 0 && lane.gatesMs === 0
857
- ? []
858
- : [
859
- [
860
- span(
861
- `turns ${lane.turns.length} · avg ${elapsedMs(
862
- lane.turns.length === 0
863
- ? 0
864
- : lane.turns.reduce((total, turn) => total + turn, 0) / lane.turns.length
865
- )} · gates ${elapsedMs(lane.gatesMs)}`
866
- )
867
- ]
868
- ]),
869
- statusLine(lane, now, idleAfterMs)
1101
+ counter("verdicts", state.judge.verdicts),
1102
+ ...(state.judge.borrowed === 0 ? [] : [counter("borrowed", state.judge.borrowed)])
870
1103
  ])
871
1104
 
872
1105
  /** Work under way as a card names it: the command itself, the call, the wait. */
@@ -1025,18 +1258,347 @@ const columnsOf = <A>(
1025
1258
  )
1026
1259
  }
1027
1260
 
1028
- const expandedLane = (lane: TreeLane, width: number, now: number | undefined): Array<Line> =>
1029
- box(
1261
+ /** `◐`/`⏸`: the lane's status at a glance, as `statusLine` grades it. */
1262
+ const statusMark = (lane: TreeLane, now: number | undefined, idleAfterMs: number): Span => {
1263
+ const line = statusLine(lane, now, idleAfterMs)
1264
+ const text = line[0]?.text ?? ""
1265
+ return span(text.startsWith("◐") ? "◐" : "⏸", line[0]?.style)
1266
+ }
1267
+
1268
+ /** `3/5 wire session cookie`, or `2/5 tasks` between tasks, or nothing before the plan. */
1269
+ const taskText = (lane: TreeLane): string =>
1270
+ lane.task !== undefined
1271
+ ? `${lane.task.index}/${lane.task.count} ${lane.task.title}`
1272
+ : lane.tasks.length > 0
1273
+ ? `${lane.tasks.filter((task) => task.done).length}/${lane.tasks.length} tasks`
1274
+ : ""
1275
+
1276
+ /** `head` then `tail` in `width`: a long head is cut, the tail (a timer, a count) never is. */
1277
+ const headThenTail = (head: Line, tail: Span, width: number): Line => {
1278
+ const room = width - [...tail.text].length
1279
+ return lengthOf(head) <= room ? [...head, tail] : [...fit(head, Math.max(1, room)), tail]
1280
+ }
1281
+
1282
+ /** The one line a running lane is in the agent list (ADR 0033). */
1283
+ const laneLine = (
1284
+ lane: TreeLane,
1285
+ width: number,
1286
+ now: number | undefined,
1287
+ selected: boolean,
1288
+ idleAfterMs: number
1289
+ ): Line => {
1290
+ const task = taskText(lane)
1291
+ const stage = lane.stages.at(-1) ?? "starting"
1292
+ const head: Line = [
1293
+ span(selected ? "▸ " : " "),
1294
+ statusMark(lane, now, idleAfterMs),
1295
+ span(" "),
1296
+ span(lane.id, "bold"),
1297
+ span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}`),
1298
+ ...(task.length === 0 ? [] : [span(" · "), span(task)]),
1299
+ // A task is a stage of its own name: say it once.
1300
+ ...(stage === lane.task?.title ? [] : [span(" · "), span(stage, "dim")]),
1301
+ ...(lane.pause === undefined
1302
+ ? []
1303
+ : [span(" · "), span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")])
1304
+ ]
1305
+ return headThenTail(
1306
+ head,
1307
+ span(` · ${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok`),
1308
+ width
1309
+ )
1310
+ }
1311
+
1312
+ /** A sub-agent the lane's harness spawned, indented under it. */
1313
+ /** A task running beside the lane's coder, indented under it (ADR 0034). */
1314
+ const taskCoderLine = (coder: TreeTaskCoder, width: number, now: number | undefined): Line =>
1315
+ headThenTail(
1316
+ [
1317
+ span(" └ "),
1318
+ span("task coder ", "dim"),
1319
+ span(`${cloneName(coder.executor ?? "(leasing)", coder.clone)} · `),
1320
+ span(`${coder.index}/${coder.count} ${coder.title}`)
1321
+ ],
1322
+ span(` · ${elapsed(coder.since, now)}`, "dim"),
1323
+ width
1324
+ )
1325
+
1326
+ const childLine = (child: TreeChild, width: number, now: number | undefined): Line =>
1327
+ headThenTail(
1328
+ [
1329
+ span(" └ "),
1330
+ span("sub-agent ", "dim"),
1331
+ span(`${child.tool} ${child.args}`),
1332
+ span(` · ${child.lastTool ?? "starting"}`, "dim")
1333
+ ],
1334
+ span(` · ${elapsed(child.since, now)}`, "dim"),
1335
+ width
1336
+ )
1337
+
1338
+ /** The list's rows: a running lane, then each sub-agent still running under it. */
1339
+ const listRowsOf = (state: TreeState): number =>
1340
+ runningLanes(state).reduce(
1341
+ (total, lane) =>
1342
+ total + 1 + lane.taskCoders.length + lane.children.filter((child) => !child.ended).length,
1343
+ 0
1344
+ )
1345
+
1346
+ /**
1347
+ * Every running agent, one row each, scrolled so the selected lane is in
1348
+ * view and cut to `rows` with a count of what is out of view (ADR 0033).
1349
+ */
1350
+ const agentRows = (
1351
+ state: TreeState,
1352
+ width: number,
1353
+ view: TreeView,
1354
+ selected: TreeLane | undefined,
1355
+ rows: number | undefined
1356
+ ): Array<Line> => {
1357
+ const all: Array<{ readonly lane: string; readonly line: Line }> = runningLanes(state).flatMap(
1358
+ (lane) => [
1359
+ {
1360
+ lane: lane.id,
1361
+ line: laneLine(lane, width, state.now, lane.id === selected?.id, state.idleAfterMs)
1362
+ },
1363
+ ...lane.taskCoders.map((coder) => ({
1364
+ lane: lane.id,
1365
+ line: taskCoderLine(coder, width, state.now)
1366
+ })),
1367
+ ...lane.children
1368
+ .filter((child) => !child.ended)
1369
+ .map((child) => ({ lane: lane.id, line: childLine(child, width, state.now) }))
1370
+ ]
1371
+ )
1372
+ if (rows === undefined || all.length <= rows) {
1373
+ return all.map((row) => row.line)
1374
+ }
1375
+ const wanted = all.findIndex((row) => row.lane === selected?.id)
1376
+ // The "… n above/more" notes take rows of the budget too.
1377
+ const window = (room: number): number => {
1378
+ const start = Math.max(0, Math.min(view.scroll, all.length - room))
1379
+ return wanted < 0
1380
+ ? start
1381
+ : wanted < start
1382
+ ? wanted
1383
+ : wanted >= start + room
1384
+ ? wanted - room + 1
1385
+ : start
1386
+ }
1387
+ let room = Math.max(1, rows - 1)
1388
+ let first = window(room)
1389
+ if (first > 0 && first + room < all.length) {
1390
+ room = Math.max(1, rows - 2)
1391
+ first = window(room)
1392
+ }
1393
+ const shown = all.slice(first, first + room)
1394
+ const above = first
1395
+ const below = all.length - first - shown.length
1396
+ return [
1397
+ ...(above > 0 ? [[span(`… ${above} above`, "dim")]] : []),
1398
+ ...shown.map((row) => row.line),
1399
+ ...(below > 0 ? [[span(`… ${below} more`, "dim")]] : [])
1400
+ ]
1401
+ }
1402
+
1403
+ const tick = (task: TreeTask, running: boolean): string =>
1404
+ task.done ? "[x]" : running ? "[▶]" : "[ ]"
1405
+
1406
+ /**
1407
+ * The selected lane in full: its task checklist with the criteria each task
1408
+ * satisfies, its stage, what it is doing, its tools, a harness pause, and
1409
+ * its figures (ADR 0033). Expanded, every stage and tool it ran.
1410
+ */
1411
+ const detailBox = (
1412
+ lane: TreeLane,
1413
+ width: number,
1414
+ now: number | undefined,
1415
+ idleAfterMs: number,
1416
+ expanded: boolean
1417
+ ): Array<Line> => {
1418
+ const inner = width - 4
1419
+ const checklist: Array<Line> =
1420
+ lane.tasks.length === 0
1421
+ ? [[span("no task plan yet", "dim")]]
1422
+ : lane.tasks.map((task): Line => {
1423
+ const beside = lane.taskCoders.find((coder) => coder.index === task.index)
1424
+ const running = lane.task?.index === task.index || beside !== undefined
1425
+ const right = task.satisfies === undefined ? "" : `satisfies ${task.satisfies.join(",")}`
1426
+ const left: Line = [
1427
+ span(
1428
+ `${tick(task, running)} ${task.index} ${task.title}`,
1429
+ running ? "bold" : undefined
1430
+ ),
1431
+ ...(beside === undefined
1432
+ ? []
1433
+ : [span(` ← ${cloneName(beside.executor ?? "(leasing)", beside.clone)}`, "dim")])
1434
+ ]
1435
+ return right.length === 0
1436
+ ? left
1437
+ : [...fit(left, Math.max(1, inner - [...right].length - 1)), span(` ${right}`, "dim")]
1438
+ })
1439
+ const stages: Array<Line> = expanded
1440
+ ? lane.stages.map((stage): Line => [span(stage)])
1441
+ : [[span("stage "), span(lane.stages.at(-1) ?? "starting")]]
1442
+ const tools: Array<Line> = expanded
1443
+ ? [
1444
+ [span(`tools (last ${lane.tools.length})`, "dim")],
1445
+ ...lane.tools.map((tool): Line => [span(` ${tool}`, "dim")])
1446
+ ]
1447
+ : lane.tools.length === 0
1448
+ ? []
1449
+ : [[span("tools "), span(lane.tools.slice(-3).join(" · "), "dim")]]
1450
+ const title = `${lane.id} · ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${
1451
+ lane.task === undefined ? "" : ` · task ${lane.task.index}/${lane.task.count}`
1452
+ }`
1453
+ return box(
1030
1454
  width,
1031
1455
  "running",
1032
1456
  [
1033
- [span(`story ${lane.id}`, "bold"), span(` ${elapsed(lane.startedAt, now)}`)],
1034
- ...lane.stages.map((stage): Line => [span(stage)]),
1035
- [span(`tools (last ${lane.tools.length})`, "dim")],
1036
- ...lane.tools.map((tool): Line => [span(` ${tool}`, "dim")]),
1037
- [span(`${formatCount(lane.tokens)} tok · ${dollars(lane.costUsd)}`)]
1457
+ ...checklist,
1458
+ ...stages,
1459
+ doingLine(lane, now),
1460
+ ...(lane.pause === undefined
1461
+ ? []
1462
+ : [[span(`⏸ ${lane.pause.label} ${elapsed(lane.pause.since, now)}`, "judge")]]),
1463
+ ...tools,
1464
+ [
1465
+ span(
1466
+ `${elapsed(lane.startedAt, now)} · ${formatCount(lane.tokens)} tok${lane.costUsd > 0 ? ` · ${dollars(lane.costUsd)}` : ""}`
1467
+ )
1468
+ ],
1469
+ ...(lane.turns.length === 0 && lane.gatesMs === 0
1470
+ ? []
1471
+ : [
1472
+ [
1473
+ span(
1474
+ `turns ${lane.turns.length} · avg ${elapsedMs(
1475
+ lane.turns.length === 0
1476
+ ? 0
1477
+ : lane.turns.reduce((total, turn) => total + turn, 0) / lane.turns.length
1478
+ )} · gates ${elapsedMs(lane.gatesMs)}`
1479
+ )
1480
+ ]
1481
+ ]),
1482
+ statusLine(lane, now, idleAfterMs)
1483
+ ],
1484
+ title
1485
+ )
1486
+ }
1487
+
1488
+ const bar = (done: number, total: number, width = 5): string => {
1489
+ const filled = total === 0 ? 0 : Math.round((done / total) * width)
1490
+ return "█".repeat(filled) + "░".repeat(width - filled)
1491
+ }
1492
+
1493
+ const boardColumns: ReadonlyArray<readonly [string, ChipStatus]> = [
1494
+ ["planned", "planned"],
1495
+ ["active", "running"],
1496
+ ["waiting", "waiting"],
1497
+ ["done", "done"],
1498
+ ["failed", "failed"]
1499
+ ]
1500
+
1501
+ /**
1502
+ * The boards (ADR 0033): the epic's stories by column, with a running lane's
1503
+ * clone and progress on its card, then the selected lane's tasks by column.
1504
+ */
1505
+ const boardsOf = (
1506
+ state: TreeState,
1507
+ width: number,
1508
+ selected: TreeLane | undefined,
1509
+ now: number | undefined
1510
+ ): Array<Line> => {
1511
+ const chips = chipsOf(state)
1512
+ // Five columns need ~16 characters each to read; narrower, two rows of columns.
1513
+ const epic = chunked(boardColumns, width >= 5 * 16 + 8 ? 5 : 3).flatMap((group) =>
1514
+ columnsOf(group, width, ([name, status], each) => {
1515
+ const members = chips.filter((chip) => chip.status === status)
1516
+ const cards = members.flatMap((chip): Array<Line> => {
1517
+ const lane = state.lanes.find((candidate) => candidate.id === chip.id)
1518
+ if (lane === undefined || lane.status !== "running") {
1519
+ return [[span(chip.id)]]
1520
+ }
1521
+ const done = lane.tasks.filter((task) => task.done).length
1522
+ const progress =
1523
+ lane.tasks.length === 0
1524
+ ? ""
1525
+ : ` ${done}/${lane.tasks.length}${each >= 24 ? ` ${bar(done, lane.tasks.length)}` : ""}`
1526
+ return [
1527
+ [span(chip.id, "bold")],
1528
+ [span(` ${cloneName(lane.executor ?? "(leasing)", lane.clone)}${progress}`, "dim")]
1529
+ ]
1530
+ })
1531
+ return box(
1532
+ each,
1533
+ "running",
1534
+ cards.length === 0 ? [[span("—", "dim")]] : cards,
1535
+ `${name} (${members.length})`
1536
+ )
1537
+ })
1538
+ )
1539
+ if (selected === undefined) {
1540
+ return [[span("epic board", "bold")], ...epic]
1541
+ }
1542
+ const taskColumns: ReadonlyArray<readonly [string, (task: TreeTask) => boolean]> = [
1543
+ [
1544
+ "todo",
1545
+ (task) =>
1546
+ !task.done &&
1547
+ task.index !== selected.task?.index &&
1548
+ !selected.taskCoders.some((coder) => coder.index === task.index)
1549
+ ],
1550
+ [
1551
+ "doing",
1552
+ (task) =>
1553
+ !task.done &&
1554
+ (task.index === selected.task?.index ||
1555
+ selected.taskCoders.some((coder) => coder.index === task.index))
1038
1556
  ],
1039
- `${lane.id} · ${lane.executor ?? "(leasing)"}`
1557
+ ["review", () => false],
1558
+ ["done", (task) => task.done]
1559
+ ]
1560
+ const story = chunked(taskColumns, width >= 4 * 16 + 6 ? 4 : 2).flatMap((group) =>
1561
+ columnsOf(group, width, ([name, keep], each) => {
1562
+ const members = selected.tasks.filter(keep)
1563
+ return box(
1564
+ each,
1565
+ "running",
1566
+ members.length === 0
1567
+ ? [[span("—", "dim")]]
1568
+ : members.map((task): Line => [span(`${task.index} ${task.title}`)]),
1569
+ `${name} (${members.length})`
1570
+ )
1571
+ })
1572
+ )
1573
+ return [
1574
+ [span("epic board", "bold")],
1575
+ ...epic,
1576
+ [],
1577
+ [
1578
+ span(
1579
+ `story board · ${selected.id} · ${cloneName(selected.executor ?? "(leasing)", selected.clone)}`,
1580
+ "bold"
1581
+ ),
1582
+ span(` · ${elapsed(selected.startedAt, now)}`)
1583
+ ],
1584
+ ...story
1585
+ ]
1586
+ }
1587
+
1588
+ /** `codex ×2 · claude ×1`: how many leases each executor holds now. */
1589
+ const leaseCounts = (state: TreeState): string =>
1590
+ state.executors
1591
+ .map(
1592
+ (executor) =>
1593
+ [executor, state.leases.filter((lease) => lease.executor === executor).length] as const
1594
+ )
1595
+ .filter(([, count]) => count > 0)
1596
+ .map(([executor, count]) => `${executor} ×${count}`)
1597
+ .join(" · ")
1598
+
1599
+ const chunked = <A>(items: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> =>
1600
+ Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
1601
+ items.slice(index * size, index * size + size)
1040
1602
  )
1041
1603
 
1042
1604
  const barWidth = 10
@@ -1112,9 +1674,10 @@ const mainOf = (
1112
1674
  state: TreeState,
1113
1675
  width: number,
1114
1676
  view: TreeView,
1115
- tail: ReadonlyArray<string> | undefined
1677
+ tail: ReadonlyArray<string> | undefined,
1678
+ listRows: number | undefined
1116
1679
  ): Array<Line> => {
1117
- const running = state.lanes.filter((lane) => lane.status === "running")
1680
+ const running = runningLanes(state)
1118
1681
  const orchestratorWidth = Math.min(46, width)
1119
1682
  const stage = state.stages.at(-1) ?? (running.length > 0 ? "implement stories" : "idle")
1120
1683
  // The run's own work under way (its gates, outside any story), innermost.
@@ -1140,32 +1703,37 @@ const mainOf = (
1140
1703
  ],
1141
1704
  state.title
1142
1705
  )
1143
- const selected = running.find((lane) => lane.id === view.selected)
1144
- const shown = running.slice(0, maxLaneBoxes)
1145
- const executors = state.executors.slice(0, maxLaneBoxes + 1)
1146
- const lanes =
1706
+ const selected = selectedLane(state, view)
1707
+ const counts = leaseCounts(state)
1708
+ const agents = centre(
1709
+ [span(`agents · ${running.length} running${counts.length === 0 ? "" : ` · ${counts}`}`)],
1710
+ width
1711
+ )
1712
+ const body =
1147
1713
  view.tail && view.selected !== undefined
1148
1714
  ? tailOf(view, tail, width)
1149
- : view.expanded && selected !== undefined
1150
- ? expandedLane(selected, width, state.now)
1151
- : view.mode === "executors"
1152
- ? executors.length === 0
1153
- ? [centre([span("no executors yet", "dim")], width)]
1154
- : columnsOf(executors, width, (executor, each) =>
1715
+ : view.mode === "executors"
1716
+ ? state.executors.length === 0
1717
+ ? [centre([span("no executors yet", "dim")], width)]
1718
+ : chunked(state.executors, 4).flatMap((group) =>
1719
+ columnsOf(group, width, (executor, each) =>
1155
1720
  executorBox(state, executor, each, executor === view.selected)
1156
1721
  )
1157
- : shown.length === 0
1722
+ )
1723
+ : view.mode === "boards"
1724
+ ? boardsOf(state, width, selected, state.now)
1725
+ : running.length === 0 || selected === undefined
1158
1726
  ? [centre([span("no stories in flight", "dim")], width)]
1159
- : columnsOf(shown, width, (lane, each) =>
1160
- laneBox(lane, each, state.now, lane.id === view.selected, state.idleAfterMs)
1161
- )
1727
+ : [
1728
+ ...agentRows(state, width, view, selected, listRows),
1729
+ ...detailBox(selected, width, state.now, state.idleAfterMs, view.expanded)
1730
+ ]
1162
1731
  return [
1163
1732
  ...orchestrator.map((line) => centre(line, width)),
1164
1733
  centre([span("•", "orchestrator")], width),
1165
1734
  ...judgmentOf(state, width),
1166
- centre([span(`delegate to roster · ${running.length} running`)], width),
1167
- centre([span("▼", "dim")], width),
1168
- ...lanes,
1735
+ agents,
1736
+ ...body,
1169
1737
  [],
1170
1738
  ...wrapChips(chipsOf(state).map(chipOf), width)
1171
1739
  ]
@@ -1198,7 +1766,8 @@ const timeSplitOf = (time: TreeTimeSplit): Array<Line> => {
1198
1766
  const frameOf = (
1199
1767
  state: TreeState,
1200
1768
  options: TreeRenderOptions,
1201
- logCount: number | "none"
1769
+ logCount: number | "none",
1770
+ listRows: number | undefined
1202
1771
  ): ReadonlyArray<string> => {
1203
1772
  const { width } = options
1204
1773
  const mainWidth = width - railWidth - 2
@@ -1214,7 +1783,7 @@ const frameOf = (
1214
1783
  [span("═".repeat(width), "dim")],
1215
1784
  [],
1216
1785
  ...beside(
1217
- [railOf(state), mainOf(state, mainWidth, options.view, options.tail)],
1786
+ [railOf(state), mainOf(state, mainWidth, options.view, options.tail, listRows)],
1218
1787
  [railWidth, mainWidth]
1219
1788
  ),
1220
1789
  ...(logCount === "none"
@@ -1236,24 +1805,38 @@ const frameOf = (
1236
1805
  }
1237
1806
 
1238
1807
  /**
1239
- * The tree at a width and, when `height` is given, no taller: the session
1240
- * log gives way first — fewer lines, down to one, then none — and only then
1241
- * is the frame cut, keeping its status lines, so a redraw never scrolls.
1808
+ * The tree at a width and, when `height` is given, no taller: the agent
1809
+ * list gives way first (down to `minListRows`, with a count of what is out
1810
+ * of view), then the session log — fewer lines, down to one, then none —
1811
+ * and only then is the frame cut, keeping its status lines, so a redraw
1812
+ * never scrolls.
1242
1813
  */
1243
1814
  export const renderTree = (state: TreeState, options: TreeRenderOptions): ReadonlyArray<string> => {
1244
1815
  const wanted = options.view.fullLog ? fullLogLines : logLines
1245
- const full = frameOf(state, options, wanted)
1816
+ const full = frameOf(state, options, wanted, undefined)
1246
1817
  const { height } = options
1247
1818
  if (height === undefined || full.length <= height) {
1248
1819
  return full
1249
1820
  }
1821
+ const total = listRowsOf(state)
1822
+ const floor = Math.min(total, minListRows)
1823
+ const listRows =
1824
+ options.view.mode === "lanes" && total > floor
1825
+ ? Math.max(floor, total - (full.length - height))
1826
+ : undefined
1827
+ if (listRows !== undefined) {
1828
+ const shorter = frameOf(state, options, wanted, listRows)
1829
+ if (shorter.length <= height) {
1830
+ return shorter
1831
+ }
1832
+ }
1250
1833
  for (let count = wanted - 1; count >= 1; count -= 1) {
1251
- const shorter = frameOf(state, options, count)
1834
+ const shorter = frameOf(state, options, count, listRows)
1252
1835
  if (shorter.length <= height) {
1253
1836
  return shorter
1254
1837
  }
1255
1838
  }
1256
- return fitToRows(frameOf(state, options, "none"), height)
1839
+ return fitToRows(frameOf(state, options, "none", listRows), height)
1257
1840
  }
1258
1841
 
1259
1842
  /**