@cruxy/cli 1.6.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 CHANGED
@@ -143,6 +143,122 @@ default 10; disable with `checkpoint.enabled = false`).
143
143
  **Boundary:** checkpoints cover working-tree files only. Commits, pushes, and
144
144
  PRs made during a run are never undone — the rollback preview says so.
145
145
 
146
+ ## Terminal & accessibility
147
+
148
+ Cruxy probes the terminal once per run and reduces it to a handful of
149
+ capabilities — color, unicode, cursor control, motion, screen-reader mode, size
150
+ — which every rendered surface resolves a theme from. That is why degradation is
151
+ consistent: there is one place that decides, not one decision per panel.
152
+
153
+ Each axis has an environment override, and **the axes are independent**. Turning
154
+ color off does not change which glyphs are used; asking for ASCII glyphs does not
155
+ turn color off. A `NO_COLOR` terminal still gets `✓`, a colored `CRUXY_ASCII`
156
+ terminal gets a green `[ok]`.
157
+
158
+ ### Color
159
+
160
+ | Variable | Effect |
161
+ | ------------- | ---------------------------------------------------------------- |
162
+ | `NO_COLOR` | No ANSI color anywhere. Not muted color — **zero** escape bytes. |
163
+ | `FORCE_COLOR` | Keep color even when stdout is a pipe or a file. |
164
+
165
+ Color is on by default only when the stream is a TTY. `TERM=dumb` forces it off
166
+ regardless.
167
+
168
+ ### Glyphs
169
+
170
+ | Variable | Effect |
171
+ | ------------- | ----------------------------------------------------------------------------------------- |
172
+ | `CRUXY_ASCII` | Use the ASCII glyph table: `[ok]` `[x]` `[ ]` `->` `...` in place of `✓` `✗` `○` `→` `…`. |
173
+
174
+ Also implied by `TERM=dumb`. Piped output keeps unicode — writing `✓` to a file
175
+ is fine, and forcing ASCII there would change long-standing behaviour for
176
+ everything that already parses cruxy's output.
177
+
178
+ ### Screen readers
179
+
180
+ | Variable | Effect |
181
+ | --------------------- | ---------------------------------------------- |
182
+ | `CRUXY_SCREEN_READER` | Screen-reader mode. |
183
+ | `ACCESSIBLE` | The same thing, under the ecosystem-wide name. |
184
+
185
+ In this mode:
186
+
187
+ - **status glyphs become words** — `✓ read_file` is announced as `done
188
+ read_file`, and `◐` / `○` become `working` / `pending`, so a status mark is
189
+ never read out as bare punctuation;
190
+ - **decorative marks collapse to nothing** — the `↻` cache marker, the text
191
+ cursor bar, and the cells of a progress bar all render as empty, because a run
192
+ of block characters announces as noise and the percentage beside it already
193
+ carries the whole meaning;
194
+ - **animation is off** — screen-reader mode implies reduced motion, since a
195
+ spinner has no live region to animate into.
196
+
197
+ **It is opt-in and never inferred.** Cruxy has no way to detect a screen reader,
198
+ and inferring one from a non-TTY stdout would reword every piped and CI run in
199
+ the world:
200
+
201
+ ```bash
202
+ export CRUXY_SCREEN_READER=1
203
+ ```
204
+
205
+ ### Motion
206
+
207
+ | Variable | Effect |
208
+ | ---------------------- | ---------------------------------------------- |
209
+ | `NO_MOTION` | The ecosystem-wide reduced-motion signal. |
210
+ | `CRUXY_REDUCED_MOTION` | The explicit cruxy knob. |
211
+ | `CRUXY_NO_SPINNER` | Kept as an alias; flows through the same axis. |
212
+
213
+ Any of them disables the frame clock outright — nothing is scheduled and no
214
+ frame ever ticks, so every animation collapses to its static end-state, drawn
215
+ once. Reduced motion is also implied by screen-reader mode.
216
+
217
+ ### The alternate screen
218
+
219
+ | Variable | Effect |
220
+ | --------------------- | ------------------------------------------------------------------------------ |
221
+ | `CRUXY_NO_ALT_SCREEN` | Keep the full-screen TUI in the normal buffer instead of the alternate screen. |
222
+
223
+ By default the TUI runs on the terminal's alternate screen — the second buffer
224
+ `less` and `vim` use. Leaving it restores the normal buffer byte for byte, so
225
+ the prompt you started cruxy from comes back exactly as it was, with no
226
+ frame-shaped hole in your scrollback.
227
+
228
+ On a clean exit the last dozen lines of the conversation are echoed into the
229
+ normal buffer, so the session survives in the shell's scrollback rather than
230
+ being discarded with the alternate screen. The full transcript is the session
231
+ log; this is the tail.
232
+
233
+ The opt-out exists because the alternate screen is not universally available or
234
+ wanted — multiplexers and emulators can be configured to refuse it, some
235
+ capture-and-replay tooling reads only the normal buffer, and you may simply
236
+ prefer your shell to keep the frame. It changes nothing else about the TUI, and
237
+ it is ignored where the TUI does not run at all (a pipe, a screen reader,
238
+ `TERM=dumb`).
239
+
240
+ However cruxy exits — quit, `kill -TERM`, a hangup when the window closes, an
241
+ uncaught error — the terminal is handed back: the frame erased, the cursor
242
+ shown, the alternate screen left.
243
+
244
+ ### Size
245
+
246
+ | Variable | Effect |
247
+ | --------- | -------------------------------------------- |
248
+ | `COLUMNS` | Terminal width, overriding what it reports. |
249
+ | `LINES` | Terminal height, overriding what it reports. |
250
+
251
+ These win over the stream's own values, which is what makes
252
+ `COLUMNS=100 cruxy …` work in CI where the terminal reports no size at all. They
253
+ fall back to 80 × 24. Unlike the flags above they take a **positive integer**; a
254
+ value that is not one is ignored rather than treated as "set".
255
+
256
+ ### The "set" convention
257
+
258
+ Every variable except `COLUMNS` / `LINES` follows the `NO_COLOR` rule: **any
259
+ non-empty value counts as set.** `CRUXY_ASCII=1` and `CRUXY_ASCII=false` both
260
+ enable ASCII glyphs — to turn one off, unset it or set it to the empty string.
261
+
146
262
  ## Errors & exit codes
147
263
 
148
264
  Every user-facing error prints a title, the cause (when known), concrete next
@@ -10,7 +10,7 @@ import { UsageCollector, accumulateCacheTokens, } from "../usage/index.js";
10
10
  import { Budget } from "./budget.js";
11
11
  import { estimateTokens, findCut } from "./context.js";
12
12
  import { runAgent, } from "./loop.js";
13
- import { DEFAULT_MODE, modeAutoApproves, modePlans, nextMode, parseMode, } from "./mode.js";
13
+ import { DEFAULT_MODE, modeAutoApproves, modePlans, parseMode, } from "./mode.js";
14
14
  import { SUMMARY_SYSTEM, COMPACTION_MARKER } from "./prompts.js";
15
15
  /**
16
16
  * Re-exported from `agent/context.ts`, where the estimate now lives beside the
@@ -189,10 +189,6 @@ export class Session {
189
189
  this.args.recorder?.mode(this.mode);
190
190
  return this.mode;
191
191
  }
192
- /** Advance one step around the mode ring (Shift+Tab). Returns the new mode. */
193
- cycleMode() {
194
- return this.setMode(nextMode(this.mode));
195
- }
196
192
  /** Whether this session proposes a plan before executing (C.31). */
197
193
  getPlanMode() {
198
194
  return modePlans(this.mode);
@@ -4,9 +4,15 @@ import { resolveTheme } from "../theme/index.js";
4
4
  const CLEAR_LINE = "\r\x1b[2K";
5
5
  /** Move the cursor up one row. */
6
6
  const CURSOR_UP = "\x1b[1A";
7
+ /** Erase the whole row the cursor is on, wherever the cursor is in it. */
8
+ const ERASE_ROW = "\x1b[2K";
9
+ /** Park the cursor at row 1, column 1. */
10
+ const CURSOR_HOME = "\x1b[H";
11
+ /** Park the cursor at the start of an absolute row (1-based). */
12
+ const cursorToRow = (row) => `\x1b[${row};1H`;
7
13
  /** The visible text of a possibly-styled row (re-exported from the U.12 home). */
8
14
  export { stripAnsi };
9
- export function createFrame(write, caps) {
15
+ export function createFrame(write, caps, opts = {}) {
10
16
  let drawn = 0;
11
17
  let lastLines = [];
12
18
  const ellipsis = resolveTheme(caps).glyph.ellipsis;
@@ -20,6 +26,16 @@ export function createFrame(write, caps) {
20
26
  const erase = () => {
21
27
  if (drawn === 0)
22
28
  return;
29
+ if (opts.absolute) {
30
+ // Every row named, then home — nothing is inferred from where the cursor
31
+ // happens to be, which is the whole point of the mode.
32
+ let out = "";
33
+ for (let row = 1; row <= drawn; row++)
34
+ out += cursorToRow(row) + ERASE_ROW;
35
+ write(out + CURSOR_HOME);
36
+ drawn = 0;
37
+ return;
38
+ }
23
39
  // Cursor sits at the end of the last drawn row: clear it, then walk up
24
40
  // clearing each prior row, ending at column 0 of the first frame row.
25
41
  let out = CLEAR_LINE;
@@ -28,7 +44,29 @@ export function createFrame(write, caps) {
28
44
  write(out);
29
45
  drawn = 0;
30
46
  };
47
+ const paintAbsolute = (lines) => {
48
+ // NO ERASE PASS. Each row is cleared as part of being rewritten, so the
49
+ // screen never passes through a blank intermediate state — which is both
50
+ // one fewer write and one fewer chance to flicker.
51
+ let out = "";
52
+ for (const [index, line] of lines.entries()) {
53
+ out += cursorToRow(index + 1) + ERASE_ROW + fitRow(line);
54
+ }
55
+ // Rows the previous paint used and this one does not. A shorter frame must
56
+ // not leave its own tail on screen.
57
+ for (let row = lines.length + 1; row <= drawn; row++) {
58
+ out += cursorToRow(row) + ERASE_ROW;
59
+ }
60
+ if (out !== "")
61
+ write(out);
62
+ drawn = lines.length;
63
+ };
31
64
  const paint = (lines) => {
65
+ if (opts.absolute) {
66
+ lastLines = lines;
67
+ paintAbsolute(lines);
68
+ return;
69
+ }
32
70
  erase();
33
71
  lastLines = lines;
34
72
  if (lines.length === 0)
@@ -657,15 +657,29 @@ export function gitPushFailed(branch, stderr) {
657
657
  });
658
658
  }
659
659
  // ── plan mode (exit 2 / 10) ───────────────────────────────────────────────────
660
- /** The agent's proposed plan was missing or malformed (plan mode, C.31). */
660
+ /**
661
+ * The agent's proposed plan was MALFORMED (plan mode, C.31).
662
+ *
663
+ * Malformed, and only malformed. A turn that ends with no plan at all is not
664
+ * this: the propose phase is read-only plus `submit_plan`, so the model cannot
665
+ * have acted, and a plain answer to a plain question is an ordinary completed
666
+ * turn — `runPlanSession` returns it rather than raising here.
667
+ *
668
+ * The next steps say `/mode manual` because that is the lever a user in a
669
+ * planning mode actually has. Shift+Tab is how most people arrive at `plan` and
670
+ * `full-auto` in the first place, and there is no flag on the running session to
671
+ * take back; `--plan` only chooses what mode a fresh `cruxy run` STARTS in, so it
672
+ * is named last and only for that case.
673
+ */
661
674
  export function planInvalid(reason) {
662
675
  return new CruxyError({
663
676
  code: ErrorCode.PlanInvalid,
664
677
  title: "the agent did not produce a valid plan",
665
678
  cause: reason,
666
679
  nextSteps: [
667
- "retry the task — the model must call `submit_plan` with at least one step",
668
- "or run without `--plan` to execute directly",
680
+ "retry the task — a plan needs at least one step, each with a title and a rationale",
681
+ "or leave planning for this session: `/mode manual` (Shift+Tab cycles the same ring)",
682
+ "for a one-shot `cruxy run`, drop `--plan` / `agent.planMode` to execute directly",
669
683
  ],
670
684
  meta: { reason },
671
685
  });
@@ -678,7 +692,10 @@ export function planRevisionLimit(limit) {
678
692
  cause: "the revision limit was reached without an approved plan",
679
693
  nextSteps: [
680
694
  "restate the task more concretely, or split it into smaller tasks",
681
- "run without `--plan` to execute directly",
695
+ // Same lever as planInvalid's, for the same reason: the session was most
696
+ // likely cycled into a planning mode with Shift+Tab, and there is no flag
697
+ // on it to drop.
698
+ "or leave planning for this session: `/mode manual` (Shift+Tab cycles the same ring)",
682
699
  ],
683
700
  meta: { limit },
684
701
  });
package/dist/lsp/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./types.js";
2
- export { DEFAULT_SPECS, EXT_TO_LANGUAGE, LspRegistry, binaryOnPath, languageForFile, } from "./registry.js";
2
+ export { DEFAULT_SPECS, EXT_TO_LANGUAGE, LspRegistry, binaryOnPath, languageForFile, resolveBinary, } from "./registry.js";
3
3
  export { StdioTransport, TransportTimeoutError, killTree, } from "./transport.js";
4
4
  export { Server } from "./server.js";
5
5
  export { LspPool } from "./pool.js";
@@ -58,27 +58,45 @@ export function languageForFile(filePath) {
58
58
  return EXT_TO_LANGUAGE[ext] ?? null;
59
59
  }
60
60
  /**
61
- * Default binary-presence check: resolves `command` the way a shell would.
61
+ * Resolve `command` to an executable FILE, the way a shell would, or `null`.
62
+ *
62
63
  * An absolute/relative path is tested directly; a bare name is searched across
63
- * `PATH` (with `PATHEXT` on win32). Pure filesystem probing never spawns.
64
- * Injectable via {@link LspRegistry} so tests can force present/absent.
64
+ * `PATH`. On win32 each candidate is also tried with every `PATHEXT` suffix,
65
+ * which is the only way `pnpm` finds `pnpm.cmd` — Windows installs almost every
66
+ * npm-shipped tool as a `.cmd` shim, and a lookup that tries the bare name alone
67
+ * reports a globally installed binary as absent.
68
+ *
69
+ * Pure filesystem probing — never spawns. THE ONLY PATH RESOLVER in the CLI
70
+ * besides `tools/shell/resolve-shell.ts`'s win32 shell hunt, deliberately: a
71
+ * third one would be a third place for the PATHEXT rule to be forgotten.
65
72
  */
66
- export const binaryOnPath = (command) => {
73
+ export function resolveBinary(command, dirs = (process.env.PATH ?? "")
74
+ .split(path.delimiter)
75
+ .filter(Boolean)) {
76
+ // An explicit path names the file outright — nothing to search.
67
77
  if (command.includes(path.sep) || command.includes("/")) {
68
- return isExecutable(command);
78
+ return isExecutable(command) ? command : null;
69
79
  }
70
- const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
80
+ // PATHEXT only, never a bare `command` fallback on win32: a directory that
81
+ // holds `pnpm.cmd` usually holds an extensionless `pnpm` beside it (the sh
82
+ // shim npm installs for Git Bash), and that one cannot be spawned there.
71
83
  const exts = process.platform === "win32"
72
84
  ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
73
85
  : [""];
74
86
  for (const dir of dirs) {
75
87
  for (const ext of exts) {
76
- if (isExecutable(path.join(dir, command + ext)))
77
- return true;
88
+ const candidate = path.join(dir, command + ext);
89
+ if (isExecutable(candidate))
90
+ return candidate;
78
91
  }
79
92
  }
80
- return false;
81
- };
93
+ return null;
94
+ }
95
+ /**
96
+ * Default binary-presence check — {@link resolveBinary} reduced to a yes/no.
97
+ * Injectable via {@link LspRegistry} so tests can force present/absent.
98
+ */
99
+ export const binaryOnPath = (command) => resolveBinary(command) !== null;
82
100
  function isExecutable(candidate) {
83
101
  try {
84
102
  const stat = fs.statSync(candidate);
@@ -10,6 +10,9 @@ import { makeSubmitPlanTool } from "./submit-plan.js";
10
10
  * read-only + `submit_plan` registry so the agent cannot act before approval;
11
11
  * execution runs with the full registry, one step at a time, per-action U.3
12
12
  * gating intact.
13
+ *
14
+ * A propose phase that ends with NO plan is a completed conversational turn, not
15
+ * an error — see the `!holder.plan` branch below.
13
16
  */
14
17
  /** Default cap on plan revisions before failing loud. */
15
18
  export const MAX_PLAN_REVISIONS = 3;
@@ -81,9 +84,31 @@ export async function runPlanSession(args) {
81
84
  onRequestUsage: args.onRequestUsage,
82
85
  }));
83
86
  if (!holder.plan) {
84
- throw planInvalid("the model ended its turn without calling submit_plan");
87
+ // NO PLAN IS NOT A FAILURE. The propose phase's registry is read-only plus
88
+ // `submit_plan`, so a turn that ends without a plan has structurally not
89
+ // ACTED — it has ANSWERED. "what does this function do?" is a perfectly
90
+ // ordinary thing to type while a planning mode is on, and the model
91
+ // replying to it is the correct outcome, not a malformed plan.
92
+ //
93
+ // Throwing here was also silently LOSING that reply. The session records
94
+ // the user's turn before the runner is called and adopts the returned
95
+ // history after it returns, so an exception left the question on disk with
96
+ // no answer beside it — a transcript that is wrong, not merely unhelpful.
97
+ // Returning the accumulated history keeps the answer in both.
98
+ //
99
+ // This holds on a revision pass too: a model that answers a piece of
100
+ // feedback with a question instead of a new plan is asking the user
101
+ // something, and the user can now answer it on the next turn.
102
+ return finish();
85
103
  }
86
104
  const plan = holder.plan;
105
+ // The one thing that IS invalid: a plan object with nothing executable in
106
+ // it. `submit_plan` rejects this to the model's face, so reaching it means
107
+ // something set the holder directly — fail loud rather than run an empty
108
+ // approval prompt over zero steps.
109
+ if (plan.steps.length === 0) {
110
+ throw planInvalid("submit_plan produced a plan with no steps");
111
+ }
87
112
  const decision = await promptPlanDecision(plan, args.io);
88
113
  if (decision.kind === "abort") {
89
114
  // Committed output goes through the renderer, not the prompt io (P3):
@@ -38,6 +38,17 @@ export function makeSubmitPlanTool(holder) {
38
38
  if (input.steps.length === 0) {
39
39
  return { ok: false, error: "a plan must have at least one step" };
40
40
  }
41
+ // `min(1)` counts characters, so a title of `" "` clears zod and then
42
+ // trims to nothing — a step that renders as an empty row in the approval
43
+ // prompt and as an empty instruction during execution. Rejected the same
44
+ // way as any other tool-input error, so the model resubmits.
45
+ const blank = input.steps.findIndex((s) => s.title.trim() === "" || s.rationale.trim() === "");
46
+ if (blank !== -1) {
47
+ return {
48
+ ok: false,
49
+ error: `step ${blank + 1} has a blank title or rationale — every step needs both`,
50
+ };
51
+ }
41
52
  const steps = input.steps.map((s, i) => ({
42
53
  id: String(i + 1),
43
54
  title: s.title.trim(),
@@ -1,7 +1,14 @@
1
1
  import { shouldUseColor } from "../errors/index.js";
2
2
  import { detectScreenReader, detectUnicode } from "../theme/index.js";
3
- /** Set-and-non-empty (the NO_COLOR convention): any non-empty value counts. */
4
- function isSet(value) {
3
+ /**
4
+ * Set-and-non-empty (the NO_COLOR convention): any non-empty value counts.
5
+ *
6
+ * Exported so every cruxy env flag reads the SAME rule. The README states it
7
+ * once for all of them — `CRUXY_ASCII=false` enables ASCII — and a flag that
8
+ * quietly parsed its value instead would make that documentation wrong for one
9
+ * variable with nothing to point at.
10
+ */
11
+ export function isSet(value) {
5
12
  return value !== undefined && value !== "";
6
13
  }
7
14
  /** Fallback width when the terminal reports none (non-TTY, pipe, unknown). */
@@ -8,7 +8,7 @@ import { TtyRenderer } from "./tty-renderer.js";
8
8
  import { TuiRenderer } from "../tui/renderer.js";
9
9
  import { GitStatusCache } from "../tui/git-status.js";
10
10
  import { ToolVersions } from "../tui/tool-versions.js";
11
- import { supportsTui } from "../tui/supports.js";
11
+ import { supportsTui, usesAltScreen } from "../tui/supports.js";
12
12
  export { detectCapabilities, detectReducedMotion, resolveColumns, resolveRows, DEFAULT_COLUMNS, DEFAULT_ROWS, } from "./capabilities.js";
13
13
  export { attachResize, processResizeSignal, } from "./resize.js";
14
14
  export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
@@ -53,6 +53,11 @@ export function createRenderer(out = process.stdout, err = process.stderr, env =
53
53
  // Constructed, not started: `ToolVersions` probes nothing until the
54
54
  // renderer paints the panel, so this costs an object and no subprocess.
55
55
  tools: new ToolVersions(),
56
+ // Resolved against THIS factory's env (Q4 track 3), not `process.env`.
57
+ // The renderer's own default reads the real environment, which is right
58
+ // for a direct construction and wrong here: an injected env exists
59
+ // precisely so a caller can describe a terminal that is not this one.
60
+ altScreen: usesAltScreen(caps, env),
56
61
  });
57
62
  }
58
63
  return caps.cursor
package/dist/tui/app.js CHANGED
@@ -5,6 +5,7 @@ import { TUI_ONLY_COMMANDS } from "../cli/command-catalog.js";
5
5
  import { selectList } from "../components/select.js";
6
6
  import { viewLabel, viewOrder } from "./views.js";
7
7
  import { canOverlay, createKeyLease, createOverlayIO, } from "./overlay.js";
8
+ import { ModeRing } from "./mode-ring.js";
8
9
  import { openPalette } from "./palette.js";
9
10
  import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
10
11
  import { CLOSABLE_PANELS, columnOf, RAIL_PANELS, } from "./layout.js";
@@ -263,7 +264,11 @@ async function readLine(keys, renderer, hooks) {
263
264
  // Mode is a property of the session, not of the message — losing a
264
265
  // half-written prompt to a mode switch would make the binding one
265
266
  // people learn not to press.
266
- 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);
267
272
  paint();
268
273
  break;
269
274
  case "page-up":
@@ -302,6 +307,11 @@ async function readLine(keys, renderer, hooks) {
302
307
  }
303
308
  }
304
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();
305
315
  keys.restore();
306
316
  }
307
317
  }
@@ -476,9 +486,14 @@ export async function runTui(session, renderer, opts = {}) {
476
486
  theme: renderer.theme,
477
487
  fit: (line) => line,
478
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);
479
493
  const hooks = {
480
- mode: () => session.getMode(),
481
- cycleMode: () => announceMode(out, session.cycleMode()),
494
+ mode: () => ring.current(),
495
+ cycleMode: (repaint) => ring.advance(repaint),
496
+ settleMode: () => ring.settle(),
482
497
  openPalette: () => openPalette(renderer, lease, opts.slashCommands ?? []),
483
498
  };
484
499
  // The TUI's picker (P6 track 1): an overlay drawer on the SAME reader this
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";
@@ -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
+ }
@@ -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
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The terminal-restore backstop (Q4 track 1).
3
+ *
4
+ * `TuiRenderer.close()` hands the terminal back — it erases the frame, and from
5
+ * track 3 on it also leaves the alternate screen and shows the cursor again.
6
+ * That covers exactly one way a run ends: the orderly one. Every other way, the
7
+ * process dies with the shell still painted and the terminal still configured
8
+ * the way the TUI left it, and the user's next prompt lands in the middle of a
9
+ * frame that nothing will ever erase. `kill -TERM`, a hangup when the terminal
10
+ * window closes, an `ssh` session dropping, `kill -INT` from another pane — all
11
+ * of them terminate the process by DEFAULT DISPOSITION, which runs no JavaScript
12
+ * at all.
13
+ *
14
+ * So the restore is registered with the process rather than left to the caller:
15
+ *
16
+ * - **`SIGINT` / `SIGTERM` / `SIGHUP`** — restore, then exit with the shell's
17
+ * conventional 128 + signal code. `process.exit` rather than re-raising is
18
+ * deliberate: Node restores the original termios (`ResetStdio`, an `atexit`
19
+ * hook) on a normal exit but not when the default disposition fells the
20
+ * process, and the TUI holds stdin in RAW MODE for most of its life. Dying
21
+ * from the signal itself would leave a terminal with no echo and no line
22
+ * editing — a worse outcome than the painted frame this module exists to
23
+ * clean up.
24
+ * - **`exit`** — the last line, for the paths no signal handler sees: an
25
+ * uncaught throw, an explicit `process.exit` somewhere else, and the signal
26
+ * handlers that other modules install first ({@link ../utils/child-tree.js}
27
+ * registers `SIGINT` too, and whichever ran first calls `process.exit`
28
+ * straight away). Synchronous work only, which a write to a TTY is.
29
+ *
30
+ * Handlers are installed on the FIRST registration and removed when the last
31
+ * guard goes away, so a closed TUI leaves the process exactly as it found it and
32
+ * N renderers (a test file builds dozens) still cost one listener per event.
33
+ * This is the same shape as the child-process exit backstop in
34
+ * {@link ../utils/child-tree.js}, for the same reason.
35
+ */
36
+ /** Signals that end a run before the normal teardown gets a turn. */
37
+ export const RESTORE_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
38
+ /**
39
+ * The exit code a shell reports for a process felled by each signal: 128 + n.
40
+ * Stated rather than inherited, because these handlers exit explicitly (see the
41
+ * raw-mode note above) and an exit code of 0 after a `kill -TERM` would tell
42
+ * every script that reads one that the run succeeded.
43
+ */
44
+ export const SIGNAL_EXIT_CODES = {
45
+ SIGINT: 130,
46
+ SIGTERM: 143,
47
+ SIGHUP: 129,
48
+ };
49
+ /** Live guards, in registration order. Each entry is its own idempotent runner. */
50
+ const guards = new Set();
51
+ /** The installed listeners, or null when no guard is registered. */
52
+ let handlers = null;
53
+ /**
54
+ * Run every registered restore, once each. Exported because it IS what the
55
+ * handlers run: a test that calls this is testing the real path rather than a
56
+ * reimplementation of it.
57
+ *
58
+ * Iterates a copy — a restore deregisters itself as it runs — and a restore
59
+ * that throws does not stop the others. There is nowhere to report an error
60
+ * from here (the process is on its way out, and the screen is the thing that
61
+ * would show it), and one guard failing must not leave the next one's terminal
62
+ * in the alternate screen forever.
63
+ */
64
+ export function restoreAllScreens() {
65
+ for (const run of [...guards])
66
+ run();
67
+ }
68
+ function installHandlers(host) {
69
+ if (handlers !== null)
70
+ return;
71
+ const entries = [
72
+ ["exit", restoreAllScreens],
73
+ ...RESTORE_SIGNALS.map((signal) => [
74
+ signal,
75
+ () => {
76
+ restoreAllScreens();
77
+ host.exit(SIGNAL_EXIT_CODES[signal]);
78
+ },
79
+ ]),
80
+ ];
81
+ for (const [event, listener] of entries)
82
+ host.on(event, listener);
83
+ handlers = { host, entries };
84
+ }
85
+ function removeHandlers() {
86
+ if (handlers === null)
87
+ return;
88
+ for (const [event, listener] of handlers.entries) {
89
+ handlers.host.off(event, listener);
90
+ }
91
+ handlers = null;
92
+ }
93
+ /**
94
+ * Register `restore` to run if the process is signalled or exits, and return the
95
+ * handle that also lets the owner run it directly.
96
+ *
97
+ * The first registration installs the handlers; the last one to go removes
98
+ * them. `host` is read only on that first call — it names the process the
99
+ * handlers are installed on, and there is only ever one.
100
+ */
101
+ export function installScreenGuard(restore, host = process) {
102
+ let restored = false;
103
+ let live = true;
104
+ const deregister = () => {
105
+ if (!live)
106
+ return;
107
+ live = false;
108
+ guards.delete(run);
109
+ if (guards.size === 0)
110
+ removeHandlers();
111
+ };
112
+ const run = () => {
113
+ if (restored)
114
+ return;
115
+ restored = true;
116
+ // Deregistered BEFORE the restore runs, so a throwing restore still gives
117
+ // the handlers up rather than pinning them for the life of the process.
118
+ deregister();
119
+ try {
120
+ restore();
121
+ }
122
+ catch {
123
+ // See `restoreAllScreens`: there is no surface left to report on.
124
+ }
125
+ };
126
+ guards.add(run);
127
+ installHandlers(host);
128
+ return {
129
+ restoreScreen: run,
130
+ restored: () => restored,
131
+ dispose: deregister,
132
+ };
133
+ }
134
+ /** Guards currently registered — for tests, the way `trackedTreeCount` is. */
135
+ export function screenGuardCount() {
136
+ return guards.size;
137
+ }
@@ -1,3 +1,4 @@
1
+ import { isSet } from "../render/capabilities.js";
1
2
  /**
2
3
  * Whether the environment can host the full-viewport TUI.
3
4
  *
@@ -18,3 +19,24 @@
18
19
  export function supportsTui(caps) {
19
20
  return caps.interactive && !caps.screenReader;
20
21
  }
22
+ /**
23
+ * Whether the TUI runs on the ALTERNATE SCREEN (Q4 track 3) — the terminal's
24
+ * second buffer, the one `less` and `vim` use.
25
+ *
26
+ * Gated on {@link supportsTui} and nothing new: a terminal that cannot host the
27
+ * shell has no business being switched to another buffer, and the two questions
28
+ * have exactly one answer between them. Adding a second capability check here
29
+ * would only create a state where the TUI runs but its screen management does
30
+ * not — which is the drift the one gate exists to prevent.
31
+ *
32
+ * `CRUXY_NO_ALT_SCREEN` opts out, following the same set-and-non-empty rule as
33
+ * every other cruxy flag. The opt-out exists because the alternate screen is not
34
+ * universally available or wanted: multiplexers and terminal emulators can be
35
+ * configured to refuse it, some capture-and-replay tooling reads only the normal
36
+ * buffer, and a user who prefers the shell to keep the frame in its scrollback
37
+ * should not have to stop using the TUI to get that. Opting out changes nothing
38
+ * else — the shell, the row addressing and the restore backstop are unaffected.
39
+ */
40
+ export function usesAltScreen(caps, env = process.env) {
41
+ return supportsTui(caps) && !isSet(env.CRUXY_NO_ALT_SCREEN);
42
+ }
@@ -1,4 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ // The concrete module, not `lsp/index.js`: that barrel pulls in the transport,
4
+ // the pool and the client, and this panel exists to keep cost off the startup
5
+ // path. `resolveBinary` is a leaf over `node:fs`.
6
+ import { resolveBinary } from "../lsp/registry.js";
2
7
  /**
3
8
  * Host tool versions for the rail (P4 track 5).
4
9
  *
@@ -20,9 +25,82 @@ import { execFile } from "node:child_process";
20
25
  * DAEMON and reports a stopped Docker as absent. This panel is reporting what
21
26
  * is INSTALLED, so it asks the client (`docker --version`), which answers with
22
27
  * the daemon down.
28
+ *
29
+ * WHICH tsc, though. The panel sits beside a repo, and the toolchain that
30
+ * matters there is the repo's: a project pinning TypeScript 5.4 in its
31
+ * devDependencies was being reported at whatever version happens to be global,
32
+ * or as "not found" when nothing is global at all — the ordinary case, since
33
+ * `pnpm`/`npx` run these out of `node_modules/.bin` and most people never
34
+ * install them globally. So each tool is looked for in `node_modules/.bin`,
35
+ * walking up from the working directory the way Node's own resolution does,
36
+ * before falling back to `PATH`.
37
+ *
38
+ * That lookup is {@link resolveBinary}, the LSP registry's — which also fixes a
39
+ * latent Windows bug this module had on the PATH half. Windows installs
40
+ * npm-shipped tools as `.cmd` shims, and `execFile("tsc")` there resolves
41
+ * nothing: `tsc.cmd` needs the `PATHEXT` sweep the registry already does by
42
+ * stat, and then needs `cmd.exe` to run it at all.
23
43
  */
24
44
  /** Hard ceiling on one probe; a hung binary must never wedge the panel. */
25
45
  const PROBE_TIMEOUT_MS = 5000;
46
+ /**
47
+ * Every `node_modules/.bin` from `from` up to the filesystem root, nearest
48
+ * first — the same walk Node's module resolution does, and the same order
49
+ * `pnpm`/`npx` pick a binary in. Directories that do not exist cost nothing:
50
+ * `resolveBinary` stats the candidate and moves on.
51
+ */
52
+ export function binDirsFrom(from) {
53
+ const dirs = [];
54
+ let dir = path.resolve(from);
55
+ for (;;) {
56
+ dirs.push(path.join(dir, "node_modules", ".bin"));
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir)
59
+ return dirs;
60
+ dir = parent;
61
+ }
62
+ }
63
+ /**
64
+ * Where a probe should actually spawn `bin` from: the nearest project-local
65
+ * copy, else whatever `PATH` offers, else `null` — which is an ANSWER ("not
66
+ * installed"), reached without spawning anything.
67
+ */
68
+ export function resolveToolBinary(bin, from) {
69
+ return resolveBinary(bin, binDirsFrom(from)) ?? resolveBinary(bin);
70
+ }
71
+ /**
72
+ * How to invoke a resolved binary.
73
+ *
74
+ * On win32 a `.cmd`/`.bat` is a script, not an image: `CreateProcess` cannot run
75
+ * one, and Node has REFUSED to try since the CVE-2024-27980 fix — it throws
76
+ * EINVAL rather than silently handing the arguments to a shell. So a shim is run
77
+ * through `cmd.exe` explicitly. Everything else is spawned directly, unchanged.
78
+ *
79
+ * THE DOUBLE WRAP IS NOT A TYPO, and it is the whole reason this is a named
80
+ * function with its own tests. cmd.exe strips the first and last quote of the
81
+ * `/c` string when the string both begins and ends with one. Quoting only the
82
+ * path gives `cmd /c "C:\Program Files\x\t.cmd"` — begins and ends with a quote,
83
+ * so cmd removes both and then tries to run `C:\Program`. Appending `--version`
84
+ * happens to hide it (the string no longer ends in a quote), which is the worst
85
+ * kind of working: it breaks the day a probe takes no arguments, and every tool
86
+ * here is one `args: []` away from that.
87
+ *
88
+ * Wrapping the whole command line in a second pair makes `/s` the rule instead
89
+ * of an accident: `/s` strips exactly the outer pair and preserves the rest
90
+ * verbatim, so the inner quotes reach the parser intact either way. Node must
91
+ * not re-quote on top, hence `windowsVerbatimArguments` at the spawn.
92
+ */
93
+ export function invocationFor(file, args, platform = process.platform, comSpec = process.env.ComSpec ?? "cmd.exe") {
94
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
95
+ const line = [`"${file}"`, ...args].join(" ");
96
+ return {
97
+ file: comSpec,
98
+ args: ["/d", "/s", "/c", `"${line}"`],
99
+ verbatim: true,
100
+ };
101
+ }
102
+ return { file, args: [...args], verbatim: false };
103
+ }
26
104
  /**
27
105
  * Pull a semantic version out of a tool's `--version` line.
28
106
  *
@@ -35,24 +113,47 @@ const PROBE_TIMEOUT_MS = 5000;
35
113
  export function parseVersion(output) {
36
114
  return /\d+\.\d+\.\d+(?:[-+][\w.]+)?/.exec(output)?.[0] ?? null;
37
115
  }
38
- /** Default probe: spawn, capture, and treat any failure as "not installed". */
39
- const spawnProbe = (bin, args) => new Promise((resolve) => {
40
- try {
41
- execFile(bin, [...args], { encoding: "utf8", timeout: PROBE_TIMEOUT_MS, windowsHide: true }, (err, stdout, stderr) => {
42
- // Some tools print their version to stderr; a non-zero exit with a
43
- // parseable version still tells us the tool is there.
44
- const text = `${stdout ?? ""}${stderr ?? ""}`;
45
- if (err && text.trim() === "")
46
- resolve(null);
47
- else
48
- resolve(text);
49
- });
50
- }
51
- catch {
52
- // execFile can throw synchronously on a malformed binary path.
53
- resolve(null);
54
- }
55
- });
116
+ /**
117
+ * The default probe: resolve, spawn, capture and treat any failure as "not
118
+ * installed". Resolution comes first, so a tool that is nowhere costs a few
119
+ * `stat` calls rather than a process that has to fail.
120
+ *
121
+ * `from` exists so the win32 e2e can point a real probe at a real `.cmd` shim in
122
+ * a temp project. Left unset it reads `process.cwd()` PER CALL, never once at
123
+ * module load the panel must follow the session's directory, not the one the
124
+ * process happened to start in.
125
+ */
126
+ export function makeSpawnProbe(from) {
127
+ return (bin, args) => new Promise((resolve) => {
128
+ const resolved = resolveToolBinary(bin, from ?? process.cwd());
129
+ if (resolved === null) {
130
+ resolve(null);
131
+ return;
132
+ }
133
+ const call = invocationFor(resolved, args);
134
+ try {
135
+ execFile(call.file, call.args, {
136
+ encoding: "utf8",
137
+ timeout: PROBE_TIMEOUT_MS,
138
+ windowsHide: true,
139
+ ...(call.verbatim ? { windowsVerbatimArguments: true } : {}),
140
+ }, (err, stdout, stderr) => {
141
+ // Some tools print their version to stderr; a non-zero exit with a
142
+ // parseable version still tells us the tool is there.
143
+ const text = `${stdout ?? ""}${stderr ?? ""}`;
144
+ if (err && text.trim() === "")
145
+ resolve(null);
146
+ else
147
+ resolve(text);
148
+ });
149
+ }
150
+ catch {
151
+ // execFile can throw synchronously on a malformed binary path.
152
+ resolve(null);
153
+ }
154
+ });
155
+ }
156
+ const spawnProbe = makeSpawnProbe();
56
157
  /**
57
158
  * The tools reported, in display order.
58
159
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {