@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.
- package/dist/agent/status.js +96 -0
- package/dist/cli/commands/run.js +54 -3
- package/dist/cli/session-commands.js +16 -49
- package/dist/components/keys.js +38 -0
- package/dist/config/effective.js +225 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.js +50 -20
- package/dist/errors/constructors.js +90 -10
- package/dist/limits/cache.js +100 -0
- package/dist/limits/index.js +11 -0
- package/dist/limits/reduce.js +172 -0
- package/dist/limits/types.js +25 -0
- package/dist/onboarding/detect.js +95 -9
- package/dist/onboarding/types.js +29 -1
- package/dist/render/diff.js +7 -1
- package/dist/render/status-view.js +58 -6
- package/dist/theme/tokens.js +7 -0
- package/dist/tui/app.js +125 -2
- package/dist/tui/disk-status.js +47 -0
- package/dist/tui/git-status.js +46 -1
- package/dist/tui/git-view.js +121 -0
- package/dist/tui/index.js +8 -2
- package/dist/tui/layout.js +46 -0
- package/dist/tui/limits-panel.js +255 -0
- package/dist/tui/overview.js +61 -0
- package/dist/tui/panels.js +2 -0
- package/dist/tui/renderer.js +431 -20
- package/dist/tui/settings-view.js +282 -0
- package/dist/tui/tasks-view.js +215 -0
- package/dist/tui/views.js +66 -0
- package/dist/utils/disk.js +95 -0
- package/dist/utils/git.js +113 -0
- package/package.json +2 -2
package/dist/tui/renderer.js
CHANGED
|
@@ -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
|
-
/**
|
|
67
|
-
|
|
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
|
-
/**
|
|
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,
|
|
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
|
-
|
|
212
|
-
|
|
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.
|
|
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
|
|
694
|
-
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
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 [...
|
|
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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,
|