@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,149 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { createStreamPrinter } from "../cli/stream-print.js";
|
|
3
|
+
import { renderActionPreview } from "./diff.js";
|
|
4
|
+
import { createStreamHighlighter, } from "./highlight.js";
|
|
5
|
+
/** Erase the current line and return the cursor to column 0. */
|
|
6
|
+
const CLEAR_LINE = "\r\x1b[2K";
|
|
7
|
+
/** Spinner frames (braille); a static glyph when animation is disabled. */
|
|
8
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
9
|
+
const STATIC_FRAME = "◐";
|
|
10
|
+
const SPINNER_INTERVAL_MS = 100;
|
|
11
|
+
/**
|
|
12
|
+
* The interactive renderer: committed content is append-only; the one transient
|
|
13
|
+
* thing on screen is a single managed status line, redrawn in place.
|
|
14
|
+
*
|
|
15
|
+
* The no-flicker discipline, concretely:
|
|
16
|
+
* - Only the status line is ever rewritten, via `\r` + erase-line — never a
|
|
17
|
+
* screen clear, never a repaint of committed rows.
|
|
18
|
+
* - Every committed write first erases the status line, so the live region is
|
|
19
|
+
* always the last row and committed text can never interleave with it. A
|
|
20
|
+
* committed write *dismisses* the status (it does not redraw underneath), so
|
|
21
|
+
* nothing re-renders per delta while text streams.
|
|
22
|
+
* - The status text is hard-truncated to the terminal width: a soft-wrapped
|
|
23
|
+
* status would span two rows and erase-line could no longer clean it up.
|
|
24
|
+
*
|
|
25
|
+
* Fenced code blocks are highlighted incrementally (see highlight.ts): prose
|
|
26
|
+
* deltas pass straight through, code is styled line-by-line on arrival.
|
|
27
|
+
*/
|
|
28
|
+
export class TtyRenderer {
|
|
29
|
+
caps;
|
|
30
|
+
out;
|
|
31
|
+
colors;
|
|
32
|
+
print;
|
|
33
|
+
highlighter;
|
|
34
|
+
wroteInSegment = false;
|
|
35
|
+
statusText = null;
|
|
36
|
+
timer = null;
|
|
37
|
+
frame = 0;
|
|
38
|
+
closed = false;
|
|
39
|
+
constructor(caps, out) {
|
|
40
|
+
this.caps = caps;
|
|
41
|
+
this.out = out;
|
|
42
|
+
this.colors = pc.createColors(caps.color);
|
|
43
|
+
this.highlighter = createStreamHighlighter(this.colors);
|
|
44
|
+
this.print = this.newPrinter();
|
|
45
|
+
}
|
|
46
|
+
newPrinter() {
|
|
47
|
+
return createStreamPrinter((text) => {
|
|
48
|
+
this.commit(this.highlighter.push(text));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Append committed content, erasing the status line first if one is live. */
|
|
52
|
+
commit(text) {
|
|
53
|
+
if (text === "")
|
|
54
|
+
return;
|
|
55
|
+
this.dismissStatus();
|
|
56
|
+
this.wroteInSegment = true;
|
|
57
|
+
this.out.write(text);
|
|
58
|
+
}
|
|
59
|
+
/** Erase the live status line (if any) and stop the spinner. */
|
|
60
|
+
dismissStatus() {
|
|
61
|
+
if (this.statusText === null)
|
|
62
|
+
return;
|
|
63
|
+
this.statusText = null;
|
|
64
|
+
this.stopTimer();
|
|
65
|
+
this.out.write(CLEAR_LINE);
|
|
66
|
+
}
|
|
67
|
+
stopTimer() {
|
|
68
|
+
if (this.timer !== null) {
|
|
69
|
+
clearInterval(this.timer);
|
|
70
|
+
this.timer = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
drawStatus() {
|
|
74
|
+
if (this.statusText === null)
|
|
75
|
+
return;
|
|
76
|
+
const glyph = this.caps.spinner
|
|
77
|
+
? FRAMES[this.frame % FRAMES.length]
|
|
78
|
+
: STATIC_FRAME;
|
|
79
|
+
// Reserve glyph + space; truncate so the live line can never soft-wrap.
|
|
80
|
+
const room = Math.max(1, this.caps.width - 2);
|
|
81
|
+
const text = this.statusText.length > room
|
|
82
|
+
? this.statusText.slice(0, Math.max(0, room - 1)) + "…"
|
|
83
|
+
: this.statusText;
|
|
84
|
+
this.out.write(`${CLEAR_LINE}${this.colors.cyan(glyph)} ${this.colors.dim(text)}`);
|
|
85
|
+
}
|
|
86
|
+
beginTurn() {
|
|
87
|
+
this.highlighter = createStreamHighlighter(this.colors);
|
|
88
|
+
this.print = this.newPrinter();
|
|
89
|
+
this.wroteInSegment = false;
|
|
90
|
+
}
|
|
91
|
+
write(delta) {
|
|
92
|
+
if (this.closed)
|
|
93
|
+
return;
|
|
94
|
+
this.print(delta);
|
|
95
|
+
}
|
|
96
|
+
endSegment() {
|
|
97
|
+
if (this.closed)
|
|
98
|
+
return;
|
|
99
|
+
this.commit(this.highlighter.flush());
|
|
100
|
+
if (this.wroteInSegment) {
|
|
101
|
+
this.commit("\n");
|
|
102
|
+
this.wroteInSegment = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
note(text) {
|
|
106
|
+
if (this.closed)
|
|
107
|
+
return;
|
|
108
|
+
const room = Math.max(1, this.caps.width);
|
|
109
|
+
const line = text.length > room ? text.slice(0, room - 1) + "…" : text;
|
|
110
|
+
this.commit(this.colors.dim(line) + "\n");
|
|
111
|
+
}
|
|
112
|
+
preview(preview) {
|
|
113
|
+
if (this.closed)
|
|
114
|
+
return;
|
|
115
|
+
const block = renderActionPreview(preview, this.colors);
|
|
116
|
+
if (block)
|
|
117
|
+
this.commit(block + "\n");
|
|
118
|
+
}
|
|
119
|
+
status(text) {
|
|
120
|
+
if (this.closed)
|
|
121
|
+
return;
|
|
122
|
+
if (text === null) {
|
|
123
|
+
this.dismissStatus();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.statusText = text;
|
|
127
|
+
this.drawStatus();
|
|
128
|
+
if (this.caps.spinner && this.timer === null) {
|
|
129
|
+
this.timer = setInterval(() => {
|
|
130
|
+
this.frame++;
|
|
131
|
+
this.drawStatus();
|
|
132
|
+
}, SPINNER_INTERVAL_MS);
|
|
133
|
+
// Never hold the process open for a spinner.
|
|
134
|
+
this.timer.unref?.();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
endTurn() {
|
|
138
|
+
if (this.closed)
|
|
139
|
+
return;
|
|
140
|
+
this.dismissStatus();
|
|
141
|
+
this.commit(this.highlighter.flush());
|
|
142
|
+
}
|
|
143
|
+
close() {
|
|
144
|
+
if (this.closed)
|
|
145
|
+
return;
|
|
146
|
+
this.endTurn();
|
|
147
|
+
this.closed = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ActionPreview } from "../tools/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The streaming render seam (U.2): the agent loop talks to a
|
|
4
|
+
* {@link StreamRenderer}, never to raw stdout. Two implementations exist —
|
|
5
|
+
* `TtyRenderer` (managed one-line live region, append-only committed content)
|
|
6
|
+
* and `PlainRenderer` (append-only, zero ANSI) — picked by the factory from
|
|
7
|
+
* detected {@link RenderCapabilities}. Same swappable discipline as
|
|
8
|
+
* `Formatter`/`VectorStore`.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* What the output medium supports, detected once at construction (see
|
|
12
|
+
* `detectCapabilities`). Renderers and the factory key off these flags; nothing
|
|
13
|
+
* downstream re-probes the environment.
|
|
14
|
+
*/
|
|
15
|
+
export interface RenderCapabilities {
|
|
16
|
+
/** The primary output stream is a terminal. */
|
|
17
|
+
tty: boolean;
|
|
18
|
+
/** Emit ANSI color (NO_COLOR / FORCE_COLOR / TTY resolved; `TERM=dumb` → false). */
|
|
19
|
+
color: boolean;
|
|
20
|
+
/** Cursor-control sequences are safe (`tty` and not `TERM=dumb`). */
|
|
21
|
+
cursor: boolean;
|
|
22
|
+
/** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
|
|
23
|
+
spinner: boolean;
|
|
24
|
+
/** Terminal columns; 80 when unknown (non-TTY). */
|
|
25
|
+
width: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* How the agent loop paints a turn. The contract that keeps output flicker-free:
|
|
29
|
+
*
|
|
30
|
+
* - **Committed content is append-only.** `write`/`note`/`preview` output is
|
|
31
|
+
* never repainted, moved, or cleared.
|
|
32
|
+
* - **Transient state is one line.** `status` owns a single live line that is
|
|
33
|
+
* redrawn in place and erased before any committed write lands.
|
|
34
|
+
*
|
|
35
|
+
* Call shape per user turn: `beginTurn` → per model turn: `write`* +
|
|
36
|
+
* `endSegment`, with `status`/`note`/`preview` between segments → `endTurn`.
|
|
37
|
+
* All methods must be safe to call in any environment — degradation is the
|
|
38
|
+
* implementation's job, not the caller's.
|
|
39
|
+
*/
|
|
40
|
+
export interface StreamRenderer {
|
|
41
|
+
readonly caps: RenderCapabilities;
|
|
42
|
+
/** Start a user turn: reset leading-newline trim and code-fence state. */
|
|
43
|
+
beginTurn(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Append streamed assistant text. Must forward promptly — implementations may
|
|
46
|
+
* hold back at most the current line (for fence detection), never the block
|
|
47
|
+
* or the response. Dismisses any live status first.
|
|
48
|
+
*/
|
|
49
|
+
write(delta: string): void;
|
|
50
|
+
/**
|
|
51
|
+
* Close one model-turn text segment: flush any held partial line / open
|
|
52
|
+
* fence and terminate with a single newline (only if the segment produced
|
|
53
|
+
* visible text), so whatever follows starts on its own line.
|
|
54
|
+
*/
|
|
55
|
+
endSegment(): void;
|
|
56
|
+
/** A committed one-line chrome note (e.g. `✓ read_file src/x.ts`), dim when colored. */
|
|
57
|
+
note(text: string): void;
|
|
58
|
+
/** A committed diff/action preview, rendered by the shared diff renderer. */
|
|
59
|
+
preview(preview: ActionPreview): void;
|
|
60
|
+
/**
|
|
61
|
+
* Replace the transient status line ("thinking…", "running bash…"); `null`
|
|
62
|
+
* clears it. Where in-place updates are impossible this may drop the text —
|
|
63
|
+
* status is progress decor, never information of record (use `note` for that).
|
|
64
|
+
*/
|
|
65
|
+
status(text: string | null): void;
|
|
66
|
+
/** End the user turn: clear any status, flush everything held. */
|
|
67
|
+
endTurn(): void;
|
|
68
|
+
/** Release resources (spinner timer). Further calls are no-ops. */
|
|
69
|
+
close(): void;
|
|
70
|
+
}
|
|
71
|
+
/** The minimal stream surface a renderer writes to. Injectable for tests. */
|
|
72
|
+
export interface RenderStream {
|
|
73
|
+
isTTY?: boolean;
|
|
74
|
+
columns?: number;
|
|
75
|
+
write(text: string): unknown;
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|