@cruxy/cli 0.25.0 → 0.27.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/mcp.js +106 -7
- 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/config/credentials.d.ts +9 -0
- package/dist/config/credentials.js +29 -1
- package/dist/config/manager.js +30 -3
- package/dist/config/schema.d.ts +182 -8
- package/dist/config/schema.js +43 -5
- package/dist/errors/constructors.d.ts +29 -0
- package/dist/errors/constructors.js +69 -0
- package/dist/errors/types.d.ts +15 -0
- package/dist/errors/types.js +18 -0
- package/dist/mcp/http-transport.d.ts +89 -0
- package/dist/mcp/http-transport.js +299 -0
- package/dist/mcp/index.d.ts +4 -2
- package/dist/mcp/index.js +3 -1
- package/dist/mcp/service.d.ts +19 -2
- package/dist/mcp/service.js +92 -20
- package/dist/mcp/trust-gate.d.ts +35 -11
- package/dist/mcp/trust-gate.js +87 -22
- package/dist/mcp/trust.d.ts +12 -2
- package/dist/mcp/trust.js +26 -2
- package/dist/mcp/types.d.ts +10 -0
- package/dist/mcp/url-guard.d.ts +48 -0
- package/dist/mcp/url-guard.js +62 -0
- package/dist/net/ip-guard.d.ts +55 -0
- package/dist/net/ip-guard.js +229 -0
- 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 +5 -2
- package/dist/render/index.js +9 -2
- package/dist/render/layout.d.ts +59 -0
- package/dist/render/layout.js +158 -0
- package/dist/render/motion.d.ts +76 -0
- package/dist/render/motion.js +94 -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 +25 -3
- package/dist/render/tty-renderer.js +94 -32
- package/dist/render/types.d.ts +15 -1
- package/dist/web/ssrf.d.ts +8 -22
- package/dist/web/ssrf.js +11 -183
- package/dist/web/types.d.ts +4 -2
- package/package.json +1 -1
|
@@ -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,10 @@
|
|
|
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";
|
|
7
|
+
export { createFrameClock, inTransition, intervalFrameTimer, spinnerGlyph, FRAME_INTERVAL_MS, TRANSITION_TICKS, type FrameClock, type FrameTimer, } from "./motion.js";
|
|
5
8
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
6
9
|
export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
|
|
7
10
|
export { PlainRenderer } from "./plain-renderer.js";
|
package/dist/render/index.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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";
|
|
10
|
+
export { createFrameClock, inTransition, intervalFrameTimer, spinnerGlyph, FRAME_INTERVAL_MS, TRANSITION_TICKS, } from "./motion.js";
|
|
7
11
|
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
8
12
|
export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
|
|
9
13
|
export { PlainRenderer } from "./plain-renderer.js";
|
|
@@ -20,6 +24,9 @@ export { TtyRenderer } from "./tty-renderer.js";
|
|
|
20
24
|
*/
|
|
21
25
|
export function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
|
|
22
26
|
const caps = detectCapabilities(out, env);
|
|
27
|
+
// Wire resize reactivity onto the one caps object before any surface reads
|
|
28
|
+
// its width (U.12). A no-op for non-TTY streams; the live region subscribes.
|
|
29
|
+
attachResize(caps, out, env);
|
|
23
30
|
if (caps.screenReader)
|
|
24
31
|
return new ScreenReaderRenderer(caps, out, err);
|
|
25
32
|
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,76 @@
|
|
|
1
|
+
import type { ThemeGlyphs } from "../theme/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Motion (U.10) — one frame clock drives every live animation on the render
|
|
4
|
+
* seam, and a tiny set of pure frame→glyph/style primitives express it. The
|
|
5
|
+
* design is the same honest-signal discipline as U.4 state: motion encodes real
|
|
6
|
+
* state (work is progressing, the activity changed, a result landed) and never
|
|
7
|
+
* decorates. It is a cosmetic overlay on the transient live region (U.2) — never
|
|
8
|
+
* in the content path, never touching committed bytes.
|
|
9
|
+
*
|
|
10
|
+
* The single gate is `caps.spinner` (`cursor && !reducedMotion`, from U.11):
|
|
11
|
+
* when motion is reduced the clock is *disabled* — it schedules nothing and
|
|
12
|
+
* never ticks, so every motion collapses to its static end-state, drawn once.
|
|
13
|
+
* There is exactly one clock per renderer (refcounted like the U.12 resize
|
|
14
|
+
* signal), not a timer per surface.
|
|
15
|
+
*/
|
|
16
|
+
/** The one frame cadence: 10 fps, the pre-U.10 spinner interval. Bounded by construction. */
|
|
17
|
+
export declare const FRAME_INTERVAL_MS = 100;
|
|
18
|
+
/**
|
|
19
|
+
* How many frames a phase-transition emphasis lasts before it settles — ~300ms
|
|
20
|
+
* at {@link FRAME_INTERVAL_MS}. Long enough for the eye to catch "the activity
|
|
21
|
+
* changed", short enough to never read as decoration.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TRANSITION_TICKS = 3;
|
|
24
|
+
/** Schedule/cancel a repeating tick; injectable so tests drive frames deterministically. */
|
|
25
|
+
export interface FrameTimer {
|
|
26
|
+
schedule(callback: () => void, intervalMs: number): {
|
|
27
|
+
unref?: () => void;
|
|
28
|
+
};
|
|
29
|
+
cancel(handle: {
|
|
30
|
+
unref?: () => void;
|
|
31
|
+
}): void;
|
|
32
|
+
}
|
|
33
|
+
/** The real timer: a single unref'd interval that never holds the process open. */
|
|
34
|
+
export declare const intervalFrameTimer: FrameTimer;
|
|
35
|
+
/**
|
|
36
|
+
* The one clock that drives all live animation. Subscribers get a monotonic
|
|
37
|
+
* frame number on each tick; the underlying timer runs ONLY while at least one
|
|
38
|
+
* subscriber is attached (refcounted), and is torn down on the last unsubscribe
|
|
39
|
+
* — the same discipline that keeps the U.12 resize listener from leaking.
|
|
40
|
+
*/
|
|
41
|
+
export interface FrameClock {
|
|
42
|
+
/**
|
|
43
|
+
* Attach a per-frame callback. The first subscriber starts the single timer;
|
|
44
|
+
* the returned function detaches and, when it was the last subscriber, stops
|
|
45
|
+
* the timer. A no-op subscription (returns a no-op unsubscribe) when the clock
|
|
46
|
+
* is disabled — reduced motion never schedules anything.
|
|
47
|
+
*/
|
|
48
|
+
subscribe(onFrame: (frame: number) => void): () => void;
|
|
49
|
+
/** The current monotonic frame counter. Stays 0 while disabled (no ticks). */
|
|
50
|
+
readonly frame: number;
|
|
51
|
+
/** Whether motion runs at all — false under reduced motion (`caps.spinner`). */
|
|
52
|
+
readonly enabled: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Build the frame clock. `enabled` is `caps.spinner`: when false the clock is
|
|
56
|
+
* inert — {@link FrameClock.subscribe} schedules nothing, never ticks, and
|
|
57
|
+
* `frame` never advances, so callers draw their static end-state once and stop.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createFrameClock(enabled: boolean, timer?: FrameTimer, intervalMs?: number): FrameClock;
|
|
60
|
+
/**
|
|
61
|
+
* The spinner glyph for a frame (U.10 progress motion): the animated braille
|
|
62
|
+
* (or single-column ASCII) cycle when the clock runs, the static marker
|
|
63
|
+
* otherwise. `animated=false` (reduced motion / no cursor) yields the static
|
|
64
|
+
* glyph with no reference to `frame` — the honest still form of "working".
|
|
65
|
+
*/
|
|
66
|
+
export declare function spinnerGlyph(frame: number, glyph: ThemeGlyphs, animated: boolean): string;
|
|
67
|
+
/**
|
|
68
|
+
* Whether a phase-transition emphasis is still playing (U.10): true for the
|
|
69
|
+
* first {@link TRANSITION_TICKS} frames after the phase *identity* changed. The
|
|
70
|
+
* caller records the frame the identity began on and passes it here; when the
|
|
71
|
+
* clock is not advancing (reduced motion), `startFrame === frame` forever, so
|
|
72
|
+
* this is briefly true only if drawn on the very first frame — the renderer
|
|
73
|
+
* gates the whole emphasis behind `clock.enabled`, so a static draw always uses
|
|
74
|
+
* the settled end-state.
|
|
75
|
+
*/
|
|
76
|
+
export declare function inTransition(frame: number, startFrame: number): boolean;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion (U.10) — one frame clock drives every live animation on the render
|
|
3
|
+
* seam, and a tiny set of pure frame→glyph/style primitives express it. The
|
|
4
|
+
* design is the same honest-signal discipline as U.4 state: motion encodes real
|
|
5
|
+
* state (work is progressing, the activity changed, a result landed) and never
|
|
6
|
+
* decorates. It is a cosmetic overlay on the transient live region (U.2) — never
|
|
7
|
+
* in the content path, never touching committed bytes.
|
|
8
|
+
*
|
|
9
|
+
* The single gate is `caps.spinner` (`cursor && !reducedMotion`, from U.11):
|
|
10
|
+
* when motion is reduced the clock is *disabled* — it schedules nothing and
|
|
11
|
+
* never ticks, so every motion collapses to its static end-state, drawn once.
|
|
12
|
+
* There is exactly one clock per renderer (refcounted like the U.12 resize
|
|
13
|
+
* signal), not a timer per surface.
|
|
14
|
+
*/
|
|
15
|
+
/** The one frame cadence: 10 fps, the pre-U.10 spinner interval. Bounded by construction. */
|
|
16
|
+
export const FRAME_INTERVAL_MS = 100;
|
|
17
|
+
/**
|
|
18
|
+
* How many frames a phase-transition emphasis lasts before it settles — ~300ms
|
|
19
|
+
* at {@link FRAME_INTERVAL_MS}. Long enough for the eye to catch "the activity
|
|
20
|
+
* changed", short enough to never read as decoration.
|
|
21
|
+
*/
|
|
22
|
+
export const TRANSITION_TICKS = 3;
|
|
23
|
+
/** The real timer: a single unref'd interval that never holds the process open. */
|
|
24
|
+
export const intervalFrameTimer = {
|
|
25
|
+
schedule(callback, intervalMs) {
|
|
26
|
+
const handle = setInterval(callback, intervalMs);
|
|
27
|
+
handle.unref?.();
|
|
28
|
+
return handle;
|
|
29
|
+
},
|
|
30
|
+
cancel(handle) {
|
|
31
|
+
clearInterval(handle);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Build the frame clock. `enabled` is `caps.spinner`: when false the clock is
|
|
36
|
+
* inert — {@link FrameClock.subscribe} schedules nothing, never ticks, and
|
|
37
|
+
* `frame` never advances, so callers draw their static end-state once and stop.
|
|
38
|
+
*/
|
|
39
|
+
export function createFrameClock(enabled, timer = intervalFrameTimer, intervalMs = FRAME_INTERVAL_MS) {
|
|
40
|
+
const subscribers = new Set();
|
|
41
|
+
let handle = null;
|
|
42
|
+
let frame = 0;
|
|
43
|
+
const tick = () => {
|
|
44
|
+
frame++;
|
|
45
|
+
for (const fn of [...subscribers])
|
|
46
|
+
fn(frame);
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
get frame() {
|
|
50
|
+
return frame;
|
|
51
|
+
},
|
|
52
|
+
enabled,
|
|
53
|
+
subscribe(onFrame) {
|
|
54
|
+
// Reduced motion: register nothing, schedule nothing. The single absolute
|
|
55
|
+
// gate — no timer can ever start, so no frame can ever be emitted.
|
|
56
|
+
if (!enabled)
|
|
57
|
+
return () => { };
|
|
58
|
+
subscribers.add(onFrame);
|
|
59
|
+
if (handle === null)
|
|
60
|
+
handle = timer.schedule(tick, intervalMs);
|
|
61
|
+
return () => {
|
|
62
|
+
subscribers.delete(onFrame);
|
|
63
|
+
if (subscribers.size === 0 && handle !== null) {
|
|
64
|
+
timer.cancel(handle);
|
|
65
|
+
handle = null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The spinner glyph for a frame (U.10 progress motion): the animated braille
|
|
73
|
+
* (or single-column ASCII) cycle when the clock runs, the static marker
|
|
74
|
+
* otherwise. `animated=false` (reduced motion / no cursor) yields the static
|
|
75
|
+
* glyph with no reference to `frame` — the honest still form of "working".
|
|
76
|
+
*/
|
|
77
|
+
export function spinnerGlyph(frame, glyph, animated) {
|
|
78
|
+
if (!animated)
|
|
79
|
+
return glyph.spinnerStatic;
|
|
80
|
+
const frames = glyph.spinnerFrames;
|
|
81
|
+
return frames[frame % frames.length];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether a phase-transition emphasis is still playing (U.10): true for the
|
|
85
|
+
* first {@link TRANSITION_TICKS} frames after the phase *identity* changed. The
|
|
86
|
+
* caller records the frame the identity began on and passes it here; when the
|
|
87
|
+
* clock is not advancing (reduced motion), `startFrame === frame` forever, so
|
|
88
|
+
* this is briefly true only if drawn on the very first frame — the renderer
|
|
89
|
+
* gates the whole emphasis behind `clock.enabled`, so a static draw always uses
|
|
90
|
+
* the settled end-state.
|
|
91
|
+
*/
|
|
92
|
+
export function inTransition(frame, startFrame) {
|
|
93
|
+
return frame - startFrame < TRANSITION_TICKS;
|
|
94
|
+
}
|
|
@@ -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;
|