@gr8ful/spf 0.7.0 → 0.8.1

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.
Files changed (39) hide show
  1. package/dist/chains/context.d.ts +26 -0
  2. package/dist/chains/simple_sdlc.js +9 -0
  3. package/dist/chains/steps.d.ts +17 -0
  4. package/dist/chains/steps.js +24 -2
  5. package/dist/cli/ask.d.ts +13 -0
  6. package/dist/cli/ask.js +15 -1
  7. package/dist/cli/commands/doctor.js +43 -7
  8. package/dist/cli/commands/fanout.js +49 -5
  9. package/dist/cli/commands/init.js +7 -2
  10. package/dist/cli/commands/list.d.ts +1 -1
  11. package/dist/cli/commands/list.js +31 -12
  12. package/dist/cli/commands/phases.d.ts +1 -1
  13. package/dist/cli/commands/phases.js +18 -4
  14. package/dist/cli/commands/run.js +21 -0
  15. package/dist/cli/commands/sessions.d.ts +1 -1
  16. package/dist/cli/commands/sessions.js +11 -3
  17. package/dist/cli/commands/watch.js +50 -6
  18. package/dist/cli/index.js +3 -3
  19. package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
  20. package/dist/cli/ui/fanout_dashboard.js +102 -0
  21. package/dist/cli/ui/ink_asker.d.ts +13 -0
  22. package/dist/cli/ui/ink_asker.js +247 -0
  23. package/dist/cli/ui/reports.d.ts +30 -0
  24. package/dist/cli/ui/reports.js +61 -0
  25. package/dist/cli/ui/run_dashboard.d.ts +15 -0
  26. package/dist/cli/ui/run_dashboard.js +131 -0
  27. package/dist/cli/ui/watch_dashboard.d.ts +22 -0
  28. package/dist/cli/ui/watch_dashboard.js +78 -0
  29. package/dist/core/console.d.ts +40 -1
  30. package/dist/core/console.js +25 -3
  31. package/dist/core/fanout.d.ts +9 -0
  32. package/dist/core/fanout.js +6 -2
  33. package/dist/core/issues/github_provider.js +16 -1
  34. package/dist/core/issues/jira_provider.js +13 -1
  35. package/dist/core/runner.d.ts +5 -1
  36. package/dist/core/runner.js +2 -1
  37. package/dist/core/session.d.ts +7 -1
  38. package/dist/core/session.js +5 -1
  39. package/package.json +6 -1
@@ -22,6 +22,7 @@ import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../..
22
22
  import { ReviewOutput } from "../../core/data_types.js";
23
23
  import { SfDb } from "../../ui/server/db.js";
24
24
  import { parseCli } from "../../core/utils.js";
25
+ import { isInteractive } from "../ask.js";
25
26
  /** Kept well under Slack's own 2900-char slice on `detail` (see `slack_channel.ts`) — a reviewer can emit a lot of findings, but the PR body/notification only needs enough to tell a human whether to look closer. */
26
27
  const MAX_REVIEW_DIGEST_CHARS = 1200;
27
28
  /**
@@ -456,6 +457,18 @@ export async function watchCommand(argv) {
456
457
  catch {
457
458
  // best-effort, same as above — no questions file means this run wasn't an escalation
458
459
  }
460
+ // Defense in depth: `steps.publishIssues()` now clears whichever of
461
+ // these two files it's NOT about to write, so both should never be
462
+ // non-empty here — but if some future change (or an older worktree's
463
+ // leftover files, before that fix existed) ever produces both, a real
464
+ // completed publish must never be silently overridden by a stale
465
+ // question. `runSpec` checks `questions.length > 0` first, so without
466
+ // this it would re-escalate over issues that already landed on the
467
+ // tracker seconds earlier.
468
+ if (created.length > 0 && questions.length > 0) {
469
+ console.error(`watch: ${opts.adwId}: refine_publish.json AND refine_questions.json both had content — treating the ${created.length} published issue(s) as authoritative and discarding the stale question(s)`);
470
+ questions = [];
471
+ }
459
472
  return { accepted: true, adwId: opts.adwId, detail: "", created, questions };
460
473
  };
461
474
  // `IssueAuthoringProvider`'s read-back half — `isAuthoringProvider()` is a
@@ -469,6 +482,23 @@ export async function watchCommand(argv) {
469
482
  // Not a second `resolveAuthoringProvider()` call: that helper's own config
470
483
  // validation already ran to produce `provider` itself.
471
484
  const authoringProvider = isAuthoringProvider(provider) ? provider : null;
485
+ // Only for the one dispatch a human is actually watching a terminal for —
486
+ // `core/watch.ts` itself is untouched; this only ever swaps `deps.log`
487
+ // below and reads `state.inflight`/`state.refining` sizes after each
488
+ // `tick()`, exactly the seam the plan called for.
489
+ let dashboard;
490
+ if (isInteractive()) {
491
+ const { mountWatchDashboard } = await import("../ui/watch_dashboard.js");
492
+ dashboard = mountWatchDashboard({
493
+ repo: `${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo}`,
494
+ labelPrefix: cfg.watch.label_prefix,
495
+ chain: cfg.watch.chain,
496
+ concurrency: cfg.watch.concurrency,
497
+ refineChain: cfg.watch.refine.enabled ? cfg.watch.refine.chain : undefined,
498
+ refineConcurrency: cfg.watch.refine.enabled ? cfg.watch.refine.concurrency : undefined,
499
+ dryRun: Boolean(flags["dry-run"]),
500
+ });
501
+ }
472
502
  const deps = {
473
503
  provider,
474
504
  codeHost,
@@ -488,8 +518,11 @@ export async function watchCommand(argv) {
488
518
  dryRun: Boolean(flags["dry-run"]),
489
519
  runChain,
490
520
  listChildren: authoringProvider ? (parent) => authoringProvider.listChildren(parent) : undefined,
491
- log: (message) => console.log(message),
492
- notify: (event) => notifier?.send(event),
521
+ log: (message) => (dashboard ? dashboard.log(message) : console.log(message)),
522
+ notify: (event) => {
523
+ notifier?.send(event); // unaffected either way — see mountWatchDashboard's own doc comment
524
+ dashboard?.mirrorNotify(event);
525
+ },
493
526
  };
494
527
  const state = createWatchState();
495
528
  let stopping = false;
@@ -513,6 +546,7 @@ export async function watchCommand(argv) {
513
546
  interrupt?.();
514
547
  sigints++;
515
548
  if (sigints >= 2) {
549
+ dashboard?.unmountNow(); // best-effort cursor-visibility restore before the abrupt exit — see WatchDashboard.unmountNow's own comment
516
550
  console.error("\n[spf] watch second interrupt — exiting immediately, without draining");
517
551
  releaseLock(lockPath);
518
552
  process.exit(130);
@@ -523,9 +557,15 @@ export async function watchCommand(argv) {
523
557
  };
524
558
  process.on("SIGINT", stop);
525
559
  process.on("SIGTERM", stop);
526
- console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}` +
527
- (cfg.watch.refine.enabled ? ` refine "${cfg.watch.refine.chain}" concurrency ${cfg.watch.refine.concurrency}` : "") +
528
- (flags["dry-run"] ? " (dry run)" : ""));
560
+ // The dashboard already renders this exact information as its own live
561
+ // header (repo/label/chain/concurrency/dry-run) printing it again here
562
+ // would both duplicate it and interleave a raw console.log with an
563
+ // active Ink region.
564
+ if (!dashboard) {
565
+ console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}` +
566
+ (cfg.watch.refine.enabled ? ` refine "${cfg.watch.refine.chain}" concurrency ${cfg.watch.refine.concurrency}` : "") +
567
+ (flags["dry-run"] ? " (dry run)" : ""));
568
+ }
529
569
  deps.notify({
530
570
  kind: "watch_started",
531
571
  level: "info",
@@ -538,15 +578,18 @@ export async function watchCommand(argv) {
538
578
  });
539
579
  try {
540
580
  for (;;) {
581
+ dashboard?.setNextPollAt(null); // clears any stale countdown while a tick is actually running
541
582
  await tick(deps, state);
583
+ dashboard?.setCounts(state.inflight.size, state.refining.size);
542
584
  if (flags["once"] || stopping)
543
585
  break;
586
+ dashboard?.setNextPollAt(Date.now() + cfg.watch.poll_ms);
544
587
  await interruptibleSleep(cfg.watch.poll_ms);
545
588
  if (stopping)
546
589
  break;
547
590
  }
548
591
  while (state.inflight.size > 0) {
549
- console.log(`[spf] watch draining ${state.inflight.size} in-flight issue(s)...`);
592
+ deps.log(`[spf] watch draining ${state.inflight.size} in-flight issue(s)...`); // deps.log already routes to the dashboard when one is mounted, console.log otherwise
550
593
  await interruptibleSleep(1000);
551
594
  if (stopping && sigints >= 2)
552
595
  break; // stop() itself already exits on the 2nd signal; this is belt-and-suspenders
@@ -559,5 +602,6 @@ export async function watchCommand(argv) {
559
602
  process.off("SIGINT", stop);
560
603
  process.off("SIGTERM", stop);
561
604
  releaseLock(lockPath);
605
+ await dashboard?.close();
562
606
  }
563
607
  }
package/dist/cli/index.js CHANGED
@@ -151,7 +151,7 @@ export async function main() {
151
151
  process.exitCode = await estimateCommand(rest);
152
152
  return;
153
153
  case "list":
154
- process.exitCode = listCommand();
154
+ process.exitCode = await listCommand();
155
155
  return;
156
156
  case "init":
157
157
  process.exitCode = await initCommand(rest);
@@ -177,10 +177,10 @@ export async function main() {
177
177
  return;
178
178
  }
179
179
  case "sessions":
180
- process.exitCode = sessionsCommand(rest);
180
+ process.exitCode = await sessionsCommand(rest);
181
181
  return;
182
182
  case "phases":
183
- process.exitCode = phasesCommand(rest);
183
+ process.exitCode = await phasesCommand(rest);
184
184
  return;
185
185
  case "events":
186
186
  process.exitCode = await eventsCommand(rest);
@@ -0,0 +1,22 @@
1
+ import { type FanoutAttempt } from "../../core/fanout.ts";
2
+ export interface FanoutDashboard {
3
+ onAttempt: (attempt: FanoutAttempt) => void;
4
+ /** Routes `FanoutDeps.log()` here instead of `console.log` while this is mounted — see the module header for why a raw console write can't share the terminal with an active Ink instance. */
5
+ log: (message: string) => void;
6
+ /** Tears the Ink instance down. Call exactly once, after `runBestOf()` settles — the plain-text winner/basis lines after it are unaffected; only the redundant header + final table are skipped when a dashboard was used (see `cli/commands/fanout.ts`'s call site). */
7
+ close(): Promise<void>;
8
+ /**
9
+ * Synchronous, no flush-wait — for a signal handler, which stays
10
+ * synchronous by design and gets cut off by `process.exit()` immediately
11
+ * after: an `await` here would never get a chance to resume. Only
12
+ * restores terminal state (cursor visibility) on a best-effort basis; the
13
+ * final frame may not have caught up to the last attempt yet.
14
+ */
15
+ unmountNow(): void;
16
+ }
17
+ export declare function mountFanoutDashboard(opts: {
18
+ n: number;
19
+ chainName: string;
20
+ baseBranch: string;
21
+ baseAdwId: string;
22
+ }): FanoutDashboard;
@@ -0,0 +1,102 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A live results table for `spf fanout` — the same columns
4
+ * `cli/commands/fanout.ts`'s `printTable()` prints once at the end, updated
5
+ * row by row as each attempt settles instead. Reached only through a
6
+ * dynamic `import()` and only when `isInteractive()`; non-TTY keeps the
7
+ * exact one-shot `printTable()` path, byte for byte (`fanout_cli.test.ts`
8
+ * runs with no TTY, so it never touches this file).
9
+ *
10
+ * One persistent Ink instance for the whole `runBestOf()` call — same shape
11
+ * as `run_dashboard.tsx`: a `<Static>` history for `deps.log()`'s own
12
+ * lines (the "chain X x3 off main" opener, an "attempt N skipped" line —
13
+ * `cli/commands/fanout.ts` routes these here instead of straight to
14
+ * `console.log` specifically so nothing writes to stdout outside Ink while
15
+ * this is mounted; interleaving a raw `console.log` with an active Ink
16
+ * live region corrupts the redraw) plus a LIVE section below it — here,
17
+ * the whole results table, not one line, since every row can still change
18
+ * until the very last attempt settles and there's no "this phase is done"
19
+ * moment to freeze one into history the way `run_dashboard.tsx` does per
20
+ * phase. Nothing here calls `useInput`, so raw mode never engages.
21
+ */
22
+ import { useState, useRef } from "react";
23
+ import { render, Static, Box, Text } from "ink";
24
+ import * as agents from "../../core/agents.js";
25
+ import { attemptAdwId, attemptBranch } from "../../core/fanout.js";
26
+ function statusLabel(row) {
27
+ if (!row)
28
+ return "running…";
29
+ return row.status;
30
+ }
31
+ function gatesLabel(row) {
32
+ if (!row || row.status === "skipped")
33
+ return "-";
34
+ return `${row.gate_passes}p/${row.gate_failures}f`;
35
+ }
36
+ function timeLabel(row) {
37
+ if (!row || row.status === "skipped")
38
+ return "-";
39
+ const seconds = row.wall_ms / 1000;
40
+ return seconds < 90 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${String(Math.round(seconds % 60)).padStart(2, "0")}s`;
41
+ }
42
+ function rowColor(row) {
43
+ if (!row)
44
+ return "dim";
45
+ if (row.status === "success")
46
+ return "green";
47
+ if (row.status === "fail" || row.status === "error")
48
+ return "red";
49
+ return undefined;
50
+ }
51
+ function FanoutRoot(props) {
52
+ const [rows, setRows] = useState(new Map());
53
+ const [log, setLog] = useState([]);
54
+ const keyRef = useRef(0); // a ref, not state — see ink_asker.tsx's identical comment on why two pushes in one tick must not collide
55
+ props.handleRef.current = {
56
+ setAttempt(attempt) {
57
+ setRows((prev) => {
58
+ const next = new Map(prev);
59
+ next.set(attempt.index, attempt);
60
+ return next;
61
+ });
62
+ },
63
+ pushLog(text) {
64
+ const key = keyRef.current++;
65
+ setLog((prev) => [...prev, { key, text }]);
66
+ },
67
+ };
68
+ const indices = Array.from({ length: props.n }, (_, i) => i + 1);
69
+ const widths = {
70
+ adw: Math.max(6, ...indices.map((i) => attemptAdwId(props.baseAdwId, i).length)),
71
+ branch: Math.max(6, ...indices.map((i) => attemptBranch(props.baseAdwId, i).length)),
72
+ };
73
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: log, children: (line) => _jsx(Text, { children: line.text }, line.key) }), _jsxs(Text, { children: ["fanout ", props.chainName, " \u2014 ", props.n, " attempt(s), base ", props.baseBranch] }), _jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(Text, { dimColor: true, children: "#" }) }), _jsx(Box, { width: widths.adw, marginRight: 2, children: _jsx(Text, { dimColor: true, children: "adw_id" }) }), _jsx(Box, { width: widths.branch, marginRight: 2, children: _jsx(Text, { dimColor: true, children: "branch" }) }), _jsx(Box, { width: 9, children: _jsx(Text, { dimColor: true, children: "status" }) }), _jsx(Box, { width: 9, children: _jsx(Text, { dimColor: true, children: "gates" }) }), _jsx(Box, { width: 10, children: _jsx(Text, { dimColor: true, children: "cost" }) }), _jsx(Text, { dimColor: true, children: "time" })] }), indices.map((i) => {
74
+ const row = rows.get(i) ?? null;
75
+ const color = rowColor(row);
76
+ return (_jsxs(Box, { children: [_jsx(Box, { width: 3, children: _jsx(Text, { color: color, children: i }) }), _jsx(Box, { width: widths.adw, marginRight: 2, children: _jsx(Text, { color: color, children: attemptAdwId(props.baseAdwId, i) }) }), _jsx(Box, { width: widths.branch, marginRight: 2, children: _jsx(Text, { color: color, children: attemptBranch(props.baseAdwId, i) }) }), _jsx(Box, { width: 9, children: _jsx(Text, { color: color, children: statusLabel(row) }) }), _jsx(Box, { width: 9, children: _jsx(Text, { color: color, children: gatesLabel(row) }) }), _jsx(Box, { width: 10, children: _jsx(Text, { color: color, children: row ? agents.formatUsd(row.cost) : "-" }) }), _jsx(Text, { color: color, children: timeLabel(row) })] }, i));
77
+ })] }));
78
+ }
79
+ export function mountFanoutDashboard(opts) {
80
+ const handleRef = { current: null };
81
+ const app = render(_jsx(FanoutRoot, { n: opts.n, chainName: opts.chainName, baseBranch: opts.baseBranch, baseAdwId: opts.baseAdwId, handleRef: handleRef }),
82
+ // `interactive: true` overrides Ink's own CI auto-detection — see
83
+ // `ink_asker.tsx`'s identical `render()` call for why: the caller
84
+ // (`commands/fanout.ts`) only reaches this file after its own
85
+ // `isInteractive()` check has already passed.
86
+ { patchConsole: false, interactive: true });
87
+ return {
88
+ onAttempt(attempt) {
89
+ handleRef.current?.setAttempt(attempt);
90
+ },
91
+ log(message) {
92
+ handleRef.current?.pushLog(message);
93
+ },
94
+ async close() {
95
+ await app.waitUntilRenderFlush();
96
+ app.unmount();
97
+ },
98
+ unmountNow() {
99
+ app.unmount();
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,13 @@
1
+ import type { Asker } from "../ask.ts";
2
+ /**
3
+ * The real `stdin`/`stdout` pair the interview reads/writes — defaults to
4
+ * the process's own. `src/test/ink_asker.test.ts` passes a fake TTY pair
5
+ * instead (`node --test` has neither a real terminal nor raw-mode
6
+ * support), the same seam Ink's own render options already expose; this
7
+ * is not new surface, just threaded through so a test can reach it.
8
+ */
9
+ export interface RenderStreams {
10
+ stdin?: NodeJS.ReadStream;
11
+ stdout?: NodeJS.WriteStream;
12
+ }
13
+ export declare function createInkAsker(streams?: RenderStreams): Asker;
@@ -0,0 +1,247 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Ink-backed implementation of the `Asker` interface (`cli/ask.ts`) — a
4
+ * drop-in replacement for `createAsker()`'s readline prompts, used only
5
+ * when `inkAvailable()` says raw-mode stdin is there to drive it.
6
+ * `runInterview()` (`cli/interview.ts`) never sees this file: it only ever
7
+ * imports the `Asker` type, so it is byte-for-byte unchanged by this
8
+ * module existing, and every `createFakeAsker`-driven test still exercises
9
+ * it exactly as before.
10
+ *
11
+ * This file is reached only through a dynamic `import()` (see
12
+ * `commands/init.ts`) so the ~150-300ms cost of loading `ink`+`react` is
13
+ * never paid on a non-interactive run.
14
+ *
15
+ * ONE Ink instance is mounted lazily on the first prompt and kept alive for
16
+ * the whole interview, torn down only in `close()`. An earlier version
17
+ * mounted a fresh instance per question instead — that toggled raw mode
18
+ * off (on unmount) and back on (on the next mount) between every single
19
+ * question, and empirically, over a real pty, a keystroke landing in that
20
+ * gap got echoed to the screen instead of consumed, and could be lost or
21
+ * misrouted entirely (arrow-key-then-Enter on the second question the
22
+ * default readline-vs-Ink smoke test tried was the one that caught it —
23
+ * the model select accepted the arrow move but the following Enter never
24
+ * registered). Keeping raw mode continuously enabled for the interview's
25
+ * entire lifetime removes the gap outright, and is also just the correct
26
+ * Ink pattern for a multi-step wizard: finished questions accumulate in an
27
+ * `<Static>` list (rendered once, never touched again — exactly the
28
+ * "leaves the final frame in scrollback" transcript this asker owes
29
+ * `runInterview()`) while the current question lives in one live slot
30
+ * below it, both children of the SAME root, updated by `rerender()`.
31
+ *
32
+ * `exitOnCtrlC: false`: `spf watch` and `core/session.ts` own SIGINT/
33
+ * SIGTERM handling, and Ink's default Ctrl-C behavior would call
34
+ * `process.exit()` out from under both. Ctrl-C (and Ctrl-D, treated the
35
+ * same way the readline asker treats EOF) is instead caught once, at the
36
+ * root, and turned into the same `InterviewAborted` the readline asker
37
+ * throws — `initCommand` catches it and exits 130, writing nothing.
38
+ */
39
+ import { useRef, useState } from "react";
40
+ import { render, Static, Box, Text, useInput } from "ink";
41
+ import { ConfirmInput, PasswordInput, TextInput } from "@inkjs/ui";
42
+ import { InterviewAborted, maskForPrompt } from "../ask.js";
43
+ import { paint } from "../../core/console.js";
44
+ function InterviewRoot(props) {
45
+ const [history, setHistory] = useState([]);
46
+ const [prompt, setPrompt] = useState(null);
47
+ // A ref, not `useState` — two `pushHistory` calls can happen back to back
48
+ // synchronously (e.g. `heading()` immediately followed by `note()`, exactly
49
+ // how `runInterview()` opens each section) with no re-render in between to
50
+ // flush a `nextKey` state update, so both would read the same stale value
51
+ // and mint duplicate keys. A ref's mutation is immediate or it wouldn't be
52
+ // safe as a counter at all.
53
+ const keyRef = useRef(0);
54
+ props.handleRef.current = {
55
+ pushHistory(node) {
56
+ const key = keyRef.current++;
57
+ setHistory((h) => [...h, { key, node }]);
58
+ },
59
+ setPrompt,
60
+ };
61
+ useInput((input, key) => {
62
+ if (key.ctrl && (input === "c" || input === "d"))
63
+ props.onAbort();
64
+ });
65
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (line) => _jsx(Box, { children: line.node }, line.key) }), prompt] }));
66
+ }
67
+ function TextPromptView(props) {
68
+ const [error, setError] = useState(null);
69
+ // `TextInput` is uncontrolled — `defaultValue` only seeds its FIRST value,
70
+ // so a rejected submit otherwise leaves the rejected text sitting in the
71
+ // box and whatever's typed next appends onto it instead of replacing it.
72
+ // Changing `key` forces React to unmount and remount a fresh instance
73
+ // (starting empty again) on every rejection, which is the standard way
74
+ // to "reset" an uncontrolled component.
75
+ const [attempt, setAttempt] = useState(0);
76
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: props.label }), props.defaultValue ? _jsxs(Text, { dimColor: true, children: [" [", props.defaultValue, "]"] }) : null, _jsx(Text, { children: ": " }), _jsx(TextInput, { defaultValue: props.defaultValue, onSubmit: (value) => {
77
+ const resolved = value || props.defaultValue || "";
78
+ const problem = props.validate?.(resolved) ?? null;
79
+ if (problem) {
80
+ setError(problem);
81
+ setAttempt((n) => n + 1);
82
+ return;
83
+ }
84
+ props.onSubmit(resolved);
85
+ } }, attempt)] }), error ? _jsxs(Text, { color: "red", children: [" ", error] }) : null] }));
86
+ }
87
+ /**
88
+ * Hand-rolled instead of `@inkjs/ui`'s `Select` — that component's
89
+ * `onChange` only fires when its internal `value` differs from
90
+ * `previousValue` (`use-select-state.js`'s reducer + effect), and accepting
91
+ * the already-highlighted default via an immediate Enter, with no arrow
92
+ * key pressed first, never changes that value. That's the single most
93
+ * common interaction with any select prompt (take the default), so the
94
+ * bug isn't an edge case: every `select()` call whose first keypress is
95
+ * Enter would hang forever. Confirmed empirically before writing this —
96
+ * the same prompt resolves instantly the moment an arrow key precedes
97
+ * Enter. `ConfirmInput`/`TextInput`/`PasswordInput` call their callbacks
98
+ * unconditionally from their own input handlers and don't share this bug,
99
+ * so they're untouched.
100
+ */
101
+ function SelectPromptView(props) {
102
+ const dfltIndex = props.choices.findIndex((c) => c.value === props.dflt);
103
+ const [index, setIndex] = useState(dfltIndex === -1 ? 0 : dfltIndex);
104
+ useInput((_input, key) => {
105
+ if (key.downArrow)
106
+ setIndex((i) => Math.min(props.choices.length - 1, i + 1));
107
+ if (key.upArrow)
108
+ setIndex((i) => Math.max(0, i - 1));
109
+ if (key.return)
110
+ props.onSubmit(props.choices[index].value);
111
+ });
112
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: props.label }), props.choices.map((c, i) => {
113
+ const focused = i === index;
114
+ const hint = c.hint ? paint("dim", ` — ${c.hint}`) : "";
115
+ return (_jsxs(Text, { color: focused ? "cyan" : undefined, children: [focused ? "❯ " : " ", c.label ?? c.value, hint] }, c.value));
116
+ })] }));
117
+ }
118
+ function ConfirmPromptView(props) {
119
+ return (_jsxs(Box, { children: [_jsxs(Text, { children: [props.label, " "] }), _jsx(ConfirmInput, { defaultChoice: props.dflt ? "confirm" : "cancel", onConfirm: () => props.onSubmit(true), onCancel: () => props.onSubmit(false) })] }));
120
+ }
121
+ function SecretPromptView(props) {
122
+ return (_jsxs(Box, { children: [_jsx(Text, { children: props.label }), props.current ? _jsxs(Text, { dimColor: true, children: [" [keep current: ", maskForPrompt(props.current), "]"] }) : null, _jsx(Text, { children: ": " }), _jsx(PasswordInput, { onSubmit: props.onSubmit })] }));
123
+ }
124
+ export function createInkAsker(streams = {}) {
125
+ // Mirrors `createAsker()`'s own `aborted` flag: once a Ctrl-C/Ctrl-D fires,
126
+ // every later call throws immediately instead of showing a prompt that
127
+ // would just get torn down again.
128
+ let aborted = false;
129
+ // Whichever prompt is currently live — `InterviewRoot`'s single Ctrl-C/
130
+ // Ctrl-D handler rejects THIS, whatever it is, since only one prompt is
131
+ // ever showing at a time.
132
+ let currentReject = null;
133
+ let app;
134
+ const handleRef = { current: null };
135
+ function handle() {
136
+ if (!app) {
137
+ app = render(_jsx(InterviewRoot, { handleRef: handleRef, onAbort: () => {
138
+ aborted = true;
139
+ currentReject?.(new InterviewAborted());
140
+ } }),
141
+ // `interactive: true` overrides Ink's own auto-detection
142
+ // (`stdout.isTTY` + the `is-in-ci` package) rather than relying on
143
+ // it: the caller here is `commands/init.ts`, which only ever
144
+ // reaches this file after `inkAvailable()` has ALREADY confirmed a
145
+ // real interactive terminal — that check is the one source of
146
+ // truth, and Ink's own CI detection is redundant at best. At
147
+ // worst it actively lies: GitHub Actions sets `CI=true`
148
+ // unconditionally, and `node --test` running there is exactly
149
+ // where this file's own test suite (`ink_asker.test.ts`) drives a
150
+ // real Ink instance against a fake TTY that reports `isTTY: true`
151
+ // — Ink's non-interactive mode then writes NOTHING incrementally
152
+ // (only the final frame, at unmount), so `stdout.frames` never
153
+ // grows and every keystroke-driven test hangs until its own
154
+ // timeout. Confirmed by reproducing the exact CI failure locally
155
+ // with `CI=1 node --test ...` before this fix, and confirming it
156
+ // disappears after.
157
+ { ...streams, exitOnCtrlC: false, patchConsole: false, interactive: true });
158
+ }
159
+ // `InterviewRoot` populates this synchronously during its first render,
160
+ // which `render()` above has already forced to happen by the time it
161
+ // returns.
162
+ return handleRef.current;
163
+ }
164
+ async function guarded(run) {
165
+ if (aborted)
166
+ throw new InterviewAborted();
167
+ try {
168
+ return await run(handle());
169
+ }
170
+ catch (error) {
171
+ if (error instanceof InterviewAborted)
172
+ aborted = true;
173
+ throw error;
174
+ }
175
+ finally {
176
+ currentReject = null;
177
+ }
178
+ }
179
+ return {
180
+ text(label, opts) {
181
+ return guarded((h) => new Promise((resolve, reject) => {
182
+ currentReject = reject;
183
+ h.setPrompt(_jsx(TextPromptView, { label: label, defaultValue: opts?.default, validate: opts?.validate, onSubmit: (value) => {
184
+ h.setPrompt(null);
185
+ h.pushHistory(_jsxs(Text, { children: [label, ": ", value] }));
186
+ resolve(value);
187
+ } }));
188
+ }));
189
+ },
190
+ select(label, choices, dflt) {
191
+ return guarded((h) => new Promise((resolve, reject) => {
192
+ currentReject = reject;
193
+ h.setPrompt(_jsx(SelectPromptView, { label: label, choices: choices, dflt: dflt, onSubmit: (value) => {
194
+ const chosen = choices.find((c) => c.value === value);
195
+ h.setPrompt(null);
196
+ h.pushHistory(_jsxs(Text, { children: [label, " ", _jsxs(Text, { color: "cyan", children: ["\u276F ", chosen?.label ?? value] })] }));
197
+ resolve(value);
198
+ } }));
199
+ }));
200
+ },
201
+ confirm(label, dflt, opts) {
202
+ return guarded((h) => new Promise((resolve, reject) => {
203
+ let settled = false;
204
+ let timer;
205
+ currentReject = reject;
206
+ const finish = (value, note) => {
207
+ if (settled)
208
+ return;
209
+ settled = true;
210
+ if (timer)
211
+ clearTimeout(timer);
212
+ h.setPrompt(null);
213
+ h.pushHistory(_jsxs(Text, { children: [label, " ", value ? "yes" : "no", note ? _jsxs(Text, { color: "yellow", children: [" (", note, ")"] }) : null] }));
214
+ resolve(value);
215
+ };
216
+ if (opts?.timeoutMs !== undefined) {
217
+ timer = setTimeout(() => finish(dflt, `timed out — using default (${dflt ? "yes" : "no"})`), opts.timeoutMs);
218
+ timer.unref?.();
219
+ }
220
+ h.setPrompt(_jsx(ConfirmPromptView, { label: label, dflt: dflt, onSubmit: (value) => finish(value) }));
221
+ }));
222
+ },
223
+ secret(label, opts) {
224
+ return guarded((h) => new Promise((resolve, reject) => {
225
+ currentReject = reject;
226
+ h.setPrompt(_jsx(SecretPromptView, { label: label, current: opts?.current, onSubmit: (value) => {
227
+ h.setPrompt(null);
228
+ h.pushHistory(_jsxs(Text, { children: [label, ": ", value ? maskForPrompt(value) : "(unchanged)"] }));
229
+ resolve(value);
230
+ } }));
231
+ }));
232
+ },
233
+ note(text) {
234
+ if (aborted)
235
+ return;
236
+ handle().pushHistory(_jsxs(Text, { dimColor: true, children: [" ", text] }));
237
+ },
238
+ heading(text) {
239
+ if (aborted)
240
+ return;
241
+ handle().pushHistory(_jsxs(Text, { bold: true, color: "cyan", children: ["\u2500\u2500 ", text, " \u2500\u2500"] }));
242
+ },
243
+ close() {
244
+ app?.unmount();
245
+ },
246
+ };
247
+ }
@@ -0,0 +1,30 @@
1
+ export type CheckIcon = "ok" | "fail" | "warn" | "info";
2
+ export interface CheckLine {
3
+ icon: CheckIcon;
4
+ name: string;
5
+ detail: string;
6
+ }
7
+ /** `spf doctor`'s check list — same one line per check as the plain-text path, just with the icon colored instead of a bare glyph. */
8
+ export declare function renderChecklist(lines: CheckLine[], footer: {
9
+ ok: boolean;
10
+ message: string;
11
+ }): Promise<void>;
12
+ /**
13
+ * A column-aligned table from pre-formatted string cells — callers still own
14
+ * field selection/truncation (same as the `.padEnd()` code this replaces);
15
+ * this only owns column width and alignment, via Ink's own box layout
16
+ * instead of hand-computed pad strings. `rowColor(i)` colors an entire row
17
+ * (e.g. red for a failed phase) — `undefined` leaves the terminal default.
18
+ */
19
+ export declare function renderTable(rows: string[][], opts?: {
20
+ rowColor?: (rowIndex: number) => string | undefined;
21
+ }): Promise<void>;
22
+ export interface ChainListEntry {
23
+ name: string;
24
+ phases: string;
25
+ describe: string;
26
+ agentsLine: string;
27
+ repoLabel?: string;
28
+ }
29
+ /** `spf list` — one styled block per chain (bold name, dimmed description) instead of the plain-text path's hand-padded columns. */
30
+ export declare function renderChainList(entries: ChainListEntry[], footer: string[]): Promise<void>;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { render, Box, Text } from "ink";
3
+ /**
4
+ * Mounts, waits for the frame to actually flush, then unmounts — the frame
5
+ * it just wrote stays in scrollback exactly like `console.log` output
6
+ * would.
7
+ *
8
+ * A long, line-wrapping tree (doctor's ~25-line checklist reliably does
9
+ * this) makes Ink emit an initial paint, then a corrective full
10
+ * `clear-screen + clear-scrollback + home` repaint once it recalculates the
11
+ * content's true height — the raw byte stream this writes really does
12
+ * contain the whole tree twice, which reads as a bug if you inspect it
13
+ * with `grep`/`wc` the way a naive capture would. It isn't one: replayed
14
+ * through an actual terminal emulator (verified with `pyte`, not just
15
+ * eyeballed), the erase sequence takes effect exactly as a real terminal
16
+ * would apply it, and only the corrected repaint remains on screen —
17
+ * confirmed for both `render()`'s default mode and `incrementalRendering:
18
+ * true` (neither changes the outcome; this file uses the default).
19
+ * `waitUntilRenderFlush()` before `unmount()` is still worth keeping: it's
20
+ * the correct way to let that settle before tearing the instance down,
21
+ * even though skipping it turned out not to be the duplicate's actual
22
+ * cause.
23
+ */
24
+ async function paint(node) {
25
+ // `interactive: true` overrides Ink's own `stdout.isTTY`/`is-in-ci`
26
+ // auto-detection — every caller here already reached this file only
27
+ // after its own `isInteractive()` check passed (which itself checks
28
+ // `!process.env["CI"]`), so Ink's redundant CI detection can only ever
29
+ // disagree by mistake. See `ink_asker.tsx`'s identical `render()` call
30
+ // for where disagreeing actually broke something (a CI-run test hung).
31
+ const app = render(node, { patchConsole: false, interactive: true });
32
+ await app.waitUntilRenderFlush();
33
+ app.unmount();
34
+ }
35
+ const ICON_GLYPH = { ok: "✓", fail: "✗", warn: "⚠", info: "ℹ" };
36
+ const ICON_COLOR = { ok: "green", fail: "red", warn: "yellow", info: "cyan" };
37
+ /** `spf doctor`'s check list — same one line per check as the plain-text path, just with the icon colored instead of a bare glyph. */
38
+ export async function renderChecklist(lines, footer) {
39
+ await paint(_jsxs(Box, { flexDirection: "column", children: [lines.map((line, i) => (_jsxs(Text, { children: [_jsx(Text, { color: ICON_COLOR[line.icon], children: ICON_GLYPH[line.icon] }), " ", line.name, ": ", line.detail] }, i))), _jsx(Text, { children: " " }), _jsx(Text, { color: footer.ok ? "green" : "red", children: footer.message })] }));
40
+ }
41
+ /**
42
+ * A column-aligned table from pre-formatted string cells — callers still own
43
+ * field selection/truncation (same as the `.padEnd()` code this replaces);
44
+ * this only owns column width and alignment, via Ink's own box layout
45
+ * instead of hand-computed pad strings. `rowColor(i)` colors an entire row
46
+ * (e.g. red for a failed phase) — `undefined` leaves the terminal default.
47
+ */
48
+ export async function renderTable(rows, opts) {
49
+ if (rows.length === 0)
50
+ return;
51
+ const colCount = Math.max(...rows.map((r) => r.length));
52
+ const widths = Array.from({ length: colCount }, (_, c) => Math.max(...rows.map((r) => (r[c] ?? "").length)));
53
+ await paint(_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsx(Box, { children: row.map((cell, c) => {
54
+ const isLast = c === row.length - 1;
55
+ return (_jsx(Box, { width: isLast ? undefined : widths[c], marginRight: isLast ? 0 : 2, children: _jsx(Text, { color: opts?.rowColor?.(i), children: cell }) }, c));
56
+ }) }, i))) }));
57
+ }
58
+ /** `spf list` — one styled block per chain (bold name, dimmed description) instead of the plain-text path's hand-padded columns. */
59
+ export async function renderChainList(entries, footer) {
60
+ await paint(_jsxs(Box, { flexDirection: "column", children: [entries.map((entry, i) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "cyan", children: entry.name }), " ", entry.phases] }), _jsxs(Text, { dimColor: true, children: [" ", entry.describe] }), _jsxs(Text, { dimColor: true, children: [" ", entry.agentsLine] }), entry.repoLabel ? _jsxs(Text, { dimColor: true, children: [" (repo: ", entry.repoLabel, ")"] }) : null] }, i))), footer.map((line, i) => (_jsx(Text, { children: line }, i)))] }));
61
+ }
@@ -0,0 +1,15 @@
1
+ import type { RunObserver } from "../../core/console.ts";
2
+ export interface RunDashboard {
3
+ sink: (line: string) => void;
4
+ observer: RunObserver;
5
+ /** Detaches the live phase/spend slot and stops the elapsed-time ticker, then tears the Ink instance down entirely — call around a nested prompt (the sign-off gate) that needs the terminal to itself. Awaits the pending frame flush first: unmounting before Ink's initial commit for a fresh/updated tree has actually flushed can drop the whole accumulated `<Static>` history instead of leaving it in scrollback (confirmed empirically — the exact failure mode `reports.tsx`'s `paint()` guards against, just worse here: nothing printed at all instead of printing twice). */
6
+ pause(): Promise<void>;
7
+ /** Re-attaches after `pause()`. */
8
+ resume(): void;
9
+ /** Tears the Ink instance down. Call exactly once, when the run finishes (success or error) — same "call this in both the try and the catch" discipline `asker.close()` already follows in `commands/init.ts`. Same flush-before-unmount reasoning as `pause()`. */
10
+ close(): Promise<void>;
11
+ }
12
+ export declare function mountRunDashboard(opts: {
13
+ maxCost?: number;
14
+ maxTokens?: number;
15
+ }): RunDashboard;