@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 +116 -0
- package/dist/agent/session.js +1 -5
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +21 -4
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- 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/index.js +6 -1
- package/dist/tui/app.js +18 -3
- package/dist/tui/index.js +3 -2
- package/dist/tui/mode-ring.js +84 -0
- 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/package.json +1 -1
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
|
package/dist/agent/session.js
CHANGED
|
@@ -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,
|
|
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);
|
package/dist/components/frame.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 —
|
|
668
|
-
"or
|
|
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
|
-
|
|
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";
|
package/dist/lsp/registry.js
CHANGED
|
@@ -58,27 +58,45 @@ export function languageForFile(filePath) {
|
|
|
58
58
|
return EXT_TO_LANGUAGE[ext] ?? null;
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
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
|
|
64
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
88
|
+
const candidate = path.join(dir, command + ext);
|
|
89
|
+
if (isExecutable(candidate))
|
|
90
|
+
return candidate;
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
|
-
return
|
|
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);
|
package/dist/plan/service.js
CHANGED
|
@@ -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
|
-
|
|
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):
|
package/dist/plan/submit-plan.js
CHANGED
|
@@ -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
|
-
/**
|
|
4
|
-
|
|
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). */
|
package/dist/render/index.js
CHANGED
|
@@ -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
|
-
|
|
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: () =>
|
|
481
|
-
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
|
+
}
|
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
|
}
|
|
@@ -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
|
+
}
|
package/dist/tui/supports.js
CHANGED
|
@@ -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
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
*
|