@cruxy/cli 0.24.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.
Files changed (62) hide show
  1. package/dist/agent/session.d.ts +13 -0
  2. package/dist/agent/session.js +6 -0
  3. package/dist/approval/prompt.d.ts +7 -1
  4. package/dist/approval/prompt.js +52 -17
  5. package/dist/checkpoint/gate-hook.d.ts +28 -0
  6. package/dist/checkpoint/gate-hook.js +98 -0
  7. package/dist/checkpoint/gate.d.ts +7 -1
  8. package/dist/checkpoint/gate.js +8 -2
  9. package/dist/checkpoint/index.d.ts +1 -0
  10. package/dist/checkpoint/index.js +1 -0
  11. package/dist/cli/commands/rollback.d.ts +4 -1
  12. package/dist/cli/commands/rollback.js +16 -9
  13. package/dist/cli/commands/run.js +12 -0
  14. package/dist/cli/commands/skills.js +10 -2
  15. package/dist/cli/repl.d.ts +1 -1
  16. package/dist/cli/repl.js +113 -1
  17. package/dist/cli/session-factory.d.ts +4 -12
  18. package/dist/cli/session-factory.js +50 -96
  19. package/dist/components/frame.d.ts +6 -3
  20. package/dist/components/frame.js +21 -23
  21. package/dist/components/fuzzy.js +5 -1
  22. package/dist/components/select.js +4 -1
  23. package/dist/config/schema.d.ts +86 -0
  24. package/dist/config/schema.js +41 -0
  25. package/dist/errors/constructors.d.ts +18 -0
  26. package/dist/errors/constructors.js +49 -0
  27. package/dist/errors/types.d.ts +13 -0
  28. package/dist/errors/types.js +21 -0
  29. package/dist/jobs/approval-queue.d.ts +85 -0
  30. package/dist/jobs/approval-queue.js +96 -0
  31. package/dist/jobs/dispatch-tool.d.ts +34 -0
  32. package/dist/jobs/dispatch-tool.js +96 -0
  33. package/dist/jobs/index.d.ts +6 -0
  34. package/dist/jobs/index.js +6 -0
  35. package/dist/jobs/log-buffer.d.ts +31 -0
  36. package/dist/jobs/log-buffer.js +30 -0
  37. package/dist/jobs/log-renderer.d.ts +32 -0
  38. package/dist/jobs/log-renderer.js +70 -0
  39. package/dist/jobs/manager.d.ts +139 -0
  40. package/dist/jobs/manager.js +397 -0
  41. package/dist/jobs/types.d.ts +81 -0
  42. package/dist/jobs/types.js +10 -0
  43. package/dist/render/capabilities.d.ts +11 -0
  44. package/dist/render/capabilities.js +19 -3
  45. package/dist/render/diff.d.ts +1 -1
  46. package/dist/render/diff.js +23 -7
  47. package/dist/render/index.d.ts +4 -2
  48. package/dist/render/index.js +8 -2
  49. package/dist/render/layout.d.ts +59 -0
  50. package/dist/render/layout.js +158 -0
  51. package/dist/render/resize.d.ts +36 -0
  52. package/dist/render/resize.js +45 -0
  53. package/dist/render/state.d.ts +13 -0
  54. package/dist/render/state.js +38 -0
  55. package/dist/render/tty-renderer.d.ts +8 -0
  56. package/dist/render/tty-renderer.js +36 -11
  57. package/dist/render/types.d.ts +15 -1
  58. package/dist/subagent/orchestrator.d.ts +9 -0
  59. package/dist/subagent/orchestrator.js +6 -1
  60. package/dist/subagent/semaphore.d.ts +40 -11
  61. package/dist/subagent/semaphore.js +23 -26
  62. package/package.json +1 -1
@@ -0,0 +1,10 @@
1
+ /** The terminal states — a job in one of these will never run again. */
2
+ export const TERMINAL_JOB_STATUSES = [
3
+ "done",
4
+ "failed",
5
+ "cancelled",
6
+ ];
7
+ /** Whether a status is terminal (no further execution). */
8
+ export function isTerminal(status) {
9
+ return TERMINAL_JOB_STATUSES.includes(status);
10
+ }
@@ -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: typeof stream.columns === "number" && stream.columns > 0
48
- ? stream.columns
49
- : 80,
65
+ width: resolveColumns(stream, env),
50
66
  };
51
67
  }
@@ -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;
@@ -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
- function renderPatchFiles(files, c) {
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
- return lines.map((l) => ` ${l}`).join("\n");
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
  }
@@ -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 { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
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";
@@ -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 { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
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
+ }
@@ -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;
@@ -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 { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
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
- return composeStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph);
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; truncate so the live line can never soft-wrap.
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.length > room
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
- const room = Math.max(1, this.caps.width);
172
- const line = text.length > room
173
- ? text.slice(0, room - 1) + this.theme.glyph.ellipsis
174
- : text;
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
  }