@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.
Files changed (56) hide show
  1. package/README.md +116 -0
  2. package/dist/agent/session.js +76 -7
  3. package/dist/budget/index.js +9 -0
  4. package/dist/budget/session-budget.js +223 -0
  5. package/dist/checkpoint/diff.js +130 -0
  6. package/dist/checkpoint/git-store.js +52 -0
  7. package/dist/checkpoint/index.js +2 -0
  8. package/dist/checkpoint/run-rollback.js +100 -0
  9. package/dist/cli/command-catalog.js +144 -0
  10. package/dist/cli/commands/hooks.js +1 -1
  11. package/dist/cli/commands/rollback.js +21 -57
  12. package/dist/cli/commands/run.js +9 -2
  13. package/dist/cli/commands/test.js +28 -16
  14. package/dist/cli/session-commands.js +315 -69
  15. package/dist/cli/session-factory.js +13 -0
  16. package/dist/components/frame.js +39 -1
  17. package/dist/errors/constructors.js +43 -4
  18. package/dist/errors/types.js +15 -0
  19. package/dist/hooks/config.js +18 -0
  20. package/dist/hooks/index.js +1 -1
  21. package/dist/hooks/router.js +1 -1
  22. package/dist/hooks/service.js +4 -4
  23. package/dist/hooks/slash.js +10 -26
  24. package/dist/lsp/index.js +1 -1
  25. package/dist/lsp/registry.js +28 -10
  26. package/dist/memory/secrets.js +43 -0
  27. package/dist/plan/service.js +26 -1
  28. package/dist/plan/submit-plan.js +11 -0
  29. package/dist/render/capabilities.js +9 -2
  30. package/dist/render/context-view.js +2 -2
  31. package/dist/render/index.js +6 -1
  32. package/dist/render/plan-view.js +1 -1
  33. package/dist/render/status-view.js +5 -5
  34. package/dist/render/units.js +22 -0
  35. package/dist/session/index.js +1 -0
  36. package/dist/session/log.js +19 -0
  37. package/dist/session/redact.js +74 -0
  38. package/dist/session/replay.js +16 -0
  39. package/dist/session/resume.js +8 -0
  40. package/dist/session/types.js +38 -0
  41. package/dist/subagent/orchestrator.js +82 -5
  42. package/dist/theme/resolve.js +1 -0
  43. package/dist/tui/app.js +25 -7
  44. package/dist/tui/approval-overlay.js +7 -1
  45. package/dist/tui/index.js +3 -2
  46. package/dist/tui/layout.js +7 -2
  47. package/dist/tui/limits-panel.js +6 -14
  48. package/dist/tui/mode-ring.js +84 -0
  49. package/dist/tui/palette.js +11 -19
  50. package/dist/tui/renderer.js +145 -4
  51. package/dist/tui/restore.js +137 -0
  52. package/dist/tui/supports.js +22 -0
  53. package/dist/tui/tool-versions.js +119 -18
  54. package/dist/usage/weighted.js +14 -0
  55. package/dist/utils/disk.js +11 -3
  56. 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(specs.length);
193
- const total = specs.length;
216
+ const results = new Array(dispatch.length);
217
+ const total = dispatch.length;
194
218
  try {
195
- const settled = await Promise.allSettled(specs.map((spec, i) => this.sem.run(async () => {
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() } : {};
@@ -47,6 +47,7 @@ export function resolveTheme(caps) {
47
47
  sep: ` ${glyph.sep} `,
48
48
  color: caps.color,
49
49
  unicode: caps.unicode,
50
+ screenReader: caps.screenReader ?? false,
50
51
  };
51
52
  }
52
53
  /**
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
- "/close",
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
- hooks.cycleMode();
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: () => session.getMode(),
478
- cycleMode: () => announceMode(out, session.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";
@@ -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
- const glyph = theme.unicode ? "" : "v";
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;
@@ -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
- /** 14_000_000 → "14M", 8_700_000 → "8.7M", 125_000 → "125k", 900 → "900". */
53
- export function compactTokens(n) {
54
- const abs = Math.abs(n);
55
- if (abs >= 1_000_000)
56
- return `${trimZero(n / 1_000_000)}M`;
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
+ }
@@ -1,29 +1,21 @@
1
1
  import { fuzzyFind } from "../components/fuzzy.js";
2
- import { COMMAND_CATALOG } from "../cli/session-commands.js";
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 panel
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
- * Custom commands come LAST and are labelled. `resolveSlash` consults builtins
22
- * first, so a custom command named `clear` can never shadow `/clear` listing
23
- * it above the builtin would show an order the dispatcher does not honour.
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, ...PANEL_COMMANDS].map((c) => ({
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 }),
@@ -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.frame = createFrame((text) => this.out.write(text), caps);
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: after the TUI exits the terminal holds zero
767
- // leftover bytes from it, exactly like a resolved component frame.
768
- this.frame.clear();
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
  }