@cruxy/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -13
- package/dist/agent/loop.d.ts +35 -6
- package/dist/agent/loop.js +84 -10
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/agent/session.d.ts +6 -4
- package/dist/agent/session.js +6 -5
- package/dist/approval/classify.js +26 -0
- package/dist/approval/prompt.d.ts +9 -0
- package/dist/approval/prompt.js +2 -77
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +51 -0
- package/dist/cli/commands/run.js +24 -10
- package/dist/cli/onboard.js +9 -4
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +10 -4
- package/dist/cli/repl.js +26 -12
- package/dist/cli/session-factory.d.ts +15 -1
- package/dist/cli/session-factory.js +104 -18
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +86 -0
- package/dist/errors/types.d.ts +7 -0
- package/dist/errors/types.js +16 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.d.ts +2 -1
- package/dist/plan/service.js +7 -3
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/capabilities.d.ts +12 -0
- package/dist/render/capabilities.js +27 -0
- package/dist/render/diff.d.ts +19 -0
- package/dist/render/diff.js +107 -0
- package/dist/render/highlight.d.ts +47 -0
- package/dist/render/highlight.js +265 -0
- package/dist/render/index.d.ts +15 -0
- package/dist/render/index.js +21 -0
- package/dist/render/plain-renderer.d.ts +38 -0
- package/dist/render/plain-renderer.js +87 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +83 -0
- package/dist/render/tty-renderer.js +276 -0
- package/dist/render/types.d.ts +160 -0
- package/dist/render/types.js +1 -0
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- package/package.json +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const FRESH_CARRY = { blockComment: false, stringDelim: null };
|
|
2
|
+
/** A fence opener: ``` or longer, optional language word. */
|
|
3
|
+
const FENCE_OPEN = /^(`{3,})([\w+#.-]*)\s*$/;
|
|
4
|
+
/** A line that could still grow into a fence opener. */
|
|
5
|
+
const FENCE_PLAUSIBLE = /^(?:`{1,2}|`{3,}[\w+#.-]*\s*)$/;
|
|
6
|
+
/**
|
|
7
|
+
* Create the per-segment streaming highlighter. `highlightLine` is injectable
|
|
8
|
+
* for tests (e.g. to prove a throwing tokenizer degrades to plain text).
|
|
9
|
+
*/
|
|
10
|
+
export function createStreamHighlighter(c, highlightLine = defaultLineHighlighter(c)) {
|
|
11
|
+
let mode = "prose";
|
|
12
|
+
let atLineStart = true;
|
|
13
|
+
let lineBuf = "";
|
|
14
|
+
let lang = null;
|
|
15
|
+
let carry = FRESH_CARRY;
|
|
16
|
+
/** Highlight one completed code line; any tokenizer throw → plain text. */
|
|
17
|
+
const styleCodeLine = (line) => {
|
|
18
|
+
try {
|
|
19
|
+
const res = highlightLine(line, lang, carry);
|
|
20
|
+
carry = res.carry;
|
|
21
|
+
return res.text;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
carry = FRESH_CARRY; // state is suspect after a throw; start clean
|
|
25
|
+
return line;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const push = (delta) => {
|
|
29
|
+
let out = "";
|
|
30
|
+
for (const ch of delta) {
|
|
31
|
+
if (mode === "prose") {
|
|
32
|
+
if (atLineStart && ch === "`") {
|
|
33
|
+
mode = "maybe-fence";
|
|
34
|
+
lineBuf = ch;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
out += ch;
|
|
38
|
+
atLineStart = ch === "\n";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else if (mode === "maybe-fence") {
|
|
42
|
+
if (ch === "\n") {
|
|
43
|
+
const m = FENCE_OPEN.exec(lineBuf);
|
|
44
|
+
if (m) {
|
|
45
|
+
lang = m[2] ? m[2].toLowerCase() : null;
|
|
46
|
+
carry = FRESH_CARRY;
|
|
47
|
+
mode = "code";
|
|
48
|
+
out += c.dim(lineBuf) + "\n";
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
mode = "prose";
|
|
52
|
+
out += lineBuf + "\n";
|
|
53
|
+
}
|
|
54
|
+
lineBuf = "";
|
|
55
|
+
atLineStart = true;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
lineBuf += ch;
|
|
59
|
+
if (!FENCE_PLAUSIBLE.test(lineBuf)) {
|
|
60
|
+
// Can no longer become a fence (e.g. inline `code`) — release it.
|
|
61
|
+
mode = "prose";
|
|
62
|
+
out += lineBuf;
|
|
63
|
+
lineBuf = "";
|
|
64
|
+
atLineStart = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// code
|
|
70
|
+
if (ch === "\n") {
|
|
71
|
+
if (/^`{3,}\s*$/.test(lineBuf)) {
|
|
72
|
+
mode = "prose";
|
|
73
|
+
out += c.dim(lineBuf) + "\n";
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
out += styleCodeLine(lineBuf) + "\n";
|
|
77
|
+
}
|
|
78
|
+
lineBuf = "";
|
|
79
|
+
atLineStart = true;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
lineBuf += ch;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
const flush = () => {
|
|
89
|
+
// Segment ended mid-line: release the held text as-is (highlighted when we
|
|
90
|
+
// know it's code — styled *before* the language resets) and start the next
|
|
91
|
+
// segment back in prose state.
|
|
92
|
+
const out = lineBuf === "" ? "" : mode === "code" ? styleCodeLine(lineBuf) : lineBuf;
|
|
93
|
+
mode = "prose";
|
|
94
|
+
lineBuf = "";
|
|
95
|
+
atLineStart = true;
|
|
96
|
+
lang = null;
|
|
97
|
+
carry = FRESH_CARRY;
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
return { push, flush };
|
|
101
|
+
}
|
|
102
|
+
const JS_KEYWORDS = "abstract as async await break case catch class const continue debugger default delete do else enum export extends false finally for from function get if implements import in instanceof interface let new null of private protected public readonly return satisfies set static super switch this throw true try type typeof undefined var void while with yield";
|
|
103
|
+
const PY_KEYWORDS = "and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield match case self";
|
|
104
|
+
const SH_KEYWORDS = "if then else elif fi for while until do done case esac function in select time coproc break continue return exit export local readonly declare set unset shift trap source alias cd echo printf read test";
|
|
105
|
+
const GO_KEYWORDS = "break case chan const continue default defer else fallthrough for func go goto if import interface map nil package range return select struct switch true false type var";
|
|
106
|
+
const RUST_KEYWORDS = "as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while";
|
|
107
|
+
const words = (list) => new Set(list.split(" "));
|
|
108
|
+
const JS_DEF = {
|
|
109
|
+
keywords: words(JS_KEYWORDS),
|
|
110
|
+
lineComment: "//",
|
|
111
|
+
blockComment: ["/*", "*/"],
|
|
112
|
+
quotes: ['"', "'", "`"],
|
|
113
|
+
multiline: ["`"],
|
|
114
|
+
};
|
|
115
|
+
const LANGS = {
|
|
116
|
+
js: JS_DEF,
|
|
117
|
+
jsx: JS_DEF,
|
|
118
|
+
ts: JS_DEF,
|
|
119
|
+
tsx: JS_DEF,
|
|
120
|
+
javascript: JS_DEF,
|
|
121
|
+
typescript: JS_DEF,
|
|
122
|
+
json: {
|
|
123
|
+
keywords: words("true false null"),
|
|
124
|
+
quotes: ['"'],
|
|
125
|
+
multiline: [],
|
|
126
|
+
},
|
|
127
|
+
py: {
|
|
128
|
+
keywords: words(PY_KEYWORDS),
|
|
129
|
+
lineComment: "#",
|
|
130
|
+
quotes: ['"', "'", '"""', "'''"],
|
|
131
|
+
multiline: ['"""', "'''"],
|
|
132
|
+
},
|
|
133
|
+
sh: {
|
|
134
|
+
keywords: words(SH_KEYWORDS),
|
|
135
|
+
lineComment: "#",
|
|
136
|
+
quotes: ['"', "'"],
|
|
137
|
+
multiline: [],
|
|
138
|
+
},
|
|
139
|
+
go: {
|
|
140
|
+
keywords: words(GO_KEYWORDS),
|
|
141
|
+
lineComment: "//",
|
|
142
|
+
blockComment: ["/*", "*/"],
|
|
143
|
+
quotes: ['"', "'", "`"],
|
|
144
|
+
multiline: ["`"],
|
|
145
|
+
},
|
|
146
|
+
rust: {
|
|
147
|
+
keywords: words(RUST_KEYWORDS),
|
|
148
|
+
lineComment: "//",
|
|
149
|
+
blockComment: ["/*", "*/"],
|
|
150
|
+
quotes: ['"'],
|
|
151
|
+
multiline: [],
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
// Aliases.
|
|
155
|
+
LANGS.python = LANGS.py;
|
|
156
|
+
LANGS.bash = LANGS.sh;
|
|
157
|
+
LANGS.shell = LANGS.sh;
|
|
158
|
+
LANGS.zsh = LANGS.sh;
|
|
159
|
+
LANGS.golang = LANGS.go;
|
|
160
|
+
LANGS.rs = LANGS.rust;
|
|
161
|
+
/** Word characters for keyword/identifier scanning. */
|
|
162
|
+
const WORD = /[A-Za-z0-9_$]/;
|
|
163
|
+
/**
|
|
164
|
+
* Build the default per-line tokenizer over `c`. A plain left-to-right scan:
|
|
165
|
+
* comments dim, strings green, keywords magenta, numbers yellow, everything
|
|
166
|
+
* else untouched. Unknown language → identity.
|
|
167
|
+
*/
|
|
168
|
+
export function defaultLineHighlighter(c) {
|
|
169
|
+
return (line, lang, carry) => {
|
|
170
|
+
const def = lang ? LANGS[lang] : undefined;
|
|
171
|
+
if (!def)
|
|
172
|
+
return { text: line, carry };
|
|
173
|
+
// Longest quote first so `"""` wins over `"` in python.
|
|
174
|
+
const quotes = [...def.quotes].sort((a, b) => b.length - a.length);
|
|
175
|
+
let out = "";
|
|
176
|
+
let i = 0;
|
|
177
|
+
let next = { ...carry };
|
|
178
|
+
// Resume a multi-line construct from the previous line.
|
|
179
|
+
if (next.blockComment && def.blockComment) {
|
|
180
|
+
const close = line.indexOf(def.blockComment[1]);
|
|
181
|
+
if (close === -1)
|
|
182
|
+
return { text: c.dim(line), carry: next };
|
|
183
|
+
const end = close + def.blockComment[1].length;
|
|
184
|
+
out += c.dim(line.slice(0, end));
|
|
185
|
+
i = end;
|
|
186
|
+
next.blockComment = false;
|
|
187
|
+
}
|
|
188
|
+
else if (next.stringDelim) {
|
|
189
|
+
const close = findStringEnd(line, 0, next.stringDelim);
|
|
190
|
+
if (close === -1)
|
|
191
|
+
return { text: c.green(line), carry: next };
|
|
192
|
+
out += c.green(line.slice(0, close));
|
|
193
|
+
i = close;
|
|
194
|
+
next.stringDelim = null;
|
|
195
|
+
}
|
|
196
|
+
while (i < line.length) {
|
|
197
|
+
const rest = line.slice(i);
|
|
198
|
+
if (def.lineComment && rest.startsWith(def.lineComment)) {
|
|
199
|
+
out += c.dim(rest);
|
|
200
|
+
i = line.length;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
if (def.blockComment && rest.startsWith(def.blockComment[0])) {
|
|
204
|
+
const close = line.indexOf(def.blockComment[1], i + def.blockComment[0].length);
|
|
205
|
+
if (close === -1) {
|
|
206
|
+
out += c.dim(rest);
|
|
207
|
+
next = { ...next, blockComment: true };
|
|
208
|
+
i = line.length;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
const end = close + def.blockComment[1].length;
|
|
212
|
+
out += c.dim(line.slice(i, end));
|
|
213
|
+
i = end;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const quote = quotes.find((q) => rest.startsWith(q));
|
|
217
|
+
if (quote) {
|
|
218
|
+
const close = findStringEnd(line, i + quote.length, quote);
|
|
219
|
+
if (close === -1) {
|
|
220
|
+
out += c.green(rest);
|
|
221
|
+
if (def.multiline.includes(quote))
|
|
222
|
+
next = { ...next, stringDelim: quote };
|
|
223
|
+
i = line.length;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
out += c.green(line.slice(i, close));
|
|
227
|
+
i = close;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const ch = line[i];
|
|
231
|
+
if (WORD.test(ch)) {
|
|
232
|
+
let j = i + 1;
|
|
233
|
+
while (j < line.length && WORD.test(line[j]))
|
|
234
|
+
j++;
|
|
235
|
+
const word = line.slice(i, j);
|
|
236
|
+
if (def.keywords.has(word))
|
|
237
|
+
out += c.magenta(word);
|
|
238
|
+
else if (/^\d/.test(word))
|
|
239
|
+
out += c.yellow(word);
|
|
240
|
+
else
|
|
241
|
+
out += word;
|
|
242
|
+
i = j;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
out += ch;
|
|
246
|
+
i++;
|
|
247
|
+
}
|
|
248
|
+
return { text: out, carry: next };
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Index just past the closing `delim` starting the scan at `from`, honoring
|
|
253
|
+
* backslash escapes; -1 when the string does not close on this line.
|
|
254
|
+
*/
|
|
255
|
+
function findStringEnd(line, from, delim) {
|
|
256
|
+
for (let i = from; i < line.length; i++) {
|
|
257
|
+
if (line[i] === "\\") {
|
|
258
|
+
i++;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (line.startsWith(delim, i))
|
|
262
|
+
return i + delim.length;
|
|
263
|
+
}
|
|
264
|
+
return -1;
|
|
265
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RenderStream, StreamRenderer } from "./types.js";
|
|
2
|
+
export type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, TokenUsage, ToolLifecycleEvent, } from "./types.js";
|
|
3
|
+
export { detectCapabilities } from "./capabilities.js";
|
|
4
|
+
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
5
|
+
export { renderActionPreview, PREVIEW_MAX_LINES, type Colors } from "./diff.js";
|
|
6
|
+
export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
|
|
7
|
+
export { PlainRenderer } from "./plain-renderer.js";
|
|
8
|
+
export { TtyRenderer } from "./tty-renderer.js";
|
|
9
|
+
/**
|
|
10
|
+
* Build the renderer for the detected environment: the managed-live-region
|
|
11
|
+
* {@link TtyRenderer} when cursor control is safe, otherwise the append-only
|
|
12
|
+
* {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
|
|
13
|
+
* to the {@link StreamRenderer} interface and never re-probes the terminal.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createRenderer(out?: RenderStream, err?: RenderStream, env?: NodeJS.ProcessEnv): StreamRenderer;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { detectCapabilities } from "./capabilities.js";
|
|
2
|
+
import { PlainRenderer } from "./plain-renderer.js";
|
|
3
|
+
import { TtyRenderer } from "./tty-renderer.js";
|
|
4
|
+
export { detectCapabilities } from "./capabilities.js";
|
|
5
|
+
export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
|
|
6
|
+
export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
7
|
+
export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
|
|
8
|
+
export { PlainRenderer } from "./plain-renderer.js";
|
|
9
|
+
export { TtyRenderer } from "./tty-renderer.js";
|
|
10
|
+
/**
|
|
11
|
+
* Build the renderer for the detected environment: the managed-live-region
|
|
12
|
+
* {@link TtyRenderer} when cursor control is safe, otherwise the append-only
|
|
13
|
+
* {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
|
|
14
|
+
* to the {@link StreamRenderer} interface and never re-probes the terminal.
|
|
15
|
+
*/
|
|
16
|
+
export function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
|
|
17
|
+
const caps = detectCapabilities(out, env);
|
|
18
|
+
return caps.cursor
|
|
19
|
+
? new TtyRenderer(caps, out)
|
|
20
|
+
: new PlainRenderer(caps, out, err);
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import type { RenderCapabilities, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
|
|
5
|
+
* cursor-control sequences ever, and no color unless the capabilities say so
|
|
6
|
+
* (FORCE_COLOR); with color off, not a single ANSI byte leaves this class.
|
|
7
|
+
*
|
|
8
|
+
* Assistant text goes to `out` verbatim (beyond the per-turn leading-newline
|
|
9
|
+
* trim) so piped stdout stays pure model output; chrome (`note`) goes to `err`,
|
|
10
|
+
* matching the logger's stdout/stderr split. Transient `status` has no meaning
|
|
11
|
+
* in an append-only medium and is dropped — the loop reports anything durable
|
|
12
|
+
* via `note`.
|
|
13
|
+
*/
|
|
14
|
+
export declare class PlainRenderer implements StreamRenderer {
|
|
15
|
+
readonly caps: RenderCapabilities;
|
|
16
|
+
private readonly out;
|
|
17
|
+
private readonly err;
|
|
18
|
+
private readonly colors;
|
|
19
|
+
/** Per-turn leading-newline trim; also tells endSegment whether to newline. */
|
|
20
|
+
private print;
|
|
21
|
+
private wroteInSegment;
|
|
22
|
+
/** In-flight tool call (serial by contract) for the end-note duration. */
|
|
23
|
+
private toolStart;
|
|
24
|
+
constructor(caps: RenderCapabilities, out: RenderStream, err: RenderStream);
|
|
25
|
+
private newPrinter;
|
|
26
|
+
beginTurn(): void;
|
|
27
|
+
write(delta: string): void;
|
|
28
|
+
endSegment(): void;
|
|
29
|
+
note(text: string): void;
|
|
30
|
+
preview(preview: ActionPreview): void;
|
|
31
|
+
status(): void;
|
|
32
|
+
setPhase(): void;
|
|
33
|
+
progress(): void;
|
|
34
|
+
toolLifecycle(event: ToolLifecycleEvent): void;
|
|
35
|
+
promptResolved(): void;
|
|
36
|
+
endTurn(): void;
|
|
37
|
+
close(): void;
|
|
38
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { createStreamPrinter } from "../cli/stream-print.js";
|
|
3
|
+
import { renderActionPreview } from "./diff.js";
|
|
4
|
+
import { ELAPSED_AFTER_MS, formatElapsed } from "./state.js";
|
|
5
|
+
/**
|
|
6
|
+
* The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
|
|
7
|
+
* cursor-control sequences ever, and no color unless the capabilities say so
|
|
8
|
+
* (FORCE_COLOR); with color off, not a single ANSI byte leaves this class.
|
|
9
|
+
*
|
|
10
|
+
* Assistant text goes to `out` verbatim (beyond the per-turn leading-newline
|
|
11
|
+
* trim) so piped stdout stays pure model output; chrome (`note`) goes to `err`,
|
|
12
|
+
* matching the logger's stdout/stderr split. Transient `status` has no meaning
|
|
13
|
+
* in an append-only medium and is dropped — the loop reports anything durable
|
|
14
|
+
* via `note`.
|
|
15
|
+
*/
|
|
16
|
+
export class PlainRenderer {
|
|
17
|
+
caps;
|
|
18
|
+
out;
|
|
19
|
+
err;
|
|
20
|
+
colors;
|
|
21
|
+
/** Per-turn leading-newline trim; also tells endSegment whether to newline. */
|
|
22
|
+
print;
|
|
23
|
+
wroteInSegment = false;
|
|
24
|
+
/** In-flight tool call (serial by contract) for the end-note duration. */
|
|
25
|
+
toolStart = null;
|
|
26
|
+
constructor(caps, out, err) {
|
|
27
|
+
this.caps = caps;
|
|
28
|
+
this.out = out;
|
|
29
|
+
this.err = err;
|
|
30
|
+
this.colors = pc.createColors(caps.color);
|
|
31
|
+
this.print = this.newPrinter();
|
|
32
|
+
}
|
|
33
|
+
newPrinter() {
|
|
34
|
+
return createStreamPrinter((text) => {
|
|
35
|
+
this.wroteInSegment = true;
|
|
36
|
+
this.out.write(text);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
beginTurn() {
|
|
40
|
+
this.print = this.newPrinter();
|
|
41
|
+
this.wroteInSegment = false;
|
|
42
|
+
}
|
|
43
|
+
write(delta) {
|
|
44
|
+
this.print(delta);
|
|
45
|
+
}
|
|
46
|
+
endSegment() {
|
|
47
|
+
if (this.wroteInSegment)
|
|
48
|
+
this.out.write("\n");
|
|
49
|
+
this.wroteInSegment = false;
|
|
50
|
+
}
|
|
51
|
+
note(text) {
|
|
52
|
+
this.err.write(this.colors.dim(text) + "\n");
|
|
53
|
+
}
|
|
54
|
+
preview(preview) {
|
|
55
|
+
const block = renderActionPreview(preview, this.colors);
|
|
56
|
+
if (block)
|
|
57
|
+
this.out.write(block + "\n");
|
|
58
|
+
}
|
|
59
|
+
status() {
|
|
60
|
+
// Append-only medium: transient state is dropped by design.
|
|
61
|
+
}
|
|
62
|
+
setPhase() {
|
|
63
|
+
// Phases are live-region state; there is no live region here (U.4).
|
|
64
|
+
}
|
|
65
|
+
progress() {
|
|
66
|
+
// The committed plan trail (C.31, via PromptIO) is the record in this
|
|
67
|
+
// medium; a live [i/n] prefix would just duplicate it line by line.
|
|
68
|
+
}
|
|
69
|
+
toolLifecycle(event) {
|
|
70
|
+
if (event.event === "start") {
|
|
71
|
+
// Silent: the end note is the single durable line per call — a start
|
|
72
|
+
// line too would double the chrome in CI logs for no information.
|
|
73
|
+
this.toolStart = { label: event.label, at: Date.now() };
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const started = this.toolStart?.label === event.label ? this.toolStart : null;
|
|
77
|
+
this.toolStart = null;
|
|
78
|
+
const elapsed = started === null ? 0 : Date.now() - started.at;
|
|
79
|
+
const suffix = elapsed >= ELAPSED_AFTER_MS ? ` (${formatElapsed(elapsed)})` : "";
|
|
80
|
+
this.note(`${event.ok ? "✓" : "✗"} ${event.label}${suffix}`);
|
|
81
|
+
}
|
|
82
|
+
promptResolved() {
|
|
83
|
+
// No live region to restore.
|
|
84
|
+
}
|
|
85
|
+
endTurn() { }
|
|
86
|
+
close() { }
|
|
87
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ProgressState, RenderPhase } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The U.4 state→text mapping: pure data → string, like plan/render.ts and
|
|
4
|
+
* diff.ts, so both renderers (and tests) share one composition with no
|
|
5
|
+
* terminal in sight. Color is deliberately absent — the live line is drawn
|
|
6
|
+
* dim as a whole by the TTY renderer; state text is content, not chrome.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Threshold before elapsed time appears on a live state or a committed tool
|
|
10
|
+
* note. Below this, a timer is noise; above it, it's the answer to "is this
|
|
11
|
+
* stuck?".
|
|
12
|
+
*/
|
|
13
|
+
export declare const ELAPSED_AFTER_MS = 5000;
|
|
14
|
+
/** `342`, `1.2k`, `3.4M` — token counts at status-line width. */
|
|
15
|
+
export declare function formatTokens(n: number): string;
|
|
16
|
+
/** `37s`, `2m08s` — durations at status-line width. */
|
|
17
|
+
export declare function formatElapsed(ms: number): string;
|
|
18
|
+
/** The live-line text for a phase. `awaiting-approval` never renders (the line hides). */
|
|
19
|
+
export declare function describePhase(phase: RenderPhase): string;
|
|
20
|
+
/**
|
|
21
|
+
* Identity key for the elapsed clock: the clock resets when the phase becomes
|
|
22
|
+
* a *different activity*, not on every payload update — a thinking phase that
|
|
23
|
+
* gains token counts keeps its start time; a new tool label starts fresh.
|
|
24
|
+
*/
|
|
25
|
+
export declare function phaseIdentity(phase: RenderPhase | null): string;
|
|
26
|
+
/**
|
|
27
|
+
* Compose the single live line: `[2/5] title · read_file src/x.ts… (12s)`.
|
|
28
|
+
* Elapsed appears only past {@link ELAPSED_AFTER_MS} — callers pass it only
|
|
29
|
+
* when they can keep it ticking honestly (no timer → no frozen number).
|
|
30
|
+
*/
|
|
31
|
+
export declare function composeStatusLine(progress: ProgressState | null, phase: RenderPhase | null, elapsedMs?: number): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The U.4 state→text mapping: pure data → string, like plan/render.ts and
|
|
3
|
+
* diff.ts, so both renderers (and tests) share one composition with no
|
|
4
|
+
* terminal in sight. Color is deliberately absent — the live line is drawn
|
|
5
|
+
* dim as a whole by the TTY renderer; state text is content, not chrome.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Threshold before elapsed time appears on a live state or a committed tool
|
|
9
|
+
* note. Below this, a timer is noise; above it, it's the answer to "is this
|
|
10
|
+
* stuck?".
|
|
11
|
+
*/
|
|
12
|
+
export const ELAPSED_AFTER_MS = 5_000;
|
|
13
|
+
/** `342`, `1.2k`, `3.4M` — token counts at status-line width. */
|
|
14
|
+
export function formatTokens(n) {
|
|
15
|
+
if (n < 1_000)
|
|
16
|
+
return String(n);
|
|
17
|
+
const scaled = n < 1_000_000 ? n / 1_000 : n / 1_000_000;
|
|
18
|
+
const unit = n < 1_000_000 ? "k" : "M";
|
|
19
|
+
const s = scaled.toFixed(1);
|
|
20
|
+
return (s.endsWith(".0") ? s.slice(0, -2) : s) + unit;
|
|
21
|
+
}
|
|
22
|
+
/** `37s`, `2m08s` — durations at status-line width. */
|
|
23
|
+
export function formatElapsed(ms) {
|
|
24
|
+
const seconds = Math.floor(ms / 1000);
|
|
25
|
+
if (seconds < 60)
|
|
26
|
+
return `${seconds}s`;
|
|
27
|
+
const minutes = Math.floor(seconds / 60);
|
|
28
|
+
return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
29
|
+
}
|
|
30
|
+
/** The live-line text for a phase. `awaiting-approval` never renders (the line hides). */
|
|
31
|
+
export function describePhase(phase) {
|
|
32
|
+
switch (phase.kind) {
|
|
33
|
+
case "thinking": {
|
|
34
|
+
const t = phase.tokens;
|
|
35
|
+
// Honest numbers only: no usage yet → no figure at all.
|
|
36
|
+
return t && t.input + t.output > 0
|
|
37
|
+
? `thinking… · tokens ↑${formatTokens(t.input)} ↓${formatTokens(t.output)}`
|
|
38
|
+
: "thinking…";
|
|
39
|
+
}
|
|
40
|
+
case "calling-tool":
|
|
41
|
+
return `${phase.label}…`;
|
|
42
|
+
case "awaiting-approval":
|
|
43
|
+
return "awaiting approval…";
|
|
44
|
+
case "executing-step":
|
|
45
|
+
return "working…";
|
|
46
|
+
case "subagent":
|
|
47
|
+
return `subagent: ${phase.label}…`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Identity key for the elapsed clock: the clock resets when the phase becomes
|
|
52
|
+
* a *different activity*, not on every payload update — a thinking phase that
|
|
53
|
+
* gains token counts keeps its start time; a new tool label starts fresh.
|
|
54
|
+
*/
|
|
55
|
+
export function phaseIdentity(phase) {
|
|
56
|
+
if (phase === null)
|
|
57
|
+
return "";
|
|
58
|
+
switch (phase.kind) {
|
|
59
|
+
case "calling-tool":
|
|
60
|
+
return `calling-tool:${phase.label}`;
|
|
61
|
+
case "subagent":
|
|
62
|
+
return `subagent:${phase.label}`;
|
|
63
|
+
default:
|
|
64
|
+
return phase.kind;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Compose the single live line: `[2/5] title · read_file src/x.ts… (12s)`.
|
|
69
|
+
* Elapsed appears only past {@link ELAPSED_AFTER_MS} — callers pass it only
|
|
70
|
+
* when they can keep it ticking honestly (no timer → no frozen number).
|
|
71
|
+
*/
|
|
72
|
+
export function composeStatusLine(progress, phase, elapsedMs) {
|
|
73
|
+
const parts = [];
|
|
74
|
+
if (progress)
|
|
75
|
+
parts.push(`[${progress.step}/${progress.of}] ${progress.title}`);
|
|
76
|
+
if (phase)
|
|
77
|
+
parts.push(describePhase(phase));
|
|
78
|
+
const line = parts.join(" · ");
|
|
79
|
+
if (elapsedMs !== undefined && elapsedMs >= ELAPSED_AFTER_MS) {
|
|
80
|
+
return `${line} (${formatElapsed(elapsedMs)})`;
|
|
81
|
+
}
|
|
82
|
+
return line;
|
|
83
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The interactive renderer: committed content is append-only; the one transient
|
|
5
|
+
* thing on screen is a single managed status line, redrawn in place.
|
|
6
|
+
*
|
|
7
|
+
* The no-flicker discipline, concretely:
|
|
8
|
+
* - Only the status line is ever rewritten, via `\r` + erase-line — never a
|
|
9
|
+
* screen clear, never a repaint of committed rows.
|
|
10
|
+
* - Every committed write first erases the status line, so the live region is
|
|
11
|
+
* always the last row and committed text can never interleave with it. A
|
|
12
|
+
* committed write *dismisses* the status (it does not redraw underneath), so
|
|
13
|
+
* nothing re-renders per delta while text streams.
|
|
14
|
+
* - The status text is hard-truncated to the terminal width: a soft-wrapped
|
|
15
|
+
* status would span two rows and erase-line could no longer clean it up.
|
|
16
|
+
*
|
|
17
|
+
* Fenced code blocks are highlighted incrementally (see highlight.ts): prose
|
|
18
|
+
* deltas pass straight through, code is styled line-by-line on arrival.
|
|
19
|
+
*
|
|
20
|
+
* U.4 layers semantic state onto the SAME single live line — no new screen
|
|
21
|
+
* real estate, no extra timers. Two registers compose into it:
|
|
22
|
+
* - `phase` (loop-owned; cleared by endTurn) — thinking / calling-tool / …
|
|
23
|
+
* - `progressState` (plan-executor-owned; cleared only via progress(null))
|
|
24
|
+
* A committed write still only *hides* the drawn line (registers survive);
|
|
25
|
+
* the next state transition redraws. Nothing redraws per streamed delta, so
|
|
26
|
+
* the U.2 first-chunk-immediate guarantee is untouched.
|
|
27
|
+
*/
|
|
28
|
+
export declare class TtyRenderer implements StreamRenderer {
|
|
29
|
+
readonly caps: RenderCapabilities;
|
|
30
|
+
private readonly out;
|
|
31
|
+
private readonly colors;
|
|
32
|
+
private print;
|
|
33
|
+
private highlighter;
|
|
34
|
+
private wroteInSegment;
|
|
35
|
+
/** Ad-hoc/legacy status text; wins over composed state when set. */
|
|
36
|
+
private rawStatus;
|
|
37
|
+
private phase;
|
|
38
|
+
private progressState;
|
|
39
|
+
/** When the current phase *identity* began — drives honest elapsed display. */
|
|
40
|
+
private phaseStartedAt;
|
|
41
|
+
/** In-flight tool call (serial by contract) for end-note duration. */
|
|
42
|
+
private toolStart;
|
|
43
|
+
/** Phase (+ its clock) displaced by an approval prompt, for promptResolved. */
|
|
44
|
+
private displaced;
|
|
45
|
+
/** Whether the live line is currently drawn on screen. */
|
|
46
|
+
private lineVisible;
|
|
47
|
+
private timer;
|
|
48
|
+
private frame;
|
|
49
|
+
private closed;
|
|
50
|
+
constructor(caps: RenderCapabilities, out: RenderStream);
|
|
51
|
+
private newPrinter;
|
|
52
|
+
/** Append committed content, erasing the status line first if one is live. */
|
|
53
|
+
private commit;
|
|
54
|
+
/**
|
|
55
|
+
* Erase the drawn live line and stop the spinner — WITHOUT clearing the
|
|
56
|
+
* state registers. Committed content takes the screen; the next state
|
|
57
|
+
* transition redraws with full context. (This is what keeps streaming
|
|
58
|
+
* zero-cost: no redraw-under per delta.)
|
|
59
|
+
*/
|
|
60
|
+
private hideLine;
|
|
61
|
+
private stopTimer;
|
|
62
|
+
/**
|
|
63
|
+
* The current live-line text, composed from the registers. `null` means the
|
|
64
|
+
* line must be hidden: nothing to say, or an interactive prompt owns the
|
|
65
|
+
* terminal (`awaiting-approval` — two things can't share the last row).
|
|
66
|
+
*/
|
|
67
|
+
private currentLine;
|
|
68
|
+
/** Redraw the live line from current state, or hide it when there is none. */
|
|
69
|
+
private refresh;
|
|
70
|
+
private drawLine;
|
|
71
|
+
beginTurn(): void;
|
|
72
|
+
write(delta: string): void;
|
|
73
|
+
endSegment(): void;
|
|
74
|
+
note(text: string): void;
|
|
75
|
+
preview(preview: ActionPreview): void;
|
|
76
|
+
status(text: string | null): void;
|
|
77
|
+
setPhase(phase: RenderPhase | null): void;
|
|
78
|
+
progress(state: ProgressState | null): void;
|
|
79
|
+
toolLifecycle(event: ToolLifecycleEvent): void;
|
|
80
|
+
promptResolved(): void;
|
|
81
|
+
endTurn(): void;
|
|
82
|
+
close(): void;
|
|
83
|
+
}
|