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