@cruxy/cli 1.2.1 → 1.3.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 (76) 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/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +6 -6
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/summary.js +106 -17
  72. package/dist/usage/types.js +5 -2
  73. package/dist/usage/weighted.js +77 -0
  74. package/dist/utils/git.js +50 -4
  75. package/package.json +1 -1
  76. package/dist/usage/cost.js +0 -29
@@ -0,0 +1,776 @@
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, stackPanels, CLOSABLE_PANELS, } from "./layout.js";
12
+ import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
13
+ /**
14
+ * The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
15
+ * only one that owns the whole screen rather than a single managed line.
16
+ *
17
+ * It is built from the pieces that already existed, not alongside them:
18
+ * `components/frame.ts` paints and erases the transient region (and reflows it
19
+ * on resize), `render/layout.ts` does every ANSI-aware width measurement,
20
+ * `render/state.ts` composes the live-state line, `render/highlight.ts` styles
21
+ * fenced code, `render/motion.ts` supplies the one frame clock. This class adds
22
+ * geometry (via `tui/layout.ts`) and a scrollback buffer — nothing else.
23
+ *
24
+ * Two deliberate differences from `TtyRenderer`:
25
+ *
26
+ * 1. **Committed content is buffered, not written through.** A full-screen
27
+ * layout cannot let text land wherever the cursor happens to be, so `write`
28
+ * /`note`/`preview` append to a logical-line buffer that the main column
29
+ * renders as a tail view. The buffer holds UNWRAPPED lines and wraps at paint
30
+ * time, so a resize rewraps history instead of leaving it ragged.
31
+ * 2. **Paints are coalesced.** A repaint is the whole screen, so doing one per
32
+ * streamed delta would be by far the most expensive thing in the loop.
33
+ * Paints are marked dirty and flushed at most every {@link PAINT_INTERVAL_MS},
34
+ * which keeps streaming smooth without a redraw per token.
35
+ */
36
+ /** Coalescing window for repaints (~30fps). */
37
+ export const PAINT_INTERVAL_MS = 33;
38
+ /** Logical lines of scrollback kept in memory. Older lines roll off. */
39
+ export const SCROLLBACK_LINES = 1_000;
40
+ export class TuiRenderer {
41
+ caps;
42
+ theme;
43
+ out;
44
+ frame;
45
+ /** Committed conversation content, UNWRAPPED — wrapped fresh at each paint. */
46
+ buffer = [];
47
+ /** The streamed line still being assembled (no newline seen yet). */
48
+ partial = "";
49
+ print;
50
+ highlighter;
51
+ wroteInSegment = false;
52
+ rawStatus = null;
53
+ phase = null;
54
+ progressState = null;
55
+ /** The executing plan (P3), drawn as a live checklist pinned under `main`. */
56
+ planSteps = null;
57
+ phaseStartedAt = 0;
58
+ toolStart = null;
59
+ displaced = null;
60
+ /** The input line the app owns; painted, never edited, here. */
61
+ inputLine = "";
62
+ /** Modal rows drawn over the body (P5 track 1); null → no overlay is up. */
63
+ overlay = null;
64
+ /** Panels the user has open. `main` is a column, not a panel, and always paints. */
65
+ open = new Set(CLOSABLE_PANELS);
66
+ /** Per-panel rail content, filled in by the P4 tracks as each lands. */
67
+ railState = {};
68
+ /** Working-tree state for the git panel (P4 track 2); absent → panel unwired. */
69
+ git;
70
+ /** Context-budget reading for the context panel (P4 track 3); absent → unwired. */
71
+ context;
72
+ /**
73
+ * The configured model/tier as known at construction — the renderer is built
74
+ * before the session exists, so this covers the window before {@link attachModel}
75
+ * and every caller that never attaches one.
76
+ */
77
+ initialModel;
78
+ /** The session's live model choice (P6 track 1); absent → `initialModel` stands. */
79
+ model;
80
+ unsubscribeModel = null;
81
+ /** What the gateway last said served a request (P4 track 4). */
82
+ served;
83
+ /** Host tool versions for the tools panel (P4 track 5); absent → unwired. */
84
+ tools;
85
+ /** Rail panels the last paint had no vertical room for (honest reporting). */
86
+ railDropped = [];
87
+ clock;
88
+ unsubscribeFrame = null;
89
+ unsubscribeResize = null;
90
+ paintTimer = null;
91
+ lastPaintAt = 0;
92
+ dirty = false;
93
+ closed = false;
94
+ /** Header context (model / cwd), supplied by the app at construction. */
95
+ headerRight = "";
96
+ /** Provider name for the composed header (P4 track 4). */
97
+ provider = "";
98
+ /** Saved sessions shown in the sidebar (P2), and which one is running. */
99
+ sessions = [];
100
+ activeSessionId;
101
+ constructor(caps, out, opts = {}) {
102
+ this.caps = caps;
103
+ this.out = out;
104
+ this.theme = resolveTheme(caps);
105
+ this.highlighter = createStreamHighlighter(this.theme);
106
+ this.print = this.newPrinter();
107
+ this.headerRight = opts.headerRight ?? "";
108
+ this.sessions = opts.sessions ?? [];
109
+ this.activeSessionId = opts.activeSessionId;
110
+ this.git = opts.git;
111
+ this.provider = opts.provider ?? "";
112
+ this.initialModel = opts.model;
113
+ this.tools = opts.tools;
114
+ this.buffer = mainWelcome(this.theme, opts.hint ?? "/help for commands · /exit to quit");
115
+ this.frame = createFrame((text) => this.out.write(text), caps);
116
+ // The single shared clock (U.10): an inert clock under reduced motion, so
117
+ // the spinner is drawn once statically and no timer is ever scheduled.
118
+ this.clock = createFrameClock(caps.spinner);
119
+ // Reflow on resize. The frame reflows its own rows, but the TUI must also
120
+ // RECOMPUTE geometry (column budget, body height, rewrapped history), so it
121
+ // repaints from the view model rather than relying on the frame's reflow.
122
+ this.unsubscribeResize = caps.onResize?.(() => this.paintNow()) ?? null;
123
+ }
124
+ // ── panels ────────────────────────────────────────────────────────────────
125
+ /** Panels currently open (a copy — the set is the renderer's own state). */
126
+ panels() {
127
+ return new Set(this.open);
128
+ }
129
+ /** Show or hide a closable panel. Returns whether it changed. */
130
+ setPanelOpen(id, open) {
131
+ const had = this.open.has(id);
132
+ if (had === open)
133
+ return false;
134
+ if (open)
135
+ this.open.add(id);
136
+ else
137
+ this.open.delete(id);
138
+ this.schedulePaint();
139
+ return true;
140
+ }
141
+ /** Columns open but not currently paintable at this WIDTH (honest reporting). */
142
+ droppedColumns() {
143
+ return droppedForWidth(budgetColumns(this.viewportWidth(), this.open), this.open);
144
+ }
145
+ /**
146
+ * Rail panels the current HEIGHT cannot show — the vertical counterpart to
147
+ * {@link droppedColumns}. Read from the last composed view model rather than
148
+ * recomputed, so it can never disagree with what was actually painted.
149
+ */
150
+ droppedRailPanels() {
151
+ // Compose against current geometry so a caller asking BEFORE the next paint
152
+ // (the `/open` acknowledgement does) gets the answer for the screen it is
153
+ // about to see, not the previous one.
154
+ this.viewModel(this.viewportWidth(), this.caps.height);
155
+ return this.railDropped;
156
+ }
157
+ /**
158
+ * Attach the context gauge (P4 track 3). Set after construction because the
159
+ * gauge reads the SESSION's history, and the session is built after the
160
+ * renderer — the renderer is one of its constructor arguments.
161
+ */
162
+ attachContext(gauge) {
163
+ this.context = gauge;
164
+ gauge.sample();
165
+ this.schedulePaint();
166
+ }
167
+ /**
168
+ * Adopt the session's live model choice (P6 track 1). Set after construction
169
+ * for the same reason the context gauge is: the choice belongs to the session,
170
+ * and the session is built with this renderer as one of its arguments.
171
+ *
172
+ * THE SUBSCRIPTION IS THE POINT, not the read. A change has to drop
173
+ * {@link served} as well, and that is not housekeeping — it is the invariant
174
+ * #184 was about. `served` is what the gateway said it ran LAST time; the
175
+ * moment the choice moves, that tier describes a request made under a setting
176
+ * that no longer applies. Left standing it would put `mira` in the panel and
177
+ * on the status line while the header, the panel's own configured value, and
178
+ * every subsequent request all say `kavi`. Clearing it makes the panel say
179
+ * "not resolved yet", which is the true statement: nothing has been served
180
+ * under the new choice, and the next turn will say what was.
181
+ */
182
+ attachModel(model) {
183
+ this.unsubscribeModel?.();
184
+ this.model = model;
185
+ this.unsubscribeModel = model.onChange(() => {
186
+ if (this.closed)
187
+ return;
188
+ this.served = undefined;
189
+ this.schedulePaint();
190
+ });
191
+ this.schedulePaint();
192
+ }
193
+ /** The configured model/tier, live when a session choice is attached. */
194
+ configuredModel() {
195
+ return this.model?.current() ?? this.initialModel;
196
+ }
197
+ /**
198
+ * Begin the tool probes, at the first paint that actually SHOWS the panel
199
+ * (P4 track 5) — never at startup, and never at all if the user keeps
200
+ * `tools` closed. `start` is memoized, so calling it on every frame spawns
201
+ * nothing after the first; the repaint callback is what lets each row appear
202
+ * as its own probe lands rather than all at once at the end.
203
+ */
204
+ startTools(tools) {
205
+ tools.start(() => {
206
+ if (!this.closed)
207
+ this.schedulePaint();
208
+ });
209
+ return tools;
210
+ }
211
+ /** Replace one rail panel's content (P4). Absent state renders as "not wired yet". */
212
+ setRailPanel(id, lines) {
213
+ if (this.closed)
214
+ return;
215
+ this.railState = { ...this.railState, [id]: lines };
216
+ this.schedulePaint();
217
+ }
218
+ // ── app-owned surfaces ────────────────────────────────────────────────────
219
+ /**
220
+ * Replace the sidebar's session list (P2). Called once the session log is
221
+ * open, so the running session is itself listed; a live refresh later (P3)
222
+ * is the same call.
223
+ */
224
+ setSessions(sessions, activeSessionId) {
225
+ if (this.closed)
226
+ return;
227
+ this.sessions = sessions;
228
+ this.activeSessionId = activeSessionId;
229
+ this.schedulePaint();
230
+ }
231
+ /** Set the input line's rendered text (prompt + buffer + caret). */
232
+ setInput(line) {
233
+ if (this.closed)
234
+ return;
235
+ this.inputLine = line;
236
+ this.schedulePaint();
237
+ }
238
+ // ── overlay (P5 track 1) ──────────────────────────────────────────────────
239
+ /**
240
+ * Paint modal rows inside the viewport, or clear them with `null`.
241
+ *
242
+ * This is the seam every keyed, framed interaction sits on — the approval
243
+ * prompt, the command palette, any future picker. It exists because the two
244
+ * alternatives are both broken: a component that opens its own
245
+ * `components/frame.ts` writes ANSI cursor control to stderr into rows THIS
246
+ * renderer owns and repaints over, and the pre-P5 answer to that — clearing
247
+ * the entire shell for the duration — meant the user answered a question with
248
+ * the conversation it was about wiped off the screen.
249
+ *
250
+ * Rows come out of the body (see `layout.ts`), never out of the column width
251
+ * budget, so the conversation stays visible behind the drawer and what
252
+ * `/open` reports as dropped-for-width does not change while a modal is up.
253
+ */
254
+ setOverlay(lines) {
255
+ if (this.closed)
256
+ return;
257
+ // Reference equality is not enough — callers repaint from a fresh array
258
+ // every keystroke — but an unchanged overlay must not schedule a paint, or
259
+ // a picker's no-op key (an arrow at the end of the list) costs a frame.
260
+ if (sameLines(this.overlay, lines))
261
+ return;
262
+ this.overlay = lines;
263
+ this.schedulePaint();
264
+ }
265
+ /**
266
+ * How many rows an overlay may use at the current height — published so a
267
+ * caller composes within the drawer rather than discovering `fitOverlay`'s
268
+ * safety net. Falls to 0 on a terminal with no room at all, which callers
269
+ * must read as "do not open a modal here".
270
+ */
271
+ overlayRows() {
272
+ return overlayRows(this.caps.height);
273
+ }
274
+ /**
275
+ * Usable width for an overlay row — the full viewport, since a drawer spans
276
+ * the grid rather than sitting in a column. The frame reserves its trailing
277
+ * column here exactly as it does for every other row.
278
+ */
279
+ overlayWidth() {
280
+ return this.viewportWidth();
281
+ }
282
+ /** Append an app-authored line to the conversation (help text, command replies). */
283
+ println(line = "") {
284
+ if (this.closed)
285
+ return;
286
+ this.pushLines([line]);
287
+ this.schedulePaint();
288
+ }
289
+ // ── StreamRenderer ────────────────────────────────────────────────────────
290
+ beginTurn() {
291
+ this.highlighter = createStreamHighlighter(this.theme);
292
+ this.print = this.newPrinter();
293
+ this.wroteInSegment = false;
294
+ }
295
+ write(delta) {
296
+ if (this.closed)
297
+ return;
298
+ this.print(delta);
299
+ }
300
+ endSegment() {
301
+ if (this.closed)
302
+ return;
303
+ this.commit(this.highlighter.flush());
304
+ if (this.wroteInSegment) {
305
+ this.flushPartial();
306
+ this.wroteInSegment = false;
307
+ this.schedulePaint();
308
+ }
309
+ }
310
+ note(text) {
311
+ if (this.closed)
312
+ return;
313
+ this.flushPartial();
314
+ this.pushLines([this.theme.muted(text)]);
315
+ this.schedulePaint();
316
+ }
317
+ preview(preview) {
318
+ if (this.closed)
319
+ return;
320
+ // Rendered at the MAIN column's width, not the terminal's — a preview that
321
+ // assumed full width would be re-wrapped into a ragged block by the column.
322
+ // The line cap is the column's HEIGHT for the same reason (P3): a block
323
+ // collapsing at a fixed 40 either overflows a short pane or hides rows a
324
+ // tall one could have shown. One screenful is the honest budget — the
325
+ // scrollback keeps what the collapse marker accounts for.
326
+ const block = renderActionPreview(preview, this.theme, this.mainWidth(), bodyRows(this.caps.height));
327
+ if (!block)
328
+ return;
329
+ this.flushPartial();
330
+ this.pushLines(block.split("\n"));
331
+ this.schedulePaint();
332
+ }
333
+ status(text) {
334
+ if (this.closed)
335
+ return;
336
+ this.rawStatus = text;
337
+ this.schedulePaint();
338
+ }
339
+ setPhase(phase) {
340
+ if (this.closed)
341
+ return;
342
+ if (phase?.kind === "awaiting-approval") {
343
+ if (this.phase?.kind !== "awaiting-approval") {
344
+ this.displaced = { phase: this.phase, startedAt: this.phaseStartedAt };
345
+ }
346
+ }
347
+ else {
348
+ this.displaced = null;
349
+ }
350
+ const before = phaseIdentity(this.phase);
351
+ this.phase = phase;
352
+ if (phaseIdentity(phase) !== before)
353
+ this.phaseStartedAt = Date.now();
354
+ this.syncClock();
355
+ this.schedulePaint();
356
+ }
357
+ progress(state) {
358
+ if (this.closed)
359
+ return;
360
+ this.progressState = state;
361
+ this.schedulePaint();
362
+ }
363
+ /**
364
+ * Set (or clear) the live plan checklist (P3). Unlike every other committed
365
+ * surface here this does NOT append to the scrollback buffer: the list is
366
+ * re-sent in full on every status change, so buffering it would stack one
367
+ * near-identical copy of the plan per step. It is live state, composed fresh
368
+ * at each paint by {@link viewModel} — which is also what makes a resize
369
+ * rewindow it for free.
370
+ */
371
+ setPlan(steps) {
372
+ if (this.closed)
373
+ return;
374
+ // Clearing commits ONE final copy to the scrollback. Without this the
375
+ // checklist would simply vanish when the run ends and the TUI would keep no
376
+ // record of it at all — the append-only renderers get theirs from having
377
+ // committed each transition as it happened, which is exactly what this
378
+ // renderer does not do.
379
+ if (steps === null) {
380
+ if (this.planSteps !== null && this.planSteps.length > 0) {
381
+ this.flushPartial();
382
+ this.pushLines(planChecklist(this.planSteps, this.theme));
383
+ }
384
+ this.planSteps = null;
385
+ this.schedulePaint();
386
+ return;
387
+ }
388
+ this.planSteps = steps.map((s) => ({ ...s }));
389
+ this.schedulePaint();
390
+ }
391
+ /**
392
+ * Committed to the scrollback at the MAIN column's width — a block sized for
393
+ * the whole terminal would be re-wrapped into a ragged one by the column,
394
+ * same reasoning as `preview`.
395
+ */
396
+ testResult(report) {
397
+ if (this.closed)
398
+ return;
399
+ this.flushPartial();
400
+ this.pushLines(testResultLines(report, this.theme, this.mainWidth()));
401
+ this.schedulePaint();
402
+ }
403
+ toolLifecycle(event) {
404
+ if (this.closed)
405
+ return;
406
+ if (event.event === "start") {
407
+ this.toolStart = { label: event.label, at: Date.now() };
408
+ this.setPhase({ kind: "calling-tool", label: event.label });
409
+ return;
410
+ }
411
+ const started = this.toolStart?.label === event.label ? this.toolStart : null;
412
+ this.toolStart = null;
413
+ if (this.phase?.kind === "calling-tool") {
414
+ this.phase = null;
415
+ this.syncClock();
416
+ }
417
+ // Measured start→end, never animated — honest under reduced motion too.
418
+ const elapsed = started === null ? 0 : Date.now() - started.at;
419
+ const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
420
+ const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
421
+ this.note(`${mark} ${event.label}${suffix}`);
422
+ // A finished tool may have written files. Invalidate on EVERY tool rather
423
+ // than guessing which ones mutate: the cache coalesces, so over-calling is
424
+ // free, while under-calling leaves the panel quietly wrong.
425
+ this.refreshGit();
426
+ // The history just grew by a tool_use/tool_result pair, so the context
427
+ // reading is stale. Cheap here; ruinous on the paint path — see the gauge.
428
+ this.context?.sample();
429
+ }
430
+ /**
431
+ * Adopt the tier the gateway says actually served the request (P4 track 4).
432
+ *
433
+ * Repainted rather than committed: this is standing state, not an event. It
434
+ * is re-sent on every request, so the panel and header always describe the
435
+ * MOST RECENT one — which is what matters when a budget downgrade changes the
436
+ * tier mid-session and the earlier value would otherwise stick.
437
+ */
438
+ servedRouting(routing) {
439
+ if (this.closed)
440
+ return;
441
+ const unchanged = this.served?.tier === routing.tier && this.served?.mode === routing.mode;
442
+ if (unchanged)
443
+ return;
444
+ this.served = routing;
445
+ this.schedulePaint();
446
+ }
447
+ promptResolved() {
448
+ if (this.closed)
449
+ return;
450
+ if (this.phase?.kind !== "awaiting-approval")
451
+ return;
452
+ const displaced = this.displaced;
453
+ this.displaced = null;
454
+ this.phase = displaced?.phase ?? null;
455
+ this.phaseStartedAt = displaced?.startedAt ?? Date.now();
456
+ this.syncClock();
457
+ this.schedulePaint();
458
+ }
459
+ endTurn() {
460
+ if (this.closed)
461
+ return;
462
+ this.commit(this.highlighter.flush());
463
+ this.flushPartial();
464
+ this.phase = null;
465
+ this.rawStatus = null;
466
+ this.displaced = null;
467
+ this.syncClock();
468
+ // Sampled BEFORE the paint, so the turn's final frame already shows the
469
+ // history it just finished adding to rather than the previous turn's.
470
+ this.context?.sample();
471
+ this.paintNow();
472
+ this.refreshGit();
473
+ }
474
+ /**
475
+ * Re-probe the working tree, off the paint path, and repaint when it lands.
476
+ *
477
+ * Deliberately not awaited: a turn must never wait on `git status`, and a
478
+ * probe that fails or times out has to leave the UI exactly as it was. The
479
+ * cache coalesces concurrent calls, so the ten invalidations a file-writing
480
+ * turn produces cost one probe.
481
+ */
482
+ refreshGit() {
483
+ const git = this.git;
484
+ if (git === undefined || this.closed)
485
+ return;
486
+ git.invalidate();
487
+ void git.refresh().then(() => {
488
+ if (this.closed)
489
+ return;
490
+ this.schedulePaint();
491
+ });
492
+ }
493
+ close() {
494
+ if (this.closed)
495
+ return;
496
+ this.closed = true;
497
+ this.stopFrames();
498
+ if (this.paintTimer !== null) {
499
+ clearTimeout(this.paintTimer);
500
+ this.paintTimer = null;
501
+ }
502
+ this.unsubscribeResize?.();
503
+ this.unsubscribeResize = null;
504
+ this.unsubscribeModel?.();
505
+ this.unsubscribeModel = null;
506
+ // Erase the whole shell: after the TUI exits the terminal holds zero
507
+ // leftover bytes from it, exactly like a resolved component frame.
508
+ this.frame.clear();
509
+ }
510
+ // ── internals ─────────────────────────────────────────────────────────────
511
+ newPrinter() {
512
+ return createStreamPrinter((text) => {
513
+ this.commit(this.highlighter.push(text));
514
+ });
515
+ }
516
+ /** Fold streamed text into the buffer, splitting on newlines. */
517
+ commit(text) {
518
+ if (text === "")
519
+ return;
520
+ this.rawStatus = null;
521
+ this.wroteInSegment = true;
522
+ const parts = (this.partial + text).split("\n");
523
+ this.partial = parts.pop() ?? "";
524
+ if (parts.length > 0)
525
+ this.pushLines(parts);
526
+ this.schedulePaint();
527
+ }
528
+ /** Commit the in-flight partial line, if any. */
529
+ flushPartial() {
530
+ if (this.partial === "")
531
+ return;
532
+ this.pushLines([this.partial]);
533
+ this.partial = "";
534
+ }
535
+ pushLines(lines) {
536
+ this.buffer.push(...lines);
537
+ if (this.buffer.length > SCROLLBACK_LINES) {
538
+ this.buffer = this.buffer.slice(this.buffer.length - SCROLLBACK_LINES);
539
+ }
540
+ }
541
+ /** Inner width the frame accepts without truncating (it reserves one column). */
542
+ viewportWidth() {
543
+ return Math.max(1, this.caps.width - 1);
544
+ }
545
+ mainWidth() {
546
+ return budgetColumns(this.viewportWidth(), this.open).main;
547
+ }
548
+ /**
549
+ * Subscribe to the frame clock only while something is actually animating —
550
+ * a live phase. Idle (or reduced motion) leaves no timer running at all.
551
+ */
552
+ syncClock() {
553
+ const wants = this.clock.enabled &&
554
+ this.phase !== null &&
555
+ this.phase.kind !== "awaiting-approval";
556
+ if (wants && this.unsubscribeFrame === null) {
557
+ this.unsubscribeFrame = this.clock.subscribe(() => this.paintNow());
558
+ }
559
+ else if (!wants) {
560
+ this.stopFrames();
561
+ }
562
+ }
563
+ stopFrames() {
564
+ if (this.unsubscribeFrame !== null) {
565
+ this.unsubscribeFrame();
566
+ this.unsubscribeFrame = null;
567
+ }
568
+ }
569
+ /**
570
+ * Mark the screen dirty and paint on the next coalescing tick. Streaming a
571
+ * turn can call this hundreds of times a second; at most one paint per
572
+ * {@link PAINT_INTERVAL_MS} results.
573
+ */
574
+ schedulePaint() {
575
+ if (this.closed)
576
+ return;
577
+ this.dirty = true;
578
+ if (this.paintTimer !== null)
579
+ return;
580
+ const since = Date.now() - this.lastPaintAt;
581
+ if (since >= PAINT_INTERVAL_MS) {
582
+ this.paintNow();
583
+ return;
584
+ }
585
+ this.paintTimer = setTimeout(() => {
586
+ this.paintTimer = null;
587
+ if (this.dirty)
588
+ this.paintNow();
589
+ }, PAINT_INTERVAL_MS - since);
590
+ // Never hold the process open for a pending repaint.
591
+ this.paintTimer.unref?.();
592
+ }
593
+ /** Compose and paint immediately. */
594
+ paintNow() {
595
+ if (this.closed)
596
+ return;
597
+ this.dirty = false;
598
+ this.lastPaintAt = Date.now();
599
+ // An approval prompt used to yield the ENTIRE shell here, the way
600
+ // `TtyRenderer` hides its one live line. That was never what this renderer
601
+ // wanted — it was the only defence available while the prompt wrote to
602
+ // stderr, whose bytes would land inside the frame's rows and be erased by
603
+ // the next repaint. The cost was that you approved a change with the
604
+ // conversation it was about wiped off the screen.
605
+ //
606
+ // P5 track 2 moves the prompt onto the overlay seam, so it is composed INTO
607
+ // this paint rather than written around it, and the shell keeps painting
608
+ // underneath. `awaiting-approval` still displaces the phase (see
609
+ // `setPhase`/`promptResolved`) and still stops the clock — a spinner has
610
+ // nothing to say while the turn is blocked on a human — but it no longer
611
+ // blanks anything.
612
+ const width = this.viewportWidth();
613
+ const height = this.caps.height;
614
+ this.frame.render(composeScreen(this.viewModel(width, height), width, height, this.open, this.theme));
615
+ }
616
+ /** The live-state line, composed by the shared U.4 mapping. */
617
+ /**
618
+ * True when the model panel is actually on screen — open AND its column
619
+ * survived the width budget. `droppedForWidth` is the same check `/open` uses
620
+ * to tell a user their panel will not fit, so "visible" means one thing in
621
+ * this file.
622
+ */
623
+ modelPanelVisible(width) {
624
+ if (this.configuredModel() === undefined || !this.open.has("model")) {
625
+ return false;
626
+ }
627
+ return !droppedForWidth(budgetColumns(width, this.open), this.open).includes("rail");
628
+ }
629
+ /**
630
+ * The phase as the STATUS LINE should say it — which is not always the phase
631
+ * the loop published.
632
+ *
633
+ * Since P4 track 4 there are two places a tier can appear at once, and they
634
+ * are sourced differently. `phase.tier` is `routed?.tier`: what this run's
635
+ * client-side router (C.30) ASKED for, fixed before `provider.stream` opens.
636
+ * The model panel shows what the gateway said actually SERVED the request.
637
+ * Those disagree exactly when it matters — `auto` resolving to a real tier,
638
+ * or a budget downgrade to one nobody selected — so a single screen could
639
+ * assert `kavi` on the status line and `mira` in the rail, with nothing to
640
+ * say which was true.
641
+ *
642
+ * Two tiers contradicting each other in one viewport is worse than either
643
+ * being absent, so:
644
+ *
645
+ * - MODEL PANEL VISIBLE → the status line drops the tier entirely. The panel
646
+ * is the better surface for it: it is persistent, it has room to say HOW
647
+ * the tier was chosen, and it is never truncated away. One surface owns
648
+ * the claim.
649
+ * - MODEL PANEL NOT VISIBLE (closed, or its column dropped for width) → the
650
+ * status line keeps the tier, because otherwise nothing would show it —
651
+ * but it prefers the SERVED tier over the asked-for one, so what it shows
652
+ * is the same fact the panel would have shown.
653
+ *
654
+ * This only removes text from the line, so `fitStatusLine`'s degradation
655
+ * order (phase > progress prefix > elapsed) is untouched: the tier was baked
656
+ * into the phase text, i.e. into the element that survives longest, which is
657
+ * the last place a redundant or contradictory value belongs.
658
+ */
659
+ statusPhase(width) {
660
+ const phase = this.phase;
661
+ if (phase === null || phase.kind !== "thinking")
662
+ return phase;
663
+ if (this.modelPanelVisible(width)) {
664
+ // Rebuilt rather than destructured, so the fields kept are stated rather
665
+ // than implied: only the tier goes, the token counts stay.
666
+ return phase.tokens === undefined
667
+ ? { kind: "thinking" }
668
+ : { kind: "thinking", tokens: phase.tokens };
669
+ }
670
+ const tier = this.served?.tier ?? phase.tier;
671
+ return tier === undefined ? phase : { ...phase, tier };
672
+ }
673
+ statusLine(width) {
674
+ if (this.rawStatus !== null)
675
+ return this.rawStatus;
676
+ if (this.phase === null && this.progressState === null)
677
+ return "";
678
+ const elapsed = this.phase !== null && this.caps.spinner
679
+ ? Date.now() - this.phaseStartedAt
680
+ : undefined;
681
+ const glyph = spinnerGlyph(this.clock.frame, this.theme.glyph, this.caps.spinner && this.phase !== null);
682
+ const room = Math.max(1, width - 2);
683
+ const line = fitStatusLine(this.progressState, this.statusPhase(width), elapsed, this.theme.glyph, room);
684
+ return line === "" ? "" : `${glyph} ${line}`;
685
+ }
686
+ viewModel(width, height) {
687
+ // An overlay borrows rows from the columns (P5 track 1). Resolved BEFORE
688
+ // anything is composed, because every block below is fitted to the row
689
+ // budget — the rail especially, which `stackPanels` packs to an exact
690
+ // height and which `fitBlock`'s tail rule would otherwise decapitate.
691
+ const drawer = fitOverlay(this.overlay ?? [], overlayRows(height));
692
+ const rows = Math.max(1, bodyRows(height) - drawer.length);
693
+ const mainCols = budgetColumns(width, this.open).main;
694
+ // Wrap only the tail we could possibly show. Rewrapping 1,000 lines on
695
+ // every frame would be the one genuinely hot cost in this path.
696
+ const slice = this.buffer.slice(Math.max(0, this.buffer.length - rows * 4));
697
+ const live = this.partial === "" ? [] : [this.partial];
698
+ const wrapped = [];
699
+ for (const line of [...slice, ...live]) {
700
+ if (line === "") {
701
+ wrapped.push("");
702
+ continue;
703
+ }
704
+ wrapped.push(...reflow(line, mainCols));
705
+ }
706
+ // The plan checklist is PINNED under the conversation (P3). `main` is a
707
+ // tail view — `fitBlock` keeps its last `rows` lines — so appending is what
708
+ // makes the checklist survive a long conversation while the oldest
709
+ // scrollback rolls off the top. No reservation arithmetic is needed; the
710
+ // cap below is what stops a 40-step plan from evicting the conversation
711
+ // entirely, and `planChecklist` windows around the running step and says
712
+ // how many it hid.
713
+ const plan = this.planSteps === null || this.planSteps.length === 0
714
+ ? []
715
+ : planChecklist(this.planSteps, this.theme, Math.max(2, Math.floor(rows / 2)), mainCols);
716
+ // The rail is a STACK of fixed panels, not a feed: it is composed to the
717
+ // exact row budget here (dropping whole panels and counting them) rather
718
+ // than handed to `fitBlock`, whose tail rule would silently eat the top
719
+ // panel. `composeScreen` still pads it, which is now a no-op on height.
720
+ // A pure field read of the cache — never a probe. See `git-status.ts`.
721
+ // What the model panel and the header both describe. The configured value
722
+ // is known from the start, so neither is ever blank before the first turn.
723
+ const configured = this.configuredModel();
724
+ const model = configured === undefined
725
+ ? undefined
726
+ : {
727
+ configured,
728
+ ...(this.served === undefined ? {} : { served: this.served }),
729
+ };
730
+ // Probing starts at the first paint that actually SHOWS the panel — never
731
+ // at startup, and never at all while the user keeps `tools` closed.
732
+ const toolRows = this.tools === undefined || !this.open.has("tools")
733
+ ? undefined
734
+ : this.startTools(this.tools).current();
735
+ const railState = {
736
+ ...this.railState,
737
+ ...(model === undefined
738
+ ? {}
739
+ : { model: modelPanelLines(this.theme, model) }),
740
+ ...(this.context === undefined
741
+ ? {}
742
+ : { context: contextPanelLines(this.theme, this.context.current()) }),
743
+ ...(this.git === undefined
744
+ ? {}
745
+ : { git: gitPanelLines(this.theme, this.git.current()) }),
746
+ ...(toolRows === undefined
747
+ ? {}
748
+ : { tools: toolsPanelLines(this.theme, toolRows) }),
749
+ };
750
+ const stacked = stackPanels(railBlocks(this.theme, railState, this.open), rows, this.theme);
751
+ this.railDropped = stacked.dropped;
752
+ return {
753
+ headerLeft: "cruxy",
754
+ // Recomposed each paint, so a served tier that arrives (or changes under
755
+ // a budget downgrade) reaches the header. P1 set this once at
756
+ // construction, which is why a run routed to `kavi` read `auto` forever.
757
+ headerRight: model === undefined
758
+ ? this.headerRight
759
+ : headerModel(this.theme, this.provider, model),
760
+ sidebar: sidebarLines(this.theme, this.sessions, this.activeSessionId),
761
+ main: plan.length === 0 ? wrapped : [...wrapped, "", ...plan],
762
+ rail: stacked.lines,
763
+ status: this.statusLine(width),
764
+ input: this.inputLine,
765
+ overlay: drawer,
766
+ };
767
+ }
768
+ }
769
+ /** Row-wise equality for overlay content — `null` and `[]` both mean "no drawer". */
770
+ function sameLines(a, b) {
771
+ if (a === b)
772
+ return true;
773
+ const left = a ?? [];
774
+ const right = b ?? [];
775
+ return (left.length === right.length && left.every((line, i) => line === right[i]));
776
+ }