@cruxy/cli 1.2.1 → 1.4.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.
Files changed (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,1121 @@
1
+ import { resolveTheme } from "../theme/index.js";
2
+ import { createStreamPrinter } from "../cli/stream-print.js";
3
+ import { createFrame } from "../components/frame.js";
4
+ import { renderActionPreview } from "../render/diff.js";
5
+ import { createStreamHighlighter, } from "../render/highlight.js";
6
+ import { reflow } from "../render/layout.js";
7
+ import { createFrameClock, spinnerGlyph, } from "../render/motion.js";
8
+ import { planChecklist } from "../render/plan-view.js";
9
+ import { testResultLines } from "../render/test-view.js";
10
+ import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "../render/state.js";
11
+ import { budgetColumns, bodyRows, composeScreen, droppedForWidth, fitOverlay, overlayRows, scrollNotice, scrollWindow, stackPanels, CLOSABLE_PANELS, } from "./layout.js";
12
+ import { CONVERSATION_VIEW, cycleView, navLines, } from "./views.js";
13
+ import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
14
+ /**
15
+ * The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
16
+ * only one that owns the whole screen rather than a single managed line.
17
+ *
18
+ * It is built from the pieces that already existed, not alongside them:
19
+ * `components/frame.ts` paints and erases the transient region (and reflows it
20
+ * on resize), `render/layout.ts` does every ANSI-aware width measurement,
21
+ * `render/state.ts` composes the live-state line, `render/highlight.ts` styles
22
+ * fenced code, `render/motion.ts` supplies the one frame clock. This class adds
23
+ * geometry (via `tui/layout.ts`) and a scrollback buffer — nothing else.
24
+ *
25
+ * Two deliberate differences from `TtyRenderer`:
26
+ *
27
+ * 1. **Committed content is buffered, not written through.** A full-screen
28
+ * layout cannot let text land wherever the cursor happens to be, so `write`
29
+ * /`note`/`preview` append to a logical-line buffer that the main column
30
+ * renders as a tail view. The buffer holds UNWRAPPED lines and wraps at paint
31
+ * time, so a resize rewraps history instead of leaving it ragged.
32
+ * 2. **Paints are coalesced.** A repaint is the whole screen, so doing one per
33
+ * streamed delta would be by far the most expensive thing in the loop.
34
+ * Paints are marked dirty and flushed at most every {@link PAINT_INTERVAL_MS},
35
+ * which keeps streaming smooth without a redraw per token.
36
+ */
37
+ /** Coalescing window for repaints (~30fps). */
38
+ export const PAINT_INTERVAL_MS = 33;
39
+ /**
40
+ * How often a selected view that reports itself {@link ViewSource.live} gets
41
+ * repainted with no other event to prompt it (P7 track 5).
42
+ *
43
+ * 2fps, not 30: this exists so a background job's log tail advances while the
44
+ * foreground sits idle at the prompt, and a log line arriving half a second
45
+ * late is imperceptible where a full-screen redraw thirty times a second for
46
+ * the duration of a long job is not free. The pulse only runs while such a view
47
+ * is the one on screen.
48
+ */
49
+ export const VIEW_PULSE_MS = 500;
50
+ /** Logical lines of scrollback kept in memory. Older lines roll off. */
51
+ export const SCROLLBACK_LINES = 1_000;
52
+ /** Lines a Page Up/Down keeps in common across the jump, so context survives. */
53
+ export const SCROLL_PAGE_OVERLAP = 2;
54
+ export class TuiRenderer {
55
+ caps;
56
+ theme;
57
+ out;
58
+ frame;
59
+ /** Committed conversation content, UNWRAPPED — wrapped fresh at each paint. */
60
+ buffer = [];
61
+ /** The streamed line still being assembled (no newline seen yet). */
62
+ partial = "";
63
+ print;
64
+ highlighter;
65
+ wroteInSegment = false;
66
+ rawStatus = null;
67
+ phase = null;
68
+ progressState = null;
69
+ /** The executing plan (P3), drawn as a live checklist pinned under `main`. */
70
+ planSteps = null;
71
+ phaseStartedAt = 0;
72
+ toolStart = null;
73
+ displaced = null;
74
+ /** The input line the app owns; painted, never edited, here. */
75
+ inputLine = "";
76
+ /** Modal rows drawn over the body (P5 track 1); null → no overlay is up. */
77
+ overlay = null;
78
+ /** Panels the user has open. `main` is a column, not a panel, and always paints. */
79
+ open = new Set(CLOSABLE_PANELS);
80
+ /**
81
+ * Display lines the main column is scrolled back from the live tail (P7
82
+ * track 1). `0` is the home position and the default, and at `0` this
83
+ * renderer composes exactly what it composed before scrolling existed.
84
+ */
85
+ scrollOffset = 0;
86
+ /** Registered main-pane views (P7 track 2); conversation is not among them. */
87
+ views = [];
88
+ /** Which view owns the main column. Always a real id — see {@link setView}. */
89
+ selectedView = CONVERSATION_VIEW;
90
+ /** Whether the sidebar nav holds the keyboard rather than the input line. */
91
+ sidebarFocused = false;
92
+ /** Working-tree state for the git panel (P4 track 2); absent → panel unwired. */
93
+ git;
94
+ /** Context-budget reading for the context panel (P4 track 3); absent → unwired. */
95
+ context;
96
+ /**
97
+ * The configured model/tier as known at construction — the renderer is built
98
+ * before the session exists, so this covers the window before {@link attachModel}
99
+ * and every caller that never attaches one.
100
+ */
101
+ initialModel;
102
+ /** The session's live model choice (P6 track 1); absent → `initialModel` stands. */
103
+ model;
104
+ unsubscribeModel = null;
105
+ /** What the gateway last said served a request (P4 track 4). */
106
+ served;
107
+ /** Host tool versions for the tools panel (P4 track 5); absent → unwired. */
108
+ tools;
109
+ /** Rail panels the last paint had no vertical room for (honest reporting). */
110
+ railDropped = [];
111
+ clock;
112
+ unsubscribeFrame = null;
113
+ unsubscribeResize = null;
114
+ paintTimer = null;
115
+ /** The self-changing-view pulse (P7 track 5); null when nothing needs one. */
116
+ viewPulseTimer = null;
117
+ lastPaintAt = 0;
118
+ dirty = false;
119
+ closed = false;
120
+ /** Header context (model / cwd), supplied by the app at construction. */
121
+ headerRight = "";
122
+ /** Provider name for the composed header (P4 track 4). */
123
+ provider = "";
124
+ /**
125
+ * Saved sessions shown in the sidebar (P2), and which one is running.
126
+ *
127
+ * Populated only by {@link setSessions}, never at construction. The running
128
+ * session has to be listed, and its meta line does not exist until the
129
+ * session log is open — which happens after the renderer is built. A
130
+ * constructor argument could therefore only ever carry the list from BEFORE
131
+ * this run, i.e. a sidebar missing the very session the user is in.
132
+ */
133
+ sessions = [];
134
+ activeSessionId;
135
+ constructor(caps, out, opts = {}) {
136
+ this.caps = caps;
137
+ this.out = out;
138
+ this.theme = resolveTheme(caps);
139
+ this.highlighter = createStreamHighlighter(this.theme);
140
+ this.print = this.newPrinter();
141
+ this.headerRight = opts.headerRight ?? "";
142
+ this.git = opts.git;
143
+ this.provider = opts.provider ?? "";
144
+ this.initialModel = opts.model;
145
+ this.tools = opts.tools;
146
+ this.buffer = mainWelcome(this.theme, "/help for commands · /exit to quit");
147
+ this.frame = createFrame((text) => this.out.write(text), caps);
148
+ // The single shared clock (U.10): an inert clock under reduced motion, so
149
+ // the spinner is drawn once statically and no timer is ever scheduled.
150
+ this.clock = createFrameClock(caps.spinner);
151
+ // Reflow on resize. The frame reflows its own rows, but the TUI must also
152
+ // RECOMPUTE geometry (column budget, body height, rewrapped history), so it
153
+ // repaints from the view model rather than relying on the frame's reflow.
154
+ this.unsubscribeResize = caps.onResize?.(() => this.paintNow()) ?? null;
155
+ }
156
+ // ── panels ────────────────────────────────────────────────────────────────
157
+ /** Panels currently open (a copy — the set is the renderer's own state). */
158
+ panels() {
159
+ return new Set(this.open);
160
+ }
161
+ /** Show or hide a closable panel. Returns whether it changed. */
162
+ setPanelOpen(id, open) {
163
+ const had = this.open.has(id);
164
+ if (had === open)
165
+ return false;
166
+ if (open)
167
+ this.open.add(id);
168
+ else
169
+ this.open.delete(id);
170
+ this.schedulePaint();
171
+ return true;
172
+ }
173
+ /** Columns open but not currently paintable at this WIDTH (honest reporting). */
174
+ droppedColumns() {
175
+ return droppedForWidth(budgetColumns(this.viewportWidth(), this.open), this.open);
176
+ }
177
+ /**
178
+ * Rail panels the current HEIGHT cannot show — the vertical counterpart to
179
+ * {@link droppedColumns}. Read from the last composed view model rather than
180
+ * recomputed, so it can never disagree with what was actually painted.
181
+ */
182
+ droppedRailPanels() {
183
+ // Compose against current geometry so a caller asking BEFORE the next paint
184
+ // (the `/open` acknowledgement does) gets the answer for the screen it is
185
+ // about to see, not the previous one.
186
+ this.viewModel(this.viewportWidth(), this.caps.height);
187
+ return this.railDropped;
188
+ }
189
+ /**
190
+ * Attach the context gauge (P4 track 3). Set after construction because the
191
+ * gauge reads the SESSION's history, and the session is built after the
192
+ * renderer — the renderer is one of its constructor arguments.
193
+ */
194
+ attachContext(gauge) {
195
+ this.context = gauge;
196
+ gauge.sample();
197
+ this.schedulePaint();
198
+ }
199
+ /**
200
+ * Adopt the session's live model choice (P6 track 1). Set after construction
201
+ * for the same reason the context gauge is: the choice belongs to the session,
202
+ * and the session is built with this renderer as one of its arguments.
203
+ *
204
+ * THE SUBSCRIPTION IS THE POINT, not the read. A change has to drop
205
+ * {@link served} as well, and that is not housekeeping — it is the invariant
206
+ * #184 was about. `served` is what the gateway said it ran LAST time; the
207
+ * moment the choice moves, that tier describes a request made under a setting
208
+ * that no longer applies. Left standing it would put `mira` in the panel and
209
+ * on the status line while the header, the panel's own configured value, and
210
+ * every subsequent request all say `kavi`. Clearing it makes the panel say
211
+ * "not resolved yet", which is the true statement: nothing has been served
212
+ * under the new choice, and the next turn will say what was.
213
+ */
214
+ attachModel(model) {
215
+ this.unsubscribeModel?.();
216
+ this.model = model;
217
+ this.unsubscribeModel = model.onChange(() => {
218
+ if (this.closed)
219
+ return;
220
+ this.served = undefined;
221
+ this.schedulePaint();
222
+ });
223
+ this.schedulePaint();
224
+ }
225
+ /** The configured model/tier, live when a session choice is attached. */
226
+ configuredModel() {
227
+ return this.model?.current() ?? this.initialModel;
228
+ }
229
+ /**
230
+ * Begin the tool probes, at the first paint that actually SHOWS the panel
231
+ * (P4 track 5) — never at startup, and never at all if the user keeps
232
+ * `tools` closed. `start` is memoized, so calling it on every frame spawns
233
+ * nothing after the first; the repaint callback is what lets each row appear
234
+ * as its own probe lands rather than all at once at the end.
235
+ */
236
+ startTools(tools) {
237
+ tools.start(() => {
238
+ if (!this.closed)
239
+ this.schedulePaint();
240
+ });
241
+ return tools;
242
+ }
243
+ // ── views (P7 track 2) ────────────────────────────────────────────────────
244
+ /**
245
+ * Register the main-pane views. Set after construction for the same reason
246
+ * the context gauge is: a view reads session-scoped state, and the session is
247
+ * built with this renderer as one of its arguments.
248
+ *
249
+ * Re-attaching replaces the set. If the selected view is not in the new set
250
+ * the selection falls back to the conversation rather than pointing at
251
+ * nothing — an unresolvable id would paint an empty main column with no way
252
+ * for the user to tell a missing view from a broken one.
253
+ */
254
+ attachViews(sources) {
255
+ if (this.closed)
256
+ return;
257
+ this.views = [...sources];
258
+ if (this.selectedView !== CONVERSATION_VIEW &&
259
+ !this.views.some((v) => v.id === this.selectedView)) {
260
+ this.selectedView = CONVERSATION_VIEW;
261
+ }
262
+ this.schedulePaint();
263
+ }
264
+ /** The registered views, conversation excluded (it is built in). */
265
+ viewSources() {
266
+ return this.views;
267
+ }
268
+ /** Which view owns the main column. */
269
+ view() {
270
+ return this.selectedView;
271
+ }
272
+ /**
273
+ * The tier the gateway last said actually served a request, or undefined
274
+ * before the first answer (P4 track 4).
275
+ *
276
+ * Published so the Overview view can report the same tier the model panel and
277
+ * the status line do. `SessionStatus.servedTier` has existed and been rendered
278
+ * since P6 track 4 with nothing ever supplying it — this renderer was the only
279
+ * object that knew the value, and it had no way to hand it over.
280
+ */
281
+ servedTier() {
282
+ return this.served?.tier;
283
+ }
284
+ /**
285
+ * Select a view. Returns false for an unknown id — the caller reports it,
286
+ * rather than this silently selecting something the user did not ask for.
287
+ *
288
+ * THE SCROLL POSITION RESETS. Offsets are display-line counts into whatever
289
+ * content is in the pane, and two views share no coordinate system: carrying
290
+ * one across would land at an arbitrary place in the other. One offset that
291
+ * always means "into what you are looking at" is simpler to reason about than
292
+ * per-view bookkeeping, and cheaper to be right about.
293
+ */
294
+ setView(id) {
295
+ if (this.closed)
296
+ return false;
297
+ if (id !== CONVERSATION_VIEW && !this.views.some((v) => v.id === id)) {
298
+ return false;
299
+ }
300
+ if (this.selectedView === id)
301
+ return true;
302
+ this.selectedView = id;
303
+ this.scrollOffset = 0;
304
+ this.schedulePaint();
305
+ return true;
306
+ }
307
+ /** Move `steps` around the view ring and select what lands. */
308
+ cycleView(steps) {
309
+ const next = cycleView(this.selectedView, this.views, steps);
310
+ this.setView(next);
311
+ return next;
312
+ }
313
+ /**
314
+ * Give the sidebar nav the keyboard, or take it back. Returns false when
315
+ * focus could not move, which is how the caller learns to say why.
316
+ *
317
+ * Refused while the sidebar is not on screen — closed by the user, or dropped
318
+ * by the width budget. Focus you cannot see is the worst kind: the arrow keys
319
+ * would quietly change meaning with nothing on screen accounting for it.
320
+ */
321
+ focusSidebar(focused) {
322
+ if (this.closed)
323
+ return false;
324
+ if (focused && !this.sidebarVisible())
325
+ return false;
326
+ if (this.sidebarFocused === focused)
327
+ return false;
328
+ this.sidebarFocused = focused;
329
+ this.schedulePaint();
330
+ return true;
331
+ }
332
+ /** Whether the sidebar nav currently holds the keyboard. */
333
+ sidebarHasFocus() {
334
+ return this.sidebarFocused;
335
+ }
336
+ /** Open AND wide enough to paint — the same test `/open` reports against. */
337
+ sidebarVisible() {
338
+ if (!this.open.has("sidebar"))
339
+ return false;
340
+ return budgetColumns(this.viewportWidth(), this.open).sidebar > 0;
341
+ }
342
+ // ── scrollback (P7 track 1) ───────────────────────────────────────────────
343
+ /**
344
+ * Scroll the main column by `delta` display lines — positive is BACK, into
345
+ * history; negative is forward, toward the live tail. Returns whether the
346
+ * position actually moved, so a caller can leave a key unhandled (and let
347
+ * something else claim it) rather than swallowing a no-op.
348
+ *
349
+ * Only the lower bound is applied here. The upper one needs the wrapped line
350
+ * count at the current width, which is composed per paint — so `viewModel`
351
+ * clamps and writes the real value back. Pressing Page Up at the top of a
352
+ * short buffer therefore reports movement once and then stops, instead of
353
+ * silently banking offset that Page Down would have to unwind.
354
+ */
355
+ scrollBy(delta) {
356
+ if (this.closed)
357
+ return false;
358
+ const next = Math.max(0, this.scrollOffset + delta);
359
+ if (next === this.scrollOffset)
360
+ return false;
361
+ this.scrollOffset = next;
362
+ this.schedulePaint();
363
+ return true;
364
+ }
365
+ /**
366
+ * Scroll by one screenful, keeping {@link SCROLL_PAGE_OVERLAP} lines of
367
+ * context across the jump. A page that turns cleanly loses the sentence
368
+ * straddling the boundary, and in a conversation that is usually the one
369
+ * being read.
370
+ */
371
+ scrollPage(direction) {
372
+ const page = Math.max(1, bodyRows(this.caps.height) - SCROLL_PAGE_OVERLAP);
373
+ return this.scrollBy(direction * page);
374
+ }
375
+ /** Return to the live tail. Returns false when already there. */
376
+ scrollToLive() {
377
+ if (this.closed || this.scrollOffset === 0)
378
+ return false;
379
+ this.scrollOffset = 0;
380
+ this.schedulePaint();
381
+ return true;
382
+ }
383
+ /** Whether the main column is showing history rather than the live tail. */
384
+ isScrolledBack() {
385
+ return this.scrollOffset > 0;
386
+ }
387
+ // ── app-owned surfaces ────────────────────────────────────────────────────
388
+ /**
389
+ * Replace the sidebar's session list (P2). Called once the session log is
390
+ * open, so the running session is itself listed; a live refresh later (P3)
391
+ * is the same call.
392
+ */
393
+ setSessions(sessions, activeSessionId) {
394
+ if (this.closed)
395
+ return;
396
+ this.sessions = sessions;
397
+ this.activeSessionId = activeSessionId;
398
+ this.schedulePaint();
399
+ }
400
+ /** Set the input line's rendered text (prompt + buffer + caret). */
401
+ setInput(line) {
402
+ if (this.closed)
403
+ return;
404
+ this.inputLine = line;
405
+ this.schedulePaint();
406
+ }
407
+ // ── overlay (P5 track 1) ──────────────────────────────────────────────────
408
+ /**
409
+ * Paint modal rows inside the viewport, or clear them with `null`.
410
+ *
411
+ * This is the seam every keyed, framed interaction sits on — the approval
412
+ * prompt, the command palette, any future picker. It exists because the two
413
+ * alternatives are both broken: a component that opens its own
414
+ * `components/frame.ts` writes ANSI cursor control to stderr into rows THIS
415
+ * renderer owns and repaints over, and the pre-P5 answer to that — clearing
416
+ * the entire shell for the duration — meant the user answered a question with
417
+ * the conversation it was about wiped off the screen.
418
+ *
419
+ * Rows come out of the body (see `layout.ts`), never out of the column width
420
+ * budget, so the conversation stays visible behind the drawer and what
421
+ * `/open` reports as dropped-for-width does not change while a modal is up.
422
+ */
423
+ setOverlay(lines) {
424
+ if (this.closed)
425
+ return;
426
+ // Reference equality is not enough — callers repaint from a fresh array
427
+ // every keystroke — but an unchanged overlay must not schedule a paint, or
428
+ // a picker's no-op key (an arrow at the end of the list) costs a frame.
429
+ if (sameLines(this.overlay, lines))
430
+ return;
431
+ this.overlay = lines;
432
+ this.schedulePaint();
433
+ }
434
+ /**
435
+ * How many rows an overlay may use at the current height — published so a
436
+ * caller composes within the drawer rather than discovering `fitOverlay`'s
437
+ * safety net. Falls to 0 on a terminal with no room at all, which callers
438
+ * must read as "do not open a modal here".
439
+ */
440
+ overlayRows() {
441
+ return overlayRows(this.caps.height);
442
+ }
443
+ /**
444
+ * Usable width for an overlay row — the full viewport, since a drawer spans
445
+ * the grid rather than sitting in a column. The frame reserves its trailing
446
+ * column here exactly as it does for every other row.
447
+ */
448
+ overlayWidth() {
449
+ return this.viewportWidth();
450
+ }
451
+ /** Append an app-authored line to the conversation (help text, command replies). */
452
+ println(line = "") {
453
+ if (this.closed)
454
+ return;
455
+ this.pushLines([line]);
456
+ this.schedulePaint();
457
+ }
458
+ // ── StreamRenderer ────────────────────────────────────────────────────────
459
+ beginTurn() {
460
+ this.highlighter = createStreamHighlighter(this.theme);
461
+ this.print = this.newPrinter();
462
+ this.wroteInSegment = false;
463
+ }
464
+ write(delta) {
465
+ if (this.closed)
466
+ return;
467
+ this.print(delta);
468
+ }
469
+ endSegment() {
470
+ if (this.closed)
471
+ return;
472
+ this.commit(this.highlighter.flush());
473
+ if (this.wroteInSegment) {
474
+ this.flushPartial();
475
+ this.wroteInSegment = false;
476
+ this.schedulePaint();
477
+ }
478
+ }
479
+ note(text) {
480
+ if (this.closed)
481
+ return;
482
+ this.flushPartial();
483
+ this.pushLines([this.theme.muted(text)]);
484
+ this.schedulePaint();
485
+ }
486
+ preview(preview) {
487
+ if (this.closed)
488
+ return;
489
+ // Rendered at the MAIN column's width, not the terminal's — a preview that
490
+ // assumed full width would be re-wrapped into a ragged block by the column.
491
+ // The line cap is the column's HEIGHT for the same reason (P3): a block
492
+ // collapsing at a fixed 40 either overflows a short pane or hides rows a
493
+ // tall one could have shown. One screenful is the honest budget — the
494
+ // scrollback keeps what the collapse marker accounts for.
495
+ const block = renderActionPreview(preview, this.theme, this.mainWidth(), bodyRows(this.caps.height));
496
+ if (!block)
497
+ return;
498
+ this.flushPartial();
499
+ this.pushLines(block.split("\n"));
500
+ this.schedulePaint();
501
+ }
502
+ status(text) {
503
+ if (this.closed)
504
+ return;
505
+ this.rawStatus = text;
506
+ this.schedulePaint();
507
+ }
508
+ setPhase(phase) {
509
+ if (this.closed)
510
+ return;
511
+ if (phase?.kind === "awaiting-approval") {
512
+ if (this.phase?.kind !== "awaiting-approval") {
513
+ this.displaced = { phase: this.phase, startedAt: this.phaseStartedAt };
514
+ }
515
+ }
516
+ else {
517
+ this.displaced = null;
518
+ }
519
+ const before = phaseIdentity(this.phase);
520
+ this.phase = phase;
521
+ if (phaseIdentity(phase) !== before)
522
+ this.phaseStartedAt = Date.now();
523
+ this.syncClock();
524
+ this.schedulePaint();
525
+ }
526
+ progress(state) {
527
+ if (this.closed)
528
+ return;
529
+ this.progressState = state;
530
+ this.schedulePaint();
531
+ }
532
+ /**
533
+ * Set (or clear) the live plan checklist (P3). Unlike every other committed
534
+ * surface here this does NOT append to the scrollback buffer: the list is
535
+ * re-sent in full on every status change, so buffering it would stack one
536
+ * near-identical copy of the plan per step. It is live state, composed fresh
537
+ * at each paint by {@link viewModel} — which is also what makes a resize
538
+ * rewindow it for free.
539
+ */
540
+ setPlan(steps) {
541
+ if (this.closed)
542
+ return;
543
+ // Clearing commits ONE final copy to the scrollback. Without this the
544
+ // checklist would simply vanish when the run ends and the TUI would keep no
545
+ // record of it at all — the append-only renderers get theirs from having
546
+ // committed each transition as it happened, which is exactly what this
547
+ // renderer does not do.
548
+ if (steps === null) {
549
+ if (this.planSteps !== null && this.planSteps.length > 0) {
550
+ this.flushPartial();
551
+ this.pushLines(planChecklist(this.planSteps, this.theme));
552
+ }
553
+ this.planSteps = null;
554
+ this.schedulePaint();
555
+ return;
556
+ }
557
+ this.planSteps = steps.map((s) => ({ ...s }));
558
+ this.schedulePaint();
559
+ }
560
+ /**
561
+ * Committed to the scrollback at the MAIN column's width — a block sized for
562
+ * the whole terminal would be re-wrapped into a ragged one by the column,
563
+ * same reasoning as `preview`.
564
+ */
565
+ testResult(report) {
566
+ if (this.closed)
567
+ return;
568
+ this.flushPartial();
569
+ this.pushLines(testResultLines(report, this.theme, this.mainWidth()));
570
+ this.schedulePaint();
571
+ }
572
+ toolLifecycle(event) {
573
+ if (this.closed)
574
+ return;
575
+ if (event.event === "start") {
576
+ this.toolStart = { label: event.label, at: Date.now() };
577
+ this.setPhase({ kind: "calling-tool", label: event.label });
578
+ return;
579
+ }
580
+ const started = this.toolStart?.label === event.label ? this.toolStart : null;
581
+ this.toolStart = null;
582
+ if (this.phase?.kind === "calling-tool") {
583
+ this.phase = null;
584
+ this.syncClock();
585
+ }
586
+ // Measured start→end, never animated — honest under reduced motion too.
587
+ const elapsed = started === null ? 0 : Date.now() - started.at;
588
+ const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
589
+ const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
590
+ this.note(`${mark} ${event.label}${suffix}`);
591
+ // A finished tool may have written files. Invalidate on EVERY tool rather
592
+ // than guessing which ones mutate: the cache coalesces, so over-calling is
593
+ // free, while under-calling leaves the panel quietly wrong.
594
+ this.refreshGit();
595
+ // The history just grew by a tool_use/tool_result pair, so the context
596
+ // reading is stale. Cheap here; ruinous on the paint path — see the gauge.
597
+ this.context?.sample();
598
+ }
599
+ /**
600
+ * Adopt the tier the gateway says actually served the request (P4 track 4).
601
+ *
602
+ * Repainted rather than committed: this is standing state, not an event. It
603
+ * is re-sent on every request, so the panel and header always describe the
604
+ * MOST RECENT one — which is what matters when a budget downgrade changes the
605
+ * tier mid-session and the earlier value would otherwise stick.
606
+ */
607
+ servedRouting(routing) {
608
+ if (this.closed)
609
+ return;
610
+ const unchanged = this.served?.tier === routing.tier && this.served?.mode === routing.mode;
611
+ if (unchanged)
612
+ return;
613
+ this.served = routing;
614
+ this.schedulePaint();
615
+ }
616
+ promptResolved() {
617
+ if (this.closed)
618
+ return;
619
+ if (this.phase?.kind !== "awaiting-approval")
620
+ return;
621
+ const displaced = this.displaced;
622
+ this.displaced = null;
623
+ this.phase = displaced?.phase ?? null;
624
+ this.phaseStartedAt = displaced?.startedAt ?? Date.now();
625
+ this.syncClock();
626
+ this.schedulePaint();
627
+ }
628
+ endTurn() {
629
+ if (this.closed)
630
+ return;
631
+ this.commit(this.highlighter.flush());
632
+ this.flushPartial();
633
+ this.phase = null;
634
+ this.rawStatus = null;
635
+ this.displaced = null;
636
+ this.syncClock();
637
+ // Sampled BEFORE the paint, so the turn's final frame already shows the
638
+ // history it just finished adding to rather than the previous turn's.
639
+ this.context?.sample();
640
+ this.paintNow();
641
+ this.refreshGit();
642
+ this.refreshViews();
643
+ }
644
+ /**
645
+ * Re-probe the working tree, off the paint path, and repaint when it lands.
646
+ *
647
+ * Deliberately not awaited: a turn must never wait on `git status`, and a
648
+ * probe that fails or times out has to leave the UI exactly as it was. The
649
+ * cache coalesces concurrent calls, so the ten invalidations a file-writing
650
+ * turn produces cost one probe.
651
+ */
652
+ refreshGit() {
653
+ const git = this.git;
654
+ if (git === undefined || this.closed)
655
+ return;
656
+ git.invalidate();
657
+ void git.refresh().then(() => {
658
+ if (this.closed)
659
+ return;
660
+ this.schedulePaint();
661
+ });
662
+ }
663
+ /**
664
+ * Let each view re-probe what it reads (P7 track 3), on the same post-turn
665
+ * trigger and under the same rules as the rail's git cache: not awaited, and
666
+ * a failure leaves the pane as it was.
667
+ *
668
+ * EVERY view refreshes, not just the selected one. A probe exists so that
669
+ * SELECTING a view shows something true immediately; refreshing only the
670
+ * visible one would mean every switch lands on a stale pane and then flickers
671
+ * — which is the "checking…" state the tri-state was added to be honest
672
+ * about, shown at the one moment it is avoidable.
673
+ */
674
+ refreshViews() {
675
+ if (this.closed)
676
+ return;
677
+ for (const source of this.views) {
678
+ const refresh = source.refresh;
679
+ if (refresh === undefined)
680
+ continue;
681
+ void Promise.resolve(refresh.call(source))
682
+ .then(() => {
683
+ if (!this.closed)
684
+ this.schedulePaint();
685
+ })
686
+ // A view that cannot re-probe keeps its last value; it must never take
687
+ // the shell down with an unhandled rejection.
688
+ .catch(() => { });
689
+ }
690
+ }
691
+ close() {
692
+ if (this.closed)
693
+ return;
694
+ this.closed = true;
695
+ this.stopFrames();
696
+ if (this.paintTimer !== null) {
697
+ clearTimeout(this.paintTimer);
698
+ this.paintTimer = null;
699
+ }
700
+ if (this.viewPulseTimer !== null) {
701
+ clearTimeout(this.viewPulseTimer);
702
+ this.viewPulseTimer = null;
703
+ }
704
+ this.unsubscribeResize?.();
705
+ this.unsubscribeResize = null;
706
+ this.unsubscribeModel?.();
707
+ this.unsubscribeModel = null;
708
+ // Erase the whole shell: after the TUI exits the terminal holds zero
709
+ // leftover bytes from it, exactly like a resolved component frame.
710
+ this.frame.clear();
711
+ }
712
+ // ── internals ─────────────────────────────────────────────────────────────
713
+ newPrinter() {
714
+ return createStreamPrinter((text) => {
715
+ this.commit(this.highlighter.push(text));
716
+ });
717
+ }
718
+ /** Fold streamed text into the buffer, splitting on newlines. */
719
+ commit(text) {
720
+ if (text === "")
721
+ return;
722
+ this.rawStatus = null;
723
+ this.wroteInSegment = true;
724
+ const parts = (this.partial + text).split("\n");
725
+ this.partial = parts.pop() ?? "";
726
+ if (parts.length > 0)
727
+ this.pushLines(parts);
728
+ this.schedulePaint();
729
+ }
730
+ /** Commit the in-flight partial line, if any. */
731
+ flushPartial() {
732
+ if (this.partial === "")
733
+ return;
734
+ this.pushLines([this.partial]);
735
+ this.partial = "";
736
+ }
737
+ pushLines(lines) {
738
+ // Hold the scrolled view still (P7 track 1). The offset is measured from
739
+ // the END of the document, so appending would otherwise slide the content
740
+ // up under the reader by exactly the number of lines that arrived — the
741
+ // failure this compensation exists to prevent, and the reason scrollback is
742
+ // worth having DURING a stream rather than only after one.
743
+ //
744
+ // Counted in DISPLAY lines, at the width the window is composed at, because
745
+ // that is the unit the offset is in. Skipped entirely at the live tail,
746
+ // where following the newest output is the whole point.
747
+ // Only while the conversation is the thing being scrolled. A view's offset
748
+ // indexes ITS lines, so nudging it because the conversation grew behind the
749
+ // pane would scroll a document that did not change.
750
+ if (this.scrollOffset > 0 && this.selectedView === CONVERSATION_VIEW) {
751
+ const cols = this.mainWidth();
752
+ let added = 0;
753
+ for (const line of lines) {
754
+ added += line === "" ? 1 : reflow(line, cols).length;
755
+ }
756
+ this.scrollOffset += added;
757
+ }
758
+ this.buffer.push(...lines);
759
+ if (this.buffer.length > SCROLLBACK_LINES) {
760
+ this.buffer = this.buffer.slice(this.buffer.length - SCROLLBACK_LINES);
761
+ }
762
+ }
763
+ /** Inner width the frame accepts without truncating (it reserves one column). */
764
+ viewportWidth() {
765
+ return Math.max(1, this.caps.width - 1);
766
+ }
767
+ mainWidth() {
768
+ return budgetColumns(this.viewportWidth(), this.open).main;
769
+ }
770
+ /**
771
+ * Subscribe to the frame clock only while something is actually animating —
772
+ * a live phase. Idle (or reduced motion) leaves no timer running at all.
773
+ */
774
+ syncClock() {
775
+ const wants = this.clock.enabled &&
776
+ this.phase !== null &&
777
+ this.phase.kind !== "awaiting-approval";
778
+ if (wants && this.unsubscribeFrame === null) {
779
+ this.unsubscribeFrame = this.clock.subscribe(() => this.paintNow());
780
+ }
781
+ else if (!wants) {
782
+ this.stopFrames();
783
+ }
784
+ }
785
+ stopFrames() {
786
+ if (this.unsubscribeFrame !== null) {
787
+ this.unsubscribeFrame();
788
+ this.unsubscribeFrame = null;
789
+ }
790
+ }
791
+ /**
792
+ * Mark the screen dirty and paint on the next coalescing tick. Streaming a
793
+ * turn can call this hundreds of times a second; at most one paint per
794
+ * {@link PAINT_INTERVAL_MS} results.
795
+ */
796
+ schedulePaint() {
797
+ if (this.closed)
798
+ return;
799
+ this.dirty = true;
800
+ if (this.paintTimer !== null)
801
+ return;
802
+ const since = Date.now() - this.lastPaintAt;
803
+ if (since >= PAINT_INTERVAL_MS) {
804
+ this.paintNow();
805
+ return;
806
+ }
807
+ this.paintTimer = setTimeout(() => {
808
+ this.paintTimer = null;
809
+ if (this.dirty)
810
+ this.paintNow();
811
+ }, PAINT_INTERVAL_MS - since);
812
+ // Never hold the process open for a pending repaint.
813
+ this.paintTimer.unref?.();
814
+ }
815
+ /** Compose and paint immediately. */
816
+ paintNow() {
817
+ if (this.closed)
818
+ return;
819
+ this.dirty = false;
820
+ this.lastPaintAt = Date.now();
821
+ // An approval prompt used to yield the ENTIRE shell here, the way
822
+ // `TtyRenderer` hides its one live line. That was never what this renderer
823
+ // wanted — it was the only defence available while the prompt wrote to
824
+ // stderr, whose bytes would land inside the frame's rows and be erased by
825
+ // the next repaint. The cost was that you approved a change with the
826
+ // conversation it was about wiped off the screen.
827
+ //
828
+ // P5 track 2 moves the prompt onto the overlay seam, so it is composed INTO
829
+ // this paint rather than written around it, and the shell keeps painting
830
+ // underneath. `awaiting-approval` still displaces the phase (see
831
+ // `setPhase`/`promptResolved`) and still stops the clock — a spinner has
832
+ // nothing to say while the turn is blocked on a human — but it no longer
833
+ // blanks anything.
834
+ const width = this.viewportWidth();
835
+ const height = this.caps.height;
836
+ this.frame.render(composeScreen(this.viewModel(width, height), width, height, this.open, this.theme));
837
+ this.armViewPulse();
838
+ }
839
+ /**
840
+ * Keep repainting a selected view that says its content is moving on its own
841
+ * (P7 track 5) — a background job appending to its log while the foreground
842
+ * waits at the prompt.
843
+ *
844
+ * Armed from `paintNow` rather than from an event, because the condition it
845
+ * watches has no event: the pulse paints, that paint re-arms, and the chain
846
+ * ends by itself the moment the view stops being selected or stops being live.
847
+ * Nothing else in this class needs to know the pulse exists.
848
+ */
849
+ armViewPulse() {
850
+ if (this.viewPulseTimer !== null || this.closed)
851
+ return;
852
+ const active = this.views.find((v) => v.id === this.selectedView);
853
+ // `live` is optional and false by default: every view whose content only
854
+ // moves when a turn, a key or a `refresh` moved it already repaints.
855
+ if (active?.live?.() !== true)
856
+ return;
857
+ this.viewPulseTimer = setTimeout(() => {
858
+ this.viewPulseTimer = null;
859
+ if (!this.closed)
860
+ this.schedulePaint();
861
+ }, VIEW_PULSE_MS);
862
+ // Never hold the process open for a pulse — the same rule as `paintTimer`.
863
+ // A pending repaint must not be why `cruxy` fails to exit.
864
+ this.viewPulseTimer.unref?.();
865
+ }
866
+ /** The live-state line, composed by the shared U.4 mapping. */
867
+ /**
868
+ * True when the model panel is actually on screen — open AND its column
869
+ * survived the width budget. `droppedForWidth` is the same check `/open` uses
870
+ * to tell a user their panel will not fit, so "visible" means one thing in
871
+ * this file.
872
+ */
873
+ modelPanelVisible(width) {
874
+ if (this.configuredModel() === undefined || !this.open.has("model")) {
875
+ return false;
876
+ }
877
+ return !droppedForWidth(budgetColumns(width, this.open), this.open).includes("rail");
878
+ }
879
+ /**
880
+ * The phase as the STATUS LINE should say it — which is not always the phase
881
+ * the loop published.
882
+ *
883
+ * Since P4 track 4 there are two places a tier can appear at once, and they
884
+ * are sourced differently. `phase.tier` is `routed?.tier`: what this run's
885
+ * client-side router (C.30) ASKED for, fixed before `provider.stream` opens.
886
+ * The model panel shows what the gateway said actually SERVED the request.
887
+ * Those disagree exactly when it matters — `auto` resolving to a real tier,
888
+ * or a budget downgrade to one nobody selected — so a single screen could
889
+ * assert `kavi` on the status line and `mira` in the rail, with nothing to
890
+ * say which was true.
891
+ *
892
+ * Two tiers contradicting each other in one viewport is worse than either
893
+ * being absent, so:
894
+ *
895
+ * - MODEL PANEL VISIBLE → the status line drops the tier entirely. The panel
896
+ * is the better surface for it: it is persistent, it has room to say HOW
897
+ * the tier was chosen, and it is never truncated away. One surface owns
898
+ * the claim.
899
+ * - MODEL PANEL NOT VISIBLE (closed, or its column dropped for width) → the
900
+ * status line keeps the tier, because otherwise nothing would show it —
901
+ * but it prefers the SERVED tier over the asked-for one, so what it shows
902
+ * is the same fact the panel would have shown.
903
+ *
904
+ * This only removes text from the line, so `fitStatusLine`'s degradation
905
+ * order (phase > progress prefix > elapsed) is untouched: the tier was baked
906
+ * into the phase text, i.e. into the element that survives longest, which is
907
+ * the last place a redundant or contradictory value belongs.
908
+ */
909
+ statusPhase(width) {
910
+ const phase = this.phase;
911
+ if (phase === null || phase.kind !== "thinking")
912
+ return phase;
913
+ if (this.modelPanelVisible(width)) {
914
+ // Rebuilt rather than destructured, so the fields kept are stated rather
915
+ // than implied: only the tier goes, the token counts stay.
916
+ return phase.tokens === undefined
917
+ ? { kind: "thinking" }
918
+ : { kind: "thinking", tokens: phase.tokens };
919
+ }
920
+ const tier = this.served?.tier ?? phase.tier;
921
+ return tier === undefined ? phase : { ...phase, tier };
922
+ }
923
+ statusLine(width) {
924
+ if (this.rawStatus !== null)
925
+ return this.rawStatus;
926
+ if (this.phase === null && this.progressState === null)
927
+ return "";
928
+ const elapsed = this.phase !== null && this.caps.spinner
929
+ ? Date.now() - this.phaseStartedAt
930
+ : undefined;
931
+ const glyph = spinnerGlyph(this.clock.frame, this.theme.glyph, this.caps.spinner && this.phase !== null);
932
+ const room = Math.max(1, width - 2);
933
+ const line = fitStatusLine(this.progressState, this.statusPhase(width), elapsed, this.theme.glyph, room);
934
+ return line === "" ? "" : `${glyph} ${line}`;
935
+ }
936
+ viewModel(width, height) {
937
+ // An overlay borrows rows from the columns (P5 track 1). Resolved BEFORE
938
+ // anything is composed, because every block below is fitted to the row
939
+ // budget — the rail especially, which `stackPanels` packs to an exact
940
+ // height and which `fitBlock`'s tail rule would otherwise decapitate.
941
+ const drawer = fitOverlay(this.overlay ?? [], overlayRows(height));
942
+ const rows = Math.max(1, bodyRows(height) - drawer.length);
943
+ const columns = budgetColumns(width, this.open);
944
+ const mainCols = columns.main;
945
+ // Focus cannot outlive the thing holding it (P7 track 2). `/close sidebar`
946
+ // and a narrowing terminal both take the nav off screen, and either would
947
+ // otherwise leave the arrow keys silently rebound to a column that is not
948
+ // there. Dropped here rather than in the two call sites because this is
949
+ // where "on screen" is actually decided.
950
+ if (this.sidebarFocused &&
951
+ (!this.open.has("sidebar") || columns.sidebar === 0)) {
952
+ this.sidebarFocused = false;
953
+ }
954
+ const scrolled = this.scrollOffset > 0;
955
+ // A registered view owns the main column outright (P7 track 2). Its lines
956
+ // are recomposed every paint from its own state — this class never learns
957
+ // what they mean — and none of the conversation machinery below applies:
958
+ // there is no partial line to hold back, no plan to pin, and no append to
959
+ // compensate for, because a view does not accumulate.
960
+ const active = this.selectedView === CONVERSATION_VIEW
961
+ ? undefined
962
+ : this.views.find((v) => v.id === this.selectedView);
963
+ // AT THE LIVE TAIL, wrap only the tail we could possibly show. Rewrapping
964
+ // 1,000 lines on every frame would be the one genuinely hot cost in this
965
+ // path, and this is the path every streaming frame takes.
966
+ //
967
+ // SCROLLED BACK, wrap the whole retained buffer instead. The offset is in
968
+ // display lines and it has to be clamped against the real total, which a
969
+ // slice cannot supply — and a conservative bound would stop short of the
970
+ // oldest line, making content unreachable rather than merely slow. The cost
971
+ // is bounded by SCROLLBACK_LINES and only paid while a human is reading
972
+ // history, which is not a hot path.
973
+ //
974
+ // Skipped entirely while a view owns the pane: the conversation keeps
975
+ // accumulating behind it, but composing what nobody is looking at would
976
+ // make every other view pay the conversation's cost.
977
+ const source = active !== undefined
978
+ ? []
979
+ : scrolled
980
+ ? this.buffer
981
+ : this.buffer.slice(Math.max(0, this.buffer.length - rows * 4));
982
+ // The streaming partial line belongs to the LIVE view only. While scrolled
983
+ // it is excluded, which is what makes the offset arithmetic exact: the
984
+ // document then changes only through `pushLines`, which compensates. It is
985
+ // also the honest reading — scrollback is committed output, and a line still
986
+ // being written is not committed.
987
+ const live = active !== undefined || scrolled || this.partial === ""
988
+ ? []
989
+ : [this.partial];
990
+ const wrapped = [];
991
+ for (const line of [...source, ...live]) {
992
+ if (line === "") {
993
+ wrapped.push("");
994
+ continue;
995
+ }
996
+ wrapped.push(...reflow(line, mainCols));
997
+ }
998
+ // The plan checklist is PINNED under the conversation (P3). `main` is a
999
+ // tail view — `fitBlock` keeps its last `rows` lines — so appending is what
1000
+ // makes the checklist survive a long conversation while the oldest
1001
+ // scrollback rolls off the top. No reservation arithmetic is needed; the
1002
+ // cap below is what stops a 40-step plan from evicting the conversation
1003
+ // entirely, and `planChecklist` windows around the running step and says
1004
+ // how many it hid.
1005
+ //
1006
+ // Pinned to the LIVE tail, not to the column: while scrolled back it is
1007
+ // absent, for the same reason the partial line is. It is re-rendered from
1008
+ // mutable state on every paint rather than committed to the buffer, so
1009
+ // leaving it in a scrolled view would let it change length underneath a
1010
+ // reader and shift the history they are holding still.
1011
+ const plan = active !== undefined ||
1012
+ scrolled ||
1013
+ this.planSteps === null ||
1014
+ this.planSteps.length === 0
1015
+ ? []
1016
+ : planChecklist(this.planSteps, this.theme, Math.max(2, Math.floor(rows / 2)), mainCols);
1017
+ // The rail is a STACK of fixed panels, not a feed: it is composed to the
1018
+ // exact row budget here (dropping whole panels and counting them) rather
1019
+ // than handed to `fitBlock`, whose tail rule would silently eat the top
1020
+ // panel. `composeScreen` still pads it, which is now a no-op on height.
1021
+ // A pure field read of the cache — never a probe. See `git-status.ts`.
1022
+ // What the model panel and the header both describe. The configured value
1023
+ // is known from the start, so neither is ever blank before the first turn.
1024
+ const configured = this.configuredModel();
1025
+ const model = configured === undefined
1026
+ ? undefined
1027
+ : {
1028
+ configured,
1029
+ ...(this.served === undefined ? {} : { served: this.served }),
1030
+ };
1031
+ // Probing starts at the first paint that actually SHOWS the panel — never
1032
+ // at startup, and never at all while the user keeps `tools` closed.
1033
+ const toolRows = this.tools === undefined || !this.open.has("tools")
1034
+ ? undefined
1035
+ : this.startTools(this.tools).current();
1036
+ // Composed fresh each paint from the panels' own typed state. There is no
1037
+ // stored blob to merge over: every panel owns its source, so a key can only
1038
+ // appear here by being derived below.
1039
+ const railState = {
1040
+ ...(model === undefined
1041
+ ? {}
1042
+ : { model: modelPanelLines(this.theme, model) }),
1043
+ ...(this.context === undefined
1044
+ ? {}
1045
+ : { context: contextPanelLines(this.theme, this.context.current()) }),
1046
+ ...(this.git === undefined
1047
+ ? {}
1048
+ : { git: gitPanelLines(this.theme, this.git.current()) }),
1049
+ ...(toolRows === undefined
1050
+ ? {}
1051
+ : { tools: toolsPanelLines(this.theme, toolRows) }),
1052
+ };
1053
+ const stacked = stackPanels(railBlocks(this.theme, railState, this.open), rows, this.theme);
1054
+ this.railDropped = stacked.dropped;
1055
+ // Window the conversation (P7 track 1). At the live tail this is the tail
1056
+ // view `fitBlock` already produced — `scrollWindow` agrees with it
1057
+ // line-for-line at offset 0 — so the default path is unchanged and the
1058
+ // notice costs nothing. Scrolled back, one row goes to the notice, which is
1059
+ // why the window is asked for `rows - 1`.
1060
+ //
1061
+ // The clamp is written back because only this pass knows the wrapped total;
1062
+ // `scrollBy` bounds the keypress from below and leaves the ceiling here.
1063
+ // The one place the two content sources meet. Everything downstream —
1064
+ // windowing, the notice, `fitBlock` — treats them identically, which is
1065
+ // what "a view is just lines" has to mean to be worth anything.
1066
+ //
1067
+ // A view's lines are reflowed here rather than trusted at `mainCols`: the
1068
+ // contract asks a view to lay out to the width it is given, and reflow makes
1069
+ // that a courtesy rather than a rule it can break the grid by ignoring.
1070
+ const body = active !== undefined
1071
+ ? active
1072
+ .lines(this.theme, mainCols)
1073
+ .flatMap((line) => (line === "" ? [""] : reflow(line, mainCols)))
1074
+ : plan.length === 0
1075
+ ? wrapped
1076
+ : [...wrapped, "", ...plan];
1077
+ let main = body;
1078
+ if (scrolled) {
1079
+ const win = scrollWindow(body, Math.max(1, rows - 1), this.scrollOffset);
1080
+ this.scrollOffset = win.offset;
1081
+ // The clamp can land on 0 — a resize that grew the pane past the content,
1082
+ // or scrollback that rolled off underneath the offset. That is the live
1083
+ // view again, and it must not keep a notice claiming lines below it.
1084
+ main =
1085
+ win.offset === 0
1086
+ ? body
1087
+ : [...win.lines, scrollNotice(win.hiddenBelow, this.theme)];
1088
+ }
1089
+ return {
1090
+ headerLeft: "cruxy",
1091
+ // Recomposed each paint, so a served tier that arrives (or changes under
1092
+ // a budget downgrade) reaches the header. P1 set this once at
1093
+ // construction, which is why a run routed to `kavi` read `auto` forever.
1094
+ headerRight: model === undefined
1095
+ ? this.headerRight
1096
+ : headerModel(this.theme, this.provider, model),
1097
+ // Nav first, then the session list P2 put here. Two blocks rather than
1098
+ // one because they answer different questions — "where am I" and "what
1099
+ // else have I run" — and because the sessions list is destined to become
1100
+ // a view of its own, at which point this reduces to the nav.
1101
+ sidebar: [
1102
+ ...navLines(this.theme, this.views, this.selectedView, this.sidebarFocused),
1103
+ "",
1104
+ ...sidebarLines(this.theme, this.sessions, this.activeSessionId),
1105
+ ],
1106
+ main,
1107
+ rail: stacked.lines,
1108
+ status: this.statusLine(width),
1109
+ input: this.inputLine,
1110
+ overlay: drawer,
1111
+ };
1112
+ }
1113
+ }
1114
+ /** Row-wise equality for overlay content — `null` and `[]` both mean "no drawer". */
1115
+ function sameLines(a, b) {
1116
+ if (a === b)
1117
+ return true;
1118
+ const left = a ?? [];
1119
+ const right = b ?? [];
1120
+ return (left.length === right.length && left.every((line, i) => line === right[i]));
1121
+ }