@cruxy/cli 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/approval/prompt.d.ts +7 -1
- package/dist/approval/prompt.js +52 -17
- package/dist/cli/commands/skills.js +10 -2
- package/dist/cli/repl.js +9 -3
- package/dist/components/frame.d.ts +6 -3
- package/dist/components/frame.js +21 -23
- package/dist/components/fuzzy.js +5 -1
- package/dist/components/select.js +4 -1
- package/dist/render/capabilities.d.ts +11 -0
- package/dist/render/capabilities.js +19 -3
- package/dist/render/diff.d.ts +1 -1
- package/dist/render/diff.js +23 -7
- package/dist/render/index.d.ts +4 -2
- package/dist/render/index.js +8 -2
- package/dist/render/layout.d.ts +59 -0
- package/dist/render/layout.js +158 -0
- package/dist/render/resize.d.ts +36 -0
- package/dist/render/resize.js +45 -0
- package/dist/render/state.d.ts +13 -0
- package/dist/render/state.js +38 -0
- package/dist/render/tty-renderer.d.ts +8 -0
- package/dist/render/tty-renderer.js +36 -11
- package/dist/render/types.d.ts +15 -1
- package/package.json +1 -1
|
@@ -29,6 +29,12 @@ export interface PromptIO {
|
|
|
29
29
|
readLine(): Promise<string>;
|
|
30
30
|
/** Whether to emit ANSI color. */
|
|
31
31
|
color: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Terminal columns (U.12). Optional so every existing PromptIO literal is
|
|
34
|
+
* unchanged; when absent the render resolves the width itself. Threaded so a
|
|
35
|
+
* narrow prompt reflows the command and never truncates the risk marker.
|
|
36
|
+
*/
|
|
37
|
+
columns?: number;
|
|
32
38
|
}
|
|
33
39
|
/**
|
|
34
40
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
@@ -37,6 +43,6 @@ export interface PromptIO {
|
|
|
37
43
|
*/
|
|
38
44
|
export declare function promptForApproval(request: ApprovalRequest, io: PromptIO): Promise<PromptChoice>;
|
|
39
45
|
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
40
|
-
export declare function render(request: ApprovalRequest, color: boolean): string;
|
|
46
|
+
export declare function render(request: ApprovalRequest, color: boolean, columns?: number): string;
|
|
41
47
|
/** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
|
|
42
48
|
export declare function defaultPromptIO(color: boolean): PromptIO;
|
package/dist/approval/prompt.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { readSingleKey } from "../components/input.js";
|
|
3
3
|
import { renderActionPreview } from "../render/diff.js";
|
|
4
|
+
import { resolveColumns } from "../render/capabilities.js";
|
|
5
|
+
import { fitMiddle, reflow, visibleWidth } from "../render/layout.js";
|
|
4
6
|
import { themeForColor } from "../theme/index.js";
|
|
5
7
|
/**
|
|
6
8
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
@@ -8,7 +10,7 @@ import { themeForColor } from "../theme/index.js";
|
|
|
8
10
|
* is a reject.
|
|
9
11
|
*/
|
|
10
12
|
export async function promptForApproval(request, io) {
|
|
11
|
-
io.write(render(request, io.color));
|
|
13
|
+
io.write(render(request, io.color, io.columns ?? resolveColumns()));
|
|
12
14
|
const key = (await io.readKey()).toLowerCase();
|
|
13
15
|
io.write("\n");
|
|
14
16
|
switch (key) {
|
|
@@ -34,21 +36,41 @@ export async function promptForApproval(request, io) {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
/** Render the full prompt block: header, detail (diff or command+cwd), choices. */
|
|
37
|
-
export function render(request, color) {
|
|
39
|
+
export function render(request, color, columns = resolveColumns()) {
|
|
38
40
|
const t = themeForColor(color);
|
|
39
41
|
const destructive = request.tier === "destructive";
|
|
40
|
-
// Risk survives all
|
|
41
|
-
// (`!` vs `?`) for NO_COLOR,
|
|
42
|
-
// (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone
|
|
43
|
-
//
|
|
42
|
+
// Risk survives all FOUR degradations now (U.11 color/unicode/reader + U.12
|
|
43
|
+
// width): the mark carries it by *shape* (`!` vs `?`) for NO_COLOR, the label
|
|
44
|
+
// by *word* (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone —
|
|
45
|
+
// and under narrow width the header line that holds both is emitted WHOLE,
|
|
46
|
+
// never passed through a right-truncating fit that could drop the label.
|
|
44
47
|
const mark = destructive ? t.danger(t.strong("!")) : t.warning("?");
|
|
45
48
|
const label = tierLabel(request.tier, t);
|
|
46
49
|
const lines = [];
|
|
47
|
-
lines.push(
|
|
48
|
-
lines.push(detail(request, t));
|
|
50
|
+
lines.push(...header(request.summary, mark, label, t, columns));
|
|
51
|
+
lines.push(detail(request, t, columns));
|
|
49
52
|
lines.push(choices(request.scope, t));
|
|
50
53
|
return lines.filter((l) => l !== "").join("\n") + " ";
|
|
51
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The header, width-aware (U.12). Wide: the one inline line `! cruxy wants to
|
|
57
|
+
* <summary> (destructive)`. Narrow: the risk (mark + tier label) stands on its
|
|
58
|
+
* own line — emitted whole, never truncated — and the summary reflows beneath
|
|
59
|
+
* it, so the security-relevant part is always visible while the description
|
|
60
|
+
* wraps rather than soft-wrapping into a torn line.
|
|
61
|
+
*/
|
|
62
|
+
function header(summary, mark, label, t, width) {
|
|
63
|
+
const inline = `${mark} cruxy wants to ${t.strong(summary)}${label}`;
|
|
64
|
+
if (visibleWidth(inline) <= width)
|
|
65
|
+
return [inline];
|
|
66
|
+
// The risk line (mark + tier word, e.g. `! (destructive)`) is short and is
|
|
67
|
+
// NEVER passed through fit(): if it overflows an absurdly narrow terminal it
|
|
68
|
+
// wraps (nothing dropped) rather than losing its tier. The action prose +
|
|
69
|
+
// summary reflow beneath it.
|
|
70
|
+
const riskLine = `${mark}${label}`;
|
|
71
|
+
const body = reflow(`cruxy wants to ${summary}`, Math.max(1, width - 2)).map((l) => ` ${t.strong(l)}`);
|
|
72
|
+
return [riskLine, ...body];
|
|
73
|
+
}
|
|
52
74
|
/** The worded risk tag, colored by tier — always present, so meaning never
|
|
53
75
|
* rides on the `!`/`?` shape or its color alone. */
|
|
54
76
|
function tierLabel(tier, t) {
|
|
@@ -62,17 +84,28 @@ function tierLabel(tier, t) {
|
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
/** The action detail: a diff for file actions, the command + cwd for shell/test. */
|
|
65
|
-
function detail(request, t) {
|
|
87
|
+
function detail(request, t, width) {
|
|
66
88
|
if (request.action.kind === "shell" || request.action.kind === "test") {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
// The command is REFLOWED, never truncated (U.12): you always see the whole
|
|
90
|
+
// thing you are authorizing — it wraps across as many lines as it needs. The
|
|
91
|
+
// cwd (a path) middle-truncates so its leaf survives.
|
|
92
|
+
const command = request.action.command ?? "";
|
|
93
|
+
const wrapped = reflow(command, Math.max(1, width - 4));
|
|
94
|
+
const cmdLines = [
|
|
95
|
+
` ${t.muted("$")} ${wrapped[0] ?? ""}`,
|
|
96
|
+
...wrapped.slice(1).map((l) => ` ${l}`),
|
|
97
|
+
];
|
|
98
|
+
const cwd = fitMiddle(request.cwd, Math.max(1, width - 5), t.glyph.ellipsis);
|
|
99
|
+
return [...cmdLines, ` ${t.muted(`in ${cwd}`)}`].join("\n");
|
|
71
100
|
}
|
|
72
101
|
if (request.action.kind === "mcp") {
|
|
73
102
|
// The server runs UNSANDBOXED with the user's privileges — say so at the
|
|
74
|
-
// point of the call, not just at trust time.
|
|
75
|
-
|
|
103
|
+
// point of the call, not just at trust time. Reflowed so the whole warning
|
|
104
|
+
// survives narrow width (it must not be the part that gets clipped).
|
|
105
|
+
const note = `external MCP server "${request.action.server ?? ""}" — runs unsandboxed with your privileges`;
|
|
106
|
+
return reflow(note, Math.max(1, width - 2))
|
|
107
|
+
.map((l) => ` ${t.muted(l)}`)
|
|
108
|
+
.join("\n");
|
|
76
109
|
}
|
|
77
110
|
if (request.action.kind === "vcs" && request.action.root) {
|
|
78
111
|
// C.26 Step 4 (⚖︎JC-4): name the acting root alongside the resolved owner/repo
|
|
@@ -80,12 +113,12 @@ function detail(request, t) {
|
|
|
80
113
|
// PR acts in and the real API destination before approving.
|
|
81
114
|
return [
|
|
82
115
|
` ${t.muted(`root ${request.action.root}`)}`,
|
|
83
|
-
renderActionPreview(request.action.preview, t),
|
|
116
|
+
renderActionPreview(request.action.preview, t, width),
|
|
84
117
|
]
|
|
85
118
|
.filter((l) => l !== "")
|
|
86
119
|
.join("\n");
|
|
87
120
|
}
|
|
88
|
-
return renderActionPreview(request.action.preview, t);
|
|
121
|
+
return renderActionPreview(request.action.preview, t, width);
|
|
89
122
|
}
|
|
90
123
|
/** The choices line, including a short label of what an `a` grant would cover. */
|
|
91
124
|
function choices(scope, t) {
|
|
@@ -117,6 +150,8 @@ export function defaultPromptIO(color) {
|
|
|
117
150
|
readKey: () => readSingleKey(),
|
|
118
151
|
readLine: readLineFromStdin,
|
|
119
152
|
color,
|
|
153
|
+
// The prompt writes to stderr — width from stderr's own columns (U.12).
|
|
154
|
+
columns: resolveColumns(process.stderr),
|
|
120
155
|
};
|
|
121
156
|
}
|
|
122
157
|
/** Read one line in cooked mode; "" on EOF. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { getSkillService, resetSkillServices } from "../../skills/index.js";
|
|
3
3
|
import { shouldUseColor } from "../../errors/index.js";
|
|
4
|
+
import { kvStack, resolveColumns } from "../../render/index.js";
|
|
4
5
|
import { themeForColor } from "../../theme/index.js";
|
|
5
6
|
import { logger } from "../../utils/logger.js";
|
|
6
7
|
/**
|
|
@@ -34,8 +35,15 @@ export function skillsCommand() {
|
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
37
|
logger.print(`\n${t.heading("sources")} ${t.muted("(precedence, high to low)")}`);
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// Aligned `source dir` columns when there's room; at narrow width the
|
|
39
|
+
// dir paths would collide with the key column, so kvStack stacks each
|
|
40
|
+
// pair instead (U.12) — the paths stay readable rather than truncating.
|
|
41
|
+
const rows = status.sources.map((s) => ({
|
|
42
|
+
key: s.source,
|
|
43
|
+
value: t.muted(s.dir),
|
|
44
|
+
}));
|
|
45
|
+
for (const line of kvStack(rows, resolveColumns(process.stdout) - 2, t)) {
|
|
46
|
+
logger.print(` ${line}`);
|
|
39
47
|
}
|
|
40
48
|
logger.print("");
|
|
41
49
|
if (status.errors.length === 0) {
|
package/dist/cli/repl.js
CHANGED
|
@@ -5,10 +5,14 @@ import { runGatedShell } from "../tools/shell/exec.js";
|
|
|
5
5
|
import { addRootToWorkspace } from "../workspace/index.js";
|
|
6
6
|
import { themeForColor } from "../theme/index.js";
|
|
7
7
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
8
|
-
import { createRenderer } from "../render/index.js";
|
|
8
|
+
import { createRenderer, fit, resolveColumns, } from "../render/index.js";
|
|
9
9
|
import { logger } from "../utils/logger.js";
|
|
10
10
|
/** The REPL prompts on stdout; its chrome resolves against stdout's color. */
|
|
11
11
|
const theme = themeForColor(shouldUseColor(process.stdout));
|
|
12
|
+
/** Fit a committed REPL line to stdout's current width (U.12), id/status-first. */
|
|
13
|
+
function fitOut(line) {
|
|
14
|
+
return fit(line, resolveColumns(process.stdout), theme.glyph.ellipsis);
|
|
15
|
+
}
|
|
12
16
|
const PROMPT = `${theme.accent("cruxy")} ${theme.muted(theme.glyph.caret)} `;
|
|
13
17
|
/**
|
|
14
18
|
* The REPL's slash commands — the autocomplete candidate set (U.7). Keep in
|
|
@@ -185,7 +189,9 @@ function handleJobsList(session) {
|
|
|
185
189
|
? theme.muted(` — needs approval: ${j.pendingApproval}`)
|
|
186
190
|
: "";
|
|
187
191
|
const err = j.error ? theme.muted(` (${j.error})`) : "";
|
|
188
|
-
|
|
192
|
+
// Fit id-first so the job id + status always survive; the label/notes tail
|
|
193
|
+
// truncates with an honest ellipsis at narrow width (U.12).
|
|
194
|
+
logger.print(fitOut(`${theme.strong(j.id)} ${status} ${j.label}${pending}${err}`));
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
/** Print one job's log (`/logs <id>`). */
|
|
@@ -207,7 +213,7 @@ function handleJobLogs(input, session) {
|
|
|
207
213
|
}
|
|
208
214
|
for (const line of log.lines) {
|
|
209
215
|
const text = line.stream === "err" ? theme.danger(line.text) : line.text;
|
|
210
|
-
logger.print(text);
|
|
216
|
+
logger.print(fitOut(text));
|
|
211
217
|
}
|
|
212
218
|
logger.print(theme.muted(`(${log.status})`));
|
|
213
219
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { stripAnsi } from "../render/layout.js";
|
|
1
2
|
import type { RenderCapabilities } from "../render/index.js";
|
|
2
|
-
/** The visible text of a possibly-styled row. */
|
|
3
|
-
export
|
|
3
|
+
/** The visible text of a possibly-styled row (re-exported from the U.12 home). */
|
|
4
|
+
export { stripAnsi };
|
|
4
5
|
/**
|
|
5
6
|
* The transient multi-line region interactive components draw into (U.7) —
|
|
6
7
|
* the multi-row analog of the TTY renderer's single managed status line, with
|
|
@@ -12,6 +13,8 @@ export declare function stripAnsi(text: string): string;
|
|
|
12
13
|
* soft-wrap; wrapped rows would break erasure and leave artifacts.
|
|
13
14
|
* - `clear()` removes the frame entirely — after a component resolves, the
|
|
14
15
|
* screen holds zero leftover bytes from the interaction.
|
|
16
|
+
* - On resize (U.12) the frame reflows its current rows in place at the new
|
|
17
|
+
* width; committed output above is untouched. `clear()` also unsubscribes.
|
|
15
18
|
*
|
|
16
19
|
* Requires cursor control (`caps.cursor`); components guard on that before
|
|
17
20
|
* constructing one.
|
|
@@ -19,7 +22,7 @@ export declare function stripAnsi(text: string): string;
|
|
|
19
22
|
export interface Frame {
|
|
20
23
|
/** Repaint the frame with these rows (erases the previous paint first). */
|
|
21
24
|
render(lines: string[]): void;
|
|
22
|
-
/** Erase the frame completely. Idempotent. */
|
|
25
|
+
/** Erase the frame completely and release the resize subscription. Idempotent. */
|
|
23
26
|
clear(): void;
|
|
24
27
|
}
|
|
25
28
|
export declare function createFrame(write: (text: string) => void, caps: RenderCapabilities): Frame;
|
package/dist/components/frame.js
CHANGED
|
@@ -1,32 +1,22 @@
|
|
|
1
|
+
import { fit, stripAnsi } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
/** Erase the current line and return the cursor to column 0 (same as U.2). */
|
|
3
4
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
4
5
|
/** Move the cursor up one row. */
|
|
5
6
|
const CURSOR_UP = "\x1b[1A";
|
|
6
|
-
/**
|
|
7
|
-
|
|
8
|
-
const SGR = /\x1b\[[0-9;]*m/g;
|
|
9
|
-
/** The visible text of a possibly-styled row. */
|
|
10
|
-
export function stripAnsi(text) {
|
|
11
|
-
return text.replace(SGR, "");
|
|
12
|
-
}
|
|
7
|
+
/** The visible text of a possibly-styled row (re-exported from the U.12 home). */
|
|
8
|
+
export { stripAnsi };
|
|
13
9
|
export function createFrame(write, caps) {
|
|
14
10
|
let drawn = 0;
|
|
11
|
+
let lastLines = [];
|
|
15
12
|
const ellipsis = resolveTheme(caps).glyph.ellipsis;
|
|
16
13
|
/**
|
|
17
14
|
* Truncate to width-1 (cursor rests after the last cell; a full-width row
|
|
18
15
|
* would auto-wrap on some terminals). Width is measured on VISIBLE
|
|
19
|
-
* characters — rows may carry ANSI color
|
|
20
|
-
* styled
|
|
21
|
-
* dropped rather than risking a cut escape sequence).
|
|
16
|
+
* characters (U.12 {@link fit}) — rows may carry ANSI color; a row that fits
|
|
17
|
+
* passes through styled, an overflowing row is truncated on its visible text.
|
|
22
18
|
*/
|
|
23
|
-
const
|
|
24
|
-
const room = Math.max(1, caps.width - 1);
|
|
25
|
-
const plain = stripAnsi(line);
|
|
26
|
-
if (plain.length <= room)
|
|
27
|
-
return line;
|
|
28
|
-
return plain.slice(0, room - 1) + ellipsis;
|
|
29
|
-
};
|
|
19
|
+
const fitRow = (line) => fit(line, Math.max(1, caps.width - 1), ellipsis);
|
|
30
20
|
const erase = () => {
|
|
31
21
|
if (drawn === 0)
|
|
32
22
|
return;
|
|
@@ -38,14 +28,22 @@ export function createFrame(write, caps) {
|
|
|
38
28
|
write(out);
|
|
39
29
|
drawn = 0;
|
|
40
30
|
};
|
|
31
|
+
const paint = (lines) => {
|
|
32
|
+
erase();
|
|
33
|
+
lastLines = lines;
|
|
34
|
+
if (lines.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
write(lines.map(fitRow).join("\n"));
|
|
37
|
+
drawn = lines.length;
|
|
38
|
+
};
|
|
39
|
+
// Reflow the live frame at the new width; committed output above is immutable.
|
|
40
|
+
const unsubscribe = caps.onResize?.(() => paint(lastLines)) ?? null;
|
|
41
41
|
return {
|
|
42
|
-
render
|
|
42
|
+
render: paint,
|
|
43
|
+
clear() {
|
|
43
44
|
erase();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
write(lines.map(fit).join("\n"));
|
|
47
|
-
drawn = lines.length;
|
|
45
|
+
lastLines = [];
|
|
46
|
+
unsubscribe?.();
|
|
48
47
|
},
|
|
49
|
-
clear: erase,
|
|
50
48
|
};
|
|
51
49
|
}
|
package/dist/components/fuzzy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitMiddle } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
import { createFrame } from "./frame.js";
|
|
3
4
|
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
@@ -117,7 +118,10 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
|
|
|
117
118
|
for (const [i, row] of visible.entries()) {
|
|
118
119
|
const selected = top + i === cursor;
|
|
119
120
|
const marker = selected ? t.accent(g.pointer) : " ";
|
|
120
|
-
|
|
121
|
+
// Middle-truncate so a long hit keeps its basename at narrow width
|
|
122
|
+
// (U.12); the match highlight survives when the label fits and is
|
|
123
|
+
// dropped (plain text) only when it must truncate — identity over decor.
|
|
124
|
+
const label = fitMiddle(highlightMatch(row.label, row.match.positions, t), Math.max(1, io.caps.width - 2), g.ellipsis);
|
|
121
125
|
lines.push(`${marker} ${selected ? label : t.muted(label)}`);
|
|
122
126
|
}
|
|
123
127
|
const hidden = ranked.length - visible.length;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fitMiddle } from "../render/layout.js";
|
|
1
2
|
import { resolveTheme } from "../theme/index.js";
|
|
2
3
|
import { createFrame } from "./frame.js";
|
|
3
4
|
import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
|
|
@@ -29,7 +30,9 @@ export async function selectList(items, opts = {}, io = defaultComponentIO()) {
|
|
|
29
30
|
for (const [i, item] of visible.entries()) {
|
|
30
31
|
const selected = top + i === cursor;
|
|
31
32
|
const marker = selected ? t.accent(g.pointer) : " ";
|
|
32
|
-
|
|
33
|
+
// Middle-truncate the label so a long path keeps its basename (identity)
|
|
34
|
+
// at narrow width (U.12); reserve the marker + space.
|
|
35
|
+
const label = fitMiddle(toLabel(item), Math.max(1, io.caps.width - 2), g.ellipsis);
|
|
33
36
|
lines.push(`${marker} ${selected ? label : t.muted(label)}`);
|
|
34
37
|
}
|
|
35
38
|
const hidden = items.length - visible.length;
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
2
|
+
/** Fallback width when the terminal reports none (non-TTY, pipe, unknown). */
|
|
3
|
+
export declare const DEFAULT_COLUMNS = 80;
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the terminal width (U.12) — the single rule behind
|
|
6
|
+
* {@link RenderCapabilities.width} and every resize recompute. `COLUMNS` wins
|
|
7
|
+
* when set (honored so `COLUMNS=100 cruxy …` and CI overrides work), then the
|
|
8
|
+
* stream's own `columns`, then {@link DEFAULT_COLUMNS}. Never returns a
|
|
9
|
+
* non-positive width — an unknown terminal degrades to a sensible default, it
|
|
10
|
+
* does not crash a width calculation with 0.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveColumns(stream?: RenderStream, env?: NodeJS.ProcessEnv): number;
|
|
2
13
|
/**
|
|
3
14
|
* Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
|
|
4
15
|
* knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
|
|
@@ -4,6 +4,24 @@ import { detectScreenReader, detectUnicode } from "../theme/index.js";
|
|
|
4
4
|
function isSet(value) {
|
|
5
5
|
return value !== undefined && value !== "";
|
|
6
6
|
}
|
|
7
|
+
/** Fallback width when the terminal reports none (non-TTY, pipe, unknown). */
|
|
8
|
+
export const DEFAULT_COLUMNS = 80;
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the terminal width (U.12) — the single rule behind
|
|
11
|
+
* {@link RenderCapabilities.width} and every resize recompute. `COLUMNS` wins
|
|
12
|
+
* when set (honored so `COLUMNS=100 cruxy …` and CI overrides work), then the
|
|
13
|
+
* stream's own `columns`, then {@link DEFAULT_COLUMNS}. Never returns a
|
|
14
|
+
* non-positive width — an unknown terminal degrades to a sensible default, it
|
|
15
|
+
* does not crash a width calculation with 0.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveColumns(stream = process.stdout, env = process.env) {
|
|
18
|
+
const fromEnv = env.COLUMNS === undefined ? NaN : Number.parseInt(env.COLUMNS, 10);
|
|
19
|
+
if (Number.isFinite(fromEnv) && fromEnv > 0)
|
|
20
|
+
return fromEnv;
|
|
21
|
+
if (typeof stream.columns === "number" && stream.columns > 0)
|
|
22
|
+
return stream.columns;
|
|
23
|
+
return DEFAULT_COLUMNS;
|
|
24
|
+
}
|
|
7
25
|
/**
|
|
8
26
|
* Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
|
|
9
27
|
* knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
|
|
@@ -44,8 +62,6 @@ export function detectCapabilities(stream = process.stdout, env = process.env) {
|
|
|
44
62
|
// Unicode glyph safety (U.1) — independent of color. dumb / CRUXY_ASCII →
|
|
45
63
|
// ASCII glyphs; everything else (incl. pipes) keeps unicode.
|
|
46
64
|
unicode: detectUnicode(env),
|
|
47
|
-
width:
|
|
48
|
-
? stream.columns
|
|
49
|
-
: 80,
|
|
65
|
+
width: resolveColumns(stream, env),
|
|
50
66
|
};
|
|
51
67
|
}
|
package/dist/render/diff.d.ts
CHANGED
|
@@ -15,4 +15,4 @@ export declare const PREVIEW_MAX_LINES = 40;
|
|
|
15
15
|
* patches, a create/overwrite listing for writes, the publish plan for PRs.
|
|
16
16
|
* Long previews collapse past {@link PREVIEW_MAX_LINES}.
|
|
17
17
|
*/
|
|
18
|
-
export declare function renderActionPreview(preview: ActionPreview | undefined, c: Theme): string;
|
|
18
|
+
export declare function renderActionPreview(preview: ActionPreview | undefined, c: Theme, width?: number): string;
|
package/dist/render/diff.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fit, fitMiddle } from "./layout.js";
|
|
1
2
|
/**
|
|
2
3
|
* The one diff/action-preview renderer (U.2). The approval prompt, PR preview,
|
|
3
4
|
* and the streaming render path all draw diffs through here — there is no
|
|
@@ -13,20 +14,28 @@ function diffLines(oldStr, newStr, c) {
|
|
|
13
14
|
const added = newStr.split("\n").map((l) => c.success(`+ ${l}`));
|
|
14
15
|
return [...removed, ...added];
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Middle-truncate a file path so its basename (the strongest identifier)
|
|
19
|
+
* survives narrow width (U.12). `width` defaults to `Infinity`, so callers that
|
|
20
|
+
* don't thread width get the exact pre-U.12 bytes.
|
|
21
|
+
*/
|
|
22
|
+
function fitPath(path, c, width) {
|
|
23
|
+
return fitMiddle(path, Math.max(1, width - 10), c.glyph.ellipsis);
|
|
24
|
+
}
|
|
25
|
+
function renderPatchFiles(files, c, width = Infinity) {
|
|
17
26
|
const out = [];
|
|
18
27
|
for (const file of files) {
|
|
19
28
|
if (file.op === "delete") {
|
|
20
|
-
out.push(c.danger(`delete ${file.path}`));
|
|
29
|
+
out.push(c.danger(`delete ${fitPath(file.path, c, width)}`));
|
|
21
30
|
}
|
|
22
31
|
else if (file.op === "create") {
|
|
23
|
-
out.push(c.success(`create ${file.path}`));
|
|
32
|
+
out.push(c.success(`create ${fitPath(file.path, c, width)}`));
|
|
24
33
|
out.push(...file.lines.map((l) => c.success(`+ ${l}`)));
|
|
25
34
|
if (file.omittedLines > 0)
|
|
26
35
|
out.push(c.muted(` ...${file.omittedLines} more lines`));
|
|
27
36
|
}
|
|
28
37
|
else {
|
|
29
|
-
out.push(c.warning(`update ${file.path}`));
|
|
38
|
+
out.push(c.warning(`update ${fitPath(file.path, c, width)}`));
|
|
30
39
|
for (const hunk of file.hunks)
|
|
31
40
|
out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
|
|
32
41
|
}
|
|
@@ -116,7 +125,7 @@ function bodyLines(body) {
|
|
|
116
125
|
* patches, a create/overwrite listing for writes, the publish plan for PRs.
|
|
117
126
|
* Long previews collapse past {@link PREVIEW_MAX_LINES}.
|
|
118
127
|
*/
|
|
119
|
-
export function renderActionPreview(preview, c) {
|
|
128
|
+
export function renderActionPreview(preview, c, width = Infinity) {
|
|
120
129
|
if (!preview)
|
|
121
130
|
return "";
|
|
122
131
|
let lines;
|
|
@@ -124,7 +133,7 @@ export function renderActionPreview(preview, c) {
|
|
|
124
133
|
lines = diffLines(preview.oldStr, preview.newStr, c);
|
|
125
134
|
}
|
|
126
135
|
else if (preview.type === "patch") {
|
|
127
|
-
lines = renderPatchFiles(preview.files, c);
|
|
136
|
+
lines = renderPatchFiles(preview.files, c, width);
|
|
128
137
|
}
|
|
129
138
|
else if (preview.type === "pr") {
|
|
130
139
|
lines = renderPrPreview(preview, c);
|
|
@@ -151,5 +160,12 @@ export function renderActionPreview(preview, c) {
|
|
|
151
160
|
c.muted(`...${hidden} more`),
|
|
152
161
|
];
|
|
153
162
|
}
|
|
154
|
-
|
|
163
|
+
// Horizontal fit at the single choke point (U.12): every content line is
|
|
164
|
+
// truncated to the width available inside the 2-space indent, so no line ever
|
|
165
|
+
// soft-wraps. `fit` truncates from the RIGHT, so a diff line keeps its leading
|
|
166
|
+
// `+`/`-` sign and a labeled line keeps its `create`/`update`/`delete` verb —
|
|
167
|
+
// the meaning at line-start is never the part that's dropped. Default width
|
|
168
|
+
// Infinity → no-op, so non-threaded callers get the pre-U.12 bytes.
|
|
169
|
+
const room = Math.max(1, width - 2);
|
|
170
|
+
return lines.map((l) => ` ${fit(l, room, c.glyph.ellipsis)}`).join("\n");
|
|
155
171
|
}
|
package/dist/render/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { RenderStream, StreamRenderer } from "./types.js";
|
|
2
2
|
export type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, TokenUsage, ToolLifecycleEvent, } from "./types.js";
|
|
3
|
-
export { detectCapabilities, detectReducedMotion } from "./capabilities.js";
|
|
4
|
-
export {
|
|
3
|
+
export { detectCapabilities, detectReducedMotion, resolveColumns, DEFAULT_COLUMNS, } from "./capabilities.js";
|
|
4
|
+
export { attachResize, processResizeSignal, type ResizeSignal, } from "./resize.js";
|
|
5
|
+
export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, type KvRow, } from "./layout.js";
|
|
6
|
+
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
5
7
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
6
8
|
export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
|
|
7
9
|
export { PlainRenderer } from "./plain-renderer.js";
|
package/dist/render/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { detectCapabilities } from "./capabilities.js";
|
|
2
2
|
import { PlainRenderer } from "./plain-renderer.js";
|
|
3
|
+
import { attachResize } from "./resize.js";
|
|
3
4
|
import { ScreenReaderRenderer } from "./screen-reader-renderer.js";
|
|
4
5
|
import { TtyRenderer } from "./tty-renderer.js";
|
|
5
|
-
export { detectCapabilities, detectReducedMotion } from "./capabilities.js";
|
|
6
|
-
export {
|
|
6
|
+
export { detectCapabilities, detectReducedMotion, resolveColumns, DEFAULT_COLUMNS, } from "./capabilities.js";
|
|
7
|
+
export { attachResize, processResizeSignal, } from "./resize.js";
|
|
8
|
+
export { fit, fitMiddle, reflow, stripAnsi, visibleWidth, kvStack, MIN_VALUE_COLS, } from "./layout.js";
|
|
9
|
+
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
7
10
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
8
11
|
export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
|
|
9
12
|
export { PlainRenderer } from "./plain-renderer.js";
|
|
@@ -20,6 +23,9 @@ export { TtyRenderer } from "./tty-renderer.js";
|
|
|
20
23
|
*/
|
|
21
24
|
export function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
|
|
22
25
|
const caps = detectCapabilities(out, env);
|
|
26
|
+
// Wire resize reactivity onto the one caps object before any surface reads
|
|
27
|
+
// its width (U.12). A no-op for non-TTY streams; the live region subscribes.
|
|
28
|
+
attachResize(caps, out, env);
|
|
23
29
|
if (caps.screenReader)
|
|
24
30
|
return new ScreenReaderRenderer(caps, out, err);
|
|
25
31
|
return caps.cursor
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Theme } from "../theme/index.js";
|
|
2
|
+
/** The visible text of a possibly-styled string (SGR removed). */
|
|
3
|
+
export declare function stripAnsi(text: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Visible width in code points — the number a terminal cell count approximates.
|
|
6
|
+
* Counts by code point (via spread) so a surrogate-pair glyph is 1, not 2, and
|
|
7
|
+
* is never split. Wide (CJK/emoji) cells are counted as 1; full display-width
|
|
8
|
+
* accounting is deliberately out of scope (U.12), but code-point counting is
|
|
9
|
+
* already strictly more correct than the pre-U.12 `.length`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function visibleWidth(text: string): number;
|
|
12
|
+
/**
|
|
13
|
+
* Truncate `text` to at most `width` visible columns, appending an honest
|
|
14
|
+
* ellipsis when anything was dropped. Text that already fits is returned
|
|
15
|
+
* UNCHANGED (style preserved). A truncated string is returned as plain text:
|
|
16
|
+
* dropping the style on the cut tail is the safe choice — re-styling a slice
|
|
17
|
+
* risks emitting a half of an escape pair.
|
|
18
|
+
*
|
|
19
|
+
* `width < 1` yields "". When `width` is smaller than the ellipsis itself
|
|
20
|
+
* (very-narrow), we show that many content characters rather than only dots —
|
|
21
|
+
* a single legible char beats a clipped "…".
|
|
22
|
+
*/
|
|
23
|
+
export declare function fit(text: string, width: number, ellipsis?: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Truncate the MIDDLE, preserving the head and tail. For a path or a search
|
|
26
|
+
* hit, the tail (basename, `:line`) carries as much identity as the head
|
|
27
|
+
* (root) — a right-truncation would hide the very thing that names it. Yields
|
|
28
|
+
* `src/very/deep/…/Component.tsx`. Falls back to {@link fit} when there is no
|
|
29
|
+
* room for both sides plus the ellipsis. Returns plain text when it truncates.
|
|
30
|
+
*/
|
|
31
|
+
export declare function fitMiddle(text: string, width: number, ellipsis?: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Word-wrap `text` to `width` visible columns, returning the wrapped lines.
|
|
34
|
+
* Wraps at spaces; a single token longer than `width` is hard-broken (its style
|
|
35
|
+
* is dropped on the break). Existing newlines are preserved as paragraph
|
|
36
|
+
* breaks. Use this — not {@link fit} — when meaning must survive in full (an
|
|
37
|
+
* approval command you must see whole), trading vertical space for completeness.
|
|
38
|
+
*/
|
|
39
|
+
export declare function reflow(text: string, width: number): string[];
|
|
40
|
+
/** A key/value pair for {@link kvStack}. */
|
|
41
|
+
export interface KvRow {
|
|
42
|
+
key: string;
|
|
43
|
+
value: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Minimum value columns an aligned two-column table needs before it collapses
|
|
47
|
+
* to a stack. Below this, the value column is too cramped to read, so stacking
|
|
48
|
+
* (key on its own line, value reflowed beneath) is the honest presentation.
|
|
49
|
+
*/
|
|
50
|
+
export declare const MIN_VALUE_COLS = 16;
|
|
51
|
+
/**
|
|
52
|
+
* The table→stack collapser (U.12). At a width that affords the widest key plus
|
|
53
|
+
* {@link MIN_VALUE_COLS}, render aligned `key value` rows (values truncated to
|
|
54
|
+
* the remaining columns). Too narrow for that, STACK each pair — a bold key
|
|
55
|
+
* line, then the value reflowed and indented — so a table never soft-wraps into
|
|
56
|
+
* a misaligned mess and never hides a value. One column budgeter for every kv
|
|
57
|
+
* caller.
|
|
58
|
+
*/
|
|
59
|
+
export declare function kvStack(rows: KvRow[], width: number, theme: Theme): string[];
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Width-aware layout (U.12): the ANSI-aware truncate/reflow/collapse helpers
|
|
3
|
+
* every rendered surface uses to fit content to `RenderCapabilities.width`.
|
|
4
|
+
* Pure string → string, like diff.ts / state.ts — no terminal, no width source
|
|
5
|
+
* of its own (callers pass the one `caps.width`), so each helper is directly
|
|
6
|
+
* testable.
|
|
7
|
+
*
|
|
8
|
+
* The one rule these enforce: width is measured on VISIBLE characters, not
|
|
9
|
+
* bytes. A colored 10-visible-char string is 10 wide even though its byte
|
|
10
|
+
* length is larger (SGR escapes) — truncating on `.length` would cut early and
|
|
11
|
+
* could sever an escape sequence mid-way, leaving torn ANSI on screen. So we
|
|
12
|
+
* strip SGR for the measurement and slice on CODE POINTS (never UTF-16 units),
|
|
13
|
+
* which also prevents splitting an astral glyph into two broken halves.
|
|
14
|
+
*/
|
|
15
|
+
/** SGR escape sequences (the only ANSI our surfaces emit — via picocolors). */
|
|
16
|
+
// eslint-disable-next-line no-control-regex
|
|
17
|
+
const SGR = /\x1b\[[0-9;]*m/g;
|
|
18
|
+
/** The visible text of a possibly-styled string (SGR removed). */
|
|
19
|
+
export function stripAnsi(text) {
|
|
20
|
+
return text.replace(SGR, "");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Visible width in code points — the number a terminal cell count approximates.
|
|
24
|
+
* Counts by code point (via spread) so a surrogate-pair glyph is 1, not 2, and
|
|
25
|
+
* is never split. Wide (CJK/emoji) cells are counted as 1; full display-width
|
|
26
|
+
* accounting is deliberately out of scope (U.12), but code-point counting is
|
|
27
|
+
* already strictly more correct than the pre-U.12 `.length`.
|
|
28
|
+
*/
|
|
29
|
+
export function visibleWidth(text) {
|
|
30
|
+
return [...stripAnsi(text)].length;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Truncate `text` to at most `width` visible columns, appending an honest
|
|
34
|
+
* ellipsis when anything was dropped. Text that already fits is returned
|
|
35
|
+
* UNCHANGED (style preserved). A truncated string is returned as plain text:
|
|
36
|
+
* dropping the style on the cut tail is the safe choice — re-styling a slice
|
|
37
|
+
* risks emitting a half of an escape pair.
|
|
38
|
+
*
|
|
39
|
+
* `width < 1` yields "". When `width` is smaller than the ellipsis itself
|
|
40
|
+
* (very-narrow), we show that many content characters rather than only dots —
|
|
41
|
+
* a single legible char beats a clipped "…".
|
|
42
|
+
*/
|
|
43
|
+
export function fit(text, width, ellipsis = "…") {
|
|
44
|
+
if (width < 1)
|
|
45
|
+
return "";
|
|
46
|
+
const chars = [...stripAnsi(text)];
|
|
47
|
+
if (chars.length <= width)
|
|
48
|
+
return text;
|
|
49
|
+
const ell = [...ellipsis].length;
|
|
50
|
+
if (width <= ell)
|
|
51
|
+
return chars.slice(0, width).join("");
|
|
52
|
+
return chars.slice(0, width - ell).join("") + ellipsis;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Truncate the MIDDLE, preserving the head and tail. For a path or a search
|
|
56
|
+
* hit, the tail (basename, `:line`) carries as much identity as the head
|
|
57
|
+
* (root) — a right-truncation would hide the very thing that names it. Yields
|
|
58
|
+
* `src/very/deep/…/Component.tsx`. Falls back to {@link fit} when there is no
|
|
59
|
+
* room for both sides plus the ellipsis. Returns plain text when it truncates.
|
|
60
|
+
*/
|
|
61
|
+
export function fitMiddle(text, width, ellipsis = "…") {
|
|
62
|
+
if (width < 1)
|
|
63
|
+
return "";
|
|
64
|
+
const chars = [...stripAnsi(text)];
|
|
65
|
+
if (chars.length <= width)
|
|
66
|
+
return text;
|
|
67
|
+
const ell = [...ellipsis].length;
|
|
68
|
+
if (width <= ell + 1)
|
|
69
|
+
return fit(text, width, ellipsis);
|
|
70
|
+
const budget = width - ell;
|
|
71
|
+
const head = Math.ceil(budget / 2);
|
|
72
|
+
const tail = budget - head;
|
|
73
|
+
const start = chars.slice(0, head).join("");
|
|
74
|
+
const end = tail > 0 ? chars.slice(chars.length - tail).join("") : "";
|
|
75
|
+
return start + ellipsis + end;
|
|
76
|
+
}
|
|
77
|
+
/** Break one over-long token into `width`-wide code-point chunks (plain text). */
|
|
78
|
+
function hardBreak(word, width) {
|
|
79
|
+
const chars = [...stripAnsi(word)];
|
|
80
|
+
const chunks = [];
|
|
81
|
+
for (let i = 0; i < chars.length; i += width) {
|
|
82
|
+
chunks.push(chars.slice(i, i + width).join(""));
|
|
83
|
+
}
|
|
84
|
+
return chunks;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Word-wrap `text` to `width` visible columns, returning the wrapped lines.
|
|
88
|
+
* Wraps at spaces; a single token longer than `width` is hard-broken (its style
|
|
89
|
+
* is dropped on the break). Existing newlines are preserved as paragraph
|
|
90
|
+
* breaks. Use this — not {@link fit} — when meaning must survive in full (an
|
|
91
|
+
* approval command you must see whole), trading vertical space for completeness.
|
|
92
|
+
*/
|
|
93
|
+
export function reflow(text, width) {
|
|
94
|
+
if (width < 1)
|
|
95
|
+
return [text];
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const para of text.split("\n")) {
|
|
98
|
+
if (para === "") {
|
|
99
|
+
out.push("");
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
let line = "";
|
|
103
|
+
for (const word of para.split(" ")) {
|
|
104
|
+
const candidate = line === "" ? word : `${line} ${word}`;
|
|
105
|
+
if (visibleWidth(candidate) <= width) {
|
|
106
|
+
line = candidate;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (line !== "") {
|
|
110
|
+
out.push(line);
|
|
111
|
+
line = "";
|
|
112
|
+
}
|
|
113
|
+
if (visibleWidth(word) <= width) {
|
|
114
|
+
line = word;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const chunks = hardBreak(word, width);
|
|
118
|
+
for (let i = 0; i < chunks.length - 1; i++)
|
|
119
|
+
out.push(chunks[i]);
|
|
120
|
+
line = chunks[chunks.length - 1] ?? "";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
out.push(line);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Minimum value columns an aligned two-column table needs before it collapses
|
|
129
|
+
* to a stack. Below this, the value column is too cramped to read, so stacking
|
|
130
|
+
* (key on its own line, value reflowed beneath) is the honest presentation.
|
|
131
|
+
*/
|
|
132
|
+
export const MIN_VALUE_COLS = 16;
|
|
133
|
+
/**
|
|
134
|
+
* The table→stack collapser (U.12). At a width that affords the widest key plus
|
|
135
|
+
* {@link MIN_VALUE_COLS}, render aligned `key value` rows (values truncated to
|
|
136
|
+
* the remaining columns). Too narrow for that, STACK each pair — a bold key
|
|
137
|
+
* line, then the value reflowed and indented — so a table never soft-wraps into
|
|
138
|
+
* a misaligned mess and never hides a value. One column budgeter for every kv
|
|
139
|
+
* caller.
|
|
140
|
+
*/
|
|
141
|
+
export function kvStack(rows, width, theme) {
|
|
142
|
+
if (rows.length === 0)
|
|
143
|
+
return [];
|
|
144
|
+
const glyph = theme.glyph.ellipsis;
|
|
145
|
+
const keyWidth = Math.max(...rows.map((r) => visibleWidth(r.key)));
|
|
146
|
+
const valueRoom = width - keyWidth - 2;
|
|
147
|
+
if (valueRoom >= MIN_VALUE_COLS) {
|
|
148
|
+
return rows.map((r) => theme.kv(r.key, fit(r.value, valueRoom, glyph), keyWidth));
|
|
149
|
+
}
|
|
150
|
+
const out = [];
|
|
151
|
+
for (const r of rows) {
|
|
152
|
+
out.push(theme.strong(r.key));
|
|
153
|
+
for (const line of reflow(r.value, Math.max(1, width - 2))) {
|
|
154
|
+
out.push(` ${line}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Resize reactivity (U.12). Terminal width lives in exactly one place —
|
|
4
|
+
* {@link RenderCapabilities.width} — and this module is what keeps it current:
|
|
5
|
+
* it listens for the OS resize signal, recomputes the width through the same
|
|
6
|
+
* {@link resolveColumns} rule that seeded it, updates `caps.width` IN PLACE, and
|
|
7
|
+
* pushes the new width to subscribers (the live region / transient frames).
|
|
8
|
+
*
|
|
9
|
+
* Committed output is never re-rendered from here — only surfaces that own a
|
|
10
|
+
* redrawable region subscribe. Everything else simply reads the now-current
|
|
11
|
+
* `caps.width` the next time it emits.
|
|
12
|
+
*
|
|
13
|
+
* The OS signal is injectable ({@link ResizeSignal}) so tests drive resize
|
|
14
|
+
* deterministically without a real terminal or a real SIGWINCH.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The OS resize signal, abstracted for testability. `on` registers a callback
|
|
18
|
+
* fired on each terminal resize and returns an unsubscribe function.
|
|
19
|
+
*/
|
|
20
|
+
export interface ResizeSignal {
|
|
21
|
+
on(listener: () => void): () => void;
|
|
22
|
+
}
|
|
23
|
+
/** The real signal: Node emits `SIGWINCH` on the process when a TTY resizes. */
|
|
24
|
+
export declare const processResizeSignal: ResizeSignal;
|
|
25
|
+
/**
|
|
26
|
+
* Wire resize reactivity onto `caps`: install {@link RenderCapabilities.onResize}
|
|
27
|
+
* and start listening for terminal resizes. One OS listener is shared across all
|
|
28
|
+
* subscribers and is removed once the last one unsubscribes (and re-added if a
|
|
29
|
+
* new subscriber arrives), so nothing is leaked and the process is never held
|
|
30
|
+
* open by a stray handler.
|
|
31
|
+
*
|
|
32
|
+
* A no-op for streams that cannot resize (non-TTY / no `columns`): `onResize`
|
|
33
|
+
* stays undefined and surfaces fall back to the static width. Idempotent —
|
|
34
|
+
* calling twice on the same caps keeps the first wiring.
|
|
35
|
+
*/
|
|
36
|
+
export declare function attachResize(caps: RenderCapabilities, stream?: RenderStream, env?: NodeJS.ProcessEnv, signal?: ResizeSignal): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { resolveColumns } from "./capabilities.js";
|
|
2
|
+
/** The real signal: Node emits `SIGWINCH` on the process when a TTY resizes. */
|
|
3
|
+
export const processResizeSignal = {
|
|
4
|
+
on(listener) {
|
|
5
|
+
process.on("SIGWINCH", listener);
|
|
6
|
+
return () => void process.off("SIGWINCH", listener);
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Wire resize reactivity onto `caps`: install {@link RenderCapabilities.onResize}
|
|
11
|
+
* and start listening for terminal resizes. One OS listener is shared across all
|
|
12
|
+
* subscribers and is removed once the last one unsubscribes (and re-added if a
|
|
13
|
+
* new subscriber arrives), so nothing is leaked and the process is never held
|
|
14
|
+
* open by a stray handler.
|
|
15
|
+
*
|
|
16
|
+
* A no-op for streams that cannot resize (non-TTY / no `columns`): `onResize`
|
|
17
|
+
* stays undefined and surfaces fall back to the static width. Idempotent —
|
|
18
|
+
* calling twice on the same caps keeps the first wiring.
|
|
19
|
+
*/
|
|
20
|
+
export function attachResize(caps, stream = process.stdout, env = process.env, signal = processResizeSignal) {
|
|
21
|
+
if (!stream.isTTY || caps.onResize)
|
|
22
|
+
return;
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
let off = null;
|
|
25
|
+
const onSignal = () => {
|
|
26
|
+
const next = resolveColumns(stream, env);
|
|
27
|
+
if (next === caps.width)
|
|
28
|
+
return;
|
|
29
|
+
caps.width = next; // the single source stays current — no second copy.
|
|
30
|
+
for (const l of [...listeners])
|
|
31
|
+
l(next);
|
|
32
|
+
};
|
|
33
|
+
caps.onResize = (listener) => {
|
|
34
|
+
listeners.add(listener);
|
|
35
|
+
if (off === null)
|
|
36
|
+
off = signal.on(onSignal);
|
|
37
|
+
return () => {
|
|
38
|
+
listeners.delete(listener);
|
|
39
|
+
if (listeners.size === 0 && off !== null) {
|
|
40
|
+
off();
|
|
41
|
+
off = null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
}
|
package/dist/render/state.d.ts
CHANGED
|
@@ -34,3 +34,16 @@ export declare function phaseIdentity(phase: RenderPhase | null): string;
|
|
|
34
34
|
* when they can keep it ticking honestly (no timer → no frozen number).
|
|
35
35
|
*/
|
|
36
36
|
export declare function composeStatusLine(progress: ProgressState | null, phase: RenderPhase | null, elapsedMs?: number, glyph?: ThemeGlyphs): string;
|
|
37
|
+
/**
|
|
38
|
+
* The width-aware live line (U.12): {@link composeStatusLine} with honest
|
|
39
|
+
* degradation tiers so the status line never soft-wraps and never loses its
|
|
40
|
+
* essential meaning — *what is happening*. Priority is phase (the action) >
|
|
41
|
+
* the `[i/n] title` progress prefix (context) > the elapsed clock (decor), so
|
|
42
|
+
* as width shrinks we shed decor first and identity last:
|
|
43
|
+
*
|
|
44
|
+
* - wide: `[2/5] title · read_file src/x.ts… (12s)`
|
|
45
|
+
* - medium: drop the elapsed clock
|
|
46
|
+
* - narrow: drop the `[i/n] title` prefix, keep the phase
|
|
47
|
+
* - very-narrow: {@link fit} the phase text with an honest ellipsis
|
|
48
|
+
*/
|
|
49
|
+
export declare function fitStatusLine(progress: ProgressState | null, phase: RenderPhase | null, elapsedMs: number | undefined, glyph: ThemeGlyphs, width: number): string;
|
package/dist/render/state.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { UNICODE_GLYPHS } from "../theme/index.js";
|
|
2
|
+
import { fit, visibleWidth } from "./layout.js";
|
|
2
3
|
/**
|
|
3
4
|
* The U.4 state→text mapping: pure data → string, like plan/render.ts and
|
|
4
5
|
* diff.ts, so both renderers (and tests) share one composition with no
|
|
@@ -90,3 +91,40 @@ export function composeStatusLine(progress, phase, elapsedMs, glyph = UNICODE_GL
|
|
|
90
91
|
}
|
|
91
92
|
return line;
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The width-aware live line (U.12): {@link composeStatusLine} with honest
|
|
96
|
+
* degradation tiers so the status line never soft-wraps and never loses its
|
|
97
|
+
* essential meaning — *what is happening*. Priority is phase (the action) >
|
|
98
|
+
* the `[i/n] title` progress prefix (context) > the elapsed clock (decor), so
|
|
99
|
+
* as width shrinks we shed decor first and identity last:
|
|
100
|
+
*
|
|
101
|
+
* - wide: `[2/5] title · read_file src/x.ts… (12s)`
|
|
102
|
+
* - medium: drop the elapsed clock
|
|
103
|
+
* - narrow: drop the `[i/n] title` prefix, keep the phase
|
|
104
|
+
* - very-narrow: {@link fit} the phase text with an honest ellipsis
|
|
105
|
+
*/
|
|
106
|
+
export function fitStatusLine(progress, phase, elapsedMs, glyph, width) {
|
|
107
|
+
const sep = ` ${glyph.sep} `;
|
|
108
|
+
const phaseText = phase ? describePhase(phase, glyph) : "";
|
|
109
|
+
const prefix = progress
|
|
110
|
+
? `[${progress.step}/${progress.of}] ${progress.title}`
|
|
111
|
+
: "";
|
|
112
|
+
const elapsed = elapsedMs !== undefined && elapsedMs >= ELAPSED_AFTER_MS
|
|
113
|
+
? ` (${formatElapsed(elapsedMs)})`
|
|
114
|
+
: "";
|
|
115
|
+
const join = (parts) => parts.filter((p) => p !== "").join(sep);
|
|
116
|
+
// The identity that must survive to the last: the phase, or the progress
|
|
117
|
+
// prefix when there is no phase (between-turns step context).
|
|
118
|
+
const essential = phaseText !== "" ? phaseText : prefix;
|
|
119
|
+
const candidates = [
|
|
120
|
+
join([prefix, phaseText]) + elapsed, // full
|
|
121
|
+
join([prefix, phaseText]), // drop elapsed
|
|
122
|
+
phaseText + elapsed, // drop progress prefix
|
|
123
|
+
essential, // essential only
|
|
124
|
+
];
|
|
125
|
+
for (const c of candidates) {
|
|
126
|
+
if (c !== "" && visibleWidth(c) <= width)
|
|
127
|
+
return c;
|
|
128
|
+
}
|
|
129
|
+
return fit(essential, width, glyph.ellipsis);
|
|
130
|
+
}
|
|
@@ -48,7 +48,15 @@ export declare class TtyRenderer implements StreamRenderer {
|
|
|
48
48
|
private timer;
|
|
49
49
|
private frame;
|
|
50
50
|
private closed;
|
|
51
|
+
/** Unsubscribe from the resize signal (U.12); null when the stream can't resize. */
|
|
52
|
+
private unsubscribeResize;
|
|
51
53
|
constructor(caps: RenderCapabilities, out: RenderStream);
|
|
54
|
+
/**
|
|
55
|
+
* Redraw the live line at the current `caps.width` after a resize. A no-op
|
|
56
|
+
* when nothing is drawn (so a resize between turns writes zero bytes) — the
|
|
57
|
+
* next state transition will draw at the new width anyway.
|
|
58
|
+
*/
|
|
59
|
+
private reflowLive;
|
|
52
60
|
private newPrinter;
|
|
53
61
|
/** Append committed content, erasing the status line first if one is live. */
|
|
54
62
|
private commit;
|
|
@@ -2,7 +2,8 @@ import { resolveTheme } from "../theme/index.js";
|
|
|
2
2
|
import { createStreamPrinter } from "../cli/stream-print.js";
|
|
3
3
|
import { renderActionPreview } from "./diff.js";
|
|
4
4
|
import { createStreamHighlighter, } from "./highlight.js";
|
|
5
|
-
import {
|
|
5
|
+
import { fit } from "./layout.js";
|
|
6
|
+
import { ELAPSED_AFTER_MS, fitStatusLine, formatElapsed, phaseIdentity, } from "./state.js";
|
|
6
7
|
/** Erase the current line and return the cursor to column 0. */
|
|
7
8
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
8
9
|
const SPINNER_INTERVAL_MS = 100;
|
|
@@ -53,12 +54,32 @@ export class TtyRenderer {
|
|
|
53
54
|
timer = null;
|
|
54
55
|
frame = 0;
|
|
55
56
|
closed = false;
|
|
57
|
+
/** Unsubscribe from the resize signal (U.12); null when the stream can't resize. */
|
|
58
|
+
unsubscribeResize = null;
|
|
56
59
|
constructor(caps, out) {
|
|
57
60
|
this.caps = caps;
|
|
58
61
|
this.out = out;
|
|
59
62
|
this.theme = resolveTheme(caps);
|
|
60
63
|
this.highlighter = createStreamHighlighter(this.theme);
|
|
61
64
|
this.print = this.newPrinter();
|
|
65
|
+
// Resize reactivity (U.12): only the live line reflows at the new width —
|
|
66
|
+
// committed rows above are immutable and never rewritten.
|
|
67
|
+
this.unsubscribeResize = caps.onResize?.(() => this.reflowLive()) ?? null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Redraw the live line at the current `caps.width` after a resize. A no-op
|
|
71
|
+
* when nothing is drawn (so a resize between turns writes zero bytes) — the
|
|
72
|
+
* next state transition will draw at the new width anyway.
|
|
73
|
+
*/
|
|
74
|
+
reflowLive() {
|
|
75
|
+
if (this.closed || !this.lineVisible)
|
|
76
|
+
return;
|
|
77
|
+
const line = this.currentLine();
|
|
78
|
+
if (line === null) {
|
|
79
|
+
this.hideLine();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
this.drawLine(line);
|
|
62
83
|
}
|
|
63
84
|
newPrinter() {
|
|
64
85
|
return createStreamPrinter((text) => {
|
|
@@ -112,7 +133,10 @@ export class TtyRenderer {
|
|
|
112
133
|
const elapsed = this.phase !== null && this.caps.spinner
|
|
113
134
|
? Date.now() - this.phaseStartedAt
|
|
114
135
|
: undefined;
|
|
115
|
-
|
|
136
|
+
// Reserve the glyph + space (2 cols): the composed line is degraded and
|
|
137
|
+
// fitted to the remainder so it can never soft-wrap (U.12).
|
|
138
|
+
const room = Math.max(1, this.caps.width - 2);
|
|
139
|
+
return fitStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph, room);
|
|
116
140
|
}
|
|
117
141
|
/** Redraw the live line from current state, or hide it when there is none. */
|
|
118
142
|
refresh() {
|
|
@@ -139,11 +163,10 @@ export class TtyRenderer {
|
|
|
139
163
|
const glyph = this.caps.spinner
|
|
140
164
|
? frames[this.frame % frames.length]
|
|
141
165
|
: this.theme.glyph.spinnerStatic;
|
|
142
|
-
// Reserve glyph + space;
|
|
166
|
+
// Reserve glyph + space; ANSI-aware fit so the live line can never
|
|
167
|
+
// soft-wrap (currentLine already fits — this is defense in depth) (U.12).
|
|
143
168
|
const room = Math.max(1, this.caps.width - 2);
|
|
144
|
-
const line = text
|
|
145
|
-
? text.slice(0, Math.max(0, room - 1)) + this.theme.glyph.ellipsis
|
|
146
|
-
: text;
|
|
169
|
+
const line = fit(text, room, this.theme.glyph.ellipsis);
|
|
147
170
|
this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
|
|
148
171
|
}
|
|
149
172
|
beginTurn() {
|
|
@@ -168,16 +191,16 @@ export class TtyRenderer {
|
|
|
168
191
|
note(text) {
|
|
169
192
|
if (this.closed)
|
|
170
193
|
return;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
194
|
+
// ANSI-aware fit (U.12): a note may carry color (subagent trail marks), so
|
|
195
|
+
// width must be measured on visible chars, not bytes, or it truncates early
|
|
196
|
+
// and can sever an escape sequence.
|
|
197
|
+
const line = fit(text, Math.max(1, this.caps.width), this.theme.glyph.ellipsis);
|
|
175
198
|
this.commit(this.theme.muted(line) + "\n");
|
|
176
199
|
}
|
|
177
200
|
preview(preview) {
|
|
178
201
|
if (this.closed)
|
|
179
202
|
return;
|
|
180
|
-
const block = renderActionPreview(preview, this.theme);
|
|
203
|
+
const block = renderActionPreview(preview, this.theme, this.caps.width);
|
|
181
204
|
if (block)
|
|
182
205
|
this.commit(block + "\n");
|
|
183
206
|
}
|
|
@@ -274,6 +297,8 @@ export class TtyRenderer {
|
|
|
274
297
|
if (this.closed)
|
|
275
298
|
return;
|
|
276
299
|
this.endTurn();
|
|
300
|
+
this.unsubscribeResize?.();
|
|
301
|
+
this.unsubscribeResize = null;
|
|
277
302
|
this.closed = true;
|
|
278
303
|
}
|
|
279
304
|
}
|
package/dist/render/types.d.ts
CHANGED
|
@@ -39,8 +39,22 @@ export interface RenderCapabilities {
|
|
|
39
39
|
/** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
|
|
40
40
|
* independent of `color`. Drives the theme's glyph table, not its stylers. */
|
|
41
41
|
unicode: boolean;
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Terminal columns (U.12) — the ONE width source every surface reads; 80 when
|
|
44
|
+
* unknown (non-TTY / no `columns`). Honors `COLUMNS` when set. Mutated in
|
|
45
|
+
* place on resize (see {@link onResize}), so a surface reading it after a
|
|
46
|
+
* SIGWINCH sees the new width without re-probing anything.
|
|
47
|
+
*/
|
|
43
48
|
width: number;
|
|
49
|
+
/**
|
|
50
|
+
* Subscribe to width changes (U.12): the SIGWINCH push signal for the live
|
|
51
|
+
* region / transient frames to reflow at the new width. The listener fires
|
|
52
|
+
* with the new width AFTER {@link width} has been updated; the returned
|
|
53
|
+
* function unsubscribes. Absent when the stream cannot resize (non-TTY / no
|
|
54
|
+
* `columns`) — those surfaces just read the static {@link width}. Committed
|
|
55
|
+
* output is never re-rendered from here; only live surfaces subscribe.
|
|
56
|
+
*/
|
|
57
|
+
onResize?(listener: (width: number) => void): () => void;
|
|
44
58
|
}
|
|
45
59
|
/** Accumulated token usage the loop already tracks (U.4) — never fabricated. */
|
|
46
60
|
export interface TokenUsage {
|