@cruxy/cli 0.6.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 +39 -16
- package/dist/agent/loop.d.ts +9 -5
- package/dist/agent/loop.js +53 -10
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +6 -0
- package/dist/agent/session.d.ts +29 -3
- package/dist/agent/session.js +37 -10
- package/dist/approval/prompt.d.ts +9 -0
- package/dist/approval/prompt.js +2 -77
- package/dist/cli/commands/init.d.ts +7 -0
- package/dist/cli/commands/init.js +40 -0
- package/dist/cli/commands/login.d.ts +8 -0
- package/dist/cli/commands/login.js +36 -0
- package/dist/cli/commands/run.js +46 -62
- package/dist/cli/onboard.d.ts +25 -0
- package/dist/cli/onboard.js +59 -0
- package/dist/cli/program.js +19 -1
- package/dist/cli/repl.d.ts +9 -4
- package/dist/cli/repl.js +32 -12
- package/dist/cli/session-factory.d.ts +13 -0
- package/dist/cli/session-factory.js +109 -0
- package/dist/config/credentials.d.ts +10 -0
- package/dist/config/credentials.js +69 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.d.ts +6 -1
- package/dist/config/manager.js +11 -1
- package/dist/config/schema.d.ts +10 -0
- package/dist/config/schema.js +2 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/errors/constructors.d.ts +10 -0
- package/dist/errors/constructors.js +46 -2
- package/dist/errors/types.d.ts +3 -0
- package/dist/errors/types.js +6 -0
- package/dist/onboarding/detect.d.ts +26 -0
- package/dist/onboarding/detect.js +56 -0
- package/dist/onboarding/flow.d.ts +28 -0
- package/dist/onboarding/flow.js +100 -0
- package/dist/onboarding/index.d.ts +5 -0
- package/dist/onboarding/index.js +5 -0
- package/dist/onboarding/io.d.ts +8 -0
- package/dist/onboarding/io.js +133 -0
- package/dist/onboarding/steps.d.ts +17 -0
- package/dist/onboarding/steps.js +100 -0
- package/dist/onboarding/types.d.ts +81 -0
- package/dist/onboarding/types.js +6 -0
- package/dist/plan/approve.d.ts +16 -0
- package/dist/plan/approve.js +46 -0
- package/dist/plan/execute.d.ts +20 -0
- package/dist/plan/execute.js +31 -0
- package/dist/plan/index.d.ts +7 -0
- package/dist/plan/index.js +7 -0
- package/dist/plan/policy.d.ts +26 -0
- package/dist/plan/policy.js +45 -0
- package/dist/plan/render.d.ts +5 -0
- package/dist/plan/render.js +47 -0
- package/dist/plan/service.d.ts +40 -0
- package/dist/plan/service.js +118 -0
- package/dist/plan/submit-plan.d.ts +33 -0
- package/dist/plan/submit-plan.js +57 -0
- package/dist/plan/types.d.ts +60 -0
- package/dist/plan/types.js +6 -0
- 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,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The `submit_plan` tool (C.31): the agent's only way to propose a plan. It is a
|
|
4
|
+
* normal tool on the same loop seam as everything else — no hidden control flow.
|
|
5
|
+
* During the propose phase this is the *only* non-read-only tool registered, so
|
|
6
|
+
* the agent structurally cannot act before approval.
|
|
7
|
+
*
|
|
8
|
+
* Validation is fail-loud: a malformed or empty plan returns `{ok:false}` so the
|
|
9
|
+
* model sees the error and retries, exactly like any other tool.
|
|
10
|
+
*/
|
|
11
|
+
const stepSchema = z.object({
|
|
12
|
+
title: z.string().min(1).describe("One-line imperative title for the step."),
|
|
13
|
+
rationale: z
|
|
14
|
+
.string()
|
|
15
|
+
.min(1)
|
|
16
|
+
.describe("Why this step exists / what it accomplishes."),
|
|
17
|
+
kind: z
|
|
18
|
+
.enum(["read", "mutate", "destructive"])
|
|
19
|
+
.describe("Risk estimate: read (no side effects), mutate (reversible file writes), " +
|
|
20
|
+
"destructive (shell, deletes, PR-open). Advisory — the approval gate re-checks each action."),
|
|
21
|
+
});
|
|
22
|
+
const parameters = z.object({
|
|
23
|
+
steps: z
|
|
24
|
+
.array(stepSchema)
|
|
25
|
+
.min(1)
|
|
26
|
+
.describe("Ordered steps that fully accomplish the task."),
|
|
27
|
+
});
|
|
28
|
+
/** Build a `submit_plan` tool bound to `holder`, which captures the last plan. */
|
|
29
|
+
export function makeSubmitPlanTool(holder) {
|
|
30
|
+
return {
|
|
31
|
+
name: "submit_plan",
|
|
32
|
+
description: "Propose an ordered, step-by-step plan for the user to approve before you act. " +
|
|
33
|
+
"Call this first (and only this) in plan mode; do not take any other action until the plan is approved.",
|
|
34
|
+
parameters,
|
|
35
|
+
async execute(input) {
|
|
36
|
+
// zod already guarantees ≥1 step with non-empty fields; this is the
|
|
37
|
+
// fail-loud belt-and-suspenders for an empty array slipping through.
|
|
38
|
+
if (input.steps.length === 0) {
|
|
39
|
+
return { ok: false, error: "a plan must have at least one step" };
|
|
40
|
+
}
|
|
41
|
+
const steps = input.steps.map((s, i) => ({
|
|
42
|
+
id: String(i + 1),
|
|
43
|
+
title: s.title.trim(),
|
|
44
|
+
rationale: s.rationale.trim(),
|
|
45
|
+
kind: s.kind,
|
|
46
|
+
status: "pending",
|
|
47
|
+
}));
|
|
48
|
+
const plan = { steps };
|
|
49
|
+
holder.plan = plan;
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
output: `Plan received (${steps.length} step${steps.length === 1 ? "" : "s"}) and shown to the user for approval. ` +
|
|
53
|
+
"Do not take any further action now — end your turn.",
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for plan mode (C.31). A {@link Plan} is a first-class, typed artifact the
|
|
3
|
+
* agent produces via the `submit_plan` tool — never free text — so the user can
|
|
4
|
+
* review the shape of the work once, and execution can track per-step status.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The agent's risk *estimate* for a step, used only to render intent and shape
|
|
8
|
+
* the approval copy. It is NOT authoritative: the real U.3 gate re-classifies
|
|
9
|
+
* every concrete action at execution time (a step the agent calls "mutate" whose
|
|
10
|
+
* action turns out destructive still confirms).
|
|
11
|
+
*/
|
|
12
|
+
export type PlanStepKind = "read" | "mutate" | "destructive";
|
|
13
|
+
/** Live execution status of a step. */
|
|
14
|
+
export type PlanStepStatus = "pending" | "running" | "done" | "failed";
|
|
15
|
+
export interface PlanStep {
|
|
16
|
+
/** Stable 1-based id assigned on submit (e.g. "1"). */
|
|
17
|
+
readonly id: string;
|
|
18
|
+
/** One-line imperative title. */
|
|
19
|
+
readonly title: string;
|
|
20
|
+
/** Why this step exists / what it accomplishes. */
|
|
21
|
+
readonly rationale: string;
|
|
22
|
+
/** The agent's risk estimate (advisory; see {@link PlanStepKind}). */
|
|
23
|
+
readonly kind: PlanStepKind;
|
|
24
|
+
/** Mutated in place as execution progresses. */
|
|
25
|
+
status: PlanStepStatus;
|
|
26
|
+
}
|
|
27
|
+
export interface Plan {
|
|
28
|
+
readonly steps: PlanStep[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The user's decision at the plan-approval prompt.
|
|
32
|
+
* - `approve` — execute; every action still hits the U.3 gate.
|
|
33
|
+
* - `approve-grant` — execute AND auto-allow the safe (read/mutate, grantable)
|
|
34
|
+
* steps for this run; destructive/ungrantable actions still confirm.
|
|
35
|
+
* - `revise` — send `feedback` back to the agent for a revised plan.
|
|
36
|
+
* - `abort` — cancel; nothing executes (also the default-deny for EOF/Ctrl-C).
|
|
37
|
+
*/
|
|
38
|
+
export type PlanDecision = {
|
|
39
|
+
readonly kind: "approve";
|
|
40
|
+
} | {
|
|
41
|
+
readonly kind: "approve-grant";
|
|
42
|
+
} | {
|
|
43
|
+
readonly kind: "revise";
|
|
44
|
+
readonly feedback: string;
|
|
45
|
+
} | {
|
|
46
|
+
readonly kind: "abort";
|
|
47
|
+
};
|
|
48
|
+
/** Outcome of executing an approved plan. */
|
|
49
|
+
export interface PlanExecutionResult {
|
|
50
|
+
/** True if every step reached `done`. */
|
|
51
|
+
readonly completed: boolean;
|
|
52
|
+
/** True if a step failed and the user chose to abort the run. */
|
|
53
|
+
readonly halted: boolean;
|
|
54
|
+
/** The id of the step that failed, when halted. */
|
|
55
|
+
readonly failedStepId?: string;
|
|
56
|
+
}
|
|
57
|
+
/** A mutable holder the `submit_plan` tool writes the captured plan into. */
|
|
58
|
+
export interface PlanHolder {
|
|
59
|
+
plan: Plan | null;
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|