securevibe 0.1.8 → 0.1.12

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/dist/index.js CHANGED
@@ -22,16 +22,18 @@ function addCommon(cmd) {
22
22
  .option("--sarif", "output SARIF 2.1.0 (for CI / GitHub code scanning)")
23
23
  .option("--no-color", "disable coloured output")
24
24
  .option("--staged", "scan only git-staged files (for pre-commit guards)")
25
+ .option("--diff <base>", "scan only files changed since <base> (for PR checks)")
25
26
  .option("--ci", "CI mode: exit non-zero based on deployment readiness");
26
27
  }
27
28
  /**
28
29
  * Free beta metering: one use per analysis command against the daily quota.
29
- * The --staged commit guard is exempt a quota must never block a commit.
30
- * Returns the usage state so callers can show it, or undefined when this run
31
- * wasn't metered (--staged).
30
+ * The --staged commit guard and --diff (PR-scoped CI scans) are both exempt
31
+ * an always-on automated guard must never be the thing that breaks. Returns
32
+ * the usage state so callers can show it, or undefined when this run wasn't
33
+ * metered.
32
34
  */
33
35
  async function meter(opts = {}) {
34
- if (opts.staged)
36
+ if (opts.staged || opts.diff)
35
37
  return undefined;
36
38
  const { recordUse, limitMessage } = await import("./usage.js");
37
39
  const r = await recordUse();
@@ -62,6 +64,20 @@ async function runScan(pathArg, opts, scanOpts = {}) {
62
64
  scanOpts = { ...scanOpts, files: staged };
63
65
  }
64
66
  }
67
+ // --diff <base>: analyze only files changed since <base> (PR-scoped CI checks).
68
+ if (opts.diff) {
69
+ const path = await import("node:path");
70
+ const { listChangedFiles } = await import("./engine/git.js");
71
+ const absRoot = path.resolve(target);
72
+ const changed = await listChangedFiles(absRoot, opts.diff);
73
+ if (changed === null) {
74
+ if (!quiet)
75
+ process.stderr.write(` · could not diff against ${opts.diff} — scanning the whole tree\n`);
76
+ }
77
+ else {
78
+ scanOpts = { ...scanOpts, files: changed };
79
+ }
80
+ }
65
81
  const result = await scan(target, {
66
82
  ...scanOpts,
67
83
  onProgress: quiet ? undefined : (m) => process.stderr.write(` · ${m}\n`),
@@ -128,6 +144,8 @@ program
128
144
  const { renderInit } = await import("./ui/init.js");
129
145
  const result = await runInit(pathArg ?? ".", { force: opts.force });
130
146
  process.stdout.write(renderInit(result) + "\n");
147
+ const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
148
+ await maybeOfferAiFixerSetupDuringInit(pathArg ?? ".");
131
149
  });
132
150
  program
133
151
  .command("fix")
@@ -169,6 +187,66 @@ program
169
187
  }
170
188
  await maybeNotifyUpdate({ json: opts.json });
171
189
  });
190
+ program
191
+ .command("explain")
192
+ .description("AI-powered plain-language explanations for critical/high findings")
193
+ .argument("[path]", "path to the repository", ".")
194
+ .option("--json", "output machine-readable JSON")
195
+ .option("--no-color", "disable coloured output")
196
+ .action(async (pathArg, opts) => {
197
+ if (opts.color === false)
198
+ process.env.NO_COLOR = "1";
199
+ const { applyStoredKeysToEnv } = await import("./config.js");
200
+ await applyStoredKeysToEnv();
201
+ if (!opts.json) {
202
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
203
+ await maybeOfferAiFixerSetup();
204
+ }
205
+ const { result, usage } = await runScan(pathArg, { json: opts.json, color: opts.color });
206
+ const { explainFindings } = await import("./engine/explain.js");
207
+ const report = await explainFindings(result, {
208
+ onProgress: opts.json ? undefined : (m) => process.stderr.write(` · ${m}\n`),
209
+ });
210
+ if (opts.json) {
211
+ const { explainToJson } = await import("./ui/explain.js");
212
+ process.stdout.write(explainToJson(report) + "\n");
213
+ }
214
+ else {
215
+ const { renderExplain } = await import("./ui/explain.js");
216
+ process.stdout.write(renderExplain(report, usage) + "\n");
217
+ }
218
+ await maybeNotifyUpdate({ json: opts.json });
219
+ });
220
+ program
221
+ .command("pr-comment")
222
+ .description("Post/update a PR summary of changed-file findings (used by the GitHub Actions workflow)")
223
+ .argument("[path]", "path to the repository", ".")
224
+ .option("--diff <base>", "base ref to diff against (the workflow passes the PR's base SHA)")
225
+ .action(async (pathArg, opts) => {
226
+ if (!opts.diff) {
227
+ process.stderr.write(" pr-comment: --diff <base> is required (the workflow passes the PR's base SHA).\n");
228
+ process.exit(64);
229
+ }
230
+ const { result } = await runScan(pathArg, { diff: opts.diff });
231
+ const { renderPrComment } = await import("./ui/prComment.js");
232
+ const body = renderPrComment(result);
233
+ const token = process.env.GITHUB_TOKEN;
234
+ const repoSlug = process.env.GITHUB_REPOSITORY;
235
+ const prNumber = process.env.PR_NUMBER ? Number(process.env.PR_NUMBER) : undefined;
236
+ if (!token || !repoSlug || !prNumber) {
237
+ process.stdout.write(body + "\n");
238
+ return;
239
+ }
240
+ const [owner, repo] = repoSlug.split("/");
241
+ const { postOrUpdateComment } = await import("./engine/github.js");
242
+ const posted = await postOrUpdateComment({ owner, repo, prNumber, token }, body);
243
+ if (!posted.posted) {
244
+ process.stderr.write(` pr-comment: could not post to GitHub (${posted.error ?? "unknown error"}) — printing instead.\n`);
245
+ process.stdout.write(body + "\n");
246
+ return;
247
+ }
248
+ process.stderr.write(` pr-comment: ${posted.updated ? "updated" : "posted"} the summary on PR #${prNumber}.\n`);
249
+ });
172
250
  addCommon(program
173
251
  .command("ai-audit")
174
252
  .description("Focus on the AI-application / agent attack surface (doc 04)")
@@ -243,7 +321,7 @@ addCommon(program
243
321
  });
244
322
  program
245
323
  .command("db")
246
- .description("Manage the local OSV vulnerability database (the only networked command)")
324
+ .description("Manage the local OSV vulnerability database (update is networked)")
247
325
  .argument("<action>", "update | status")
248
326
  .option("--no-color", "disable coloured output")
249
327
  .action(async (action, opts) => {
package/dist/repl.js CHANGED
@@ -40,27 +40,62 @@ async function meterOrPrint(io) {
40
40
  }
41
41
  return r;
42
42
  }
43
- const HELP = [
44
- " Commands:",
45
- " scan [--json] full scan",
46
- " fix [--apply] [--yes] [--no-llm] [--json] fix findings",
47
- " ai-audit | protect | attack-map | score | deps | ready [--json]",
48
- " init [--force] wire up the pre-commit guard + CI",
49
- " config show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
50
- " db update | status manage the local OSV database",
51
- " cd <path> change the scan target",
52
- " pwd show the current scan target",
53
- " help this message",
54
- " exit | quit leave the session",
55
- ].join("\n");
56
- /** Public entry point: real stdio, refuses to start without a TTY. */
43
+ /** Public entry point: real stdio, refuses to start without a TTY. Mounts
44
+ * the Ink interactive app (ui/repl-tui/App.ts) -- runReplSession above stays
45
+ * the tested, TTY-free core; this is the real-terminal presentation layer. */
57
46
  export async function runRepl(initialPath) {
58
47
  if (!isInteractive()) {
59
48
  process.stderr.write(" repl needs an interactive terminal.\n");
60
49
  process.exitCode = 64;
61
50
  return;
62
51
  }
63
- await runReplSession(initialPath, { input: process.stdin, output: process.stdout });
52
+ const { render } = await import("ink");
53
+ const React = (await import("react")).default;
54
+ const { App } = await import("./ui/repl-tui/App.js");
55
+ const { waitUntilExit } = render(React.createElement(App, { initialPath }));
56
+ await waitUntilExit();
57
+ }
58
+ /**
59
+ * Handle one line of input against the given state: built-in commands
60
+ * (exit/quit/q, help, pwd, cd) plus everything dispatch() handles. Returns
61
+ * true if the session should end after this line. Shared by the legacy
62
+ * readline loop (runReplSession, below) and the Ink app (ui/repl-tui/App.ts)
63
+ * — extracted so both drive the exact same behavior from one place.
64
+ */
65
+ export async function processLine(line, state, io) {
66
+ const trimmed = line.trim();
67
+ if (!trimmed)
68
+ return false;
69
+ const tokens = tokenize(trimmed);
70
+ const cmd = tokens[0];
71
+ const rest = tokens.slice(1);
72
+ try {
73
+ if (cmd === "exit" || cmd === "quit" || cmd === "q")
74
+ return true;
75
+ if (cmd === "help" || cmd === "?") {
76
+ const { renderHelp } = await import("./ui/repl-tui/commandRegistry.js");
77
+ write(io, renderHelp() + "\n\n");
78
+ return false;
79
+ }
80
+ if (cmd === "pwd") {
81
+ write(io, ` ${state.root}\n\n`);
82
+ return false;
83
+ }
84
+ if (cmd === "cd") {
85
+ if (!rest[0]) {
86
+ write(io, " usage: cd <path>\n\n");
87
+ return false;
88
+ }
89
+ state.root = path.resolve(state.root, rest[0]);
90
+ write(io, ` target: ${state.root}\n\n`);
91
+ return false;
92
+ }
93
+ await dispatch(cmd, rest, state, io);
94
+ }
95
+ catch (err) {
96
+ write(io, ` error: ${err?.message ?? err}\n\n`);
97
+ }
98
+ return false;
64
99
  }
65
100
  /** The actual session loop, over injectable streams — testable without a TTY. */
66
101
  export async function runReplSession(initialPath, io) {
@@ -72,39 +107,17 @@ export async function runReplSession(initialPath, io) {
72
107
  write(io, ` target: ${state.root}\n\n`);
73
108
  while (true) {
74
109
  const raw = await askLine(`securevibe (${path.basename(state.root)})> `, io.input, io.output);
75
- const line = raw.trim();
76
- if (!line)
77
- continue;
78
- const tokens = tokenize(line);
79
- const cmd = tokens[0];
80
- const rest = tokens.slice(1);
81
- try {
82
- if (cmd === "exit" || cmd === "quit" || cmd === "q")
83
- break;
84
- if (cmd === "help" || cmd === "?") {
85
- write(io, HELP + "\n\n");
86
- continue;
87
- }
88
- if (cmd === "pwd") {
89
- write(io, ` ${state.root}\n\n`);
90
- continue;
91
- }
92
- if (cmd === "cd") {
93
- if (!rest[0]) {
94
- write(io, " usage: cd <path>\n\n");
95
- continue;
96
- }
97
- state.root = path.resolve(state.root, rest[0]);
98
- write(io, ` target: ${state.root}\n\n`);
99
- continue;
100
- }
101
- await dispatch(cmd, rest, state, io);
102
- }
103
- catch (err) {
104
- write(io, ` error: ${err?.message ?? err}\n\n`);
105
- }
110
+ if (await processLine(raw, state, io))
111
+ break;
106
112
  }
107
113
  }
114
+ /** Progress callback for scan()/runFix()/explainFindings(): prefers io.onProgress
115
+ * (Ink path) over the default scrolling-line behavior (legacy readline path). */
116
+ function progressCallback(io, asJson) {
117
+ if (asJson)
118
+ return undefined;
119
+ return io.onProgress ?? ((m) => write(io, ` · ${m}\n`));
120
+ }
108
121
  async function dispatch(cmd, tokens, state, io) {
109
122
  const asJson = hasFlag(tokens, "--json");
110
123
  if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd)) {
@@ -112,7 +125,7 @@ async function dispatch(cmd, tokens, state, io) {
112
125
  const usage = await meterOrPrint(io);
113
126
  const result = await scan(state.root, {
114
127
  aiOnly: cmd === "ai-audit",
115
- onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
128
+ onProgress: progressCallback(io, asJson),
116
129
  });
117
130
  state.lastResult = result;
118
131
  if (asJson) {
@@ -129,15 +142,28 @@ async function dispatch(cmd, tokens, state, io) {
129
142
  const opts = { apply: hasFlag(tokens, "--apply"), yes: hasFlag(tokens, "--yes"), json: asJson };
130
143
  const decision = decideApply(opts, true);
131
144
  if (decision.apply && !hasFlag(tokens, "--no-llm")) {
132
- const { maybeOfferAiFixerSetup } = await import("./index-support.js");
133
- await maybeOfferAiFixerSetup();
145
+ if (io.inkMode) {
146
+ const { noKeyNudgeMessage } = await import("./index-support.js");
147
+ const msg = noKeyNudgeMessage();
148
+ if (msg)
149
+ write(io, msg);
150
+ }
151
+ else {
152
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
153
+ await maybeOfferAiFixerSetup();
154
+ }
134
155
  }
135
156
  const usage = await meterOrPrint(io);
157
+ const confirmFn = decision.prompt
158
+ ? io.makeConfirm
159
+ ? io.makeConfirm(renderApprovalRequest)
160
+ : makeFixConfirm(renderApprovalRequest, io.input, io.output)
161
+ : undefined;
136
162
  const result = await runFix(state.root, {
137
163
  apply: decision.apply,
138
164
  noLlm: hasFlag(tokens, "--no-llm"),
139
- confirm: decision.prompt ? makeFixConfirm(renderApprovalRequest, io.input, io.output) : undefined,
140
- onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
165
+ confirm: confirmFn,
166
+ onProgress: progressCallback(io, asJson),
141
167
  });
142
168
  if (asJson) {
143
169
  write(io, fixToJson(result) + "\n\n");
@@ -149,11 +175,52 @@ async function dispatch(cmd, tokens, state, io) {
149
175
  }
150
176
  return;
151
177
  }
178
+ if (cmd === "explain") {
179
+ if (!asJson) {
180
+ if (io.inkMode) {
181
+ const { noKeyNudgeMessage } = await import("./index-support.js");
182
+ const msg = noKeyNudgeMessage();
183
+ if (msg)
184
+ write(io, msg);
185
+ }
186
+ else {
187
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
188
+ await maybeOfferAiFixerSetup();
189
+ }
190
+ }
191
+ const { explainFindings } = await import("./engine/explain.js");
192
+ const usage = await meterOrPrint(io);
193
+ const { scan } = await import("./engine/scan.js");
194
+ const scanResult = await scan(state.root, { onProgress: progressCallback(io, asJson) });
195
+ state.lastResult = scanResult;
196
+ const report = await explainFindings(scanResult, {
197
+ onProgress: progressCallback(io, asJson),
198
+ });
199
+ if (asJson) {
200
+ const { explainToJson } = await import("./ui/explain.js");
201
+ write(io, explainToJson(report) + "\n\n");
202
+ return;
203
+ }
204
+ const { renderExplain } = await import("./ui/explain.js");
205
+ write(io, renderExplain(report, usage) + "\n");
206
+ return;
207
+ }
152
208
  if (cmd === "init") {
153
209
  const { runInit } = await import("./engine/init.js");
154
210
  const { renderInit } = await import("./ui/init.js");
155
211
  const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
156
212
  write(io, renderInit(result) + "\n");
213
+ const { maybeOfferAiFixerSetupDuringInit, noKeyNudgeMessage } = await import("./index-support.js");
214
+ await maybeOfferAiFixerSetupDuringInit(state.root, io.inkMode
215
+ ? {
216
+ writePreamble: (text) => write(io, text),
217
+ offer: async () => {
218
+ const msg = noKeyNudgeMessage();
219
+ if (msg)
220
+ write(io, msg);
221
+ },
222
+ }
223
+ : undefined);
157
224
  return;
158
225
  }
159
226
  if (cmd === "config") {
@@ -207,6 +274,10 @@ async function handleConfig(tokens, io) {
207
274
  return;
208
275
  }
209
276
  if (action === "set-key") {
277
+ if (io.inkMode) {
278
+ write(io, ` config set-key needs a real terminal prompt — run \`securevibe config set-key ${provider}\` as a one-shot command outside the REPL (or set the ${provider === "groq" ? "GROQ_API_KEY" : "ANTHROPIC_API_KEY"} env var directly).\n\n`);
279
+ return;
280
+ }
210
281
  const hint = provider === "groq" ? " (free — console.groq.com/keys)" : " (console.anthropic.com)";
211
282
  const key = (await askLine(` Paste your ${provider} API key${hint}: `, io.input, io.output)).trim();
212
283
  if (!key) {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `explain` view. Shows the AI-generated explanation per finding when
3
+ * available, falling back to the existing static why/fix text otherwise —
4
+ * an AI explanation is additional context, never a replacement, so the
5
+ * static text stays a guaranteed field in both the human and JSON output.
6
+ */
7
+ import pc from "picocolors";
8
+ import { renderBanner } from "./banner.js";
9
+ import { renderUsageLine } from "./usage.js";
10
+ const SEV_TAG = {
11
+ critical: (s) => pc.bgRed(pc.white(pc.bold(s))),
12
+ high: (s) => pc.red(pc.bold(s)),
13
+ medium: (s) => pc.yellow(s),
14
+ low: (s) => pc.blue(s),
15
+ info: (s) => pc.dim(s),
16
+ };
17
+ export function renderExplain(report, usage) {
18
+ const L = [];
19
+ L.push(renderBanner({ subtitle: "AI-powered finding explanations" }));
20
+ L.push(pc.dim(` ${report.root}`));
21
+ L.push("");
22
+ if (usage) {
23
+ L.push(renderUsageLine(usage));
24
+ L.push("");
25
+ }
26
+ if (report.results.length === 0) {
27
+ L.push(pc.green(" No critical/high findings to explain."));
28
+ L.push("");
29
+ return L.join("\n");
30
+ }
31
+ for (const r of report.results) {
32
+ const f = r.finding;
33
+ const sev = SEV_TAG[f.severity](` ${f.severity.toUpperCase()} `);
34
+ L.push(` ${sev} ${pc.bold(f.title)} ${pc.dim(`(${f.file}:${f.line})`)}`);
35
+ if (r.explanation) {
36
+ L.push(` ${pc.cyan("AI:")} ${r.explanation.trim()}`);
37
+ }
38
+ else {
39
+ L.push(` ${pc.dim("why:")} ${f.why}`);
40
+ L.push(` ${pc.dim("fix:")} ${f.fix}`);
41
+ }
42
+ L.push("");
43
+ }
44
+ if (report.truncatedCount > 0) {
45
+ const total = report.results.length + report.truncatedCount;
46
+ L.push(pc.dim(` Showing the top ${report.results.length} of ${total} critical/high findings — re-run \`scan\` to see the rest.`));
47
+ L.push("");
48
+ }
49
+ if (!report.llmAvailable) {
50
+ L.push(pc.dim(" Set GROQ_API_KEY (free tier) or ANTHROPIC_API_KEY for AI-generated explanations — showing static guidance above. Run `securevibe config set-key groq`."));
51
+ L.push("");
52
+ }
53
+ return L.join("\n");
54
+ }
55
+ export function explainToJson(report) {
56
+ return JSON.stringify({
57
+ root: report.root,
58
+ llmAvailable: report.llmAvailable,
59
+ truncatedCount: report.truncatedCount,
60
+ results: report.results.map((r) => ({
61
+ id: r.finding.id,
62
+ detector: r.finding.detector,
63
+ severity: r.finding.severity,
64
+ file: r.finding.file,
65
+ line: r.finding.line,
66
+ title: r.finding.title,
67
+ why: r.finding.why,
68
+ fix: r.finding.fix,
69
+ explanation: r.explanation,
70
+ })),
71
+ }, null, 2);
72
+ }
@@ -0,0 +1,55 @@
1
+ import { SEVERITY_ORDER } from "../engine/types.js";
2
+ import { PR_COMMENT_MARKER } from "../engine/github.js";
3
+ const MAX_FINDINGS = 15;
4
+ const SEV_LABEL = {
5
+ critical: "CRITICAL",
6
+ high: "HIGH",
7
+ medium: "MEDIUM",
8
+ low: "LOW",
9
+ info: "INFO",
10
+ };
11
+ const READINESS_LABEL = {
12
+ ship: "✓ Ship",
13
+ warn: "⚠ Ship with warnings",
14
+ block: "✗ Block deploy",
15
+ };
16
+ function severityCounts(findings) {
17
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
18
+ for (const f of findings)
19
+ counts[f.severity]++;
20
+ return ["critical", "high", "medium", "low"]
21
+ .filter((s) => counts[s] > 0)
22
+ .map((s) => `**${counts[s]}** ${SEV_LABEL[s]}`)
23
+ .join(" · ");
24
+ }
25
+ export function renderPrComment(result) {
26
+ const L = [];
27
+ L.push(PR_COMMENT_MARKER);
28
+ L.push("## SecureVibe — changed-files scan");
29
+ L.push("");
30
+ L.push(`**Grade ${result.score.grade}** (${result.score.composite}/100) — ${READINESS_LABEL[result.score.readiness]}`);
31
+ L.push("");
32
+ if (result.findings.length === 0) {
33
+ L.push("No findings in the files changed by this PR. ✓");
34
+ return L.join("\n");
35
+ }
36
+ L.push(severityCounts(result.findings));
37
+ L.push("");
38
+ const sorted = [...result.findings].sort((a, b) => SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity] || b.confidence - a.confidence);
39
+ const shown = sorted.slice(0, MAX_FINDINGS);
40
+ const remaining = sorted.length - shown.length;
41
+ for (const f of shown) {
42
+ L.push(`### ${SEV_LABEL[f.severity]} — ${f.title}`);
43
+ L.push(`\`${f.file}:${f.line}\``);
44
+ L.push("");
45
+ L.push(`**why:** ${f.why}`);
46
+ L.push("");
47
+ L.push(`**fix:** ${f.fix}`);
48
+ L.push("");
49
+ }
50
+ if (remaining > 0) {
51
+ L.push(`_+${remaining} more finding${remaining === 1 ? "" : "s"} — run \`securevibe scan\` locally to see all._`);
52
+ L.push("");
53
+ }
54
+ return L.join("\n");
55
+ }
@@ -0,0 +1,130 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Box, Static, Text, useApp, useInput } from "ink";
3
+ import path from "node:path";
4
+ import { mapAnswer } from "../prompt.js";
5
+ import { processLine } from "../../repl.js";
6
+ import { makeCallbackWritable } from "./outputStream.js";
7
+ import { filterCommands } from "./commandRegistry.js";
8
+ import { StatusLine } from "./StatusLine.js";
9
+ import { InputBox } from "./InputBox.js";
10
+ import { SlashMenu } from "./SlashMenu.js";
11
+ import { Spinner } from "./Spinner.js";
12
+ const h = React.createElement;
13
+ // Static<T> is generic; createElement can't infer T from an untyped call the
14
+ // way JSX's <Static<string>> would. Pin T=string via a TS instantiation
15
+ // expression instead of casting the props object.
16
+ const StringStatic = (Static);
17
+ /**
18
+ * Top-level Ink app: <Static> scrollback (everything dispatch() writes,
19
+ * unchanged from the plain-text output the legacy readline loop already
20
+ * produces) above a live bottom region (status line, spinner, slash menu,
21
+ * input box). Every submitted line goes through the exact same
22
+ * processLine() the legacy readline loop and repl.test.ts already exercise
23
+ * (Task 1) -- this component only changes how a line is captured and how
24
+ * output is framed, never what a command does.
25
+ */
26
+ export function App({ initialPath }) {
27
+ const { exit } = useApp();
28
+ const stateRef = useRef({ root: path.resolve(initialPath) });
29
+ const [lines, setLines] = useState([]);
30
+ const [value, setValue] = useState("");
31
+ const [history, setHistory] = useState([]);
32
+ const [busy, setBusy] = useState(false);
33
+ const [progressMessage, setProgressMessage] = useState(null);
34
+ const [confirmKind, setConfirmKind] = useState(null);
35
+ const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
36
+ const confirmResolverRef = useRef(null);
37
+ const pushLine = (text) => setLines((prev) => [...prev, text]);
38
+ useEffect(() => {
39
+ (async () => {
40
+ const { applyStoredKeysToEnv } = await import("../../config.js");
41
+ await applyStoredKeysToEnv();
42
+ const { renderBanner } = await import("../banner.js");
43
+ pushLine(renderBanner({ subtitle: "interactive session — type `help` or `/`" }) + "\n");
44
+ pushLine(` target: ${stateRef.current.root}\n`);
45
+ })();
46
+ }, []);
47
+ const menuOpen = value.startsWith("/");
48
+ const filteredCommands = menuOpen ? filterCommands(value.slice(1)) : [];
49
+ const clampedIndex = Math.min(selectedMenuIndex, Math.max(0, filteredCommands.length - 1));
50
+ useInput((_input, key) => {
51
+ if (!menuOpen)
52
+ return;
53
+ if (key.upArrow)
54
+ setSelectedMenuIndex((i) => Math.max(0, i - 1));
55
+ if (key.downArrow)
56
+ setSelectedMenuIndex((i) => Math.min(filteredCommands.length - 1, i + 1));
57
+ if (key.escape)
58
+ setValue("");
59
+ });
60
+ const handleChange = (v) => {
61
+ setSelectedMenuIndex(0);
62
+ setValue(v);
63
+ };
64
+ const handleSubmit = async (line) => {
65
+ if (confirmResolverRef.current) {
66
+ const answer = mapAnswer(line, confirmKind ?? "patch");
67
+ confirmResolverRef.current(answer);
68
+ setValue("");
69
+ return;
70
+ }
71
+ // A command is already running (busy stays true for its whole duration,
72
+ // including while a confirm is pending -- but that case already returned
73
+ // above). Ignore a second real submission rather than racing two
74
+ // processLine() calls over the same mutable ReplState. The legacy
75
+ // readline loop can't hit this: only one askLine() is ever pending, so
76
+ // there's no way to submit a second line before the first resolves.
77
+ if (busy)
78
+ return;
79
+ if (menuOpen) {
80
+ const chosen = filteredCommands[clampedIndex];
81
+ setValue(chosen ? chosen.name + " " : "");
82
+ return;
83
+ }
84
+ if (!line.trim()) {
85
+ setValue("");
86
+ return;
87
+ }
88
+ setHistory((prev) => [...prev, line]);
89
+ setValue("");
90
+ setBusy(true);
91
+ setProgressMessage(null);
92
+ const io = {
93
+ input: process.stdin,
94
+ output: makeCallbackWritable(pushLine),
95
+ inkMode: true,
96
+ onProgress: (m) => setProgressMessage(m),
97
+ makeConfirm: (render) => {
98
+ return (req) => new Promise((resolve) => {
99
+ pushLine(render(req) + "\n");
100
+ setConfirmKind(req.kind);
101
+ confirmResolverRef.current = (answer) => {
102
+ setConfirmKind(null);
103
+ confirmResolverRef.current = null;
104
+ resolve(answer);
105
+ };
106
+ });
107
+ },
108
+ };
109
+ const shouldExit = await processLine(line, stateRef.current, io);
110
+ setBusy(false);
111
+ setProgressMessage(null);
112
+ if (shouldExit)
113
+ exit();
114
+ };
115
+ return h(React.Fragment, null, h(StringStatic, {
116
+ items: lines,
117
+ children: (line, i) => h(Text, { key: i }, line),
118
+ }), h(Box, { flexDirection: "column" }, h(StatusLine, { targetRoot: stateRef.current.root }), busy && progressMessage ? h(Spinner, { message: progressMessage }) : null, confirmKind
119
+ ? h(Text, { dimColor: true }, confirmKind === "patch"
120
+ ? " Apply this change? [y]es / [n]o / [a]ll / [q]uit"
121
+ : " Apply these supporting changes? [y]es / [n]o")
122
+ : null, menuOpen ? h(SlashMenu, { items: filteredCommands, selectedIndex: clampedIndex }) : null, h(InputBox, {
123
+ value,
124
+ onChange: handleChange,
125
+ onSubmit: handleSubmit,
126
+ history,
127
+ onHistoryNavigate: setValue,
128
+ menuOpen,
129
+ })));
130
+ }
@@ -0,0 +1,48 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ const h = React.createElement;
5
+ /**
6
+ * Bordered single-line input (`>` prompt, rounded border). Up/down arrow
7
+ * cycles `history`, but only when the slash menu isn't open -- App.ts's own
8
+ * useInput takes over up/down for menu-item selection in that state.
9
+ * TextInput itself only handles left/right cursor movement and
10
+ * backspace/delete, never up/down, so there's no conflict between this
11
+ * hook and TextInput's own internal input handling.
12
+ */
13
+ export function InputBox({ value, onChange, onSubmit, history, onHistoryNavigate, menuOpen }) {
14
+ const [historyIndex, setHistoryIndex] = useState(null);
15
+ useInput((_input, key) => {
16
+ if (menuOpen)
17
+ return;
18
+ if (key.upArrow) {
19
+ if (history.length === 0)
20
+ return;
21
+ const next = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
22
+ setHistoryIndex(next);
23
+ onHistoryNavigate(history[next]);
24
+ return;
25
+ }
26
+ if (key.downArrow) {
27
+ if (historyIndex === null)
28
+ return;
29
+ const next = historyIndex + 1;
30
+ if (next >= history.length) {
31
+ setHistoryIndex(null);
32
+ onHistoryNavigate("");
33
+ }
34
+ else {
35
+ setHistoryIndex(next);
36
+ onHistoryNavigate(history[next]);
37
+ }
38
+ }
39
+ });
40
+ return h(Box, { borderStyle: "round", paddingX: 1 }, h(Text, null, "> "), h(TextInput, {
41
+ value,
42
+ onChange: (v) => {
43
+ setHistoryIndex(null);
44
+ onChange(v);
45
+ },
46
+ onSubmit,
47
+ }));
48
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ const h = React.createElement;
4
+ /** Purely presentational -- App.ts owns navigation (up/down) and selection (Enter). */
5
+ export function SlashMenu({ items, selectedIndex }) {
6
+ if (items.length === 0) {
7
+ return h(Box, { paddingX: 1 }, h(Text, { dimColor: true }, "no matching commands"));
8
+ }
9
+ return h(Box, { flexDirection: "column", paddingX: 1 }, ...items.map((item, i) => h(Text, { key: item.name, color: i === selectedIndex ? "cyan" : undefined }, `${i === selectedIndex ? "› " : " "}/${item.name} ${item.args} — ${item.description}`)));
10
+ }