@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
package/README.md
CHANGED
|
@@ -58,6 +58,12 @@ export CRUXY_API_KEY=cxy_live_... # …or just use an env var (always wins)
|
|
|
58
58
|
validated, and never auto-executed.
|
|
59
59
|
- **Agent** — streaming output, multi-turn interactive sessions, context
|
|
60
60
|
compaction, and awareness of git state and project instructions (`CRUXY.md`).
|
|
61
|
+
- **Streaming render** — flicker-free live output: a single in-place status line
|
|
62
|
+
(spinner while the model thinks / tools run), append-only committed text,
|
|
63
|
+
syntax-highlighted code fences, tool-call notes, and diffs drawn by the same
|
|
64
|
+
renderer as the approval prompt. Degrades cleanly: piped/CI output is plain
|
|
65
|
+
append-only text with zero ANSI (chrome on stderr), `NO_COLOR` drops color,
|
|
66
|
+
and `CRUXY_NO_SPINNER=1` stills the animation.
|
|
61
67
|
- **Plan mode** (opt-in: `cruxy run --plan`, `/plan`, or `agent.planMode`) — the
|
|
62
68
|
agent proposes a structured, step-by-step plan; you approve it once, then it
|
|
63
69
|
executes with live per-step status. Approving consents to the _shape_ of the
|
package/dist/agent/loop.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
3
4
|
import type { ToolContext } from "../tools/index.js";
|
|
4
5
|
import { ToolRegistry } from "../tools/index.js";
|
|
5
6
|
export interface RunAgentArgs {
|
|
@@ -17,12 +18,13 @@ export interface RunAgentArgs {
|
|
|
17
18
|
/** Ambient capabilities handed to each tool. */
|
|
18
19
|
ctx: ToolContext;
|
|
19
20
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* The render seam (U.2): assistant text streams through `renderer.write`
|
|
22
|
+
* delta by delta (each non-empty segment closed via `endSegment`), and
|
|
23
|
+
* tool-call progress is surfaced as transient `status` + committed `note`
|
|
24
|
+
* lines. When omitted, behavior is unchanged (one buffered print per turn,
|
|
25
|
+
* no tool-call chrome). The loop never touches stdout directly.
|
|
24
26
|
*/
|
|
25
|
-
|
|
27
|
+
renderer?: StreamRenderer;
|
|
26
28
|
/** Git context (branch + dirty) for the system prompt's Environment section. */
|
|
27
29
|
git?: {
|
|
28
30
|
branch: string;
|
package/dist/agent/loop.js
CHANGED
|
@@ -10,10 +10,23 @@ import { buildSystemPrompt } from "./prompts.js";
|
|
|
10
10
|
* silently running tool-less.
|
|
11
11
|
*/
|
|
12
12
|
export async function runAgent(args) {
|
|
13
|
-
const { provider,
|
|
13
|
+
const { provider, config, renderer } = args;
|
|
14
14
|
if (!provider.supportsTools) {
|
|
15
15
|
throw providerUnsupported(config.model.provider);
|
|
16
16
|
}
|
|
17
|
+
renderer?.beginTurn();
|
|
18
|
+
try {
|
|
19
|
+
return await driveLoop(args, renderer);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
// Always leave the terminal clean: no orphaned status line, no held text —
|
|
23
|
+
// even when a provider error aborts the turn mid-stream.
|
|
24
|
+
renderer?.endTurn();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The body of {@link runAgent}, split out so turn cleanup lives in one finally. */
|
|
28
|
+
async function driveLoop(args, renderer) {
|
|
29
|
+
const { provider, registry, config, ctx } = args;
|
|
17
30
|
const { logger } = ctx;
|
|
18
31
|
// Work on a copy so we never mutate the caller's array as a side effect; the
|
|
19
32
|
// extended history is returned for the caller to adopt.
|
|
@@ -42,6 +55,8 @@ export async function runAgent(args) {
|
|
|
42
55
|
let turnText = "";
|
|
43
56
|
const pending = new Map();
|
|
44
57
|
const toolUses = [];
|
|
58
|
+
// Live progress while waiting on the model; dismissed by the first delta.
|
|
59
|
+
renderer?.status("thinking…");
|
|
45
60
|
for await (const ev of provider.stream({
|
|
46
61
|
system,
|
|
47
62
|
messages,
|
|
@@ -50,7 +65,7 @@ export async function runAgent(args) {
|
|
|
50
65
|
switch (ev.type) {
|
|
51
66
|
case "text_delta":
|
|
52
67
|
turnText += ev.text;
|
|
53
|
-
|
|
68
|
+
renderer?.write(ev.text);
|
|
54
69
|
break;
|
|
55
70
|
case "tool_use_start":
|
|
56
71
|
pending.set(ev.index, { id: ev.id, name: ev.name });
|
|
@@ -83,13 +98,13 @@ export async function runAgent(args) {
|
|
|
83
98
|
}
|
|
84
99
|
// ── Record the assistant turn ───────────────────────────────────────────
|
|
85
100
|
if (turnText) {
|
|
86
|
-
// Streaming (
|
|
87
|
-
// so close the segment
|
|
88
|
-
//
|
|
89
|
-
// turn, or an approval prompt starts on its own line. Otherwise
|
|
90
|
-
// whole buffered block (no-
|
|
91
|
-
if (
|
|
92
|
-
|
|
101
|
+
// Streaming (renderer set): the text already reached the user delta by
|
|
102
|
+
// delta, so close the segment through the *same* renderer — it flushes any
|
|
103
|
+
// held partial line and terminates with one newline, so tool output, the
|
|
104
|
+
// next turn, or an approval prompt starts on its own line. Otherwise
|
|
105
|
+
// render the whole buffered block (no-renderer path, unchanged).
|
|
106
|
+
if (renderer)
|
|
107
|
+
renderer.endSegment();
|
|
93
108
|
else
|
|
94
109
|
logger.print(turnText);
|
|
95
110
|
}
|
|
@@ -105,13 +120,40 @@ export async function runAgent(args) {
|
|
|
105
120
|
// ── Execute each tool call, collecting one tool_result per call ──────────
|
|
106
121
|
const toolResults = [];
|
|
107
122
|
for (const call of toolUses) {
|
|
108
|
-
|
|
123
|
+
const label = describeToolCall(call);
|
|
124
|
+
renderer?.status(`${label}…`);
|
|
125
|
+
const result = await runToolCall(call, registry, ctx);
|
|
126
|
+
renderer?.note(`${result.is_error ? "✗" : "✓"} ${label}`);
|
|
127
|
+
toolResults.push(result);
|
|
109
128
|
}
|
|
110
129
|
messages.push({ role: "user", content: toolResults });
|
|
111
130
|
}
|
|
112
131
|
logger.warn(`reached maxIterations (${maxIterations}) without completing`);
|
|
113
132
|
return { messages, iterations, stop: "max_iterations", usage };
|
|
114
133
|
}
|
|
134
|
+
/** Input keys worth surfacing in tool-call chrome, in preference order. */
|
|
135
|
+
const HINT_KEYS = ["path", "file_path", "command", "pattern", "query", "url"];
|
|
136
|
+
/** Longest hint shown before truncation — chrome, not information of record. */
|
|
137
|
+
const HINT_MAX = 60;
|
|
138
|
+
/**
|
|
139
|
+
* A short human label for a tool call ("read_file src/x.ts"): the tool name
|
|
140
|
+
* plus the first recognizable scalar argument, if any. Best-effort — unknown
|
|
141
|
+
* shapes fall back to the bare name.
|
|
142
|
+
*/
|
|
143
|
+
function describeToolCall(call) {
|
|
144
|
+
const input = call.input;
|
|
145
|
+
if (typeof input === "object" && input !== null) {
|
|
146
|
+
for (const key of HINT_KEYS) {
|
|
147
|
+
const value = input[key];
|
|
148
|
+
if (typeof value === "string" && value !== "") {
|
|
149
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
150
|
+
const hint = flat.length > HINT_MAX ? flat.slice(0, HINT_MAX - 1) + "…" : flat;
|
|
151
|
+
return `${call.name} ${hint}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return call.name;
|
|
156
|
+
}
|
|
115
157
|
/**
|
|
116
158
|
* Dispatch a single reassembled tool call to its tool and shape the outcome as
|
|
117
159
|
* a `tool_result` block. Unknown tools and invalid arguments become `is_error`
|
package/dist/agent/session.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
3
4
|
import type { ToolContext } from "../tools/index.js";
|
|
4
5
|
import type { ToolRegistry } from "../tools/index.js";
|
|
5
6
|
import { type AgentResult } from "./loop.js";
|
|
@@ -12,7 +13,7 @@ import { type AgentResult } from "./loop.js";
|
|
|
12
13
|
export type PlanRunner = (args: {
|
|
13
14
|
messages: Message[];
|
|
14
15
|
projectInstructions: string | null;
|
|
15
|
-
|
|
16
|
+
renderer?: StreamRenderer;
|
|
16
17
|
}) => Promise<AgentResult>;
|
|
17
18
|
export interface SessionArgs {
|
|
18
19
|
/** A constructed provider to stream from. */
|
|
@@ -76,10 +77,11 @@ export declare class Session {
|
|
|
76
77
|
* the threshold, drive the agent loop over the full history, adopt the
|
|
77
78
|
* extended history, and accumulate usage. Returns the turn's `AgentResult`.
|
|
78
79
|
*
|
|
79
|
-
* `
|
|
80
|
-
* caller
|
|
80
|
+
* `renderer`, when supplied, receives assistant text deltas and tool-call
|
|
81
|
+
* progress as they stream so the caller sees the turn live (see the REPL);
|
|
82
|
+
* history is unaffected.
|
|
81
83
|
*/
|
|
82
|
-
send(userPrompt: string,
|
|
84
|
+
send(userPrompt: string, renderer?: StreamRenderer): Promise<AgentResult>;
|
|
83
85
|
/**
|
|
84
86
|
* Re-read project instructions (CRUXY.md / AGENTS.md) from the working
|
|
85
87
|
* directory so edits take effect without restarting. Returns the new text, or
|
package/dist/agent/session.js
CHANGED
|
@@ -74,10 +74,11 @@ export class Session {
|
|
|
74
74
|
* the threshold, drive the agent loop over the full history, adopt the
|
|
75
75
|
* extended history, and accumulate usage. Returns the turn's `AgentResult`.
|
|
76
76
|
*
|
|
77
|
-
* `
|
|
78
|
-
* caller
|
|
77
|
+
* `renderer`, when supplied, receives assistant text deltas and tool-call
|
|
78
|
+
* progress as they stream so the caller sees the turn live (see the REPL);
|
|
79
|
+
* history is unaffected.
|
|
79
80
|
*/
|
|
80
|
-
async send(userPrompt,
|
|
81
|
+
async send(userPrompt, renderer) {
|
|
81
82
|
this.messages.push({ role: "user", content: userPrompt });
|
|
82
83
|
// Compact *before* the agent call so the turn runs against a bounded history.
|
|
83
84
|
await this.maybeCompact();
|
|
@@ -88,7 +89,7 @@ export class Session {
|
|
|
88
89
|
? await this.args.planRunner({
|
|
89
90
|
messages: this.messages,
|
|
90
91
|
projectInstructions: this.projectInstructions,
|
|
91
|
-
|
|
92
|
+
renderer,
|
|
92
93
|
})
|
|
93
94
|
: await runAgent({
|
|
94
95
|
messages: this.messages,
|
|
@@ -96,7 +97,7 @@ export class Session {
|
|
|
96
97
|
// After the spread so a mid-session `/reload` wins over the initial value.
|
|
97
98
|
projectInstructions: this.projectInstructions,
|
|
98
99
|
planMode: false, // the plan directive belongs only to the runner's propose phase
|
|
99
|
-
|
|
100
|
+
renderer,
|
|
100
101
|
});
|
|
101
102
|
this.messages = result.messages;
|
|
102
103
|
this.usage.input_tokens += result.usage.input_tokens;
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { ApprovalRequest } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The interactive prompt: render a pending action (a real diff for file edits,
|
|
4
|
+
* the exact command + cwd for shell) and read a 4-way choice. Rendering is data
|
|
5
|
+
* → string so it's testable; color is gated on `io.color` (NO_COLOR / non-TTY
|
|
6
|
+
* aware). **Default-deny**: EOF / Ctrl-C / any unrecognized key → reject.
|
|
7
|
+
*
|
|
8
|
+
* Diff/preview rendering is the shared implementation in `render/diff.ts` —
|
|
9
|
+
* the streaming path and this prompt draw the same bytes for the same change.
|
|
10
|
+
*/
|
|
2
11
|
/** The four user choices (plus the implicit default-deny). */
|
|
3
12
|
export type PromptChoice = {
|
|
4
13
|
kind: "once";
|
package/dist/approval/prompt.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import pc from "picocolors";
|
|
3
|
-
|
|
4
|
-
const PREVIEW_MAX_LINES = 40;
|
|
3
|
+
import { renderActionPreview } from "../render/diff.js";
|
|
5
4
|
/**
|
|
6
5
|
* Render the action, read one key, and map it to a {@link PromptChoice}. `n`/`t`
|
|
7
6
|
* read a follow-up line (reason / instruction). Anything else — including EOF —
|
|
@@ -53,7 +52,7 @@ function detail(request, c) {
|
|
|
53
52
|
` ${c.dim(`in ${request.cwd}`)}`,
|
|
54
53
|
].join("\n");
|
|
55
54
|
}
|
|
56
|
-
return
|
|
55
|
+
return renderActionPreview(request.action.preview, c);
|
|
57
56
|
}
|
|
58
57
|
/** The choices line, including a short label of what an `a` grant would cover. */
|
|
59
58
|
function choices(scope, c) {
|
|
@@ -71,80 +70,6 @@ function scopeLabel(scope) {
|
|
|
71
70
|
return `changes under ${path.basename(scope.root)}/`;
|
|
72
71
|
return null;
|
|
73
72
|
}
|
|
74
|
-
// ── diff rendering (shared with the old C.6 renderer) ──────────────────────────
|
|
75
|
-
function diffLines(oldStr, newStr, c) {
|
|
76
|
-
const removed = oldStr.split("\n").map((l) => c.red(`- ${l}`));
|
|
77
|
-
const added = newStr.split("\n").map((l) => c.green(`+ ${l}`));
|
|
78
|
-
return [...removed, ...added];
|
|
79
|
-
}
|
|
80
|
-
function renderPatchFiles(files, c) {
|
|
81
|
-
const out = [];
|
|
82
|
-
for (const file of files) {
|
|
83
|
-
if (file.op === "delete") {
|
|
84
|
-
out.push(c.red(`delete ${file.path}`));
|
|
85
|
-
}
|
|
86
|
-
else if (file.op === "create") {
|
|
87
|
-
out.push(c.green(`create ${file.path}`));
|
|
88
|
-
out.push(...file.lines.map((l) => c.green(`+ ${l}`)));
|
|
89
|
-
if (file.omittedLines > 0)
|
|
90
|
-
out.push(c.dim(` ...${file.omittedLines} more lines`));
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
out.push(c.yellow(`update ${file.path}`));
|
|
94
|
-
for (const hunk of file.hunks)
|
|
95
|
-
out.push(...diffLines(hunk.oldStr, hunk.newStr, c));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return out;
|
|
99
|
-
}
|
|
100
|
-
/** Render a `vcs` pull-request publish plan: branch, commit, and PR body. */
|
|
101
|
-
function renderPrPreview(preview, c) {
|
|
102
|
-
const out = [];
|
|
103
|
-
out.push(`${c.bold("branch")} ${c.green(preview.branch)} → ${preview.base}`);
|
|
104
|
-
out.push("");
|
|
105
|
-
out.push(c.bold("commit"));
|
|
106
|
-
out.push(` ${preview.commitSubject}`);
|
|
107
|
-
for (const line of bodyLines(preview.commitBody))
|
|
108
|
-
out.push(c.dim(` ${line}`));
|
|
109
|
-
out.push("");
|
|
110
|
-
out.push(`${c.bold("pull request")} ${preview.prTitle}`);
|
|
111
|
-
for (const line of bodyLines(preview.prBody))
|
|
112
|
-
out.push(c.dim(` ${line}`));
|
|
113
|
-
return out;
|
|
114
|
-
}
|
|
115
|
-
/** Split a multi-line body into trimmed-of-trailing lines, dropping a trailing blank. */
|
|
116
|
-
function bodyLines(body) {
|
|
117
|
-
const lines = body.replace(/\s+$/, "").split("\n");
|
|
118
|
-
return lines.length === 1 && lines[0] === "" ? [] : lines;
|
|
119
|
-
}
|
|
120
|
-
function renderPreview(preview, c) {
|
|
121
|
-
if (!preview)
|
|
122
|
-
return "";
|
|
123
|
-
let lines;
|
|
124
|
-
if (preview.type === "edit") {
|
|
125
|
-
lines = diffLines(preview.oldStr, preview.newStr, c);
|
|
126
|
-
}
|
|
127
|
-
else if (preview.type === "patch") {
|
|
128
|
-
lines = renderPatchFiles(preview.files, c);
|
|
129
|
-
}
|
|
130
|
-
else if (preview.type === "pr") {
|
|
131
|
-
lines = renderPrPreview(preview, c);
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
const header = preview.exists
|
|
135
|
-
? c.yellow("OVERWRITE existing")
|
|
136
|
-
: c.green("create");
|
|
137
|
-
const body = preview.lines.map((l) => ` ${l}`);
|
|
138
|
-
if (preview.omittedLines > 0)
|
|
139
|
-
body.push(c.dim(` ...${preview.omittedLines} more lines`));
|
|
140
|
-
lines = [header, ...body];
|
|
141
|
-
}
|
|
142
|
-
if (lines.length > PREVIEW_MAX_LINES) {
|
|
143
|
-
const hidden = lines.length - PREVIEW_MAX_LINES;
|
|
144
|
-
lines = [...lines.slice(0, PREVIEW_MAX_LINES), c.dim(`...${hidden} more`)];
|
|
145
|
-
}
|
|
146
|
-
return lines.map((l) => ` ${l}`).join("\n");
|
|
147
|
-
}
|
|
148
73
|
// ── default stdin-backed PromptIO ──────────────────────────────────────────────
|
|
149
74
|
/** Build the real PromptIO: prompt to stderr, read keys/lines from stdin. */
|
|
150
75
|
export function defaultPromptIO(color) {
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -3,8 +3,8 @@ import pc from "picocolors";
|
|
|
3
3
|
import { logger } from "../../utils/logger.js";
|
|
4
4
|
import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
5
5
|
import { authMissingKey, usageError } from "../../errors/index.js";
|
|
6
|
+
import { createRenderer } from "../../render/index.js";
|
|
6
7
|
import { runInteractive } from "../repl.js";
|
|
7
|
-
import { createStreamPrinter } from "../stream-print.js";
|
|
8
8
|
import { buildAgentSession } from "../session-factory.js";
|
|
9
9
|
import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
|
|
10
10
|
export function runCommand() {
|
|
@@ -50,22 +50,28 @@ export function runCommand() {
|
|
|
50
50
|
}
|
|
51
51
|
// Plan mode is opt-in: --plan flag overrides the config default.
|
|
52
52
|
const planMode = opts.plan ?? config.agent.planMode;
|
|
53
|
-
|
|
53
|
+
// One renderer for the whole run (U.2): the streaming path and the
|
|
54
|
+
// approval prompt's status-suspend hook must share the same live region.
|
|
55
|
+
const renderer = createRenderer();
|
|
56
|
+
const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer);
|
|
54
57
|
if (interactive) {
|
|
55
|
-
await runInteractive(session);
|
|
58
|
+
await runInteractive(session, undefined, renderer);
|
|
56
59
|
return;
|
|
57
60
|
}
|
|
58
61
|
// One-shot: a single turn, then exit. Preserves scripting/pipe use.
|
|
59
|
-
// Assistant text streams to stdout delta by delta (same as the REPL)
|
|
60
|
-
//
|
|
61
|
-
// loop terminates the line.
|
|
62
|
+
// Assistant text streams to stdout delta by delta (same as the REPL);
|
|
63
|
+
// piped output degrades to the plain renderer (no ANSI, chrome on stderr).
|
|
62
64
|
logger.print(`${pc.cyan("cruxy")} ${pc.dim("›")} ${prompt}\n`);
|
|
63
65
|
// Provider/network/auth failures propagate to the top-level boundary,
|
|
64
66
|
// which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
|
|
65
67
|
// the matching code — a one-shot run must fail non-zero on error.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
try {
|
|
69
|
+
const result = await session.send(prompt, renderer);
|
|
70
|
+
logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
|
|
71
|
+
`tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
renderer.close();
|
|
75
|
+
}
|
|
70
76
|
});
|
|
71
77
|
}
|
package/dist/cli/onboard.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveApiKey } from "../config/index.js";
|
|
2
2
|
import { createDefaultDeps, defaultOnboardingIO, isFirstRun, runOnboarding, } from "../onboarding/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createRenderer } from "../render/index.js";
|
|
4
4
|
import { buildAgentSession } from "./session-factory.js";
|
|
5
5
|
/**
|
|
6
6
|
* CLI-layer glue between the entry points and the onboarding module (U.6). Keeps
|
|
@@ -23,9 +23,14 @@ export async function runFirstWinTask(config, cwd, prompt) {
|
|
|
23
23
|
const apiKey = resolveApiKey(config.model.provider);
|
|
24
24
|
if (!apiKey)
|
|
25
25
|
return; // defensive — the key was just persisted
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
const renderer = createRenderer();
|
|
27
|
+
const session = buildAgentSession(config, apiKey, cwd, true, false, renderer);
|
|
28
|
+
try {
|
|
29
|
+
await session.send(prompt, renderer);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
renderer.close();
|
|
33
|
+
}
|
|
29
34
|
}
|
|
30
35
|
/**
|
|
31
36
|
* Run the guided first-run flow **iff** this is a first run; otherwise return
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Readable, Writable } from "node:stream";
|
|
2
2
|
import type { Session } from "../agent/index.js";
|
|
3
|
+
import { type StreamRenderer } from "../render/index.js";
|
|
3
4
|
/** The stdin/stdout pair the REPL reads from and prompts on. Injectable for tests. */
|
|
4
5
|
export interface ReplIO {
|
|
5
6
|
input: Readable;
|
|
@@ -7,9 +8,13 @@ export interface ReplIO {
|
|
|
7
8
|
}
|
|
8
9
|
/**
|
|
9
10
|
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
10
|
-
* commands or run a turn, repeat. Assistant text
|
|
11
|
-
* `
|
|
11
|
+
* commands or run a turn, repeat. Assistant text and tool-call progress stream
|
|
12
|
+
* through the `renderer` from within `session.send`; this loop only owns input
|
|
13
|
+
* and control.
|
|
12
14
|
*
|
|
13
|
-
* `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
|
|
15
|
+
* `io` defaults to real stdin/stdout; tests inject a scripted stream pair. The
|
|
16
|
+
* renderer defaults to whatever `io.output` supports (a TTY gets the managed
|
|
17
|
+
* live region, anything else the plain append-only renderer); `cruxy run`
|
|
18
|
+
* passes its own so the approval prompt's status-suspend hook shares it.
|
|
14
19
|
*/
|
|
15
|
-
export declare function runInteractive(session: Session, io?: ReplIO): Promise<void>;
|
|
20
|
+
export declare function runInteractive(session: Session, io?: ReplIO, renderer?: StreamRenderer): Promise<void>;
|
package/dist/cli/repl.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import readline from "node:readline";
|
|
2
2
|
import pc from "picocolors";
|
|
3
3
|
import { formatError, fromUnknown, isVerbose, shouldUseColor, } from "../errors/index.js";
|
|
4
|
+
import { createRenderer } from "../render/index.js";
|
|
4
5
|
import { logger } from "../utils/logger.js";
|
|
5
|
-
import { createStreamPrinter } from "./stream-print.js";
|
|
6
6
|
const PROMPT = `${pc.cyan("cruxy")} ${pc.dim("›")} `;
|
|
7
7
|
const HELP = `Commands:
|
|
8
8
|
/help show this help
|
|
@@ -65,13 +65,25 @@ function printReplError(err) {
|
|
|
65
65
|
}
|
|
66
66
|
/**
|
|
67
67
|
* Drive an interactive multi-turn session: prompt, read a line, dispatch slash
|
|
68
|
-
* commands or run a turn, repeat. Assistant text
|
|
69
|
-
* `
|
|
68
|
+
* commands or run a turn, repeat. Assistant text and tool-call progress stream
|
|
69
|
+
* through the `renderer` from within `session.send`; this loop only owns input
|
|
70
|
+
* and control.
|
|
70
71
|
*
|
|
71
|
-
* `io` defaults to real stdin/stdout; tests inject a scripted stream pair.
|
|
72
|
+
* `io` defaults to real stdin/stdout; tests inject a scripted stream pair. The
|
|
73
|
+
* renderer defaults to whatever `io.output` supports (a TTY gets the managed
|
|
74
|
+
* live region, anything else the plain append-only renderer); `cruxy run`
|
|
75
|
+
* passes its own so the approval prompt's status-suspend hook shares it.
|
|
72
76
|
*/
|
|
73
|
-
export async function runInteractive(session, io = defaultIO()) {
|
|
77
|
+
export async function runInteractive(session, io = defaultIO(), renderer = createRenderer(io.output, process.stderr)) {
|
|
74
78
|
logger.print(pc.dim("interactive session — /help for commands, /exit or Ctrl+D to quit"));
|
|
79
|
+
try {
|
|
80
|
+
await replLoop(session, io, renderer);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
renderer.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function replLoop(session, io, renderer) {
|
|
75
87
|
for (;;) {
|
|
76
88
|
const line = await readLine(io, PROMPT);
|
|
77
89
|
// EOF / Ctrl+D.
|
|
@@ -120,14 +132,13 @@ export async function runInteractive(session, io = defaultIO()) {
|
|
|
120
132
|
logger.print(HELP);
|
|
121
133
|
continue;
|
|
122
134
|
}
|
|
123
|
-
// A real turn. Assistant text streams
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
135
|
+
// A real turn. Assistant text streams through the renderer delta by delta
|
|
136
|
+
// (leading blank lines trimmed, code fences highlighted); the agent loop
|
|
137
|
+
// closes each segment with one newline, so the next prompt lands on its own
|
|
138
|
+
// line. Errors (provider/API failures) log and return to the prompt rather
|
|
139
|
+
// than killing the REPL.
|
|
128
140
|
try {
|
|
129
|
-
|
|
130
|
-
await session.send(line, print);
|
|
141
|
+
await session.send(line, renderer);
|
|
131
142
|
}
|
|
132
143
|
catch (err) {
|
|
133
144
|
logger.error(err.message);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
2
3
|
import { Session } from "../agent/index.js";
|
|
3
4
|
/**
|
|
4
5
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
@@ -9,4 +10,4 @@ import { Session } from "../agent/index.js";
|
|
|
9
10
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
10
11
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
11
12
|
*/
|
|
12
|
-
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean): Session;
|
|
13
|
+
export declare function buildAgentSession(config: CruxyConfig, apiKey: string, cwd: string, ttyInteractive: boolean, planMode?: boolean, renderer?: StreamRenderer): Session;
|
|
@@ -7,6 +7,23 @@ import { shouldUseColor } from "../errors/index.js";
|
|
|
7
7
|
import { buildDefaultRegistry } from "../tools/index.js";
|
|
8
8
|
import { Session } from "../agent/index.js";
|
|
9
9
|
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
10
|
+
/**
|
|
11
|
+
* Wrap a PromptIO so the renderer's transient status line is erased before any
|
|
12
|
+
* prompt text lands (U.2): the approval prompt writes to stderr while the
|
|
13
|
+
* spinner owns the last stdout row of the same terminal — clearing first keeps
|
|
14
|
+
* the prompt from tearing through the live region.
|
|
15
|
+
*/
|
|
16
|
+
function suspendStatusOnPrompt(io, renderer) {
|
|
17
|
+
if (!renderer)
|
|
18
|
+
return io;
|
|
19
|
+
return {
|
|
20
|
+
...io,
|
|
21
|
+
write: (text) => {
|
|
22
|
+
renderer.status(null);
|
|
23
|
+
io.write(text);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
10
27
|
/**
|
|
11
28
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
12
29
|
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
@@ -16,7 +33,7 @@ import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
|
16
33
|
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
17
34
|
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
18
35
|
*/
|
|
19
|
-
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false) {
|
|
36
|
+
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false, renderer) {
|
|
20
37
|
const provider = createProvider({
|
|
21
38
|
provider: config.model.provider,
|
|
22
39
|
apiKey,
|
|
@@ -31,7 +48,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
31
48
|
if (planMode) {
|
|
32
49
|
// One io + allowlist shared by the plan-approval prompt and the per-action
|
|
33
50
|
// gate, so a grant recorded during execution is honored by U.3's own check.
|
|
34
|
-
const io = defaultPromptIO(shouldUseColor());
|
|
51
|
+
const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
|
|
35
52
|
const allowlist = new SessionAllowlist();
|
|
36
53
|
const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io));
|
|
37
54
|
const approval = new ApprovalService({
|
|
@@ -46,7 +63,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
46
63
|
logger,
|
|
47
64
|
requestApproval: (action) => approval.requestApproval(action),
|
|
48
65
|
};
|
|
49
|
-
const planRunner = ({ messages, projectInstructions,
|
|
66
|
+
const planRunner = ({ messages, projectInstructions, renderer: turnRenderer, }) => runPlanSession({
|
|
50
67
|
provider,
|
|
51
68
|
config,
|
|
52
69
|
ctx,
|
|
@@ -57,7 +74,7 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
57
74
|
messages,
|
|
58
75
|
git,
|
|
59
76
|
projectInstructions,
|
|
60
|
-
|
|
77
|
+
renderer: turnRenderer,
|
|
61
78
|
});
|
|
62
79
|
return new Session({
|
|
63
80
|
provider,
|
|
@@ -70,7 +87,11 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
|
|
|
70
87
|
planRunner,
|
|
71
88
|
});
|
|
72
89
|
}
|
|
73
|
-
const approval = new ApprovalService({
|
|
90
|
+
const approval = new ApprovalService({
|
|
91
|
+
cwd,
|
|
92
|
+
interactive: ttyInteractive,
|
|
93
|
+
io: suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer),
|
|
94
|
+
});
|
|
74
95
|
const ctx = {
|
|
75
96
|
cwd,
|
|
76
97
|
config,
|
package/dist/plan/service.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CruxyConfig } from "../config/index.js";
|
|
|
3
3
|
import type { PromptIO } from "../approval/index.js";
|
|
4
4
|
import { ToolRegistry, type ToolContext } from "../tools/index.js";
|
|
5
5
|
import { type AgentResult } from "../agent/loop.js";
|
|
6
|
+
import type { StreamRenderer } from "../render/index.js";
|
|
6
7
|
import { PlanExecutionPolicy } from "./policy.js";
|
|
7
8
|
/**
|
|
8
9
|
* Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
|
|
@@ -32,7 +33,7 @@ export interface PlanSessionArgs {
|
|
|
32
33
|
dirty: boolean;
|
|
33
34
|
} | null;
|
|
34
35
|
projectInstructions?: string | null;
|
|
35
|
-
|
|
36
|
+
renderer?: StreamRenderer;
|
|
36
37
|
/** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
|
|
37
38
|
maxRevisions?: number;
|
|
38
39
|
}
|
package/dist/plan/service.js
CHANGED
|
@@ -73,7 +73,7 @@ export async function runPlanSession(args) {
|
|
|
73
73
|
ctx: args.ctx,
|
|
74
74
|
git: args.git,
|
|
75
75
|
projectInstructions: args.projectInstructions,
|
|
76
|
-
|
|
76
|
+
renderer: args.renderer,
|
|
77
77
|
planMode: true,
|
|
78
78
|
}));
|
|
79
79
|
if (!holder.plan) {
|
|
@@ -105,7 +105,7 @@ export async function runPlanSession(args) {
|
|
|
105
105
|
ctx: args.ctx,
|
|
106
106
|
git: args.git,
|
|
107
107
|
projectInstructions: args.projectInstructions,
|
|
108
|
-
|
|
108
|
+
renderer: args.renderer,
|
|
109
109
|
}));
|
|
110
110
|
};
|
|
111
111
|
await executePlan(plan, { runStep, io: args.io });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RenderCapabilities, RenderStream } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
|
|
4
|
+
* given its inputs (stream + env are injectable), so every row of the
|
|
5
|
+
* degradation matrix is directly testable.
|
|
6
|
+
*
|
|
7
|
+
* Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
|
|
8
|
+
* refinement: `TERM=dumb` terminals get no color even though they are TTYs.
|
|
9
|
+
* Cursor control and color are independent axes — a NO_COLOR terminal still
|
|
10
|
+
* supports in-place status updates; a dumb terminal supports neither.
|
|
11
|
+
*/
|
|
12
|
+
export declare function detectCapabilities(stream?: RenderStream, env?: NodeJS.ProcessEnv): RenderCapabilities;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { shouldUseColor } from "../errors/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
|
|
4
|
+
* given its inputs (stream + env are injectable), so every row of the
|
|
5
|
+
* degradation matrix is directly testable.
|
|
6
|
+
*
|
|
7
|
+
* Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
|
|
8
|
+
* refinement: `TERM=dumb` terminals get no color even though they are TTYs.
|
|
9
|
+
* Cursor control and color are independent axes — a NO_COLOR terminal still
|
|
10
|
+
* supports in-place status updates; a dumb terminal supports neither.
|
|
11
|
+
*/
|
|
12
|
+
export function detectCapabilities(stream = process.stdout, env = process.env) {
|
|
13
|
+
const tty = Boolean(stream.isTTY);
|
|
14
|
+
const dumb = env.TERM === "dumb";
|
|
15
|
+
const cursor = tty && !dumb;
|
|
16
|
+
return {
|
|
17
|
+
tty,
|
|
18
|
+
color: shouldUseColor(stream, env) && !dumb,
|
|
19
|
+
cursor,
|
|
20
|
+
// Same set-and-non-empty convention as NO_COLOR: any value disables.
|
|
21
|
+
spinner: cursor &&
|
|
22
|
+
!(env.CRUXY_NO_SPINNER !== undefined && env.CRUXY_NO_SPINNER !== ""),
|
|
23
|
+
width: typeof stream.columns === "number" && stream.columns > 0
|
|
24
|
+
? stream.columns
|
|
25
|
+
: 80,
|
|
26
|
+
};
|
|
27
|
+
}
|