@cruxy/cli 1.3.0 → 1.5.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.
@@ -8,8 +8,10 @@ import { createFrameClock, spinnerGlyph, } from "../render/motion.js";
8
8
  import { planChecklist } from "../render/plan-view.js";
9
9
  import { testResultLines } from "../render/test-view.js";
10
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";
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";
12
13
  import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
14
+ import { limitsPanelLines } from "./limits-panel.js";
13
15
  /**
14
16
  * The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
15
17
  * only one that owns the whole screen rather than a single managed line.
@@ -35,8 +37,21 @@ import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelL
35
37
  */
36
38
  /** Coalescing window for repaints (~30fps). */
37
39
  export const PAINT_INTERVAL_MS = 33;
40
+ /**
41
+ * How often a selected view that reports itself {@link ViewSource.live} gets
42
+ * repainted with no other event to prompt it (P7 track 5).
43
+ *
44
+ * 2fps, not 30: this exists so a background job's log tail advances while the
45
+ * foreground sits idle at the prompt, and a log line arriving half a second
46
+ * late is imperceptible where a full-screen redraw thirty times a second for
47
+ * the duration of a long job is not free. The pulse only runs while such a view
48
+ * is the one on screen.
49
+ */
50
+ export const VIEW_PULSE_MS = 500;
38
51
  /** Logical lines of scrollback kept in memory. Older lines roll off. */
39
52
  export const SCROLLBACK_LINES = 1_000;
53
+ /** Lines a Page Up/Down keeps in common across the jump, so context survives. */
54
+ export const SCROLL_PAGE_OVERLAP = 2;
40
55
  export class TuiRenderer {
41
56
  caps;
42
57
  theme;
@@ -63,12 +78,26 @@ export class TuiRenderer {
63
78
  overlay = null;
64
79
  /** Panels the user has open. `main` is a column, not a panel, and always paints. */
65
80
  open = new Set(CLOSABLE_PANELS);
66
- /** Per-panel rail content, filled in by the P4 tracks as each lands. */
67
- railState = {};
81
+ /**
82
+ * Display lines the main column is scrolled back from the live tail (P7
83
+ * track 1). `0` is the home position and the default, and at `0` this
84
+ * renderer composes exactly what it composed before scrolling existed.
85
+ */
86
+ scrollOffset = 0;
87
+ /** Registered main-pane views (P7 track 2); conversation is not among them. */
88
+ views = [];
89
+ /** Which view owns the main column. Always a real id — see {@link setView}. */
90
+ selectedView = CONVERSATION_VIEW;
91
+ /** Whether the sidebar nav holds the keyboard rather than the input line. */
92
+ sidebarFocused = false;
68
93
  /** Working-tree state for the git panel (P4 track 2); absent → panel unwired. */
69
94
  git;
70
95
  /** Context-budget reading for the context panel (P4 track 3); absent → unwired. */
71
96
  context;
97
+ /** The account's headroom for the limits panel (P9); absent → panel unwired. */
98
+ limits;
99
+ /** Whether the first limits read has been kicked off (see {@link startLimits}). */
100
+ limitsStarted = false;
72
101
  /**
73
102
  * The configured model/tier as known at construction — the renderer is built
74
103
  * before the session exists, so this covers the window before {@link attachModel}
@@ -88,6 +117,8 @@ export class TuiRenderer {
88
117
  unsubscribeFrame = null;
89
118
  unsubscribeResize = null;
90
119
  paintTimer = null;
120
+ /** The self-changing-view pulse (P7 track 5); null when nothing needs one. */
121
+ viewPulseTimer = null;
91
122
  lastPaintAt = 0;
92
123
  dirty = false;
93
124
  closed = false;
@@ -95,7 +126,15 @@ export class TuiRenderer {
95
126
  headerRight = "";
96
127
  /** Provider name for the composed header (P4 track 4). */
97
128
  provider = "";
98
- /** Saved sessions shown in the sidebar (P2), and which one is running. */
129
+ /**
130
+ * Saved sessions shown in the sidebar (P2), and which one is running.
131
+ *
132
+ * Populated only by {@link setSessions}, never at construction. The running
133
+ * session has to be listed, and its meta line does not exist until the
134
+ * session log is open — which happens after the renderer is built. A
135
+ * constructor argument could therefore only ever carry the list from BEFORE
136
+ * this run, i.e. a sidebar missing the very session the user is in.
137
+ */
99
138
  sessions = [];
100
139
  activeSessionId;
101
140
  constructor(caps, out, opts = {}) {
@@ -105,13 +144,11 @@ export class TuiRenderer {
105
144
  this.highlighter = createStreamHighlighter(this.theme);
106
145
  this.print = this.newPrinter();
107
146
  this.headerRight = opts.headerRight ?? "";
108
- this.sessions = opts.sessions ?? [];
109
- this.activeSessionId = opts.activeSessionId;
110
147
  this.git = opts.git;
111
148
  this.provider = opts.provider ?? "";
112
149
  this.initialModel = opts.model;
113
150
  this.tools = opts.tools;
114
- this.buffer = mainWelcome(this.theme, opts.hint ?? "/help for commands · /exit to quit");
151
+ this.buffer = mainWelcome(this.theme, "/help for commands · /exit to quit");
115
152
  this.frame = createFrame((text) => this.out.write(text), caps);
116
153
  // The single shared clock (U.10): an inert clock under reduced motion, so
117
154
  // the spinner is drawn once statically and no timer is ever scheduled.
@@ -164,6 +201,20 @@ export class TuiRenderer {
164
201
  gauge.sample();
165
202
  this.schedulePaint();
166
203
  }
204
+ /**
205
+ * Attach the limits cache (P9). Set after construction like the context gauge,
206
+ * because the credential it reads with is resolved alongside the session.
207
+ *
208
+ * NO PROBE HAPPENS HERE. The first read is deferred to the first paint that
209
+ * actually shows the panel — the rule the tool probes already follow — so a
210
+ * user who keeps `limits` closed, or who runs a one-shot that never paints a
211
+ * rail, never makes the request at all. The panel is a status surface; it does
212
+ * not get to spend a round trip on someone who is not looking at it.
213
+ */
214
+ attachLimits(limits) {
215
+ this.limits = limits;
216
+ this.schedulePaint();
217
+ }
167
218
  /**
168
219
  * Adopt the session's live model choice (P6 track 1). Set after construction
169
220
  * for the same reason the context gauge is: the choice belongs to the session,
@@ -201,6 +252,24 @@ export class TuiRenderer {
201
252
  * nothing after the first; the repaint callback is what lets each row appear
202
253
  * as its own probe lands rather than all at once at the end.
203
254
  */
255
+ /**
256
+ * Kick off the first limits read, at the first paint that actually SHOWS the
257
+ * panel. Returns the cache so the paint path can read it in one expression.
258
+ *
259
+ * Guarded by its own flag rather than by the cache's interval floor: the floor
260
+ * exists to coalesce refreshes AFTER a reading exists, and would not stop the
261
+ * paint path from firing a request on every frame before the first one lands.
262
+ */
263
+ startLimits(limits) {
264
+ if (!this.limitsStarted) {
265
+ this.limitsStarted = true;
266
+ void limits.refresh().then(() => {
267
+ if (!this.closed)
268
+ this.schedulePaint();
269
+ });
270
+ }
271
+ return limits;
272
+ }
204
273
  startTools(tools) {
205
274
  tools.start(() => {
206
275
  if (!this.closed)
@@ -208,13 +277,150 @@ export class TuiRenderer {
208
277
  });
209
278
  return tools;
210
279
  }
211
- /** Replace one rail panel's content (P4). Absent state renders as "not wired yet". */
212
- setRailPanel(id, lines) {
280
+ // ── views (P7 track 2) ────────────────────────────────────────────────────
281
+ /**
282
+ * Register the main-pane views. Set after construction for the same reason
283
+ * the context gauge is: a view reads session-scoped state, and the session is
284
+ * built with this renderer as one of its arguments.
285
+ *
286
+ * Re-attaching replaces the set. If the selected view is not in the new set
287
+ * the selection falls back to the conversation rather than pointing at
288
+ * nothing — an unresolvable id would paint an empty main column with no way
289
+ * for the user to tell a missing view from a broken one.
290
+ */
291
+ attachViews(sources) {
213
292
  if (this.closed)
214
293
  return;
215
- this.railState = { ...this.railState, [id]: lines };
294
+ this.views = [...sources];
295
+ if (this.selectedView !== CONVERSATION_VIEW &&
296
+ !this.views.some((v) => v.id === this.selectedView)) {
297
+ this.selectedView = CONVERSATION_VIEW;
298
+ }
216
299
  this.schedulePaint();
217
300
  }
301
+ /** The registered views, conversation excluded (it is built in). */
302
+ viewSources() {
303
+ return this.views;
304
+ }
305
+ /** Which view owns the main column. */
306
+ view() {
307
+ return this.selectedView;
308
+ }
309
+ /**
310
+ * The tier the gateway last said actually served a request, or undefined
311
+ * before the first answer (P4 track 4).
312
+ *
313
+ * Published so the Overview view can report the same tier the model panel and
314
+ * the status line do. `SessionStatus.servedTier` has existed and been rendered
315
+ * since P6 track 4 with nothing ever supplying it — this renderer was the only
316
+ * object that knew the value, and it had no way to hand it over.
317
+ */
318
+ servedTier() {
319
+ return this.served?.tier;
320
+ }
321
+ /**
322
+ * Select a view. Returns false for an unknown id — the caller reports it,
323
+ * rather than this silently selecting something the user did not ask for.
324
+ *
325
+ * THE SCROLL POSITION RESETS. Offsets are display-line counts into whatever
326
+ * content is in the pane, and two views share no coordinate system: carrying
327
+ * one across would land at an arbitrary place in the other. One offset that
328
+ * always means "into what you are looking at" is simpler to reason about than
329
+ * per-view bookkeeping, and cheaper to be right about.
330
+ */
331
+ setView(id) {
332
+ if (this.closed)
333
+ return false;
334
+ if (id !== CONVERSATION_VIEW && !this.views.some((v) => v.id === id)) {
335
+ return false;
336
+ }
337
+ if (this.selectedView === id)
338
+ return true;
339
+ this.selectedView = id;
340
+ this.scrollOffset = 0;
341
+ this.schedulePaint();
342
+ return true;
343
+ }
344
+ /** Move `steps` around the view ring and select what lands. */
345
+ cycleView(steps) {
346
+ const next = cycleView(this.selectedView, this.views, steps);
347
+ this.setView(next);
348
+ return next;
349
+ }
350
+ /**
351
+ * Give the sidebar nav the keyboard, or take it back. Returns false when
352
+ * focus could not move, which is how the caller learns to say why.
353
+ *
354
+ * Refused while the sidebar is not on screen — closed by the user, or dropped
355
+ * by the width budget. Focus you cannot see is the worst kind: the arrow keys
356
+ * would quietly change meaning with nothing on screen accounting for it.
357
+ */
358
+ focusSidebar(focused) {
359
+ if (this.closed)
360
+ return false;
361
+ if (focused && !this.sidebarVisible())
362
+ return false;
363
+ if (this.sidebarFocused === focused)
364
+ return false;
365
+ this.sidebarFocused = focused;
366
+ this.schedulePaint();
367
+ return true;
368
+ }
369
+ /** Whether the sidebar nav currently holds the keyboard. */
370
+ sidebarHasFocus() {
371
+ return this.sidebarFocused;
372
+ }
373
+ /** Open AND wide enough to paint — the same test `/open` reports against. */
374
+ sidebarVisible() {
375
+ if (!this.open.has("sidebar"))
376
+ return false;
377
+ return budgetColumns(this.viewportWidth(), this.open).sidebar > 0;
378
+ }
379
+ // ── scrollback (P7 track 1) ───────────────────────────────────────────────
380
+ /**
381
+ * Scroll the main column by `delta` display lines — positive is BACK, into
382
+ * history; negative is forward, toward the live tail. Returns whether the
383
+ * position actually moved, so a caller can leave a key unhandled (and let
384
+ * something else claim it) rather than swallowing a no-op.
385
+ *
386
+ * Only the lower bound is applied here. The upper one needs the wrapped line
387
+ * count at the current width, which is composed per paint — so `viewModel`
388
+ * clamps and writes the real value back. Pressing Page Up at the top of a
389
+ * short buffer therefore reports movement once and then stops, instead of
390
+ * silently banking offset that Page Down would have to unwind.
391
+ */
392
+ scrollBy(delta) {
393
+ if (this.closed)
394
+ return false;
395
+ const next = Math.max(0, this.scrollOffset + delta);
396
+ if (next === this.scrollOffset)
397
+ return false;
398
+ this.scrollOffset = next;
399
+ this.schedulePaint();
400
+ return true;
401
+ }
402
+ /**
403
+ * Scroll by one screenful, keeping {@link SCROLL_PAGE_OVERLAP} lines of
404
+ * context across the jump. A page that turns cleanly loses the sentence
405
+ * straddling the boundary, and in a conversation that is usually the one
406
+ * being read.
407
+ */
408
+ scrollPage(direction) {
409
+ const page = Math.max(1, bodyRows(this.caps.height) - SCROLL_PAGE_OVERLAP);
410
+ return this.scrollBy(direction * page);
411
+ }
412
+ /** Return to the live tail. Returns false when already there. */
413
+ scrollToLive() {
414
+ if (this.closed || this.scrollOffset === 0)
415
+ return false;
416
+ this.scrollOffset = 0;
417
+ this.schedulePaint();
418
+ return true;
419
+ }
420
+ /** Whether the main column is showing history rather than the live tail. */
421
+ isScrolledBack() {
422
+ return this.scrollOffset > 0;
423
+ }
218
424
  // ── app-owned surfaces ────────────────────────────────────────────────────
219
425
  /**
220
426
  * Replace the sidebar's session list (P2). Called once the session log is
@@ -470,6 +676,28 @@ export class TuiRenderer {
470
676
  this.context?.sample();
471
677
  this.paintNow();
472
678
  this.refreshGit();
679
+ this.refreshLimits();
680
+ this.refreshViews();
681
+ }
682
+ /**
683
+ * Re-read the account's headroom after a turn (P9) — the one moment it is
684
+ * KNOWN to have moved, because this process just spent some of it.
685
+ *
686
+ * Same rules as the git probe: never awaited, a failure leaves the last good
687
+ * reading standing, and the cache coalesces. Unlike git it is also floored by
688
+ * a minimum interval, because this one crosses the network — and unlike git it
689
+ * is skipped entirely while the panel is closed, since a user who has hidden
690
+ * the figures has no use for the request that fetches them.
691
+ */
692
+ refreshLimits() {
693
+ const limits = this.limits;
694
+ if (limits === undefined || this.closed || !this.open.has("limits"))
695
+ return;
696
+ void limits.refresh().then(() => {
697
+ if (this.closed)
698
+ return;
699
+ this.schedulePaint();
700
+ });
473
701
  }
474
702
  /**
475
703
  * Re-probe the working tree, off the paint path, and repaint when it lands.
@@ -490,6 +718,34 @@ export class TuiRenderer {
490
718
  this.schedulePaint();
491
719
  });
492
720
  }
721
+ /**
722
+ * Let each view re-probe what it reads (P7 track 3), on the same post-turn
723
+ * trigger and under the same rules as the rail's git cache: not awaited, and
724
+ * a failure leaves the pane as it was.
725
+ *
726
+ * EVERY view refreshes, not just the selected one. A probe exists so that
727
+ * SELECTING a view shows something true immediately; refreshing only the
728
+ * visible one would mean every switch lands on a stale pane and then flickers
729
+ * — which is the "checking…" state the tri-state was added to be honest
730
+ * about, shown at the one moment it is avoidable.
731
+ */
732
+ refreshViews() {
733
+ if (this.closed)
734
+ return;
735
+ for (const source of this.views) {
736
+ const refresh = source.refresh;
737
+ if (refresh === undefined)
738
+ continue;
739
+ void Promise.resolve(refresh.call(source))
740
+ .then(() => {
741
+ if (!this.closed)
742
+ this.schedulePaint();
743
+ })
744
+ // A view that cannot re-probe keeps its last value; it must never take
745
+ // the shell down with an unhandled rejection.
746
+ .catch(() => { });
747
+ }
748
+ }
493
749
  close() {
494
750
  if (this.closed)
495
751
  return;
@@ -499,6 +755,10 @@ export class TuiRenderer {
499
755
  clearTimeout(this.paintTimer);
500
756
  this.paintTimer = null;
501
757
  }
758
+ if (this.viewPulseTimer !== null) {
759
+ clearTimeout(this.viewPulseTimer);
760
+ this.viewPulseTimer = null;
761
+ }
502
762
  this.unsubscribeResize?.();
503
763
  this.unsubscribeResize = null;
504
764
  this.unsubscribeModel?.();
@@ -533,6 +793,26 @@ export class TuiRenderer {
533
793
  this.partial = "";
534
794
  }
535
795
  pushLines(lines) {
796
+ // Hold the scrolled view still (P7 track 1). The offset is measured from
797
+ // the END of the document, so appending would otherwise slide the content
798
+ // up under the reader by exactly the number of lines that arrived — the
799
+ // failure this compensation exists to prevent, and the reason scrollback is
800
+ // worth having DURING a stream rather than only after one.
801
+ //
802
+ // Counted in DISPLAY lines, at the width the window is composed at, because
803
+ // that is the unit the offset is in. Skipped entirely at the live tail,
804
+ // where following the newest output is the whole point.
805
+ // Only while the conversation is the thing being scrolled. A view's offset
806
+ // indexes ITS lines, so nudging it because the conversation grew behind the
807
+ // pane would scroll a document that did not change.
808
+ if (this.scrollOffset > 0 && this.selectedView === CONVERSATION_VIEW) {
809
+ const cols = this.mainWidth();
810
+ let added = 0;
811
+ for (const line of lines) {
812
+ added += line === "" ? 1 : reflow(line, cols).length;
813
+ }
814
+ this.scrollOffset += added;
815
+ }
536
816
  this.buffer.push(...lines);
537
817
  if (this.buffer.length > SCROLLBACK_LINES) {
538
818
  this.buffer = this.buffer.slice(this.buffer.length - SCROLLBACK_LINES);
@@ -612,6 +892,34 @@ export class TuiRenderer {
612
892
  const width = this.viewportWidth();
613
893
  const height = this.caps.height;
614
894
  this.frame.render(composeScreen(this.viewModel(width, height), width, height, this.open, this.theme));
895
+ this.armViewPulse();
896
+ }
897
+ /**
898
+ * Keep repainting a selected view that says its content is moving on its own
899
+ * (P7 track 5) — a background job appending to its log while the foreground
900
+ * waits at the prompt.
901
+ *
902
+ * Armed from `paintNow` rather than from an event, because the condition it
903
+ * watches has no event: the pulse paints, that paint re-arms, and the chain
904
+ * ends by itself the moment the view stops being selected or stops being live.
905
+ * Nothing else in this class needs to know the pulse exists.
906
+ */
907
+ armViewPulse() {
908
+ if (this.viewPulseTimer !== null || this.closed)
909
+ return;
910
+ const active = this.views.find((v) => v.id === this.selectedView);
911
+ // `live` is optional and false by default: every view whose content only
912
+ // moves when a turn, a key or a `refresh` moved it already repaints.
913
+ if (active?.live?.() !== true)
914
+ return;
915
+ this.viewPulseTimer = setTimeout(() => {
916
+ this.viewPulseTimer = null;
917
+ if (!this.closed)
918
+ this.schedulePaint();
919
+ }, VIEW_PULSE_MS);
920
+ // Never hold the process open for a pulse — the same rule as `paintTimer`.
921
+ // A pending repaint must not be why `cruxy` fails to exit.
922
+ this.viewPulseTimer.unref?.();
615
923
  }
616
924
  /** The live-state line, composed by the shared U.4 mapping. */
617
925
  /**
@@ -690,13 +998,55 @@ export class TuiRenderer {
690
998
  // height and which `fitBlock`'s tail rule would otherwise decapitate.
691
999
  const drawer = fitOverlay(this.overlay ?? [], overlayRows(height));
692
1000
  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];
1001
+ const columns = budgetColumns(width, this.open);
1002
+ const mainCols = columns.main;
1003
+ // Focus cannot outlive the thing holding it (P7 track 2). `/close sidebar`
1004
+ // and a narrowing terminal both take the nav off screen, and either would
1005
+ // otherwise leave the arrow keys silently rebound to a column that is not
1006
+ // there. Dropped here rather than in the two call sites because this is
1007
+ // where "on screen" is actually decided.
1008
+ if (this.sidebarFocused &&
1009
+ (!this.open.has("sidebar") || columns.sidebar === 0)) {
1010
+ this.sidebarFocused = false;
1011
+ }
1012
+ const scrolled = this.scrollOffset > 0;
1013
+ // A registered view owns the main column outright (P7 track 2). Its lines
1014
+ // are recomposed every paint from its own state — this class never learns
1015
+ // what they mean — and none of the conversation machinery below applies:
1016
+ // there is no partial line to hold back, no plan to pin, and no append to
1017
+ // compensate for, because a view does not accumulate.
1018
+ const active = this.selectedView === CONVERSATION_VIEW
1019
+ ? undefined
1020
+ : this.views.find((v) => v.id === this.selectedView);
1021
+ // AT THE LIVE TAIL, wrap only the tail we could possibly show. Rewrapping
1022
+ // 1,000 lines on every frame would be the one genuinely hot cost in this
1023
+ // path, and this is the path every streaming frame takes.
1024
+ //
1025
+ // SCROLLED BACK, wrap the whole retained buffer instead. The offset is in
1026
+ // display lines and it has to be clamped against the real total, which a
1027
+ // slice cannot supply — and a conservative bound would stop short of the
1028
+ // oldest line, making content unreachable rather than merely slow. The cost
1029
+ // is bounded by SCROLLBACK_LINES and only paid while a human is reading
1030
+ // history, which is not a hot path.
1031
+ //
1032
+ // Skipped entirely while a view owns the pane: the conversation keeps
1033
+ // accumulating behind it, but composing what nobody is looking at would
1034
+ // make every other view pay the conversation's cost.
1035
+ const source = active !== undefined
1036
+ ? []
1037
+ : scrolled
1038
+ ? this.buffer
1039
+ : this.buffer.slice(Math.max(0, this.buffer.length - rows * 4));
1040
+ // The streaming partial line belongs to the LIVE view only. While scrolled
1041
+ // it is excluded, which is what makes the offset arithmetic exact: the
1042
+ // document then changes only through `pushLines`, which compensates. It is
1043
+ // also the honest reading — scrollback is committed output, and a line still
1044
+ // being written is not committed.
1045
+ const live = active !== undefined || scrolled || this.partial === ""
1046
+ ? []
1047
+ : [this.partial];
698
1048
  const wrapped = [];
699
- for (const line of [...slice, ...live]) {
1049
+ for (const line of [...source, ...live]) {
700
1050
  if (line === "") {
701
1051
  wrapped.push("");
702
1052
  continue;
@@ -710,7 +1060,16 @@ export class TuiRenderer {
710
1060
  // cap below is what stops a 40-step plan from evicting the conversation
711
1061
  // entirely, and `planChecklist` windows around the running step and says
712
1062
  // how many it hid.
713
- const plan = this.planSteps === null || this.planSteps.length === 0
1063
+ //
1064
+ // Pinned to the LIVE tail, not to the column: while scrolled back it is
1065
+ // absent, for the same reason the partial line is. It is re-rendered from
1066
+ // mutable state on every paint rather than committed to the buffer, so
1067
+ // leaving it in a scrolled view would let it change length underneath a
1068
+ // reader and shift the history they are holding still.
1069
+ const plan = active !== undefined ||
1070
+ scrolled ||
1071
+ this.planSteps === null ||
1072
+ this.planSteps.length === 0
714
1073
  ? []
715
1074
  : planChecklist(this.planSteps, this.theme, Math.max(2, Math.floor(rows / 2)), mainCols);
716
1075
  // The rail is a STACK of fixed panels, not a feed: it is composed to the
@@ -732,14 +1091,24 @@ export class TuiRenderer {
732
1091
  const toolRows = this.tools === undefined || !this.open.has("tools")
733
1092
  ? undefined
734
1093
  : this.startTools(this.tools).current();
1094
+ // Composed fresh each paint from the panels' own typed state. There is no
1095
+ // stored blob to merge over: every panel owns its source, so a key can only
1096
+ // appear here by being derived below.
735
1097
  const railState = {
736
- ...this.railState,
737
1098
  ...(model === undefined
738
1099
  ? {}
739
1100
  : { model: modelPanelLines(this.theme, model) }),
740
1101
  ...(this.context === undefined
741
1102
  ? {}
742
1103
  : { context: contextPanelLines(this.theme, this.context.current()) }),
1104
+ // Like the tool probes: the first network read starts at the first paint
1105
+ // that SHOWS this panel, never at startup and never at all while it is
1106
+ // closed. `startLimits` is idempotent, so painting it costs one request.
1107
+ ...(this.limits === undefined || !this.open.has("limits")
1108
+ ? {}
1109
+ : {
1110
+ limits: limitsPanelLines(this.theme, this.startLimits(this.limits).current()),
1111
+ }),
743
1112
  ...(this.git === undefined
744
1113
  ? {}
745
1114
  : { git: gitPanelLines(this.theme, this.git.current()) }),
@@ -749,6 +1118,40 @@ export class TuiRenderer {
749
1118
  };
750
1119
  const stacked = stackPanels(railBlocks(this.theme, railState, this.open), rows, this.theme);
751
1120
  this.railDropped = stacked.dropped;
1121
+ // Window the conversation (P7 track 1). At the live tail this is the tail
1122
+ // view `fitBlock` already produced — `scrollWindow` agrees with it
1123
+ // line-for-line at offset 0 — so the default path is unchanged and the
1124
+ // notice costs nothing. Scrolled back, one row goes to the notice, which is
1125
+ // why the window is asked for `rows - 1`.
1126
+ //
1127
+ // The clamp is written back because only this pass knows the wrapped total;
1128
+ // `scrollBy` bounds the keypress from below and leaves the ceiling here.
1129
+ // The one place the two content sources meet. Everything downstream —
1130
+ // windowing, the notice, `fitBlock` — treats them identically, which is
1131
+ // what "a view is just lines" has to mean to be worth anything.
1132
+ //
1133
+ // A view's lines are reflowed here rather than trusted at `mainCols`: the
1134
+ // contract asks a view to lay out to the width it is given, and reflow makes
1135
+ // that a courtesy rather than a rule it can break the grid by ignoring.
1136
+ const body = active !== undefined
1137
+ ? active
1138
+ .lines(this.theme, mainCols)
1139
+ .flatMap((line) => (line === "" ? [""] : reflow(line, mainCols)))
1140
+ : plan.length === 0
1141
+ ? wrapped
1142
+ : [...wrapped, "", ...plan];
1143
+ let main = body;
1144
+ if (scrolled) {
1145
+ const win = scrollWindow(body, Math.max(1, rows - 1), this.scrollOffset);
1146
+ this.scrollOffset = win.offset;
1147
+ // The clamp can land on 0 — a resize that grew the pane past the content,
1148
+ // or scrollback that rolled off underneath the offset. That is the live
1149
+ // view again, and it must not keep a notice claiming lines below it.
1150
+ main =
1151
+ win.offset === 0
1152
+ ? body
1153
+ : [...win.lines, scrollNotice(win.hiddenBelow, this.theme)];
1154
+ }
752
1155
  return {
753
1156
  headerLeft: "cruxy",
754
1157
  // Recomposed each paint, so a served tier that arrives (or changes under
@@ -757,8 +1160,16 @@ export class TuiRenderer {
757
1160
  headerRight: model === undefined
758
1161
  ? this.headerRight
759
1162
  : headerModel(this.theme, this.provider, model),
760
- sidebar: sidebarLines(this.theme, this.sessions, this.activeSessionId),
761
- main: plan.length === 0 ? wrapped : [...wrapped, "", ...plan],
1163
+ // Nav first, then the session list P2 put here. Two blocks rather than
1164
+ // one because they answer different questions "where am I" and "what
1165
+ // else have I run" — and because the sessions list is destined to become
1166
+ // a view of its own, at which point this reduces to the nav.
1167
+ sidebar: [
1168
+ ...navLines(this.theme, this.views, this.selectedView, this.sidebarFocused),
1169
+ "",
1170
+ ...sidebarLines(this.theme, this.sessions, this.activeSessionId),
1171
+ ],
1172
+ main,
762
1173
  rail: stacked.lines,
763
1174
  status: this.statusLine(width),
764
1175
  input: this.inputLine,