@cruxy/cli 1.5.0 → 1.7.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/README.md +116 -0
- package/dist/agent/session.js +76 -7
- package/dist/budget/index.js +9 -0
- package/dist/budget/session-budget.js +223 -0
- package/dist/checkpoint/diff.js +130 -0
- package/dist/checkpoint/git-store.js +52 -0
- package/dist/checkpoint/index.js +2 -0
- package/dist/checkpoint/run-rollback.js +100 -0
- package/dist/cli/command-catalog.js +144 -0
- package/dist/cli/commands/hooks.js +1 -1
- package/dist/cli/commands/rollback.js +21 -57
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/test.js +28 -16
- package/dist/cli/session-commands.js +315 -69
- package/dist/cli/session-factory.js +13 -0
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +43 -4
- package/dist/errors/types.js +15 -0
- package/dist/hooks/config.js +18 -0
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/router.js +1 -1
- package/dist/hooks/service.js +4 -4
- package/dist/hooks/slash.js +10 -26
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- package/dist/memory/secrets.js +43 -0
- package/dist/plan/service.js +26 -1
- package/dist/plan/submit-plan.js +11 -0
- package/dist/render/capabilities.js +9 -2
- package/dist/render/context-view.js +2 -2
- package/dist/render/index.js +6 -1
- package/dist/render/plan-view.js +1 -1
- package/dist/render/status-view.js +5 -5
- package/dist/render/units.js +22 -0
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +19 -0
- package/dist/session/redact.js +74 -0
- package/dist/session/replay.js +16 -0
- package/dist/session/resume.js +8 -0
- package/dist/session/types.js +38 -0
- package/dist/subagent/orchestrator.js +82 -5
- package/dist/theme/resolve.js +1 -0
- package/dist/tui/app.js +25 -7
- package/dist/tui/approval-overlay.js +7 -1
- package/dist/tui/index.js +3 -2
- package/dist/tui/layout.js +7 -2
- package/dist/tui/limits-panel.js +6 -14
- package/dist/tui/mode-ring.js +84 -0
- package/dist/tui/palette.js +11 -19
- package/dist/tui/renderer.js +145 -4
- package/dist/tui/restore.js +137 -0
- package/dist/tui/supports.js +22 -0
- package/dist/tui/tool-versions.js +119 -18
- package/dist/usage/weighted.js +14 -0
- package/dist/utils/disk.js +11 -3
- package/package.json +2 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { runAgent } from "../agent/loop.js";
|
|
3
|
-
import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
3
|
+
import { CruxyError, ErrorCode, messageOf, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
4
|
+
import { UNRESOLVED_TIER, } from "../budget/index.js";
|
|
5
|
+
import { resolveTaskModel } from "../routing/index.js";
|
|
4
6
|
import { Workspace } from "../workspace/index.js";
|
|
5
7
|
import { Budget, resolveBudget } from "../agent/budget.js";
|
|
6
8
|
import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
|
|
@@ -181,6 +183,28 @@ export class SubagentOrchestrator {
|
|
|
181
183
|
return [];
|
|
182
184
|
// Refuse overlapping write scope BEFORE any child is dispatched.
|
|
183
185
|
this.assertDisjointWriteScopes(specs);
|
|
186
|
+
// Admission control (P10 track 3 / cli#212), also before dispatch. Ordered
|
|
187
|
+
// AFTER the scope check on purpose: an overlapping fan-out is malformed and
|
|
188
|
+
// must be refused whatever the budget says, and narrowing a malformed batch
|
|
189
|
+
// to two children would hide the overlap rather than report it.
|
|
190
|
+
const admitted = this.admitFanOut(specs);
|
|
191
|
+
if (admitted.kind === "refused") {
|
|
192
|
+
throw sessionBudgetExhausted(admitted.reason);
|
|
193
|
+
}
|
|
194
|
+
// The children that do not run come back as an explicit NOT-ADMITTED result
|
|
195
|
+
// carrying the reason, never as a silently shorter array. The parent model is
|
|
196
|
+
// the one that has to decide what to do about a half-dispatched plan, and it
|
|
197
|
+
// can only do that if it is told — a fan-out of 5 that returns 2 results with
|
|
198
|
+
// no explanation reads as three crashes. Position is preserved for the same
|
|
199
|
+
// reason the dispatched half preserves it: result i is spec i, always.
|
|
200
|
+
const dispatch = specs.slice(0, admitted.count);
|
|
201
|
+
const deferred = [];
|
|
202
|
+
if (admitted.kind === "narrowed") {
|
|
203
|
+
this.deps.logger.warn(admitted.reason);
|
|
204
|
+
for (let i = admitted.count; i < specs.length; i++) {
|
|
205
|
+
deferred.push(notAdmittedResult(admitted.reason));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
184
208
|
const controller = new AbortController();
|
|
185
209
|
const onExternalAbort = () => controller.abort();
|
|
186
210
|
if (opts.signal) {
|
|
@@ -189,10 +213,10 @@ export class SubagentOrchestrator {
|
|
|
189
213
|
else
|
|
190
214
|
opts.signal.addEventListener("abort", onExternalAbort, { once: true });
|
|
191
215
|
}
|
|
192
|
-
const results = new Array(
|
|
193
|
-
const total =
|
|
216
|
+
const results = new Array(dispatch.length);
|
|
217
|
+
const total = dispatch.length;
|
|
194
218
|
try {
|
|
195
|
-
const settled = await Promise.allSettled(
|
|
219
|
+
const settled = await Promise.allSettled(dispatch.map((spec, i) => this.sem.run(async () => {
|
|
196
220
|
// Already cancelled (a fatal sibling or Ctrl-C fired first): record an
|
|
197
221
|
// honest cancelled result instead of starting a doomed run.
|
|
198
222
|
if (controller.signal.aborted) {
|
|
@@ -223,12 +247,51 @@ export class SubagentOrchestrator {
|
|
|
223
247
|
if (results[i] === undefined)
|
|
224
248
|
results[i] = cancelledResult();
|
|
225
249
|
}
|
|
226
|
-
return results;
|
|
250
|
+
return [...results, ...deferred];
|
|
227
251
|
}
|
|
228
252
|
finally {
|
|
229
253
|
opts.signal?.removeEventListener("abort", onExternalAbort);
|
|
230
254
|
}
|
|
231
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Ask the session budget whether this batch fits (P10 track 3 / cli#212).
|
|
258
|
+
*
|
|
259
|
+
* The estimate is the batch's CEILING, not a guess at its actual draw:
|
|
260
|
+
* `count × perChildTokens × multiplier`, where `perChildTokens` is the local
|
|
261
|
+
* cap each child already runs under. A spec may narrow its own budget, so the
|
|
262
|
+
* per-child figure is the resolved one rather than the configured default —
|
|
263
|
+
* a fan-out of five deliberately-cheap children should not be refused on the
|
|
264
|
+
* arithmetic of five expensive ones.
|
|
265
|
+
*
|
|
266
|
+
* Tier: the router resolves per task class, and a child defaults to the
|
|
267
|
+
* `subagent` class. When routing cannot name a tier the request still draws on
|
|
268
|
+
* the pool, so it is weighed at the worst case rather than skipped — see
|
|
269
|
+
* `UNRESOLVED_TIER`.
|
|
270
|
+
*/
|
|
271
|
+
admitFanOut(specs) {
|
|
272
|
+
const budget = this.deps.budget;
|
|
273
|
+
if (!budget)
|
|
274
|
+
return { kind: "allow", count: specs.length, maxTokens: 0 };
|
|
275
|
+
const { defaultBudget } = this.deps.config.subagent;
|
|
276
|
+
// The heaviest child in the batch sets the per-run figure. The bound has to
|
|
277
|
+
// hold for the batch as dispatched, and averaging would let one 64k child
|
|
278
|
+
// hide behind four 4k ones.
|
|
279
|
+
const perRunTokens = specs.reduce((max, spec) => Math.max(max, resolveBudget(defaultBudget, spec.budget).maxTokens), 0);
|
|
280
|
+
return budget.admit({
|
|
281
|
+
count: specs.length,
|
|
282
|
+
perRunTokens,
|
|
283
|
+
tier: this.fanOutTier(),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/** The tier a child would route to, or the unresolved/no-pool markers. */
|
|
287
|
+
fanOutTier() {
|
|
288
|
+
const router = this.deps.router;
|
|
289
|
+
if (!router)
|
|
290
|
+
return undefined; // no cruxy routing — not a weighted-pool request
|
|
291
|
+
// No tier means the router chose `auto` and the gateway decides — a request
|
|
292
|
+
// that still draws on the pool, so it is weighed at the worst case.
|
|
293
|
+
return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
|
|
294
|
+
}
|
|
232
295
|
/**
|
|
233
296
|
* Resolve a child's scope from an optional root name. With a name: a
|
|
234
297
|
* single-root workspace over that root (writes confined to it) + that root's
|
|
@@ -344,6 +407,20 @@ function cancelledResult() {
|
|
|
344
407
|
usage: { input_tokens: 0, output_tokens: 0 },
|
|
345
408
|
};
|
|
346
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* A child the budget would not admit. Zero usage and zero iterations, because
|
|
412
|
+
* that is literally what it consumed — a fabricated non-zero here would show up
|
|
413
|
+
* in the parent's accounting as spend that never happened.
|
|
414
|
+
*/
|
|
415
|
+
function notAdmittedResult(reason) {
|
|
416
|
+
return {
|
|
417
|
+
status: "not-admitted",
|
|
418
|
+
summary: "",
|
|
419
|
+
error: `${ErrorCode.SessionBudgetExhausted}: ${reason}`,
|
|
420
|
+
iterations: 0,
|
|
421
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
422
|
+
};
|
|
423
|
+
}
|
|
347
424
|
/** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
|
|
348
425
|
function artifactsField(artifacts) {
|
|
349
426
|
return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
|
package/dist/theme/resolve.js
CHANGED
package/dist/tui/app.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { DEFAULT_MODE, MODE_LABELS, modeAutoApproves, } from "../agent/index.js";
|
|
2
2
|
import { completeLine } from "../components/autocomplete.js";
|
|
3
3
|
import { SHARED_COMMANDS, SHARED_HELP, announceMode, dispatchCommand, } from "../cli/session-commands.js";
|
|
4
|
+
import { TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
|
|
4
5
|
import { selectList } from "../components/select.js";
|
|
5
6
|
import { viewLabel, viewOrder } from "./views.js";
|
|
6
7
|
import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
|
|
8
|
+
import { ModeRing } from "./mode-ring.js";
|
|
7
9
|
import { openPalette } from "./palette.js";
|
|
8
10
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
9
11
|
import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
|
|
@@ -31,18 +33,20 @@ import { COLUMN_LABELS, PANEL_LABELS } from "./panels.js";
|
|
|
31
33
|
*/
|
|
32
34
|
/**
|
|
33
35
|
* Every command the TUI completes on Tab — the shared set plus this shell's own
|
|
34
|
-
* panel commands (P5 track 5).
|
|
36
|
+
* panel and view commands (P5 track 5).
|
|
35
37
|
*
|
|
36
38
|
* This constant existed before and was consumed by NOTHING: exported from
|
|
37
39
|
* `tui/index.ts`, imported nowhere, while the TUI had no completion at all.
|
|
38
40
|
* It is wired to Tab now rather than deleted, because the thing it was reaching
|
|
39
41
|
* for — the REPL's readline completer, which the TUI cannot use — is real.
|
|
42
|
+
*
|
|
43
|
+
* Both halves are derived (P10 track 0). The three names used to be written out
|
|
44
|
+
* here, in the palette, and in this file's `HELP`, and the reserved set knew
|
|
45
|
+
* about none of them.
|
|
40
46
|
*/
|
|
41
47
|
export const TUI_COMMANDS = [
|
|
42
48
|
...SHARED_COMMANDS,
|
|
43
|
-
|
|
44
|
-
"/open",
|
|
45
|
-
"/view",
|
|
49
|
+
...TUI_ONLY_COMMANDS.map((c) => c.name),
|
|
46
50
|
].sort();
|
|
47
51
|
const HELP = [
|
|
48
52
|
"commands:",
|
|
@@ -260,7 +264,11 @@ async function readLine(keys, renderer, hooks) {
|
|
|
260
264
|
// Mode is a property of the session, not of the message — losing a
|
|
261
265
|
// half-written prompt to a mode switch would make the binding one
|
|
262
266
|
// people learn not to press.
|
|
263
|
-
|
|
267
|
+
//
|
|
268
|
+
// The chip moves on this keypress; the session hears about it when the
|
|
269
|
+
// ring stops turning (Q5). Passing `paint` gives the ring a way to
|
|
270
|
+
// correct the chip if the settle resolves somewhere else.
|
|
271
|
+
hooks.cycleMode(paint);
|
|
264
272
|
paint();
|
|
265
273
|
break;
|
|
266
274
|
case "page-up":
|
|
@@ -299,6 +307,11 @@ async function readLine(keys, renderer, hooks) {
|
|
|
299
307
|
}
|
|
300
308
|
}
|
|
301
309
|
finally {
|
|
310
|
+
// The line is over — by Enter, by EOF, by Ctrl+C. Whatever the ring stopped
|
|
311
|
+
// on is what the user chose, and it is committed and announced HERE rather
|
|
312
|
+
// than a debounce later, so the mode is in force before the turn it was
|
|
313
|
+
// chosen for runs and its log event lands ahead of that turn's messages.
|
|
314
|
+
hooks.settleMode();
|
|
302
315
|
keys.restore();
|
|
303
316
|
}
|
|
304
317
|
}
|
|
@@ -473,9 +486,14 @@ export async function runTui(session, renderer, opts = {}) {
|
|
|
473
486
|
theme: renderer.theme,
|
|
474
487
|
fit: (line) => line,
|
|
475
488
|
};
|
|
489
|
+
// The mode ring (Q5). The chip follows every press; the session is told once,
|
|
490
|
+
// when the presses stop — see `mode-ring.ts` for why passing through a mode is
|
|
491
|
+
// not the same as choosing it.
|
|
492
|
+
const ring = new ModeRing(session, (mode) => announceMode(out, mode), opts.modeSettleMs);
|
|
476
493
|
const hooks = {
|
|
477
|
-
mode: () =>
|
|
478
|
-
cycleMode: () =>
|
|
494
|
+
mode: () => ring.current(),
|
|
495
|
+
cycleMode: (repaint) => ring.advance(repaint),
|
|
496
|
+
settleMode: () => ring.settle(),
|
|
479
497
|
openPalette: () => openPalette(renderer, lease, opts.slashCommands ?? []),
|
|
480
498
|
};
|
|
481
499
|
// The TUI's picker (P6 track 1): an overlay drawer on the SAME reader this
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { themeForColor } from "../theme/index.js";
|
|
1
2
|
/**
|
|
2
3
|
* The approval prompt as an in-viewport modal (P5 track 2).
|
|
3
4
|
*
|
|
@@ -28,6 +29,11 @@
|
|
|
28
29
|
* gives exactly that behaviour without the prompt knowing it is in a modal.
|
|
29
30
|
*/
|
|
30
31
|
export function createOverlayPromptIO(surface, lease, color) {
|
|
32
|
+
// The caret comes from the glyph table, not a literal (P11 track 1). It is a
|
|
33
|
+
// decorative mark whose meaning is carried by the text beside it, so the table
|
|
34
|
+
// degrades it to `|` on an ASCII terminal and to nothing at all for a screen
|
|
35
|
+
// reader — where a lone `▏` announces as noise on the end of every keystroke.
|
|
36
|
+
const t = themeForColor(color);
|
|
31
37
|
/** Everything the prompt has written this interaction, verbatim. */
|
|
32
38
|
let transcript = "";
|
|
33
39
|
/** The line being typed into a `readLine` follow-up, if one is open. */
|
|
@@ -46,7 +52,7 @@ export function createOverlayPromptIO(surface, lease, color) {
|
|
|
46
52
|
// always shows where the next character lands.
|
|
47
53
|
if (typing !== null) {
|
|
48
54
|
rows[Math.max(0, rows.length - 1)] =
|
|
49
|
-
`${rows[rows.length - 1] ?? ""}${typing}
|
|
55
|
+
`${rows[rows.length - 1] ?? ""}${typing}${t.glyph.cursorBar}`;
|
|
50
56
|
}
|
|
51
57
|
surface.setOverlay(rows);
|
|
52
58
|
};
|
package/dist/tui/index.js
CHANGED
|
@@ -8,9 +8,10 @@ export { createOverviewView } from "./overview.js";
|
|
|
8
8
|
export { createGitView, gitViewLines, GIT_VIEW_MAX_FILES, } from "./git-view.js";
|
|
9
9
|
export { createTasksView, tasksViewLines, TASKS_VIEW_MAX_JOBS, TASKS_VIEW_TAIL, } from "./tasks-view.js";
|
|
10
10
|
export { createSettingsView, settingsViewLines, formatSettingValue, } from "./settings-view.js";
|
|
11
|
-
export { TuiRenderer, PAINT_INTERVAL_MS, VIEW_PULSE_MS, SCROLL_PAGE_OVERLAP, SCROLLBACK_LINES, } from "./renderer.js";
|
|
11
|
+
export { TuiRenderer, EXIT_TAIL_LINES, PAINT_INTERVAL_MS, VIEW_PULSE_MS, SCROLL_PAGE_OVERLAP, SCROLLBACK_LINES, } from "./renderer.js";
|
|
12
12
|
export { CONVERSATION_VIEW, cycleView, navLines, viewLabel, viewOrder, } from "./views.js";
|
|
13
13
|
export { runTui, renderInput, TUI_COMMANDS } from "./app.js";
|
|
14
14
|
export { openPalette, paletteItems, paletteInsertion, paletteLabel, } from "./palette.js";
|
|
15
15
|
export { canOverlay, createKeyLease, createOverlayFrame, createOverlayIO, } from "./overlay.js";
|
|
16
|
-
export { supportsTui } from "./supports.js";
|
|
16
|
+
export { supportsTui, usesAltScreen } from "./supports.js";
|
|
17
|
+
export { installScreenGuard, restoreAllScreens, screenGuardCount, RESTORE_SIGNALS, SIGNAL_EXIT_CODES, } from "./restore.js";
|
package/dist/tui/layout.js
CHANGED
|
@@ -164,10 +164,15 @@ export function scrollWindow(lines, rows, offset) {
|
|
|
164
164
|
*
|
|
165
165
|
* It names the key because a scrolled view is a mode, and a mode the user
|
|
166
166
|
* cannot see the exit from is a trap — there is no scrollbar here to drag.
|
|
167
|
+
*
|
|
168
|
+
* The marks come from the glyph table (P11 track 1). This used to branch on
|
|
169
|
+
* `theme.unicode ? "↓" : "v"`, which is the resolver reimplemented inline and
|
|
170
|
+
* one table short: screen-reader mode leaves `unicode` TRUE and swaps the table
|
|
171
|
+
* instead, so the branch printed a bare `↓` to the one reader who could not use
|
|
172
|
+
* it, where `theme.glyph.caretDown` says "down".
|
|
167
173
|
*/
|
|
168
174
|
export function scrollNotice(hiddenBelow, theme) {
|
|
169
|
-
|
|
170
|
-
return theme.warning(`${glyph} ${hiddenBelow} more line${hiddenBelow === 1 ? "" : "s"} below · PgDn / Esc to return`);
|
|
175
|
+
return theme.warning(`${theme.glyph.caretDown} ${hiddenBelow} more line${hiddenBelow === 1 ? "" : "s"} below${theme.sep}PgDn / Esc to return`);
|
|
171
176
|
}
|
|
172
177
|
/** Rows the overflow notice costs when at least one panel is dropped. */
|
|
173
178
|
const OVERFLOW_ROWS = 1;
|
package/dist/tui/limits-panel.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compactTokens } from "../render/units.js";
|
|
1
2
|
import { bindingWindow, usedFraction } from "../limits/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* The limits panel (P9): what this credential may spend, and how much is left.
|
|
@@ -49,20 +50,11 @@ function stateStyle(theme, state) {
|
|
|
49
50
|
return theme.warning;
|
|
50
51
|
return theme.strong;
|
|
51
52
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (abs >= 1_000)
|
|
58
|
-
return `${trimZero(n / 1_000)}k`;
|
|
59
|
-
return `${Math.round(n)}`;
|
|
60
|
-
}
|
|
61
|
-
/** One decimal, but only when it says something: 8.7 stays, 14.0 becomes 14. */
|
|
62
|
-
function trimZero(n) {
|
|
63
|
-
const one = n.toFixed(1);
|
|
64
|
-
return one.endsWith(".0") ? one.slice(0, -2) : one;
|
|
65
|
-
}
|
|
53
|
+
/**
|
|
54
|
+
* Re-exported, not defined here any more (P10 track 3): `/budget` states the
|
|
55
|
+
* same windows in the same unit, so the two share one formatter.
|
|
56
|
+
*/
|
|
57
|
+
export { compactTokens };
|
|
66
58
|
/** 27.77 → "$27.77", 29 → "$29", 0.5 → "$0.50". */
|
|
67
59
|
export function usd(n) {
|
|
68
60
|
return Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { nextMode } from "../agent/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* The Shift+Tab mode ring, debounced (Q5).
|
|
4
|
+
*
|
|
5
|
+
* Shift+Tab walks a four-entry cycle, so reaching `full-auto` from `manual`
|
|
6
|
+
* means passing THROUGH `auto-approve` and `plan`. Every press used to be a
|
|
7
|
+
* commit: three `setMode` calls, three `mode` events appended to the session
|
|
8
|
+
* log, and three announcements — two of them for modes the user was not stopping
|
|
9
|
+
* on and never saw. The log then read as a session that deliberately chose
|
|
10
|
+
* auto-approve, then planning, then full-auto, which is not what happened; and
|
|
11
|
+
* the conversation column filled with descriptions of modes that were already
|
|
12
|
+
* gone by the time they were painted.
|
|
13
|
+
*
|
|
14
|
+
* So the ring turns freely and commits once. `current()` is the mode the ring is
|
|
15
|
+
* POINTING AT — the chip reads it, so every press still lands instantly on
|
|
16
|
+
* screen, which is the feedback the keypress owes the user. The session only
|
|
17
|
+
* hears about it once the presses stop, and that single commit is what gets
|
|
18
|
+
* announced and journaled.
|
|
19
|
+
*
|
|
20
|
+
* The chip is not a promise. `setMode` returns the EFFECTIVE mode (a session
|
|
21
|
+
* with no plan runner cannot enter a planning mode), so a settle that resolves
|
|
22
|
+
* elsewhere corrects the chip and announces where it actually landed.
|
|
23
|
+
*/
|
|
24
|
+
/** Quiet period after the last press before the ring is taken as settled. */
|
|
25
|
+
export const MODE_SETTLE_MS = 400;
|
|
26
|
+
export class ModeRing {
|
|
27
|
+
session;
|
|
28
|
+
announce;
|
|
29
|
+
settleMs;
|
|
30
|
+
/** Where the ring is pointing, or null when it matches the committed mode. */
|
|
31
|
+
pending = null;
|
|
32
|
+
timer = null;
|
|
33
|
+
/** Repaint for a settle that lands on the timer rather than on a key. */
|
|
34
|
+
repaint;
|
|
35
|
+
constructor(session, announce, settleMs = MODE_SETTLE_MS) {
|
|
36
|
+
this.session = session;
|
|
37
|
+
this.announce = announce;
|
|
38
|
+
this.settleMs = settleMs;
|
|
39
|
+
}
|
|
40
|
+
/** The mode to SHOW. Pure — safe on the paint path, called every keystroke. */
|
|
41
|
+
current() {
|
|
42
|
+
return this.pending ?? this.session.getMode();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One press of Shift+Tab: advance the ring and restart the quiet period.
|
|
46
|
+
* `repaint` runs after a settle that lands on the timer, because the input
|
|
47
|
+
* row's chip is a stored string and a correction to it needs a new paint.
|
|
48
|
+
*/
|
|
49
|
+
advance(repaint) {
|
|
50
|
+
this.pending = nextMode(this.current());
|
|
51
|
+
this.repaint = repaint;
|
|
52
|
+
this.arm();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Commit what the ring stopped on, now — the caller knows the turn is over
|
|
56
|
+
* (Enter, EOF) and will not wait out the timer. A no-op when nothing is
|
|
57
|
+
* pending, so it is safe in a `finally` on every line.
|
|
58
|
+
*/
|
|
59
|
+
settle() {
|
|
60
|
+
this.disarm();
|
|
61
|
+
const want = this.pending;
|
|
62
|
+
if (want === null)
|
|
63
|
+
return;
|
|
64
|
+
this.pending = null;
|
|
65
|
+
this.announce(this.session.setMode(want));
|
|
66
|
+
}
|
|
67
|
+
arm() {
|
|
68
|
+
this.disarm();
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
this.timer = null;
|
|
71
|
+
this.settle();
|
|
72
|
+
this.repaint?.();
|
|
73
|
+
}, this.settleMs);
|
|
74
|
+
// A decorative debounce must never be the reason a process stays alive.
|
|
75
|
+
timer.unref?.();
|
|
76
|
+
this.timer = timer;
|
|
77
|
+
}
|
|
78
|
+
disarm() {
|
|
79
|
+
if (this.timer === null)
|
|
80
|
+
return;
|
|
81
|
+
clearTimeout(this.timer);
|
|
82
|
+
this.timer = null;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/tui/palette.js
CHANGED
|
@@ -1,29 +1,21 @@
|
|
|
1
1
|
import { fuzzyFind } from "../components/fuzzy.js";
|
|
2
|
-
import { COMMAND_CATALOG } from "../cli/
|
|
2
|
+
import { COMMAND_CATALOG, TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
|
|
3
3
|
import { canOverlay, createOverlayIO } from "./overlay.js";
|
|
4
|
-
/** The TUI's own commands, which the shared catalogue deliberately excludes. */
|
|
5
|
-
const PANEL_COMMANDS = [
|
|
6
|
-
{
|
|
7
|
-
name: "/close",
|
|
8
|
-
summary: "hide a panel or the whole rail",
|
|
9
|
-
args: "<sidebar | context | model | git | tools | rail>",
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
name: "/open",
|
|
13
|
-
summary: "show a hidden panel",
|
|
14
|
-
args: "<sidebar | context | model | git | tools | rail>",
|
|
15
|
-
},
|
|
16
|
-
];
|
|
17
4
|
/**
|
|
18
|
-
* Everything the palette offers: the shared catalogue, this shell's
|
|
5
|
+
* Everything the palette offers: the shared catalogue, this shell's own
|
|
19
6
|
* commands, and the project's own slash commands.
|
|
20
7
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
8
|
+
* The TUI's own three are {@link TUI_ONLY_COMMANDS} rather than a copy kept
|
|
9
|
+
* here. The copy had drifted — it listed `/close` and `/open` and had never
|
|
10
|
+
* gained `/view`, so the one shell with a palette was also the one place `/view`
|
|
11
|
+
* could not be discovered.
|
|
12
|
+
*
|
|
13
|
+
* Custom commands come LAST and are labelled. A custom command can no longer be
|
|
14
|
+
* named after a reserved one at all (the loader refuses the file), so this list
|
|
15
|
+
* cannot contain two rows for one name.
|
|
24
16
|
*/
|
|
25
17
|
export function paletteItems(slashCommands = []) {
|
|
26
|
-
const builtins = [...COMMAND_CATALOG, ...
|
|
18
|
+
const builtins = [...COMMAND_CATALOG, ...TUI_ONLY_COMMANDS].map((c) => ({
|
|
27
19
|
name: c.name,
|
|
28
20
|
summary: c.summary,
|
|
29
21
|
...(c.args === undefined ? {} : { args: c.args }),
|
package/dist/tui/renderer.js
CHANGED
|
@@ -12,6 +12,8 @@ import { budgetColumns, bodyRows, composeScreen, droppedForWidth, fitOverlay, ov
|
|
|
12
12
|
import { CONVERSATION_VIEW, cycleView, navLines, } from "./views.js";
|
|
13
13
|
import { contextPanelLines, gitPanelLines, headerModel, mainWelcome, modelPanelLines, railBlocks, sidebarLines, toolsPanelLines, } from "./panels.js";
|
|
14
14
|
import { limitsPanelLines } from "./limits-panel.js";
|
|
15
|
+
import { installScreenGuard, } from "./restore.js";
|
|
16
|
+
import { usesAltScreen } from "./supports.js";
|
|
15
17
|
/**
|
|
16
18
|
* The full-viewport renderer (P1) — the fourth {@link StreamRenderer}, and the
|
|
17
19
|
* only one that owns the whole screen rather than a single managed line.
|
|
@@ -35,8 +37,36 @@ import { limitsPanelLines } from "./limits-panel.js";
|
|
|
35
37
|
* Paints are marked dirty and flushed at most every {@link PAINT_INTERVAL_MS},
|
|
36
38
|
* which keeps streaming smooth without a redraw per token.
|
|
37
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* Enter the alternate screen buffer and hide the cursor (Q4 track 3), and the
|
|
42
|
+
* exact inverse. Emitted as one pair by one object, in one order, so there is
|
|
43
|
+
* no arrangement of them that can leave a terminal half-restored.
|
|
44
|
+
*
|
|
45
|
+
* The cursor is hidden because this renderer DRAWS its caret (see
|
|
46
|
+
* `renderInput`): the real cursor parks wherever the last painted row ended,
|
|
47
|
+
* which is a second, wrong caret blinking somewhere in the frame.
|
|
48
|
+
*/
|
|
49
|
+
const ENTER_ALT_SCREEN = "\x1b[?1049h\x1b[?25l";
|
|
50
|
+
const LEAVE_ALT_SCREEN = "\x1b[?25h\x1b[?1049l";
|
|
38
51
|
/** Coalescing window for repaints (~30fps). */
|
|
39
52
|
export const PAINT_INTERVAL_MS = 33;
|
|
53
|
+
/**
|
|
54
|
+
* Logical lines of the conversation echoed into the NORMAL buffer when the TUI
|
|
55
|
+
* exits cleanly (Q4 track 3).
|
|
56
|
+
*
|
|
57
|
+
* The alternate screen is discarded when it is left — that is the whole point
|
|
58
|
+
* of it, and it is why the shell comes back exactly as it was. It also means
|
|
59
|
+
* the session the user just had would be gone the instant they typed `/exit`:
|
|
60
|
+
* no transcript in the scrollback, nothing to copy an error message out of,
|
|
61
|
+
* nothing to page back through. Losing that is a real regression, and an env
|
|
62
|
+
* knob nobody finds is not a fix for it.
|
|
63
|
+
*
|
|
64
|
+
* A dozen lines is chosen to be a REMINDER rather than a transcript. It is
|
|
65
|
+
* enough to carry the last answer and the command that produced it, and short
|
|
66
|
+
* enough that quitting a long session does not dump a screenful into the shell.
|
|
67
|
+
* The session log is the transcript; this is the tail.
|
|
68
|
+
*/
|
|
69
|
+
export const EXIT_TAIL_LINES = 12;
|
|
40
70
|
/**
|
|
41
71
|
* How often a selected view that reports itself {@link ViewSource.live} gets
|
|
42
72
|
* repainted with no other event to prompt it (P7 track 5).
|
|
@@ -57,6 +87,18 @@ export class TuiRenderer {
|
|
|
57
87
|
theme;
|
|
58
88
|
out;
|
|
59
89
|
frame;
|
|
90
|
+
/**
|
|
91
|
+
* The process-level restore backstop (Q4 track 1). Installed at construction
|
|
92
|
+
* — the moment this renderer starts owning the screen — and dropped in
|
|
93
|
+
* {@link close}, so a process with no TUI up carries no handlers.
|
|
94
|
+
*/
|
|
95
|
+
guard;
|
|
96
|
+
/** Whether this renderer took the alternate screen and owes the inverse. */
|
|
97
|
+
altScreen;
|
|
98
|
+
/** Lines the opening banner occupies — the buffer's contents at construction. */
|
|
99
|
+
openingLines;
|
|
100
|
+
/** Logical lines committed since construction, INCLUDING ones rolled off. */
|
|
101
|
+
committed = 0;
|
|
60
102
|
/** Committed conversation content, UNWRAPPED — wrapped fresh at each paint. */
|
|
61
103
|
buffer = [];
|
|
62
104
|
/** The streamed line still being assembled (no newline seen yet). */
|
|
@@ -149,7 +191,35 @@ export class TuiRenderer {
|
|
|
149
191
|
this.initialModel = opts.model;
|
|
150
192
|
this.tools = opts.tools;
|
|
151
193
|
this.buffer = mainWelcome(this.theme, "/help for commands · /exit to quit");
|
|
152
|
-
this.
|
|
194
|
+
this.openingLines = this.buffer.length;
|
|
195
|
+
// THE ALTERNATE SCREEN (Q4 track 3), taken before a single byte is painted.
|
|
196
|
+
//
|
|
197
|
+
// This is what makes track 2's absolute addressing safe to use here. Rows
|
|
198
|
+
// land at screen positions 1..n, so without a buffer of its own the shell
|
|
199
|
+
// would overwrite whatever the terminal was showing — the user's last
|
|
200
|
+
// screenful of shell history, gone rather than scrolled into scrollback.
|
|
201
|
+
// On the alternate screen there is nothing to overwrite, and leaving it
|
|
202
|
+
// restores the normal buffer byte for byte: the prompt cruxy was started
|
|
203
|
+
// from comes back exactly as it was, with no frame-shaped hole in it.
|
|
204
|
+
//
|
|
205
|
+
// The inverse is owed from this line onward, which is why track 1 landed
|
|
206
|
+
// first: `close()` is not the only way out of this constructor's reach.
|
|
207
|
+
this.altScreen = opts.altScreen ?? usesAltScreen(caps);
|
|
208
|
+
if (this.altScreen)
|
|
209
|
+
out.write(ENTER_ALT_SCREEN);
|
|
210
|
+
// ABSOLUTE rows (Q4 track 2). This renderer owns the viewport outright, so
|
|
211
|
+
// it has no reason to infer where its rows are from where the cursor was
|
|
212
|
+
// left — and every reason not to. A frame this tall repaints thirty times a
|
|
213
|
+
// second; one cursor position the walk got wrong (a stray byte, a soft-wrap
|
|
214
|
+
// the width math did not predict, a scroll) would put every subsequent
|
|
215
|
+
// paint one row further off, with no paint able to recover. Row 1 is row 1.
|
|
216
|
+
this.frame = createFrame((text) => this.out.write(text), caps, {
|
|
217
|
+
absolute: true,
|
|
218
|
+
});
|
|
219
|
+
// Installed here, not in `close()`'s vicinity, because the window it covers
|
|
220
|
+
// opens with the first paint: from this line on there is a shell on screen
|
|
221
|
+
// that only this object knows how to take down.
|
|
222
|
+
this.guard = installScreenGuard(() => this.releaseScreen(), opts.signals);
|
|
153
223
|
// The single shared clock (U.10): an inert clock under reduced motion, so
|
|
154
224
|
// the spinner is drawn once statically and no timer is ever scheduled.
|
|
155
225
|
this.clock = createFrameClock(caps.spinner);
|
|
@@ -763,11 +833,78 @@ export class TuiRenderer {
|
|
|
763
833
|
this.unsubscribeResize = null;
|
|
764
834
|
this.unsubscribeModel?.();
|
|
765
835
|
this.unsubscribeModel = null;
|
|
766
|
-
// Erase the whole shell
|
|
767
|
-
//
|
|
768
|
-
|
|
836
|
+
// Erase the whole shell through the SAME path a signal takes (Q4 track 1),
|
|
837
|
+
// so a clean exit and a killed one leave the terminal in one state rather
|
|
838
|
+
// than two that drift apart the next time either is edited. The guard is
|
|
839
|
+
// idempotent, so the two can also race harmlessly.
|
|
840
|
+
this.guard.restoreScreen();
|
|
841
|
+
this.guard.dispose();
|
|
842
|
+
// AFTER the screen is handed back, so this lands in the normal buffer and
|
|
843
|
+
// survives in the shell's scrollback (Q4 track 3). On the clean path only:
|
|
844
|
+
// a signal handler's job is to give the terminal up, not to write a report
|
|
845
|
+
// into a shell the user may not be looking at.
|
|
846
|
+
this.printExitTail();
|
|
769
847
|
}
|
|
770
848
|
// ── internals ─────────────────────────────────────────────────────────────
|
|
849
|
+
/**
|
|
850
|
+
* Hand the terminal back (Q4 track 1): everything {@link close} does to the
|
|
851
|
+
* SCREEN, and nothing it does to the session. This is what the signal and
|
|
852
|
+
* exit handlers run, so it must be safe from a context where nothing async
|
|
853
|
+
* can be awaited and nothing that throws will be reported.
|
|
854
|
+
*
|
|
855
|
+
* After the TUI releases the screen the terminal holds zero leftover bytes
|
|
856
|
+
* from it, exactly like a resolved component frame.
|
|
857
|
+
*
|
|
858
|
+
* `closed` is set here rather than only in `close()`: on the signal path this
|
|
859
|
+
* is the last thing that runs before `process.exit`, and a repaint scheduled
|
|
860
|
+
* in between would draw a shell back onto a terminal that has just been
|
|
861
|
+
* handed over.
|
|
862
|
+
*/
|
|
863
|
+
releaseScreen() {
|
|
864
|
+
this.closed = true;
|
|
865
|
+
this.frame.clear();
|
|
866
|
+
// The exact inverse of the constructor's pair, and the last bytes this
|
|
867
|
+
// renderer ever writes to the managed screen.
|
|
868
|
+
if (this.altScreen)
|
|
869
|
+
this.out.write(LEAVE_ALT_SCREEN);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Echo the tail of the conversation into the normal buffer (Q4 track 3).
|
|
873
|
+
*
|
|
874
|
+
* The alternate screen is discarded when it is left, so without this the
|
|
875
|
+
* session would vanish the moment the user typed `/exit` — no transcript in
|
|
876
|
+
* the scrollback, no error message left to copy, nothing to page back
|
|
877
|
+
* through. That is a regression the alternate screen would otherwise trade
|
|
878
|
+
* for a clean prompt, and it is not a trade worth making silently.
|
|
879
|
+
*
|
|
880
|
+
* Nothing is printed for a session that produced nothing: opening the shell
|
|
881
|
+
* and quitting leaves the terminal exactly as it was found, which is what the
|
|
882
|
+
* alternate screen is for. When some output DID roll past the tail, the count
|
|
883
|
+
* says so — and it counts every line ever committed, including the ones that
|
|
884
|
+
* rolled off the scrollback buffer entirely, so the number is the true amount
|
|
885
|
+
* hidden rather than the amount this object still happens to remember.
|
|
886
|
+
*
|
|
887
|
+
* Lines go out unwrapped and untruncated. The normal buffer soft-wraps them,
|
|
888
|
+
* which is right: this is the copy the user keeps, and nothing in it should be
|
|
889
|
+
* cut to a width the terminal is no longer being managed at.
|
|
890
|
+
*/
|
|
891
|
+
printExitTail() {
|
|
892
|
+
if (this.committed === 0)
|
|
893
|
+
return;
|
|
894
|
+
const shown = this.buffer.slice(Math.max(0, this.buffer.length - EXIT_TAIL_LINES));
|
|
895
|
+
if (shown.length === 0)
|
|
896
|
+
return;
|
|
897
|
+
const hidden = this.openingLines + this.committed - shown.length;
|
|
898
|
+
const notice = hidden > 0
|
|
899
|
+
? [
|
|
900
|
+
this.theme.muted(`… ${hidden} earlier line${hidden === 1 ? "" : "s"} not shown`),
|
|
901
|
+
]
|
|
902
|
+
: [];
|
|
903
|
+
// Leading and trailing blank rows: this is a quotation dropped into the
|
|
904
|
+
// shell, and it needs to read as separate from both the prompt above it and
|
|
905
|
+
// whatever the exit path prints below.
|
|
906
|
+
this.out.write(["", ...notice, ...shown, ""].join("\n"));
|
|
907
|
+
}
|
|
771
908
|
newPrinter() {
|
|
772
909
|
return createStreamPrinter((text) => {
|
|
773
910
|
this.commit(this.highlighter.push(text));
|
|
@@ -814,6 +951,10 @@ export class TuiRenderer {
|
|
|
814
951
|
this.scrollOffset += added;
|
|
815
952
|
}
|
|
816
953
|
this.buffer.push(...lines);
|
|
954
|
+
// Counted here rather than measured off the buffer at exit, because the
|
|
955
|
+
// buffer forgets: `committed` has to keep counting past the roll-off below
|
|
956
|
+
// for the exit tail's "N earlier lines" to be a true number.
|
|
957
|
+
this.committed += lines.length;
|
|
817
958
|
if (this.buffer.length > SCROLLBACK_LINES) {
|
|
818
959
|
this.buffer = this.buffer.slice(this.buffer.length - SCROLLBACK_LINES);
|
|
819
960
|
}
|