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