securevibe 0.1.10 → 0.1.13

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/repl.js CHANGED
@@ -40,28 +40,64 @@ 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
- " explain [--json] AI explanations for critical/high findings",
48
- " ai-audit | protect | attack-map | score | deps | ready [--json]",
49
- " init [--force] wire up the pre-commit guard + CI",
50
- " config show | set-key <groq|anthropic> | unset-key <groq|anthropic>",
51
- " db update | status manage the local OSV database",
52
- " cd <path> change the scan target",
53
- " pwd show the current scan target",
54
- " help this message",
55
- " exit | quit leave the session",
56
- ].join("\n");
57
- /** 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. */
58
46
  export async function runRepl(initialPath) {
59
47
  if (!isInteractive()) {
60
48
  process.stderr.write(" repl needs an interactive terminal.\n");
61
49
  process.exitCode = 64;
62
50
  return;
63
51
  }
64
- 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") && rest.length === 0)
74
+ return true;
75
+ if ((cmd === "help" || cmd === "?") && rest.length === 0) {
76
+ const { renderHelp } = await import("./ui/repl-tui/commandRegistry.js");
77
+ write(io, renderHelp() + "\n\n");
78
+ return false;
79
+ }
80
+ if (cmd === "pwd" && rest.length === 0) {
81
+ write(io, ` ${state.root}\n\n`);
82
+ return false;
83
+ }
84
+ if (cmd === "cd" && rest.length <= 1) {
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
+ state.lastResult = undefined; // stale results would ground chat in the wrong repo
91
+ state.chat?.reset();
92
+ write(io, ` target: ${state.root}\n\n`);
93
+ return false;
94
+ }
95
+ await dispatch(cmd, rest, state, io, trimmed);
96
+ }
97
+ catch (err) {
98
+ write(io, ` error: ${err?.message ?? err}\n\n`);
99
+ }
100
+ return false;
65
101
  }
66
102
  /** The actual session loop, over injectable streams — testable without a TTY. */
67
103
  export async function runReplSession(initialPath, io) {
@@ -73,49 +109,61 @@ export async function runReplSession(initialPath, io) {
73
109
  write(io, ` target: ${state.root}\n\n`);
74
110
  while (true) {
75
111
  const raw = await askLine(`securevibe (${path.basename(state.root)})> `, io.input, io.output);
76
- const line = raw.trim();
77
- if (!line)
78
- continue;
79
- const tokens = tokenize(line);
80
- const cmd = tokens[0];
81
- const rest = tokens.slice(1);
82
- try {
83
- if (cmd === "exit" || cmd === "quit" || cmd === "q")
84
- break;
85
- if (cmd === "help" || cmd === "?") {
86
- write(io, HELP + "\n\n");
87
- continue;
88
- }
89
- if (cmd === "pwd") {
90
- write(io, ` ${state.root}\n\n`);
91
- continue;
92
- }
93
- if (cmd === "cd") {
94
- if (!rest[0]) {
95
- write(io, " usage: cd <path>\n\n");
96
- continue;
97
- }
98
- state.root = path.resolve(state.root, rest[0]);
99
- write(io, ` target: ${state.root}\n\n`);
100
- continue;
101
- }
102
- await dispatch(cmd, rest, state, io);
103
- }
104
- catch (err) {
105
- write(io, ` error: ${err?.message ?? err}\n\n`);
106
- }
112
+ if (await processLine(raw, state, io))
113
+ break;
114
+ }
115
+ }
116
+ /** Progress callback for scan()/runFix()/explainFindings(): prefers io.onProgress
117
+ * (Ink path) over the default scrolling-line behavior (legacy readline path). */
118
+ function progressCallback(io, asJson) {
119
+ if (asJson)
120
+ return undefined;
121
+ return io.onProgress ?? ((m) => write(io, ` · ${m}\n`));
122
+ }
123
+ /**
124
+ * True when `rest` (the tokens after the command word) is a plausible
125
+ * argument list for `cmd`'s actual grammar -- known flags/subcommands only.
126
+ * A command WORD matching a real command but followed by tokens outside its
127
+ * grammar is natural language that happens to start with that word (e.g.
128
+ * "score seems too low, why?"), not an invocation -- dispatch() falls
129
+ * through to chat instead of running the command. Every dispatch-level name
130
+ * here must also appear in ui/repl-tui/commandRegistry.ts's COMMANDS list;
131
+ * repl-command-collision.test.ts cross-checks the two so they can't drift.
132
+ */
133
+ export function matchesCommandGrammar(cmd, rest) {
134
+ const jsonOnly = new Set(["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready", "explain"]);
135
+ if (jsonOnly.has(cmd))
136
+ return rest.every((t) => t === "--json");
137
+ if (cmd === "fix") {
138
+ const known = new Set(["--apply", "--yes", "--no-llm", "--json"]);
139
+ return rest.every((t) => known.has(t));
140
+ }
141
+ if (cmd === "init")
142
+ return rest.every((t) => t === "--force");
143
+ if (cmd === "config") {
144
+ if (rest.length === 0)
145
+ return true;
146
+ if (rest[0] === "show")
147
+ return rest.length === 1;
148
+ if (rest[0] === "set-key" || rest[0] === "unset-key")
149
+ return rest.length <= 2;
150
+ return false;
107
151
  }
152
+ if (cmd === "db")
153
+ return rest.length === 0 || (rest.length === 1 && (rest[0] === "update" || rest[0] === "status"));
154
+ return false;
108
155
  }
109
- async function dispatch(cmd, tokens, state, io) {
156
+ async function dispatch(cmd, tokens, state, io, rawLine) {
110
157
  const asJson = hasFlag(tokens, "--json");
111
- if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd)) {
158
+ if (["scan", "ai-audit", "protect", "attack-map", "score", "deps", "ready"].includes(cmd) && matchesCommandGrammar(cmd, tokens)) {
112
159
  const { scan } = await import("./engine/scan.js");
113
160
  const usage = await meterOrPrint(io);
114
161
  const result = await scan(state.root, {
115
162
  aiOnly: cmd === "ai-audit",
116
- onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
163
+ onProgress: progressCallback(io, asJson),
117
164
  });
118
165
  state.lastResult = result;
166
+ state.chat?.setScanResult(result);
119
167
  if (asJson) {
120
168
  const { toJson } = await import("./ui/emit.js");
121
169
  write(io, toJson(result) + "\n\n");
@@ -124,21 +172,34 @@ async function dispatch(cmd, tokens, state, io) {
124
172
  write(io, (await renderFor(cmd, result, usage)) + "\n");
125
173
  return;
126
174
  }
127
- if (cmd === "fix") {
175
+ if (cmd === "fix" && matchesCommandGrammar(cmd, tokens)) {
128
176
  const { runFix } = await import("./engine/fix/session.js");
129
177
  const { renderFixSession, fixToJson, renderApprovalRequest } = await import("./ui/fix.js");
130
178
  const opts = { apply: hasFlag(tokens, "--apply"), yes: hasFlag(tokens, "--yes"), json: asJson };
131
179
  const decision = decideApply(opts, true);
132
180
  if (decision.apply && !hasFlag(tokens, "--no-llm")) {
133
- const { maybeOfferAiFixerSetup } = await import("./index-support.js");
134
- await maybeOfferAiFixerSetup();
181
+ if (io.inkMode) {
182
+ const { noKeyNudgeMessage } = await import("./index-support.js");
183
+ const msg = noKeyNudgeMessage();
184
+ if (msg)
185
+ write(io, msg);
186
+ }
187
+ else {
188
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
189
+ await maybeOfferAiFixerSetup();
190
+ }
135
191
  }
136
192
  const usage = await meterOrPrint(io);
193
+ const confirmFn = decision.prompt
194
+ ? io.makeConfirm
195
+ ? io.makeConfirm(renderApprovalRequest)
196
+ : makeFixConfirm(renderApprovalRequest, io.input, io.output)
197
+ : undefined;
137
198
  const result = await runFix(state.root, {
138
199
  apply: decision.apply,
139
200
  noLlm: hasFlag(tokens, "--no-llm"),
140
- confirm: decision.prompt ? makeFixConfirm(renderApprovalRequest, io.input, io.output) : undefined,
141
- onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
201
+ confirm: confirmFn,
202
+ onProgress: progressCallback(io, asJson),
142
203
  });
143
204
  if (asJson) {
144
205
  write(io, fixToJson(result) + "\n\n");
@@ -150,18 +211,26 @@ async function dispatch(cmd, tokens, state, io) {
150
211
  }
151
212
  return;
152
213
  }
153
- if (cmd === "explain") {
214
+ if (cmd === "explain" && matchesCommandGrammar(cmd, tokens)) {
154
215
  if (!asJson) {
155
- const { maybeOfferAiFixerSetup } = await import("./index-support.js");
156
- await maybeOfferAiFixerSetup();
216
+ if (io.inkMode) {
217
+ const { noKeyNudgeMessage } = await import("./index-support.js");
218
+ const msg = noKeyNudgeMessage();
219
+ if (msg)
220
+ write(io, msg);
221
+ }
222
+ else {
223
+ const { maybeOfferAiFixerSetup } = await import("./index-support.js");
224
+ await maybeOfferAiFixerSetup();
225
+ }
157
226
  }
158
227
  const { explainFindings } = await import("./engine/explain.js");
159
228
  const usage = await meterOrPrint(io);
160
229
  const { scan } = await import("./engine/scan.js");
161
- const scanResult = await scan(state.root, { onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`) });
230
+ const scanResult = await scan(state.root, { onProgress: progressCallback(io, asJson) });
162
231
  state.lastResult = scanResult;
163
232
  const report = await explainFindings(scanResult, {
164
- onProgress: asJson ? undefined : (m) => write(io, ` · ${m}\n`),
233
+ onProgress: progressCallback(io, asJson),
165
234
  });
166
235
  if (asJson) {
167
236
  const { explainToJson } = await import("./ui/explain.js");
@@ -172,24 +241,88 @@ async function dispatch(cmd, tokens, state, io) {
172
241
  write(io, renderExplain(report, usage) + "\n");
173
242
  return;
174
243
  }
175
- if (cmd === "init") {
244
+ if (cmd === "init" && matchesCommandGrammar(cmd, tokens)) {
176
245
  const { runInit } = await import("./engine/init.js");
177
246
  const { renderInit } = await import("./ui/init.js");
178
247
  const result = await runInit(state.root, { force: hasFlag(tokens, "--force") });
179
248
  write(io, renderInit(result) + "\n");
180
- const { maybeOfferAiFixerSetupDuringInit } = await import("./index-support.js");
181
- await maybeOfferAiFixerSetupDuringInit(state.root);
249
+ const { maybeOfferAiFixerSetupDuringInit, noKeyNudgeMessage } = await import("./index-support.js");
250
+ await maybeOfferAiFixerSetupDuringInit(state.root, io.inkMode
251
+ ? {
252
+ writePreamble: (text) => write(io, text),
253
+ offer: async () => {
254
+ const msg = noKeyNudgeMessage();
255
+ if (msg)
256
+ write(io, msg);
257
+ },
258
+ }
259
+ : undefined);
182
260
  return;
183
261
  }
184
- if (cmd === "config") {
262
+ if (cmd === "config" && matchesCommandGrammar(cmd, tokens)) {
185
263
  await handleConfig(tokens, io);
186
264
  return;
187
265
  }
188
- if (cmd === "db") {
266
+ if (cmd === "db" && matchesCommandGrammar(cmd, tokens)) {
189
267
  await handleDb(tokens, io);
190
268
  return;
191
269
  }
192
- write(io, ` unknown command: ${cmd} (type \`help\`)\n\n`);
270
+ await handleChat(rawLine, state, io);
271
+ }
272
+ /** Two-space indent so answers sit inside the REPL's output gutter. */
273
+ function indentAnswer(text) {
274
+ return text
275
+ .split("\n")
276
+ .map((l) => " " + l)
277
+ .join("\n");
278
+ }
279
+ /**
280
+ * Free text = a question about the last scan (spec 2026-07-12). Order of
281
+ * gates: scan first (nothing to talk about), then key (consent would be
282
+ * pointless without one), then once-per-session consent, then the ask.
283
+ * NOT metered: no scan runs here and the call spends the user's own key,
284
+ * the same reasoning that exempts --staged/--diff.
285
+ */
286
+ async function handleChat(question, state, io) {
287
+ if (!state.lastResult) {
288
+ write(io, " Run `scan` first so I have findings to talk about. (Free text asks the AI about your last scan; type `help` for commands.)\n\n");
289
+ return;
290
+ }
291
+ const { llmAvailability } = await import("./engine/fix/llm.js");
292
+ const avail = llmAvailability(false);
293
+ if (!avail.available) {
294
+ write(io, " Chat needs an AI key. Run `securevibe config set-key groq` (outside the REPL) to enable it; `explain` and `fix` use the same key.\n\n");
295
+ return;
296
+ }
297
+ if (state.chatConsent === false) {
298
+ write(io, " Chat is off for this session (you declined earlier). Commands still work; type `help`.\n\n");
299
+ return;
300
+ }
301
+ if (state.chatConsent === undefined) {
302
+ const render = (req) => req.kind === "chat-consent"
303
+ ? ` First question this session: send your questions and the scan findings to ${req.provider}?`
304
+ : "";
305
+ const confirm = io.makeConfirm ? io.makeConfirm(render) : makeFixConfirm(render, io.input, io.output);
306
+ const answer = await confirm({ kind: "chat-consent", provider: avail.provider });
307
+ state.chatConsent = answer === "yes";
308
+ if (!state.chatConsent) {
309
+ write(io, " Understood, chat stays off for this session. Commands still work; type `help`.\n\n");
310
+ return;
311
+ }
312
+ }
313
+ if (!state.chat) {
314
+ const { ChatSession } = await import("./engine/chat.js");
315
+ state.chat = new ChatSession();
316
+ state.chat.setScanResult(state.lastResult);
317
+ }
318
+ const progress = progressCallback(io, false);
319
+ progress?.(`asking ${avail.provider}…`);
320
+ const answer = await state.chat.ask(question);
321
+ if (answer == null) {
322
+ write(io, " The AI call failed; nothing was answered. Try again, or use `explain` for per finding context.\n\n");
323
+ return;
324
+ }
325
+ write(io, indentAnswer(answer) + "\n\n");
193
326
  }
194
327
  async function renderFor(cmd, result, usage) {
195
328
  if (cmd === "scan" || cmd === "ai-audit") {
@@ -232,6 +365,10 @@ async function handleConfig(tokens, io) {
232
365
  return;
233
366
  }
234
367
  if (action === "set-key") {
368
+ if (io.inkMode) {
369
+ 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`);
370
+ return;
371
+ }
235
372
  const hint = provider === "groq" ? " (free — console.groq.com/keys)" : " (console.anthropic.com)";
236
373
  const key = (await askLine(` Paste your ${provider} API key${hint}: `, io.input, io.output)).trim();
237
374
  if (!key) {
package/dist/ui/fix.js CHANGED
@@ -131,7 +131,7 @@ export function renderApprovalRequest(req) {
131
131
  L.push(renderDiff(p.relPath, p.before, p.after));
132
132
  L.push(" " + pc.dim("verified: finding clears · no new issue · file still parses"));
133
133
  }
134
- else {
134
+ else if (req.kind === "side-effects") {
135
135
  L.push("");
136
136
  L.push(" " + pc.bold("Supporting changes") + pc.dim(" (additive, no break risk)"));
137
137
  for (const fx of req.effects) {
@@ -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
+ }
package/dist/ui/prompt.js CHANGED
@@ -26,10 +26,11 @@ export function decideApply(opts, interactive) {
26
26
  return { apply: false, prompt: false, notice: true };
27
27
  return { apply: true, prompt: true, notice: false };
28
28
  }
29
- /** Map a raw keypress/line to an answer. Empty / unknown → the safe default "no". */
29
+ /** Map a raw keypress/line to an answer. Empty / unknown → the safe default "no".
30
+ * Only "patch" offers all/quit; side-effects and chat-consent are yes/no. */
30
31
  export function mapAnswer(raw, kind) {
31
32
  const c = raw.trim().toLowerCase().charAt(0);
32
- if (kind === "side-effects")
33
+ if (kind !== "patch")
33
34
  return c === "y" ? "yes" : "no";
34
35
  if (c === "y")
35
36
  return "yes";
@@ -55,7 +56,9 @@ export function makeFixConfirm(render, input = process.stdin, output = process.s
55
56
  output.write(render(req) + "\n");
56
57
  const prompt = req.kind === "patch"
57
58
  ? " Apply this change? [y]es / [n]o / [a]ll / [q]uit: "
58
- : " Apply these supporting changes? [y]es / [n]o: ";
59
+ : req.kind === "side-effects"
60
+ ? " Apply these supporting changes? [y]es / [n]o: "
61
+ : " Send to your AI provider? [y]es / [n]o: ";
59
62
  const raw = await askLine(prompt, input, output);
60
63
  return mapAnswer(raw, req.kind);
61
64
  };
@@ -0,0 +1,132 @@
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
+ : confirmKind === "side-effects"
122
+ ? " Apply these supporting changes? [y]es / [n]o"
123
+ : " Send to your AI provider? [y]es / [n]o")
124
+ : null, menuOpen ? h(SlashMenu, { items: filteredCommands, selectedIndex: clampedIndex }) : null, h(InputBox, {
125
+ value,
126
+ onChange: handleChange,
127
+ onSubmit: handleSubmit,
128
+ history,
129
+ onHistoryNavigate: setValue,
130
+ menuOpen,
131
+ })));
132
+ }
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Box, Text } from "ink";
3
+ const h = React.createElement;
4
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
+ /**
6
+ * One live-updating line (braille spinner + current step's message),
7
+ * replacing the old scrolling "· message" lines during scan/fix/explain.
8
+ * Hand-rolled rather than a dependency -- ten frames and a setInterval is
9
+ * simple enough not to justify another package.
10
+ */
11
+ export function Spinner({ message }) {
12
+ const [frame, setFrame] = useState(0);
13
+ useEffect(() => {
14
+ const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
15
+ return () => clearInterval(id);
16
+ }, []);
17
+ return h(Box, null, h(Text, { color: "cyan" }, FRAMES[frame]), h(Text, null, ` ${message}`));
18
+ }