@cruxy/cli 0.7.0 → 0.8.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 +6 -0
- package/dist/agent/loop.d.ts +7 -5
- package/dist/agent/loop.js +52 -10
- package/dist/agent/session.d.ts +6 -4
- package/dist/agent/session.js +6 -5
- package/dist/approval/prompt.d.ts +9 -0
- package/dist/approval/prompt.js +2 -77
- package/dist/cli/commands/run.js +16 -10
- package/dist/cli/onboard.js +9 -4
- package/dist/cli/repl.d.ts +9 -4
- package/dist/cli/repl.js +23 -12
- package/dist/cli/session-factory.d.ts +2 -1
- package/dist/cli/session-factory.js +26 -5
- package/dist/plan/service.d.ts +2 -1
- package/dist/plan/service.js +2 -2
- 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 +80 -0
- package/dist/render/highlight.d.ts +47 -0
- package/dist/render/highlight.js +265 -0
- package/dist/render/index.d.ts +14 -0
- package/dist/render/index.js +20 -0
- package/dist/render/plain-renderer.d.ts +32 -0
- package/dist/render/plain-renderer.js +61 -0
- package/dist/render/tty-renderer.d.ts +47 -0
- package/dist/render/tty-renderer.js +149 -0
- package/dist/render/types.d.ts +76 -0
- package/dist/render/types.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The one diff/action-preview renderer (U.2). The approval prompt, PR preview,
|
|
5
|
+
* and the streaming render path all draw diffs through here — there is no
|
|
6
|
+
* second implementation to drift. Pure data → string; color is gated on a
|
|
7
|
+
* picocolors instance, so NO_COLOR/non-TTY callers get symbol-only `+`/`-`
|
|
8
|
+
* lines from the exact same code path.
|
|
9
|
+
*/
|
|
10
|
+
/** The picocolors instance type (colorless or not), from createColors. */
|
|
11
|
+
export type Colors = ReturnType<typeof pc.createColors>;
|
|
12
|
+
/** Cap on rendered preview lines before collapsing the rest. */
|
|
13
|
+
export declare const PREVIEW_MAX_LINES = 40;
|
|
14
|
+
/**
|
|
15
|
+
* Render any {@link ActionPreview} as an indented block: a diff for edits and
|
|
16
|
+
* patches, a create/overwrite listing for writes, the publish plan for PRs.
|
|
17
|
+
* Long previews collapse past {@link PREVIEW_MAX_LINES}.
|
|
18
|
+
*/
|
|
19
|
+
export declare function renderActionPreview(preview: ActionPreview | undefined, c: Colors): string;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Cap on rendered preview lines before collapsing the rest. */
|
|
2
|
+
export const PREVIEW_MAX_LINES = 40;
|
|
3
|
+
function diffLines(oldStr, newStr, c) {
|
|
4
|
+
const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
|
|
5
|
+
const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
|
|
6
|
+
return [...removed, ...added];
|
|
7
|
+
}
|
|
8
|
+
function renderPatchFiles(files, c) {
|
|
9
|
+
const out = [];
|
|
10
|
+
for (const file of files) {
|
|
11
|
+
if (file.op === "delete") {
|
|
12
|
+
out.push(c.red(`delete ${file.path}`));
|
|
13
|
+
}
|
|
14
|
+
else if (file.op === "create") {
|
|
15
|
+
out.push(c.green(`create ${file.path}`));
|
|
16
|
+
out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
|
|
17
|
+
if (file.omittedLines > 0)
|
|
18
|
+
out.push(c.dim(` ...${file.omittedLines} more lines`));
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
out.push(c.yellow(`update ${file.path}`));
|
|
22
|
+
for (const hunk of file.hunks)
|
|
23
|
+
out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
|
|
29
|
+
function renderPrPreview(preview, c) {
|
|
30
|
+
const out = [];
|
|
31
|
+
out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
|
|
32
|
+
out.push("");
|
|
33
|
+
out.push(c.bold("commit"));
|
|
34
|
+
out.push(` ${preview.commitSubject}`);
|
|
35
|
+
for (const line of bodyLines(preview.commitBody))
|
|
36
|
+
out.push(c.dim(` ${line}`));
|
|
37
|
+
out.push("");
|
|
38
|
+
out.push(`${c.bold("pull request")} ${preview.prTitle}`);
|
|
39
|
+
for (const line of bodyLines(preview.prBody))
|
|
40
|
+
out.push(c.dim(` ${line}`));
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
|
|
44
|
+
function bodyLines(body) {
|
|
45
|
+
const lines = body.replace(/\s+$/, "").split("\n");
|
|
46
|
+
return lines.length === 1 && lines[0] === "" ? [] : lines;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Render any {@link ActionPreview} as an indented block: a diff for edits and
|
|
50
|
+
* patches, a create/overwrite listing for writes, the publish plan for PRs.
|
|
51
|
+
* Long previews collapse past {@link PREVIEW_MAX_LINES}.
|
|
52
|
+
*/
|
|
53
|
+
export function renderActionPreview(preview, c) {
|
|
54
|
+
if (!preview)
|
|
55
|
+
return "";
|
|
56
|
+
let lines;
|
|
57
|
+
if (preview.type === "edit") {
|
|
58
|
+
lines = diffLines(preview.oldStr, preview.newStr, c);
|
|
59
|
+
}
|
|
60
|
+
else if (preview.type === "patch") {
|
|
61
|
+
lines = renderPatchFiles(preview.files, c);
|
|
62
|
+
}
|
|
63
|
+
else if (preview.type === "pr") {
|
|
64
|
+
lines = renderPrPreview(preview, c);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const header = preview.exists
|
|
68
|
+
? c.yellow("OVERWRITE existing")
|
|
69
|
+
: c.green("create");
|
|
70
|
+
const body = preview.lines.map((l) => ` ${l}`);
|
|
71
|
+
if (preview.omittedLines > 0)
|
|
72
|
+
body.push(c.dim(` ...${preview.omittedLines} more lines`));
|
|
73
|
+
lines = [header, ...body];
|
|
74
|
+
}
|
|
75
|
+
if (lines.length > PREVIEW_MAX_LINES) {
|
|
76
|
+
const hidden = lines.length - PREVIEW_MAX_LINES;
|
|
77
|
+
lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
|
|
78
|
+
}
|
|
79
|
+
return lines.map((l) => ` ${l}`).join("\n");
|
|
80
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Colors } from "./diff.js";
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort, bounded syntax highlighting for fenced code blocks in streamed
|
|
4
|
+
* markdown (U.2). Priorities, in order: never crash the stream, never hold the
|
|
5
|
+
* stream back, then look nice.
|
|
6
|
+
*
|
|
7
|
+
* - Prose passes through immediately, byte for byte. The only hold-back is a
|
|
8
|
+
* line that is still a plausible fence opener (a leading backtick run), held
|
|
9
|
+
* until disambiguated — bounded by one short line, never a block.
|
|
10
|
+
* - Inside a fence, lines are highlighted incrementally: each line is emitted
|
|
11
|
+
* the moment its newline arrives, so latency is one line, and committed
|
|
12
|
+
* output is never repainted.
|
|
13
|
+
* - Unknown language → plain text. A tokenizer throw → that line plain. The
|
|
14
|
+
* tokenizer is hand-rolled (no highlighter dependency, in the same spirit as
|
|
15
|
+
* the hand-rolled HTTP client) and colors only what it is sure about:
|
|
16
|
+
* comments, strings, keywords, numbers.
|
|
17
|
+
*/
|
|
18
|
+
/** Cross-line tokenizer state (block comments / multi-line strings). */
|
|
19
|
+
export interface HighlightCarry {
|
|
20
|
+
/** Inside a block comment (`/* … *``/`). */
|
|
21
|
+
blockComment: boolean;
|
|
22
|
+
/** Inside a multi-line string; the delimiter that will close it. */
|
|
23
|
+
stringDelim: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** One line of code → styled line + the carry for the next line. */
|
|
26
|
+
export type LineHighlighter = (line: string, lang: string | null, carry: HighlightCarry) => {
|
|
27
|
+
text: string;
|
|
28
|
+
carry: HighlightCarry;
|
|
29
|
+
};
|
|
30
|
+
/** Incremental highlighter for one streamed text segment. */
|
|
31
|
+
export interface StreamHighlighter {
|
|
32
|
+
/** Feed a delta; returns the styled text that is safe to emit now. */
|
|
33
|
+
push(delta: string): string;
|
|
34
|
+
/** Return whatever is still held (segment end); resets to prose state. */
|
|
35
|
+
flush(): string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Create the per-segment streaming highlighter. `highlightLine` is injectable
|
|
39
|
+
* for tests (e.g. to prove a throwing tokenizer degrades to plain text).
|
|
40
|
+
*/
|
|
41
|
+
export declare function createStreamHighlighter(c: Colors, highlightLine?: LineHighlighter): StreamHighlighter;
|
|
42
|
+
/**
|
|
43
|
+
* Build the default per-line tokenizer over `c`. A plain left-to-right scan:
|
|
44
|
+
* comments dim, strings green, keywords magenta, numbers yellow, everything
|
|
45
|
+
* else untouched. Unknown language → identity.
|
|
46
|
+
*/
|
|
47
|
+
export declare function defaultLineHighlighter(c: Colors): LineHighlighter;
|
|
@@ -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,14 @@
|
|
|
1
|
+
import type { RenderStream, StreamRenderer } from "./types.js";
|
|
2
|
+
export type { RenderCapabilities, RenderStream, StreamRenderer, } from "./types.js";
|
|
3
|
+
export { detectCapabilities } from "./capabilities.js";
|
|
4
|
+
export { renderActionPreview, PREVIEW_MAX_LINES, type Colors } from "./diff.js";
|
|
5
|
+
export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
|
|
6
|
+
export { PlainRenderer } from "./plain-renderer.js";
|
|
7
|
+
export { TtyRenderer } from "./tty-renderer.js";
|
|
8
|
+
/**
|
|
9
|
+
* Build the renderer for the detected environment: the managed-live-region
|
|
10
|
+
* {@link TtyRenderer} when cursor control is safe, otherwise the append-only
|
|
11
|
+
* {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
|
|
12
|
+
* to the {@link StreamRenderer} interface and never re-probes the terminal.
|
|
13
|
+
*/
|
|
14
|
+
export declare function createRenderer(out?: RenderStream, err?: RenderStream, env?: NodeJS.ProcessEnv): StreamRenderer;
|
|
@@ -0,0 +1,20 @@
|
|
|
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 { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
|
|
6
|
+
export { createStreamHighlighter, defaultLineHighlighter, } 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 function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
|
|
16
|
+
const caps = detectCapabilities(out, env);
|
|
17
|
+
return caps.cursor
|
|
18
|
+
? new TtyRenderer(caps, out)
|
|
19
|
+
: new PlainRenderer(caps, out, err);
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import type { RenderCapabilities, RenderStream, StreamRenderer } 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
|
+
constructor(caps: RenderCapabilities, out: RenderStream, err: RenderStream);
|
|
23
|
+
private newPrinter;
|
|
24
|
+
beginTurn(): void;
|
|
25
|
+
write(delta: string): void;
|
|
26
|
+
endSegment(): void;
|
|
27
|
+
note(text: string): void;
|
|
28
|
+
preview(preview: ActionPreview): void;
|
|
29
|
+
status(): void;
|
|
30
|
+
endTurn(): void;
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { createStreamPrinter } from "../cli/stream-print.js";
|
|
3
|
+
import { renderActionPreview } from "./diff.js";
|
|
4
|
+
/**
|
|
5
|
+
* The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
|
|
6
|
+
* cursor-control sequences ever, and no color unless the capabilities say so
|
|
7
|
+
* (FORCE_COLOR); with color off, not a single ANSI byte leaves this class.
|
|
8
|
+
*
|
|
9
|
+
* Assistant text goes to `out` verbatim (beyond the per-turn leading-newline
|
|
10
|
+
* trim) so piped stdout stays pure model output; chrome (`note`) goes to `err`,
|
|
11
|
+
* matching the logger's stdout/stderr split. Transient `status` has no meaning
|
|
12
|
+
* in an append-only medium and is dropped — the loop reports anything durable
|
|
13
|
+
* via `note`.
|
|
14
|
+
*/
|
|
15
|
+
export class PlainRenderer {
|
|
16
|
+
caps;
|
|
17
|
+
out;
|
|
18
|
+
err;
|
|
19
|
+
colors;
|
|
20
|
+
/** Per-turn leading-newline trim; also tells endSegment whether to newline. */
|
|
21
|
+
print;
|
|
22
|
+
wroteInSegment = false;
|
|
23
|
+
constructor(caps, out, err) {
|
|
24
|
+
this.caps = caps;
|
|
25
|
+
this.out = out;
|
|
26
|
+
this.err = err;
|
|
27
|
+
this.colors = pc.createColors(caps.color);
|
|
28
|
+
this.print = this.newPrinter();
|
|
29
|
+
}
|
|
30
|
+
newPrinter() {
|
|
31
|
+
return createStreamPrinter((text) => {
|
|
32
|
+
this.wroteInSegment = true;
|
|
33
|
+
this.out.write(text);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
beginTurn() {
|
|
37
|
+
this.print = this.newPrinter();
|
|
38
|
+
this.wroteInSegment = false;
|
|
39
|
+
}
|
|
40
|
+
write(delta) {
|
|
41
|
+
this.print(delta);
|
|
42
|
+
}
|
|
43
|
+
endSegment() {
|
|
44
|
+
if (this.wroteInSegment)
|
|
45
|
+
this.out.write("\n");
|
|
46
|
+
this.wroteInSegment = false;
|
|
47
|
+
}
|
|
48
|
+
note(text) {
|
|
49
|
+
this.err.write(this.colors.dim(text) + "\n");
|
|
50
|
+
}
|
|
51
|
+
preview(preview) {
|
|
52
|
+
const block = renderActionPreview(preview, this.colors);
|
|
53
|
+
if (block)
|
|
54
|
+
this.out.write(block + "\n");
|
|
55
|
+
}
|
|
56
|
+
status() {
|
|
57
|
+
// Append-only medium: transient state is dropped by design.
|
|
58
|
+
}
|
|
59
|
+
endTurn() { }
|
|
60
|
+
close() { }
|
|
61
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
import type { RenderCapabilities, RenderStream, StreamRenderer } 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
|
+
export declare class TtyRenderer implements StreamRenderer {
|
|
21
|
+
readonly caps: RenderCapabilities;
|
|
22
|
+
private readonly out;
|
|
23
|
+
private readonly colors;
|
|
24
|
+
private print;
|
|
25
|
+
private highlighter;
|
|
26
|
+
private wroteInSegment;
|
|
27
|
+
private statusText;
|
|
28
|
+
private timer;
|
|
29
|
+
private frame;
|
|
30
|
+
private closed;
|
|
31
|
+
constructor(caps: RenderCapabilities, out: RenderStream);
|
|
32
|
+
private newPrinter;
|
|
33
|
+
/** Append committed content, erasing the status line first if one is live. */
|
|
34
|
+
private commit;
|
|
35
|
+
/** Erase the live status line (if any) and stop the spinner. */
|
|
36
|
+
private dismissStatus;
|
|
37
|
+
private stopTimer;
|
|
38
|
+
private drawStatus;
|
|
39
|
+
beginTurn(): void;
|
|
40
|
+
write(delta: string): void;
|
|
41
|
+
endSegment(): void;
|
|
42
|
+
note(text: string): void;
|
|
43
|
+
preview(preview: ActionPreview): void;
|
|
44
|
+
status(text: string | null): void;
|
|
45
|
+
endTurn(): void;
|
|
46
|
+
close(): void;
|
|
47
|
+
}
|