@cruxy/cli 1.3.0 → 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.
@@ -8,7 +8,8 @@ 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";
13
14
  /**
14
15
  * The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
@@ -35,8 +36,21 @@ import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelL
35
36
  */
36
37
  /** Coalescing window for repaints (~30fps). */
37
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;
38
50
  /** Logical lines of scrollback kept in memory. Older lines roll off. */
39
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;
40
54
  export class TuiRenderer {
41
55
  caps;
42
56
  theme;
@@ -63,8 +77,18 @@ export class TuiRenderer {
63
77
  overlay = null;
64
78
  /** Panels the user has open. `main` is a column, not a panel, and always paints. */
65
79
  open = new Set(CLOSABLE_PANELS);
66
- /** Per-panel rail content, filled in by the P4 tracks as each lands. */
67
- railState = {};
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;
68
92
  /** Working-tree state for the git panel (P4 track 2); absent → panel unwired. */
69
93
  git;
70
94
  /** Context-budget reading for the context panel (P4 track 3); absent → unwired. */
@@ -88,6 +112,8 @@ export class TuiRenderer {
88
112
  unsubscribeFrame = null;
89
113
  unsubscribeResize = null;
90
114
  paintTimer = null;
115
+ /** The self-changing-view pulse (P7 track 5); null when nothing needs one. */
116
+ viewPulseTimer = null;
91
117
  lastPaintAt = 0;
92
118
  dirty = false;
93
119
  closed = false;
@@ -95,7 +121,15 @@ export class TuiRenderer {
95
121
  headerRight = "";
96
122
  /** Provider name for the composed header (P4 track 4). */
97
123
  provider = "";
98
- /** Saved sessions shown in the sidebar (P2), and which one is running. */
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
+ */
99
133
  sessions = [];
100
134
  activeSessionId;
101
135
  constructor(caps, out, opts = {}) {
@@ -105,13 +139,11 @@ export class TuiRenderer {
105
139
  this.highlighter = createStreamHighlighter(this.theme);
106
140
  this.print = this.newPrinter();
107
141
  this.headerRight = opts.headerRight ?? "";
108
- this.sessions = opts.sessions ?? [];
109
- this.activeSessionId = opts.activeSessionId;
110
142
  this.git = opts.git;
111
143
  this.provider = opts.provider ?? "";
112
144
  this.initialModel = opts.model;
113
145
  this.tools = opts.tools;
114
- this.buffer = mainWelcome(this.theme, opts.hint ?? "/help for commands · /exit to quit");
146
+ this.buffer = mainWelcome(this.theme, "/help for commands · /exit to quit");
115
147
  this.frame = createFrame((text) => this.out.write(text), caps);
116
148
  // The single shared clock (U.10): an inert clock under reduced motion, so
117
149
  // the spinner is drawn once statically and no timer is ever scheduled.
@@ -208,13 +240,150 @@ export class TuiRenderer {
208
240
  });
209
241
  return tools;
210
242
  }
211
- /** Replace one rail panel's content (P4). Absent state renders as "not wired yet". */
212
- setRailPanel(id, lines) {
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) {
213
255
  if (this.closed)
214
256
  return;
215
- this.railState = { ...this.railState, [id]: lines };
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
+ }
216
262
  this.schedulePaint();
217
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
+ }
218
387
  // ── app-owned surfaces ────────────────────────────────────────────────────
219
388
  /**
220
389
  * Replace the sidebar's session list (P2). Called once the session log is
@@ -470,6 +639,7 @@ export class TuiRenderer {
470
639
  this.context?.sample();
471
640
  this.paintNow();
472
641
  this.refreshGit();
642
+ this.refreshViews();
473
643
  }
474
644
  /**
475
645
  * Re-probe the working tree, off the paint path, and repaint when it lands.
@@ -490,6 +660,34 @@ export class TuiRenderer {
490
660
  this.schedulePaint();
491
661
  });
492
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
+ }
493
691
  close() {
494
692
  if (this.closed)
495
693
  return;
@@ -499,6 +697,10 @@ export class TuiRenderer {
499
697
  clearTimeout(this.paintTimer);
500
698
  this.paintTimer = null;
501
699
  }
700
+ if (this.viewPulseTimer !== null) {
701
+ clearTimeout(this.viewPulseTimer);
702
+ this.viewPulseTimer = null;
703
+ }
502
704
  this.unsubscribeResize?.();
503
705
  this.unsubscribeResize = null;
504
706
  this.unsubscribeModel?.();
@@ -533,6 +735,26 @@ export class TuiRenderer {
533
735
  this.partial = "";
534
736
  }
535
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
+ }
536
758
  this.buffer.push(...lines);
537
759
  if (this.buffer.length > SCROLLBACK_LINES) {
538
760
  this.buffer = this.buffer.slice(this.buffer.length - SCROLLBACK_LINES);
@@ -612,6 +834,34 @@ export class TuiRenderer {
612
834
  const width = this.viewportWidth();
613
835
  const height = this.caps.height;
614
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?.();
615
865
  }
616
866
  /** The live-state line, composed by the shared U.4 mapping. */
617
867
  /**
@@ -690,13 +940,55 @@ export class TuiRenderer {
690
940
  // height and which `fitBlock`'s tail rule would otherwise decapitate.
691
941
  const drawer = fitOverlay(this.overlay ?? [], overlayRows(height));
692
942
  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];
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];
698
990
  const wrapped = [];
699
- for (const line of [...slice, ...live]) {
991
+ for (const line of [...source, ...live]) {
700
992
  if (line === "") {
701
993
  wrapped.push("");
702
994
  continue;
@@ -710,7 +1002,16 @@ export class TuiRenderer {
710
1002
  // cap below is what stops a 40-step plan from evicting the conversation
711
1003
  // entirely, and `planChecklist` windows around the running step and says
712
1004
  // how many it hid.
713
- const plan = this.planSteps === null || this.planSteps.length === 0
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
714
1015
  ? []
715
1016
  : planChecklist(this.planSteps, this.theme, Math.max(2, Math.floor(rows / 2)), mainCols);
716
1017
  // The rail is a STACK of fixed panels, not a feed: it is composed to the
@@ -732,8 +1033,10 @@ export class TuiRenderer {
732
1033
  const toolRows = this.tools === undefined || !this.open.has("tools")
733
1034
  ? undefined
734
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.
735
1039
  const railState = {
736
- ...this.railState,
737
1040
  ...(model === undefined
738
1041
  ? {}
739
1042
  : { model: modelPanelLines(this.theme, model) }),
@@ -749,6 +1052,40 @@ export class TuiRenderer {
749
1052
  };
750
1053
  const stacked = stackPanels(railBlocks(this.theme, railState, this.open), rows, this.theme);
751
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
+ }
752
1089
  return {
753
1090
  headerLeft: "cruxy",
754
1091
  // Recomposed each paint, so a served tier that arrives (or changes under
@@ -757,8 +1094,16 @@ export class TuiRenderer {
757
1094
  headerRight: model === undefined
758
1095
  ? this.headerRight
759
1096
  : headerModel(this.theme, this.provider, model),
760
- sidebar: sidebarLines(this.theme, this.sessions, this.activeSessionId),
761
- main: plan.length === 0 ? wrapped : [...wrapped, "", ...plan],
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,
762
1107
  rail: stacked.lines,
763
1108
  status: this.statusLine(width),
764
1109
  input: this.inputLine,