@cruxy/cli 0.11.0 → 0.12.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.js +17 -15
- package/dist/cli/commands/checkpoint.js +6 -4
- package/dist/cli/commands/config.js +10 -7
- package/dist/cli/commands/index.js +16 -15
- package/dist/cli/commands/init.js +5 -3
- package/dist/cli/commands/login.js +5 -3
- package/dist/cli/commands/pr.js +8 -7
- package/dist/cli/commands/rollback.js +7 -6
- package/dist/cli/commands/run.js +7 -6
- package/dist/cli/commands/skills.js +12 -10
- package/dist/cli/program.js +7 -6
- package/dist/cli/repl.js +11 -9
- package/dist/components/frame.js +3 -1
- package/dist/components/fuzzy.d.ts +4 -4
- package/dist/components/fuzzy.js +14 -13
- package/dist/components/select.js +8 -7
- package/dist/errors/format.js +8 -8
- package/dist/onboarding/flow.js +6 -6
- package/dist/onboarding/steps.js +11 -11
- package/dist/plan/approve.js +6 -6
- package/dist/plan/render.js +26 -18
- package/dist/render/capabilities.js +4 -0
- package/dist/render/diff.d.ts +6 -7
- package/dist/render/diff.js +33 -22
- package/dist/render/highlight.d.ts +3 -3
- package/dist/render/highlight.js +15 -15
- package/dist/render/index.d.ts +1 -1
- package/dist/render/plain-renderer.d.ts +2 -1
- package/dist/render/plain-renderer.js +7 -6
- package/dist/render/state.d.ts +7 -2
- package/dist/render/state.js +16 -10
- package/dist/render/tty-renderer.d.ts +2 -1
- package/dist/render/tty-renderer.js +20 -17
- package/dist/render/types.d.ts +7 -0
- package/dist/subagent/orchestrator.js +21 -6
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.js +2 -0
- package/dist/theme/resolve.d.ts +32 -0
- package/dist/theme/resolve.js +73 -0
- package/dist/theme/tokens.d.ts +104 -0
- package/dist/theme/tokens.js +52 -0
- package/dist/utils/logger.d.ts +2 -0
- package/dist/utils/logger.js +7 -4
- package/package.json +1 -1
package/dist/render/highlight.js
CHANGED
|
@@ -7,7 +7,7 @@ const FENCE_PLAUSIBLE = /^(?:`{1,2}|`{3,}[\w+#.-]*\s*)$/;
|
|
|
7
7
|
* Create the per-segment streaming highlighter. `highlightLine` is injectable
|
|
8
8
|
* for tests (e.g. to prove a throwing tokenizer degrades to plain text).
|
|
9
9
|
*/
|
|
10
|
-
export function createStreamHighlighter(
|
|
10
|
+
export function createStreamHighlighter(theme, highlightLine = defaultLineHighlighter(theme)) {
|
|
11
11
|
let mode = "prose";
|
|
12
12
|
let atLineStart = true;
|
|
13
13
|
let lineBuf = "";
|
|
@@ -45,7 +45,7 @@ export function createStreamHighlighter(c, highlightLine = defaultLineHighlighte
|
|
|
45
45
|
lang = m[2] ? m[2].toLowerCase() : null;
|
|
46
46
|
carry = FRESH_CARRY;
|
|
47
47
|
mode = "code";
|
|
48
|
-
out +=
|
|
48
|
+
out += theme.muted(lineBuf) + "\n";
|
|
49
49
|
}
|
|
50
50
|
else {
|
|
51
51
|
mode = "prose";
|
|
@@ -70,7 +70,7 @@ export function createStreamHighlighter(c, highlightLine = defaultLineHighlighte
|
|
|
70
70
|
if (ch === "\n") {
|
|
71
71
|
if (/^`{3,}\s*$/.test(lineBuf)) {
|
|
72
72
|
mode = "prose";
|
|
73
|
-
out +=
|
|
73
|
+
out += theme.muted(lineBuf) + "\n";
|
|
74
74
|
}
|
|
75
75
|
else {
|
|
76
76
|
out += styleCodeLine(lineBuf) + "\n";
|
|
@@ -165,7 +165,7 @@ const WORD = /[A-Za-z0-9_$]/;
|
|
|
165
165
|
* comments dim, strings green, keywords magenta, numbers yellow, everything
|
|
166
166
|
* else untouched. Unknown language → identity.
|
|
167
167
|
*/
|
|
168
|
-
export function defaultLineHighlighter(
|
|
168
|
+
export function defaultLineHighlighter(theme) {
|
|
169
169
|
return (line, lang, carry) => {
|
|
170
170
|
const def = lang ? LANGS[lang] : undefined;
|
|
171
171
|
if (!def)
|
|
@@ -179,37 +179,37 @@ export function defaultLineHighlighter(c) {
|
|
|
179
179
|
if (next.blockComment && def.blockComment) {
|
|
180
180
|
const close = line.indexOf(def.blockComment[1]);
|
|
181
181
|
if (close === -1)
|
|
182
|
-
return { text:
|
|
182
|
+
return { text: theme.syntax.comment(line), carry: next };
|
|
183
183
|
const end = close + def.blockComment[1].length;
|
|
184
|
-
out +=
|
|
184
|
+
out += theme.syntax.comment(line.slice(0, end));
|
|
185
185
|
i = end;
|
|
186
186
|
next.blockComment = false;
|
|
187
187
|
}
|
|
188
188
|
else if (next.stringDelim) {
|
|
189
189
|
const close = findStringEnd(line, 0, next.stringDelim);
|
|
190
190
|
if (close === -1)
|
|
191
|
-
return { text:
|
|
192
|
-
out +=
|
|
191
|
+
return { text: theme.syntax.string(line), carry: next };
|
|
192
|
+
out += theme.syntax.string(line.slice(0, close));
|
|
193
193
|
i = close;
|
|
194
194
|
next.stringDelim = null;
|
|
195
195
|
}
|
|
196
196
|
while (i < line.length) {
|
|
197
197
|
const rest = line.slice(i);
|
|
198
198
|
if (def.lineComment && rest.startsWith(def.lineComment)) {
|
|
199
|
-
out +=
|
|
199
|
+
out += theme.syntax.comment(rest);
|
|
200
200
|
i = line.length;
|
|
201
201
|
break;
|
|
202
202
|
}
|
|
203
203
|
if (def.blockComment && rest.startsWith(def.blockComment[0])) {
|
|
204
204
|
const close = line.indexOf(def.blockComment[1], i + def.blockComment[0].length);
|
|
205
205
|
if (close === -1) {
|
|
206
|
-
out +=
|
|
206
|
+
out += theme.syntax.comment(rest);
|
|
207
207
|
next = { ...next, blockComment: true };
|
|
208
208
|
i = line.length;
|
|
209
209
|
break;
|
|
210
210
|
}
|
|
211
211
|
const end = close + def.blockComment[1].length;
|
|
212
|
-
out +=
|
|
212
|
+
out += theme.syntax.comment(line.slice(i, end));
|
|
213
213
|
i = end;
|
|
214
214
|
continue;
|
|
215
215
|
}
|
|
@@ -217,13 +217,13 @@ export function defaultLineHighlighter(c) {
|
|
|
217
217
|
if (quote) {
|
|
218
218
|
const close = findStringEnd(line, i + quote.length, quote);
|
|
219
219
|
if (close === -1) {
|
|
220
|
-
out +=
|
|
220
|
+
out += theme.syntax.string(rest);
|
|
221
221
|
if (def.multiline.includes(quote))
|
|
222
222
|
next = { ...next, stringDelim: quote };
|
|
223
223
|
i = line.length;
|
|
224
224
|
break;
|
|
225
225
|
}
|
|
226
|
-
out +=
|
|
226
|
+
out += theme.syntax.string(line.slice(i, close));
|
|
227
227
|
i = close;
|
|
228
228
|
continue;
|
|
229
229
|
}
|
|
@@ -234,9 +234,9 @@ export function defaultLineHighlighter(c) {
|
|
|
234
234
|
j++;
|
|
235
235
|
const word = line.slice(i, j);
|
|
236
236
|
if (def.keywords.has(word))
|
|
237
|
-
out +=
|
|
237
|
+
out += theme.syntax.keyword(word);
|
|
238
238
|
else if (/^\d/.test(word))
|
|
239
|
-
out +=
|
|
239
|
+
out += theme.syntax.number(word);
|
|
240
240
|
else
|
|
241
241
|
out += word;
|
|
242
242
|
i = j;
|
package/dist/render/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { RenderStream, StreamRenderer } from "./types.js";
|
|
|
2
2
|
export type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, TokenUsage, ToolLifecycleEvent, } from "./types.js";
|
|
3
3
|
export { detectCapabilities } from "./capabilities.js";
|
|
4
4
|
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
5
|
-
export { renderActionPreview, PREVIEW_MAX_LINES
|
|
5
|
+
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
6
6
|
export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
|
|
7
7
|
export { PlainRenderer } from "./plain-renderer.js";
|
|
8
8
|
export { TtyRenderer } from "./tty-renderer.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import { type Theme } from "../theme/index.js";
|
|
2
3
|
import type { RenderCapabilities, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
|
|
@@ -15,7 +16,7 @@ export declare class PlainRenderer implements StreamRenderer {
|
|
|
15
16
|
readonly caps: RenderCapabilities;
|
|
16
17
|
private readonly out;
|
|
17
18
|
private readonly err;
|
|
18
|
-
|
|
19
|
+
readonly theme: Theme;
|
|
19
20
|
/** Per-turn leading-newline trim; also tells endSegment whether to newline. */
|
|
20
21
|
private print;
|
|
21
22
|
private wroteInSegment;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
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 { ELAPSED_AFTER_MS, formatElapsed } from "./state.js";
|
|
@@ -17,7 +17,7 @@ export class PlainRenderer {
|
|
|
17
17
|
caps;
|
|
18
18
|
out;
|
|
19
19
|
err;
|
|
20
|
-
|
|
20
|
+
theme;
|
|
21
21
|
/** Per-turn leading-newline trim; also tells endSegment whether to newline. */
|
|
22
22
|
print;
|
|
23
23
|
wroteInSegment = false;
|
|
@@ -27,7 +27,7 @@ export class PlainRenderer {
|
|
|
27
27
|
this.caps = caps;
|
|
28
28
|
this.out = out;
|
|
29
29
|
this.err = err;
|
|
30
|
-
this.
|
|
30
|
+
this.theme = resolveTheme(caps);
|
|
31
31
|
this.print = this.newPrinter();
|
|
32
32
|
}
|
|
33
33
|
newPrinter() {
|
|
@@ -49,10 +49,10 @@ export class PlainRenderer {
|
|
|
49
49
|
this.wroteInSegment = false;
|
|
50
50
|
}
|
|
51
51
|
note(text) {
|
|
52
|
-
this.err.write(this.
|
|
52
|
+
this.err.write(this.theme.muted(text) + "\n");
|
|
53
53
|
}
|
|
54
54
|
preview(preview) {
|
|
55
|
-
const block = renderActionPreview(preview, this.
|
|
55
|
+
const block = renderActionPreview(preview, this.theme);
|
|
56
56
|
if (block)
|
|
57
57
|
this.out.write(block + "\n");
|
|
58
58
|
}
|
|
@@ -77,7 +77,8 @@ export class PlainRenderer {
|
|
|
77
77
|
this.toolStart = null;
|
|
78
78
|
const elapsed = started === null ? 0 : Date.now() - started.at;
|
|
79
79
|
const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
|
|
80
|
-
|
|
80
|
+
const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
|
|
81
|
+
this.note(`${mark} ${event.label}${suffix}`);
|
|
81
82
|
}
|
|
82
83
|
promptResolved() {
|
|
83
84
|
// No live region to restore.
|
package/dist/render/state.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { type ThemeGlyphs } from "../theme/index.js";
|
|
1
2
|
import type { ProgressState, RenderPhase } from "./types.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
|
|
5
6
|
* terminal in sight. Color is deliberately absent — the live line is drawn
|
|
6
7
|
* dim as a whole by the TTY renderer; state text is content, not chrome.
|
|
8
|
+
*
|
|
9
|
+
* Glyphs (ellipsis, token arrows, the ` · ` joiner) come from the theme (U.1);
|
|
10
|
+
* they default to the unicode table so colorless/test callers are unchanged,
|
|
11
|
+
* and the TTY renderer passes its capability-resolved set for ASCII fallback.
|
|
7
12
|
*/
|
|
8
13
|
/**
|
|
9
14
|
* Threshold before elapsed time appears on a live state or a committed tool
|
|
@@ -16,7 +21,7 @@ export declare function formatTokens(n: number): string;
|
|
|
16
21
|
/** `37s`, `2m08s` — durations at status-line width. */
|
|
17
22
|
export declare function formatElapsed(ms: number): string;
|
|
18
23
|
/** The live-line text for a phase. `awaiting-approval` never renders (the line hides). */
|
|
19
|
-
export declare function describePhase(phase: RenderPhase): string;
|
|
24
|
+
export declare function describePhase(phase: RenderPhase, glyph?: ThemeGlyphs): string;
|
|
20
25
|
/**
|
|
21
26
|
* Identity key for the elapsed clock: the clock resets when the phase becomes
|
|
22
27
|
* a *different activity*, not on every payload update — a thinking phase that
|
|
@@ -28,4 +33,4 @@ export declare function phaseIdentity(phase: RenderPhase | null): string;
|
|
|
28
33
|
* Elapsed appears only past {@link ELAPSED_AFTER_MS} — callers pass it only
|
|
29
34
|
* when they can keep it ticking honestly (no timer → no frozen number).
|
|
30
35
|
*/
|
|
31
|
-
export declare function composeStatusLine(progress: ProgressState | null, phase: RenderPhase | null, elapsedMs?: number): string;
|
|
36
|
+
export declare function composeStatusLine(progress: ProgressState | null, phase: RenderPhase | null, elapsedMs?: number, glyph?: ThemeGlyphs): string;
|
package/dist/render/state.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { UNICODE_GLYPHS } from "../theme/index.js";
|
|
1
2
|
/**
|
|
2
3
|
* The U.4 state→text mapping: pure data → string, like plan/render.ts and
|
|
3
4
|
* diff.ts, so both renderers (and tests) share one composition with no
|
|
4
5
|
* terminal in sight. Color is deliberately absent — the live line is drawn
|
|
5
6
|
* dim as a whole by the TTY renderer; state text is content, not chrome.
|
|
7
|
+
*
|
|
8
|
+
* Glyphs (ellipsis, token arrows, the ` · ` joiner) come from the theme (U.1);
|
|
9
|
+
* they default to the unicode table so colorless/test callers are unchanged,
|
|
10
|
+
* and the TTY renderer passes its capability-resolved set for ASCII fallback.
|
|
6
11
|
*/
|
|
7
12
|
/**
|
|
8
13
|
* Threshold before elapsed time appears on a live state or a committed tool
|
|
@@ -28,23 +33,24 @@ export function formatElapsed(ms) {
|
|
|
28
33
|
return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
29
34
|
}
|
|
30
35
|
/** The live-line text for a phase. `awaiting-approval` never renders (the line hides). */
|
|
31
|
-
export function describePhase(phase) {
|
|
36
|
+
export function describePhase(phase, glyph = UNICODE_GLYPHS) {
|
|
37
|
+
const e = glyph.ellipsis;
|
|
32
38
|
switch (phase.kind) {
|
|
33
39
|
case "thinking": {
|
|
34
40
|
const t = phase.tokens;
|
|
35
41
|
// Honest numbers only: no usage yet → no figure at all.
|
|
36
42
|
return t && t.input + t.output > 0
|
|
37
|
-
? `thinking
|
|
38
|
-
:
|
|
43
|
+
? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
|
|
44
|
+
: `thinking${e}`;
|
|
39
45
|
}
|
|
40
46
|
case "calling-tool":
|
|
41
|
-
return `${phase.label}
|
|
47
|
+
return `${phase.label}${e}`;
|
|
42
48
|
case "awaiting-approval":
|
|
43
|
-
return
|
|
49
|
+
return `awaiting approval${e}`;
|
|
44
50
|
case "executing-step":
|
|
45
|
-
return
|
|
51
|
+
return `working${e}`;
|
|
46
52
|
case "subagent":
|
|
47
|
-
return `subagent: ${phase.label}
|
|
53
|
+
return `subagent: ${phase.label}${e}`;
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
/**
|
|
@@ -69,13 +75,13 @@ export function phaseIdentity(phase) {
|
|
|
69
75
|
* Elapsed appears only past {@link ELAPSED_AFTER_MS} — callers pass it only
|
|
70
76
|
* when they can keep it ticking honestly (no timer → no frozen number).
|
|
71
77
|
*/
|
|
72
|
-
export function composeStatusLine(progress, phase, elapsedMs) {
|
|
78
|
+
export function composeStatusLine(progress, phase, elapsedMs, glyph = UNICODE_GLYPHS) {
|
|
73
79
|
const parts = [];
|
|
74
80
|
if (progress)
|
|
75
81
|
parts.push(`[${progress.step}/${progress.of}] ${progress.title}`);
|
|
76
82
|
if (phase)
|
|
77
|
-
parts.push(describePhase(phase));
|
|
78
|
-
const line = parts.join(
|
|
83
|
+
parts.push(describePhase(phase, glyph));
|
|
84
|
+
const line = parts.join(` ${glyph.sep} `);
|
|
79
85
|
if (elapsedMs !== undefined && elapsedMs >= ELAPSED_AFTER_MS) {
|
|
80
86
|
return `${line} (${formatElapsed(elapsedMs)})`;
|
|
81
87
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import { type Theme } from "../theme/index.js";
|
|
2
3
|
import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* The interactive renderer: committed content is append-only; the one transient
|
|
@@ -28,7 +29,7 @@ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, Stre
|
|
|
28
29
|
export declare class TtyRenderer implements StreamRenderer {
|
|
29
30
|
readonly caps: RenderCapabilities;
|
|
30
31
|
private readonly out;
|
|
31
|
-
|
|
32
|
+
readonly theme: Theme;
|
|
32
33
|
private print;
|
|
33
34
|
private highlighter;
|
|
34
35
|
private wroteInSegment;
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
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
5
|
import { composeStatusLine, ELAPSED_AFTER_MS, formatElapsed, phaseIdentity, } from "./state.js";
|
|
6
6
|
/** Erase the current line and return the cursor to column 0. */
|
|
7
7
|
const CLEAR_LINE = "\r\x1b[2K";
|
|
8
|
-
/** Spinner frames (braille); a static glyph when animation is disabled. */
|
|
9
|
-
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
|
-
const STATIC_FRAME = "◐";
|
|
11
8
|
const SPINNER_INTERVAL_MS = 100;
|
|
12
9
|
/**
|
|
13
10
|
* The interactive renderer: committed content is append-only; the one transient
|
|
@@ -37,7 +34,7 @@ const SPINNER_INTERVAL_MS = 100;
|
|
|
37
34
|
export class TtyRenderer {
|
|
38
35
|
caps;
|
|
39
36
|
out;
|
|
40
|
-
|
|
37
|
+
theme;
|
|
41
38
|
print;
|
|
42
39
|
highlighter;
|
|
43
40
|
wroteInSegment = false;
|
|
@@ -59,8 +56,8 @@ export class TtyRenderer {
|
|
|
59
56
|
constructor(caps, out) {
|
|
60
57
|
this.caps = caps;
|
|
61
58
|
this.out = out;
|
|
62
|
-
this.
|
|
63
|
-
this.highlighter = createStreamHighlighter(this.
|
|
59
|
+
this.theme = resolveTheme(caps);
|
|
60
|
+
this.highlighter = createStreamHighlighter(this.theme);
|
|
64
61
|
this.print = this.newPrinter();
|
|
65
62
|
}
|
|
66
63
|
newPrinter() {
|
|
@@ -115,7 +112,7 @@ export class TtyRenderer {
|
|
|
115
112
|
const elapsed = this.phase !== null && this.caps.spinner
|
|
116
113
|
? Date.now() - this.phaseStartedAt
|
|
117
114
|
: undefined;
|
|
118
|
-
return composeStatusLine(this.progressState, this.phase, elapsed);
|
|
115
|
+
return composeStatusLine(this.progressState, this.phase, elapsed, this.theme.glyph);
|
|
119
116
|
}
|
|
120
117
|
/** Redraw the live line from current state, or hide it when there is none. */
|
|
121
118
|
refresh() {
|
|
@@ -138,16 +135,19 @@ export class TtyRenderer {
|
|
|
138
135
|
}
|
|
139
136
|
drawLine(text) {
|
|
140
137
|
this.lineVisible = true;
|
|
138
|
+
const frames = this.theme.glyph.spinnerFrames;
|
|
141
139
|
const glyph = this.caps.spinner
|
|
142
|
-
?
|
|
143
|
-
:
|
|
140
|
+
? frames[this.frame % frames.length]
|
|
141
|
+
: this.theme.glyph.spinnerStatic;
|
|
144
142
|
// Reserve glyph + space; truncate so the live line can never soft-wrap.
|
|
145
143
|
const room = Math.max(1, this.caps.width - 2);
|
|
146
|
-
const line = text.length > room
|
|
147
|
-
|
|
144
|
+
const line = text.length > room
|
|
145
|
+
? text.slice(0, Math.max(0, room - 1)) + this.theme.glyph.ellipsis
|
|
146
|
+
: text;
|
|
147
|
+
this.out.write(`${CLEAR_LINE}${this.theme.accent(glyph)} ${this.theme.muted(line)}`);
|
|
148
148
|
}
|
|
149
149
|
beginTurn() {
|
|
150
|
-
this.highlighter = createStreamHighlighter(this.
|
|
150
|
+
this.highlighter = createStreamHighlighter(this.theme);
|
|
151
151
|
this.print = this.newPrinter();
|
|
152
152
|
this.wroteInSegment = false;
|
|
153
153
|
}
|
|
@@ -169,13 +169,15 @@ export class TtyRenderer {
|
|
|
169
169
|
if (this.closed)
|
|
170
170
|
return;
|
|
171
171
|
const room = Math.max(1, this.caps.width);
|
|
172
|
-
const line = text.length > room
|
|
173
|
-
|
|
172
|
+
const line = text.length > room
|
|
173
|
+
? text.slice(0, room - 1) + this.theme.glyph.ellipsis
|
|
174
|
+
: text;
|
|
175
|
+
this.commit(this.theme.muted(line) + "\n");
|
|
174
176
|
}
|
|
175
177
|
preview(preview) {
|
|
176
178
|
if (this.closed)
|
|
177
179
|
return;
|
|
178
|
-
const block = renderActionPreview(preview, this.
|
|
180
|
+
const block = renderActionPreview(preview, this.theme);
|
|
179
181
|
if (block)
|
|
180
182
|
this.commit(block + "\n");
|
|
181
183
|
}
|
|
@@ -237,7 +239,8 @@ export class TtyRenderer {
|
|
|
237
239
|
// honest even with CRUXY_NO_SPINNER; shown only once it means something.
|
|
238
240
|
const elapsed = started === null ? 0 : Date.now() - started.at;
|
|
239
241
|
const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
|
|
240
|
-
|
|
242
|
+
const mark = event.ok ? this.theme.glyph.success : this.theme.glyph.failure;
|
|
243
|
+
this.note(`${mark} ${event.label}${suffix}`);
|
|
241
244
|
}
|
|
242
245
|
promptResolved() {
|
|
243
246
|
if (this.closed)
|
package/dist/render/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import type { Theme } from "../theme/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* The streaming render seam (U.2): the agent loop talks to a
|
|
4
5
|
* {@link StreamRenderer}, never to raw stdout. Two implementations exist —
|
|
@@ -21,6 +22,9 @@ export interface RenderCapabilities {
|
|
|
21
22
|
cursor: boolean;
|
|
22
23
|
/** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
|
|
23
24
|
spinner: boolean;
|
|
25
|
+
/** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
|
|
26
|
+
* independent of `color`. Drives the theme's glyph table, not its stylers. */
|
|
27
|
+
unicode: boolean;
|
|
24
28
|
/** Terminal columns; 80 when unknown (non-TTY). */
|
|
25
29
|
width: number;
|
|
26
30
|
}
|
|
@@ -97,6 +101,9 @@ export type ToolLifecycleEvent = {
|
|
|
97
101
|
*/
|
|
98
102
|
export interface StreamRenderer {
|
|
99
103
|
readonly caps: RenderCapabilities;
|
|
104
|
+
/** The one resolved design system (U.1) — glyphs/roles for chrome a
|
|
105
|
+
* surface emits through this renderer (e.g. the subagent trail notes). */
|
|
106
|
+
readonly theme: Theme;
|
|
100
107
|
/** Start a user turn: reset leading-newline trim and code-fence state. */
|
|
101
108
|
beginTurn(): void;
|
|
102
109
|
/**
|
|
@@ -67,7 +67,9 @@ export class SubagentOrchestrator {
|
|
|
67
67
|
},
|
|
68
68
|
};
|
|
69
69
|
const label = taskLabel(spec.task);
|
|
70
|
-
deps.renderer
|
|
70
|
+
if (deps.renderer) {
|
|
71
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.play} subagent: ${label}`);
|
|
72
|
+
}
|
|
71
73
|
deps.renderer?.setPhase({ kind: "subagent", label });
|
|
72
74
|
// The isolation seam: a brand-new history seeded with ONLY the task. The
|
|
73
75
|
// parent's messages are never in scope here, and this array dies with the
|
|
@@ -96,7 +98,9 @@ export class SubagentOrchestrator {
|
|
|
96
98
|
deps.renderer?.setPhase(null);
|
|
97
99
|
throw err;
|
|
98
100
|
}
|
|
99
|
-
deps.renderer
|
|
101
|
+
if (deps.renderer) {
|
|
102
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.failure} subagent failed: ${label}`);
|
|
103
|
+
}
|
|
100
104
|
deps.renderer?.setPhase(null);
|
|
101
105
|
return {
|
|
102
106
|
status: "failed",
|
|
@@ -123,7 +127,9 @@ export class SubagentOrchestrator {
|
|
|
123
127
|
usage: run.usage,
|
|
124
128
|
};
|
|
125
129
|
if (run.stop === "completed") {
|
|
126
|
-
this.deps.renderer
|
|
130
|
+
const r = this.deps.renderer;
|
|
131
|
+
if (r)
|
|
132
|
+
r.note(`${r.theme.glyph.success} subagent done: ${label}`);
|
|
127
133
|
return { status: "done", ...base };
|
|
128
134
|
}
|
|
129
135
|
// Both cap paths are the same outcome for the parent: a truncated, partial
|
|
@@ -132,7 +138,9 @@ export class SubagentOrchestrator {
|
|
|
132
138
|
const reason = run.stop === "budget"
|
|
133
139
|
? (run.stopReason ?? "budget cap reached")
|
|
134
140
|
: `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
|
|
135
|
-
this.deps.renderer
|
|
141
|
+
const r = this.deps.renderer;
|
|
142
|
+
if (r)
|
|
143
|
+
r.note(`${r.theme.glyph.failure} subagent stopped (budget): ${label}`);
|
|
136
144
|
return {
|
|
137
145
|
status: "budget-exceeded",
|
|
138
146
|
...base,
|
|
@@ -195,12 +203,16 @@ function lastAssistantText(messages) {
|
|
|
195
203
|
*/
|
|
196
204
|
class SubagentRenderer {
|
|
197
205
|
caps;
|
|
206
|
+
theme;
|
|
198
207
|
inner;
|
|
199
208
|
label;
|
|
209
|
+
prefix;
|
|
200
210
|
constructor(inner, label) {
|
|
201
211
|
this.inner = inner;
|
|
202
212
|
this.label = label;
|
|
203
213
|
this.caps = inner.caps;
|
|
214
|
+
this.theme = inner.theme;
|
|
215
|
+
this.prefix = `subagent ${inner.theme.glyph.sep} `;
|
|
204
216
|
}
|
|
205
217
|
/** Turn framing belongs to the parent's turn — the child's is dropped. */
|
|
206
218
|
beginTurn() { }
|
|
@@ -209,7 +221,7 @@ class SubagentRenderer {
|
|
|
209
221
|
write() { }
|
|
210
222
|
endSegment() { }
|
|
211
223
|
note(text) {
|
|
212
|
-
this.inner.note(
|
|
224
|
+
this.inner.note(`${this.prefix}${text}`);
|
|
213
225
|
}
|
|
214
226
|
preview(preview) {
|
|
215
227
|
this.inner.preview(preview);
|
|
@@ -231,7 +243,10 @@ class SubagentRenderer {
|
|
|
231
243
|
/** The plan executor owns the progress register (C.31) — never the child. */
|
|
232
244
|
progress() { }
|
|
233
245
|
toolLifecycle(event) {
|
|
234
|
-
this.inner.toolLifecycle({
|
|
246
|
+
this.inner.toolLifecycle({
|
|
247
|
+
...event,
|
|
248
|
+
label: `${this.prefix}${event.label}`,
|
|
249
|
+
});
|
|
235
250
|
}
|
|
236
251
|
promptResolved() {
|
|
237
252
|
this.inner.promptResolved();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Theme, type ThemeCapabilities } from "./tokens.js";
|
|
2
|
+
/**
|
|
3
|
+
* Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
|
|
4
|
+
* fully independent by construction:
|
|
5
|
+
*
|
|
6
|
+
* - `color` drives the stylers only. `pc.createColors(false)` is the identity,
|
|
7
|
+
* so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
|
|
8
|
+
* tests.
|
|
9
|
+
* - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
|
|
10
|
+
* unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
|
|
11
|
+
* CRUXY_ASCII terminal prints a colored `[ok]`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveTheme(caps: ThemeCapabilities): Theme;
|
|
14
|
+
/**
|
|
15
|
+
* Whether the terminal can render unicode glyphs. `TERM=dumb` and an explicit
|
|
16
|
+
* `CRUXY_ASCII` opt-in fall back to the ASCII table; everything else (real
|
|
17
|
+
* terminals AND pipes) keeps unicode — piping ✓/✗ to a file is fine, and this
|
|
18
|
+
* preserves pre-U.1 behavior for non-TTY output.
|
|
19
|
+
*
|
|
20
|
+
* The single source of the rule: `render/capabilities.ts` calls this to fill
|
|
21
|
+
* `RenderCapabilities.unicode`, and the boolean-seam surfaces (errors,
|
|
22
|
+
* approval, plan, onboarding, CLI commands) call it to build a theme without a
|
|
23
|
+
* signature change.
|
|
24
|
+
*/
|
|
25
|
+
export declare function detectUnicode(env?: NodeJS.ProcessEnv): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Build a theme for a surface that only knows a `color` boolean (the U.3
|
|
28
|
+
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
|
|
29
|
+
* from the environment so these surfaces still degrade on dumb terminals,
|
|
30
|
+
* with no change to their public boolean signatures.
|
|
31
|
+
*/
|
|
32
|
+
export declare function themeForColor(color: boolean, env?: NodeJS.ProcessEnv): Theme;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { ASCII_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
|
|
3
|
+
/**
|
|
4
|
+
* Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
|
|
5
|
+
* fully independent by construction:
|
|
6
|
+
*
|
|
7
|
+
* - `color` drives the stylers only. `pc.createColors(false)` is the identity,
|
|
8
|
+
* so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
|
|
9
|
+
* tests.
|
|
10
|
+
* - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
|
|
11
|
+
* unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
|
|
12
|
+
* CRUXY_ASCII terminal prints a colored `[ok]`.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveTheme(caps) {
|
|
15
|
+
const c = pc.createColors(caps.color);
|
|
16
|
+
const glyph = caps.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
|
|
17
|
+
const strong = c.bold;
|
|
18
|
+
const indent = (text, level = 1) => {
|
|
19
|
+
const pad = " ".repeat(Math.max(0, level));
|
|
20
|
+
return text
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => (line === "" ? line : pad + line))
|
|
23
|
+
.join("\n");
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
danger: c.red,
|
|
27
|
+
warning: c.yellow,
|
|
28
|
+
success: c.green,
|
|
29
|
+
accent: c.cyan,
|
|
30
|
+
muted: c.dim,
|
|
31
|
+
strong,
|
|
32
|
+
syntax: {
|
|
33
|
+
keyword: c.magenta,
|
|
34
|
+
string: c.green,
|
|
35
|
+
number: c.yellow,
|
|
36
|
+
comment: c.dim,
|
|
37
|
+
},
|
|
38
|
+
glyph,
|
|
39
|
+
heading: strong,
|
|
40
|
+
indent,
|
|
41
|
+
kv: (key, value, keyWidth) => `${strong(keyWidth ? key.padEnd(keyWidth) : key)} ${value}`,
|
|
42
|
+
sep: ` ${glyph.sep} `,
|
|
43
|
+
color: caps.color,
|
|
44
|
+
unicode: caps.unicode,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether the terminal can render unicode glyphs. `TERM=dumb` and an explicit
|
|
49
|
+
* `CRUXY_ASCII` opt-in fall back to the ASCII table; everything else (real
|
|
50
|
+
* terminals AND pipes) keeps unicode — piping ✓/✗ to a file is fine, and this
|
|
51
|
+
* preserves pre-U.1 behavior for non-TTY output.
|
|
52
|
+
*
|
|
53
|
+
* The single source of the rule: `render/capabilities.ts` calls this to fill
|
|
54
|
+
* `RenderCapabilities.unicode`, and the boolean-seam surfaces (errors,
|
|
55
|
+
* approval, plan, onboarding, CLI commands) call it to build a theme without a
|
|
56
|
+
* signature change.
|
|
57
|
+
*/
|
|
58
|
+
export function detectUnicode(env = process.env) {
|
|
59
|
+
if (env.CRUXY_ASCII !== undefined && env.CRUXY_ASCII !== "")
|
|
60
|
+
return false;
|
|
61
|
+
if (env.TERM === "dumb")
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build a theme for a surface that only knows a `color` boolean (the U.3
|
|
67
|
+
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
|
|
68
|
+
* from the environment so these surfaces still degrade on dumb terminals,
|
|
69
|
+
* with no change to their public boolean signatures.
|
|
70
|
+
*/
|
|
71
|
+
export function themeForColor(color, env = process.env) {
|
|
72
|
+
return resolveTheme({ color, unicode: detectUnicode(env) });
|
|
73
|
+
}
|