@gr8ful/spf 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chains/context.d.ts +26 -0
- package/dist/chains/simple_sdlc.js +9 -0
- package/dist/chains/steps.js +1 -1
- package/dist/cli/ask.d.ts +13 -0
- package/dist/cli/ask.js +15 -1
- package/dist/cli/commands/doctor.js +43 -7
- package/dist/cli/commands/fanout.js +49 -5
- package/dist/cli/commands/init.js +7 -2
- package/dist/cli/commands/list.d.ts +1 -1
- package/dist/cli/commands/list.js +31 -12
- package/dist/cli/commands/phases.d.ts +1 -1
- package/dist/cli/commands/phases.js +18 -4
- package/dist/cli/commands/run.js +21 -0
- package/dist/cli/commands/sessions.d.ts +1 -1
- package/dist/cli/commands/sessions.js +11 -3
- package/dist/cli/commands/watch.js +38 -6
- package/dist/cli/index.js +3 -3
- package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
- package/dist/cli/ui/fanout_dashboard.js +102 -0
- package/dist/cli/ui/ink_asker.d.ts +13 -0
- package/dist/cli/ui/ink_asker.js +247 -0
- package/dist/cli/ui/reports.d.ts +30 -0
- package/dist/cli/ui/reports.js +61 -0
- package/dist/cli/ui/run_dashboard.d.ts +15 -0
- package/dist/cli/ui/run_dashboard.js +131 -0
- package/dist/cli/ui/watch_dashboard.d.ts +22 -0
- package/dist/cli/ui/watch_dashboard.js +78 -0
- package/dist/core/console.d.ts +40 -1
- package/dist/core/console.js +25 -3
- package/dist/core/fanout.d.ts +9 -0
- package/dist/core/fanout.js +6 -2
- package/dist/core/issues/github_provider.js +16 -1
- package/dist/core/issues/jira_provider.js +13 -1
- package/dist/core/runner.d.ts +5 -1
- package/dist/core/runner.js +2 -1
- package/dist/core/session.d.ts +7 -1
- package/dist/core/session.js +5 -1
- package/package.json +6 -1
|
@@ -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;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* A live TTY view for `spf <chain>` / `spf run <chain>` — the phase-by-phase
|
|
4
|
+
* transcript `core/console.ts` already prints, in a `<Static>` history
|
|
5
|
+
* exactly as it always read, plus one live line below it: the currently
|
|
6
|
+
* running phase, an elapsed timer, and spend so far (against a ceiling,
|
|
7
|
+
* when `defaults.max_run_cost`/`max_run_tokens` configure one).
|
|
8
|
+
*
|
|
9
|
+
* Reached only through a dynamic `import()` from `cli/commands/run.ts`, and
|
|
10
|
+
* only when `isInteractive()` — a CI log or a piped `spf <chain>` never
|
|
11
|
+
* imports this file, and `core/console.ts`'s default `sink`
|
|
12
|
+
* (`console.log`) and `observer` (`null`) reproduce today's plain-line
|
|
13
|
+
* behavior exactly. `RunObserver`'s hooks are deliberately the only signal
|
|
14
|
+
* this reads — never a re-parse of the printed lines themselves (see its
|
|
15
|
+
* doc comment in `core/console.ts`).
|
|
16
|
+
*
|
|
17
|
+
* One persistent Ink instance for the whole run, same reasoning as
|
|
18
|
+
* `ink_asker.tsx`: nothing here calls `useInput`, so raw mode never
|
|
19
|
+
* engages and there's no per-line mount/unmount race to worry about — but
|
|
20
|
+
* `simple_sdlc.ts`'s human sign-off prompt DOES call `useInput` (through
|
|
21
|
+
* `createInkAsker()`/`createAsker()`) mid-run, and two live Ink instances
|
|
22
|
+
* cannot share one stdout. `pause()`/`resume()` exist for exactly that
|
|
23
|
+
* handoff — `cli/commands/run.ts` calls `pause()` before the chain can
|
|
24
|
+
* reach a sign-off prompt... except it can't know when that will happen
|
|
25
|
+
* either, so instead this dashboard is paused/resumed by the same
|
|
26
|
+
* `unattended`/interactive gate `decideSignoff` already uses: see
|
|
27
|
+
* `run.ts`'s comment at its mount site.
|
|
28
|
+
*/
|
|
29
|
+
import { useEffect, useRef, useState } from "react";
|
|
30
|
+
import { render, Static, Box, Text } from "ink";
|
|
31
|
+
function formatUsd(n) {
|
|
32
|
+
return `$${n.toFixed(4)}`;
|
|
33
|
+
}
|
|
34
|
+
function Elapsed({ sinceMs }) {
|
|
35
|
+
const [now, setNow] = useState(() => Date.now());
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
38
|
+
return () => clearInterval(id);
|
|
39
|
+
}, []);
|
|
40
|
+
const seconds = Math.max(0, (now - sinceMs) / 1000);
|
|
41
|
+
return _jsxs(Text, { dimColor: true, children: [seconds.toFixed(0), "s"] });
|
|
42
|
+
}
|
|
43
|
+
function DashboardRoot(props) {
|
|
44
|
+
const [history, setHistory] = useState([]);
|
|
45
|
+
const [live, setLive] = useState(props.initialLive);
|
|
46
|
+
const [usage, setUsage] = useState(props.initialUsage);
|
|
47
|
+
const keyRef = useRef(0); // see ink_asker.tsx's identical comment — a ref, not state, so two pushes in one tick never collide
|
|
48
|
+
props.handleRef.current = {
|
|
49
|
+
pushHistory(text) {
|
|
50
|
+
const key = keyRef.current++;
|
|
51
|
+
setHistory((h) => [...h, { key, text }]);
|
|
52
|
+
},
|
|
53
|
+
setLivePhase: setLive,
|
|
54
|
+
setUsage: (tokens, cost) => setUsage({ tokens, cost }),
|
|
55
|
+
};
|
|
56
|
+
const overCost = props.maxCost !== undefined && usage.cost >= props.maxCost * 0.8;
|
|
57
|
+
const overTokens = props.maxTokens !== undefined && usage.tokens >= props.maxTokens * 0.8;
|
|
58
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: history, children: (line) => _jsx(Text, { children: line.text }, line.key) }), live ? (_jsxs(Box, { children: [_jsx(Text, { color: "magenta", children: "\u25B8 running: " }), _jsx(Text, { bold: true, children: live.name }), _jsxs(Text, { dimColor: true, children: [" (", live.kind, " \u00B7 ", live.owner, ") "] }), _jsx(Elapsed, { sinceMs: live.startedAtMs })] })) : null, _jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["spend: ", usage.tokens.toLocaleString(), " tokens \u00B7 ", formatUsd(usage.cost), props.maxCost !== undefined ? ` / ${formatUsd(props.maxCost)}` : "", props.maxTokens !== undefined ? ` (of ${props.maxTokens.toLocaleString()} tokens)` : ""] }), overCost || overTokens ? _jsx(Text, { color: "yellow", children: " \u2014 approaching ceiling" }) : null] })] }));
|
|
59
|
+
}
|
|
60
|
+
export function mountRunDashboard(opts) {
|
|
61
|
+
const handleRef = { current: null };
|
|
62
|
+
let currentPhase = null;
|
|
63
|
+
let currentUsage = { tokens: 0, cost: 0 };
|
|
64
|
+
// `undefined` while paused: Ink refuses a second `render()` on the same
|
|
65
|
+
// stdout while a prior instance is still live (the same restriction
|
|
66
|
+
// `ink_asker.tsx`'s confirm-timeout comment names), and the sign-off
|
|
67
|
+
// prompt's own Ink instance needs the terminal to itself. `pause()`
|
|
68
|
+
// fully unmounts rather than just hiding state; `resume()` mounts a
|
|
69
|
+
// fresh instance seeded with whatever `currentPhase`/`currentUsage`
|
|
70
|
+
// were at the moment of the handoff, so the live line picks up exactly
|
|
71
|
+
// where it left off instead of resetting to "nothing running".
|
|
72
|
+
let app;
|
|
73
|
+
function mount() {
|
|
74
|
+
app = render(_jsx(DashboardRoot, { maxCost: opts.maxCost, maxTokens: opts.maxTokens, handleRef: handleRef, initialLive: currentPhase, initialUsage: currentUsage }),
|
|
75
|
+
// `interactive: true` overrides Ink's own CI auto-detection — see
|
|
76
|
+
// `ink_asker.tsx`'s identical `render()` call for why: the caller
|
|
77
|
+
// (`commands/run.ts`) only reaches this file after its own
|
|
78
|
+
// `isInteractive()` check has already passed.
|
|
79
|
+
{ patchConsole: false, interactive: true });
|
|
80
|
+
}
|
|
81
|
+
mount();
|
|
82
|
+
const sink = (line) => {
|
|
83
|
+
// Never silently dropped, even while paused: falls back to the exact
|
|
84
|
+
// default `Console` would use on its own (`console.log`) rather than
|
|
85
|
+
// lose the printed half of "one narrative, two destinations" during
|
|
86
|
+
// the handoff window.
|
|
87
|
+
if (!app) {
|
|
88
|
+
console.log(line);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
handleRef.current?.pushHistory(line);
|
|
92
|
+
};
|
|
93
|
+
const observer = {
|
|
94
|
+
onPhaseStart(phase) {
|
|
95
|
+
currentPhase = { name: phase.params.name, kind: phase.params.kind, owner: phase.params.owner, startedAtMs: Date.now() };
|
|
96
|
+
// `currentPhase`/`currentUsage` above are updated regardless of `app`
|
|
97
|
+
// so a `resume()` after this always seeds the fresh mount correctly,
|
|
98
|
+
// even if the phase/usage change itself happened while paused.
|
|
99
|
+
if (app)
|
|
100
|
+
handleRef.current?.setLivePhase(currentPhase);
|
|
101
|
+
},
|
|
102
|
+
onPhaseEnd() {
|
|
103
|
+
currentPhase = null;
|
|
104
|
+
if (app)
|
|
105
|
+
handleRef.current?.setLivePhase(null);
|
|
106
|
+
},
|
|
107
|
+
onUsage(tokens, cost) {
|
|
108
|
+
currentUsage = { tokens, cost };
|
|
109
|
+
if (app)
|
|
110
|
+
handleRef.current?.setUsage(tokens, cost);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
async function unmount() {
|
|
114
|
+
if (!app)
|
|
115
|
+
return;
|
|
116
|
+
const current = app;
|
|
117
|
+
await current.waitUntilRenderFlush();
|
|
118
|
+
current.unmount();
|
|
119
|
+
app = undefined;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
sink,
|
|
123
|
+
observer,
|
|
124
|
+
pause: unmount,
|
|
125
|
+
resume() {
|
|
126
|
+
if (!app)
|
|
127
|
+
mount();
|
|
128
|
+
},
|
|
129
|
+
close: unmount,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NotifyEvent } from "../../core/notify/channel.ts";
|
|
2
|
+
export interface WatchDashboard {
|
|
3
|
+
log: (message: string) => void;
|
|
4
|
+
/** Mirrors a one-line summary into the same log history — delivery to the real `Notifier` is unaffected; call this alongside it, never instead of it. */
|
|
5
|
+
mirrorNotify: (event: NotifyEvent) => void;
|
|
6
|
+
setCounts: (inflight: number, refining: number) => void;
|
|
7
|
+
/** `null` while a tick is running or the daemon is draining — clears the countdown instead of showing a stale or negative one. */
|
|
8
|
+
setNextPollAt: (deadlineMs: number | null) => void;
|
|
9
|
+
/** Tears the Ink instance down. Call once, in the same `finally` `commands/watch.ts` already releases its lockfile in. */
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
/** Synchronous, no flush-wait — for `stop()`'s second-Ctrl-C `process.exit(130)` path, same reasoning as `fanout_dashboard.tsx`'s `unmountNow()`. */
|
|
12
|
+
unmountNow(): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function mountWatchDashboard(opts: {
|
|
15
|
+
repo: string;
|
|
16
|
+
labelPrefix: string;
|
|
17
|
+
chain: string;
|
|
18
|
+
concurrency: number;
|
|
19
|
+
refineChain?: string;
|
|
20
|
+
refineConcurrency?: number;
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
}): WatchDashboard;
|