@higherdev/cli 0.3.0 → 0.5.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.
@@ -0,0 +1,195 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { BOARD_COLUMNS, availabilityLabel, statusLabel, statusTone, } from "./data.js";
4
+ import { elapsed, pad, relativeTime, truncate } from "../out/format.js";
5
+ import { inkColor } from "../out/theme.js";
6
+ import { UI } from "./theme.js";
7
+ import { BoundedPanel as Panel, Heading, More, contentRows } from "./bounded.js";
8
+ const DOT = "●";
9
+ /**
10
+ * The single-purpose views behind /board, /agents, /feed and /inbox. Each is
11
+ * handed a row budget and draws inside it, heading and overflow row included,
12
+ * because the live frame's height is the sum of those budgets. See
13
+ * `bounded.tsx` for why the bound is structural rather than arithmetic.
14
+ */
15
+ export function AgentsPanel({ board, width = 80, rows = 12, }) {
16
+ const shown = board.agents.slice(0, contentRows(rows, board.agents.length));
17
+ return (_jsx(Panel, { width: width, rows: rows, children: [
18
+ _jsx(Heading, { text: "Agents", note: `${board.agents.length}` }, "h"),
19
+ ...(board.agents.length === 0
20
+ ? [
21
+ _jsx(Text, { color: UI.dim, children: "No agents yet." }, "empty"),
22
+ ]
23
+ : []),
24
+ ...shown.map((agent) => {
25
+ const run = board.runs.find((row) => row.agent_id === agent.id && row.status === "running");
26
+ const ticket = run?.ticket_id
27
+ ? board.tickets.find((item) => item.id === run.ticket_id)
28
+ : undefined;
29
+ const availability = board.availability.find((row) => row.provider === agent.provider);
30
+ const blocked = availability && !availability.available ? availabilityLabel(availability) : "";
31
+ const tone = !agent.enabled ? "muted" : run ? "blue" : blocked ? "warning" : "muted";
32
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Text, { color: UI.text, children: pad(truncate(agent.display_name, 13), 14) }), _jsx(Text, { color: UI.dim, children: pad(agent.role, 13) }), _jsx(Text, { color: UI.dim, children: pad(truncate(agent.model, 23), 24) }), _jsx(Text, { color: UI.text, children: pad(run ? "running" : blocked ? "blocked" : "idle", 9) }), _jsxs(Text, { color: UI.dim, children: [ticket ? `${ticket.key} ` : "", run ? elapsed(run.started_at ?? run.created_at) : blocked] })] }, agent.id));
33
+ }),
34
+ _jsx(More, { count: board.agents.length - shown.length }, "more"),
35
+ ] }));
36
+ }
37
+ export function BoardPanel({ board, width = 80, rows = 12, cursor, }) {
38
+ const active = BOARD_COLUMNS.filter((status) => board.tickets.some((ticket) => ticket.status === status));
39
+ // A group costs its heading before it costs a ticket, so both come out of
40
+ // the same budget.
41
+ const budget = contentRows(rows, board.tickets.length + active.length);
42
+ const lines = [];
43
+ let dropped = 0;
44
+ for (const status of active) {
45
+ const tickets = board.tickets.filter((ticket) => ticket.status === status);
46
+ if (lines.length + 1 >= budget) {
47
+ dropped += tickets.length;
48
+ continue;
49
+ }
50
+ lines.push(_jsxs(Text, { color: inkColor(statusTone(status)), wrap: "truncate", children: [statusLabel(status), " ", _jsxs(Text, { color: UI.dim, children: ["(", tickets.length, ")"] })] }, status));
51
+ for (const ticket of tickets) {
52
+ if (lines.length >= budget) {
53
+ dropped += 1;
54
+ continue;
55
+ }
56
+ lines.push(_jsx(TicketLine, { ticket: ticket, width: width, selected: ticket.id === cursor }, ticket.id));
57
+ }
58
+ }
59
+ return (_jsx(Panel, { width: width, rows: rows, children: [
60
+ _jsx(Heading, { text: "Board", note: `${board.tickets.length}` }, "h"),
61
+ ...(board.tickets.length === 0
62
+ ? [
63
+ _jsx(Text, { color: UI.dim, children: "No tickets yet." }, "empty"),
64
+ ]
65
+ : lines),
66
+ _jsx(More, { count: dropped }, "more"),
67
+ ] }));
68
+ }
69
+ function TicketLine({ ticket, width, selected, }) {
70
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: selected ? UI.accent : UI.dim, children: selected ? " > " : " " }), _jsx(Text, { color: UI.text, bold: true, children: pad(ticket.key, 8) }), _jsx(Text, { color: UI.text, children: pad(truncate(ticket.title, 43), 44) }), _jsx(Text, { color: UI.dim, children: pad(ticket.agent_name ?? "", 14) }), ticket.stuck ? (_jsx(Text, { color: UI.warn, children: truncate(ticket.stuck, Math.max(8, width - 70)) })) : null] }));
71
+ }
72
+ export function FeedPanel({ entries, width = 80, rows = 12, }) {
73
+ const shown = entries.slice(0, contentRows(rows, entries.length));
74
+ return (_jsx(Panel, { width: width, rows: rows, children: [
75
+ _jsx(Heading, { text: "Feed", note: `${entries.length}` }, "h"),
76
+ ...(entries.length === 0
77
+ ? [
78
+ _jsx(Text, { color: UI.dim, children: "Nothing yet." }, "empty"),
79
+ ]
80
+ : []),
81
+ ...shown.map((row, index) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: UI.dim, children: pad(relativeTime(row.at), 10) }), _jsx(Text, { color: UI.dim, children: pad(row.kind ?? "", 18) }), _jsx(Text, { color: UI.text, children: truncate(row.summary ?? "", Math.max(8, width - 30)) })] }, row.id ?? index))),
82
+ _jsx(More, { count: entries.length - shown.length }, "more"),
83
+ ] }));
84
+ }
85
+ /**
86
+ * The whole queue, one row each, numbered the way `/decide` reaches them: the
87
+ * one on screen is 1.
88
+ */
89
+ export function InboxPanel({ board, width = 80, rows = 12, }) {
90
+ const shown = board.decisions.slice(0, contentRows(rows, board.decisions.length));
91
+ return (_jsx(Panel, { width: width, rows: rows, children: [
92
+ _jsx(Heading, { text: "Decisions", note: `${board.decisions.length}` }, "h"),
93
+ ...(board.decisions.length === 0
94
+ ? [
95
+ _jsx(Text, { color: UI.dim, children: "Nothing waiting on you." }, "empty"),
96
+ ]
97
+ : []),
98
+ ...shown.map((decision, index) => {
99
+ const ticket = board.tickets.find((row) => row.id === decision.ticket_id);
100
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: index === 0 ? UI.warn : UI.dim, children: pad(`${index + 1})`, 3) }), _jsx(Text, { color: UI.dim, children: pad(ticket?.key ?? decision.id.slice(0, 8), 9) }), _jsx(Text, { color: UI.text, children: truncate(decision.question_md, Math.max(12, width - 14)) })] }, decision.id));
101
+ }),
102
+ _jsx(More, { count: board.decisions.length - shown.length }, "more"),
103
+ ] }));
104
+ }
105
+ /**
106
+ * One ticket in full, inside the rows it was given.
107
+ *
108
+ * Built as a list and sliced rather than reasoned about line by line. Every
109
+ * header line truncates, so each costs exactly one row, and the body takes
110
+ * whatever is left. Counting rows by hand is what let a wrapping title spend
111
+ * rows nothing had budgeted, and a frame past the window is one Ink stops
112
+ * repainting in place.
113
+ */
114
+ export function TicketPanel({ ticket, width = 80, rows = 24, }) {
115
+ const lines = [
116
+ _jsxs(Text, { color: UI.text, bold: true, wrap: "truncate", children: [ticket.key, " ", ticket.title] }, "title"),
117
+ _jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Text, { color: inkColor(statusTone(ticket.status)), wrap: "truncate", children: statusLabel(ticket.status) }), _jsxs(Text, { color: UI.dim, wrap: "truncate", children: [ticket.area ? ` ${ticket.area}` : "", ticket.agent_name ? ` ${ticket.agent_name}` : " unassigned", ticket.attempts ? ` attempt ${ticket.attempts}` : ""] })] }, "status"),
118
+ ];
119
+ if (ticket.stuck) {
120
+ lines.push(_jsxs(Text, { color: UI.warn, wrap: "truncate", children: ["why: ", ticket.stuck] }, "stuck"));
121
+ }
122
+ if (ticket.blocker_keys.length) {
123
+ lines.push(_jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["blocked by ", ticket.blocker_keys.join(", ")] }, "blocked"));
124
+ }
125
+ if (ticket.pr_url) {
126
+ lines.push(_jsx(Text, { color: UI.accent, wrap: "truncate", children: ticket.pr_url }, "pr"));
127
+ }
128
+ // The blank row before the body comes out of the body's own allowance.
129
+ const drawable = Math.max(0, rows);
130
+ const body = clipToRows(ticket.body_md.trim(), Math.max(20, width), Math.max(0, drawable - lines.length - 1));
131
+ if (body) {
132
+ lines.push(_jsx(Box, { height: 1 }, "gap"));
133
+ lines.push(_jsx(Text, { color: UI.text, wrap: "wrap", children: body }, "body"));
134
+ }
135
+ return (_jsx(Panel, { width: width, rows: drawable, children: lines }));
136
+ }
137
+ /**
138
+ * Text cut to the rows it may occupy once wrapped.
139
+ *
140
+ * Wrapped the way Ink wraps it, on word boundaries. Counting characters
141
+ * instead under-counts every time, because a word that will not fit ends the
142
+ * line early: 240 characters of prose is five rows of 48 columns by division
143
+ * and six rows on screen. That difference is a panel drawing past its budget.
144
+ */
145
+ export function clipToRows(text, width, rows) {
146
+ if (rows <= 0 || !text || width <= 0)
147
+ return "";
148
+ const out = [];
149
+ let dropped = false;
150
+ for (const paragraph of text.split("\n")) {
151
+ if (out.length >= rows) {
152
+ dropped = true;
153
+ break;
154
+ }
155
+ let line = "";
156
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
157
+ const candidate = line ? `${line} ${word}` : word;
158
+ if (candidate.length <= width) {
159
+ line = candidate;
160
+ continue;
161
+ }
162
+ if (line) {
163
+ out.push(line);
164
+ line = "";
165
+ if (out.length >= rows)
166
+ break;
167
+ }
168
+ // A single word wider than the column is broken, the way Ink breaks it.
169
+ let rest = word;
170
+ while (rest.length > width) {
171
+ if (out.length >= rows)
172
+ break;
173
+ out.push(rest.slice(0, width));
174
+ rest = rest.slice(width);
175
+ }
176
+ if (out.length >= rows)
177
+ break;
178
+ line = rest;
179
+ }
180
+ if (out.length >= rows) {
181
+ dropped = true;
182
+ break;
183
+ }
184
+ out.push(line);
185
+ }
186
+ if (out.length > rows) {
187
+ out.length = rows;
188
+ dropped = true;
189
+ }
190
+ if (dropped && out.length > 0) {
191
+ const last = out[out.length - 1];
192
+ out[out.length - 1] = last.length >= width ? `${last.slice(0, width - 1)}…` : `${last}…`;
193
+ }
194
+ return out.join("\n");
195
+ }
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { pad, truncate } from "../out/format.js";
4
+ import { BoundedPanel as Panel, Heading } from "./bounded.js";
5
+ import { scrollWindow } from "./Dashboard.js";
6
+ import { UI } from "./theme.js";
7
+ /**
8
+ * The settings screen. One row per thing you can change, scrolling under the
9
+ * cursor the way the board does, and bounded the way every panel here is: the
10
+ * live frame's height is the sum of the budgets its panels were given.
11
+ */
12
+ export function SettingsPanel({ entries, width, rows, title = "Settings", cursor, editing, }) {
13
+ const inner = Math.max(0, rows - 1);
14
+ const focus = cursor ? entries.findIndex((row) => row.key === cursor) : -1;
15
+ const { start, end } = scrollWindow(entries.length, inner, focus);
16
+ const label = Math.max(10, Math.min(18, Math.round(width / 4)));
17
+ return (_jsx(Panel, { width: width, rows: rows, children: [
18
+ _jsx(Heading, { text: title, note: hidden(entries.length, start, end) }, "h"),
19
+ ...entries.slice(start, end).map((row) => {
20
+ if (row.kind === "heading") {
21
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: UI.text, bold: true, children: row.label }), _jsx(Text, { color: UI.dim, children: ` ${row.value}` })] }, row.key));
22
+ }
23
+ const selected = row.key === cursor;
24
+ const typing = editing?.key === row.key;
25
+ if (row.kind === "action") {
26
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: UI.accent, children: selected ? " › " : " " }), _jsx(Text, { color: UI.cream, bold: true, inverse: selected, children: pad(truncate(row.label, label - 1), label) }), _jsx(Text, { color: UI.dim, children: truncate(row.value, Math.max(8, width - label - 6)) })] }, row.key));
27
+ }
28
+ return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: UI.accent, children: selected ? " › " : " " }), _jsx(Text, { color: UI.dim, children: pad(truncate(row.label, label - 1), label) }), _jsx(Text, { color: typing ? UI.cream : UI.text, inverse: selected && !typing, children: truncate(typing ? `${editing.draft}▏` : row.value || "-", valueWidth(width, label, row, selected)) }), selected && row.hint && !typing ? (_jsx(Text, { color: UI.dim, children: ` ${row.hint}` })) : null] }, row.key));
29
+ }),
30
+ ] }));
31
+ }
32
+ /** The value gives up room to the hint, on the one row showing a hint. */
33
+ function valueWidth(width, label, row, selected) {
34
+ const room = Math.max(8, width - label - 6);
35
+ if (!selected || !row.hint)
36
+ return room;
37
+ return Math.max(8, room - row.hint.length - 2);
38
+ }
39
+ function hidden(count, start, end) {
40
+ const above = start;
41
+ const below = count - end;
42
+ if (above === 0 && below === 0)
43
+ return `${count}`;
44
+ return `${count} ${above > 0 ? `${above}↑ ` : ""}${below > 0 ? `${below}↓` : ""}`.trimEnd();
45
+ }
@@ -0,0 +1,15 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Banner } from "./Banner.js";
3
+ import { Help, HelpHint } from "./Help.js";
4
+ /**
5
+ * What the app shows before you have done anything: the wordmark, and the
6
+ * commands under it.
7
+ *
8
+ * The measuring and the drawing live together on purpose. They were two pieces
9
+ * of code that had to agree about which form of the command list was on
10
+ * screen, and when they stopped agreeing the frame silently outgrew the window
11
+ * while the arithmetic still claimed it fitted.
12
+ */
13
+ export function Splash({ columns, rows, width, ready, helpFull, animate, onDone, }) {
14
+ return (_jsxs(_Fragment, { children: [_jsx(Banner, { animate: animate, onDone: onDone, columns: columns, rows: rows }), ready ? helpFull ? _jsx(Help, { width: width }) : _jsx(HelpHint, { width: width }) : null] }));
15
+ }
@@ -0,0 +1,137 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Text, useInput, usePaste } from "ink";
4
+ import { UI } from "./theme.js";
5
+ /**
6
+ * A single-line editor with the things a hand-rolled `value + ch` prompt never
7
+ * has: a real cursor, word operations, and bracketed paste. Adapted from the
8
+ * same component in gtm2-cli, which had already learned the edge cases.
9
+ *
10
+ * Readline bindings are supported because that is what muscle memory expects in
11
+ * a terminal: ctrl+a / ctrl+e for ends, ctrl+w to rub out a word, ctrl+u and
12
+ * ctrl+k to kill to either end.
13
+ */
14
+ /**
15
+ * Never let stray control bytes into the value; they corrupt the layout and
16
+ * are invisible to whoever is typing.
17
+ */
18
+ function printableOf(text) {
19
+ // eslint-disable-next-line no-control-regex
20
+ return text.replace(/[\x00-\x1f\x7f]/g, "");
21
+ }
22
+ export default function TextInput({ value, onChange, onSubmit, onCancel, onUp, onDown, isActive = true, placeholder = "", prompt, color, }) {
23
+ const [cursor, setCursor] = useState(value.length);
24
+ // The last value this component produced. Anything else arriving in `value`
25
+ // was swapped in by the caller.
26
+ const ours = useRef(value);
27
+ /**
28
+ * A value replaced from outside takes the cursor to its end. History recall
29
+ * and opening a settings field both swap the whole value in, and leaving the
30
+ * cursor where the old one left it put the next character typed in front of
31
+ * what had just been recalled.
32
+ */
33
+ useEffect(() => {
34
+ if (value === ours.current)
35
+ return;
36
+ ours.current = value;
37
+ setCursor(value.length);
38
+ }, [value]);
39
+ /** Reports a change this component made, so the effect above ignores it. */
40
+ const change = (next) => {
41
+ ours.current = next;
42
+ onChange(next);
43
+ };
44
+ const insert = (text) => {
45
+ const next = value.slice(0, cursor) + text + value.slice(cursor);
46
+ change(next);
47
+ setCursor(cursor + text.length);
48
+ };
49
+ usePaste((text) => {
50
+ // Newlines would break a single-line field, so flatten them to spaces.
51
+ insert(text.replace(/[\r\n]+/g, " "));
52
+ }, { isActive });
53
+ useInput((input, key) => {
54
+ // Ink only flags key.return for CR. A bare LF reaches us as ordinary
55
+ // input, which some terminals and pty layers do send, and inserting it
56
+ // would put a literal newline in a single-line field.
57
+ if (key.return || input === "\r" || input === "\n" || input === "\r\n")
58
+ return onSubmit?.(value);
59
+ if (key.escape)
60
+ return onCancel?.();
61
+ if (key.upArrow)
62
+ return onUp?.();
63
+ if (key.downArrow)
64
+ return onDown?.();
65
+ // A whole line and its Enter can arrive as a single chunk. Unbracketed
66
+ // paste does it, and so does any link that buffers, which over ssh is
67
+ // most of them. Ink reports that as ordinary input with key.return
68
+ // false, so without this the Enter is filtered out with the other
69
+ // control bytes and the line just sits at the prompt unsent.
70
+ if (input.length > 1 && /[\r\n]/.test(input)) {
71
+ const parts = input.split(/\r\n|\r|\n/);
72
+ const submits = parts.length === 2 && parts[1] === "";
73
+ if (submits) {
74
+ const next = value.slice(0, cursor) + printableOf(parts[0]) + value.slice(cursor);
75
+ change(next);
76
+ setCursor(next.length);
77
+ return onSubmit?.(next);
78
+ }
79
+ // Several lines in a single-line field. Running the first and dropping
80
+ // the rest loses what someone meant to send, so they are joined.
81
+ const flat = printableOf(input.replace(/[\r\n]+/g, " "));
82
+ change(value.slice(0, cursor) + flat + value.slice(cursor));
83
+ return setCursor(cursor + flat.length);
84
+ }
85
+ if (key.leftArrow)
86
+ return setCursor((c) => Math.max(0, c - 1));
87
+ if (key.rightArrow)
88
+ return setCursor((c) => Math.min(value.length, c + 1));
89
+ if (key.home)
90
+ return setCursor(0);
91
+ if (key.end)
92
+ return setCursor(value.length);
93
+ if (key.backspace) {
94
+ if (cursor === 0)
95
+ return;
96
+ change(value.slice(0, cursor - 1) + value.slice(cursor));
97
+ return setCursor(cursor - 1);
98
+ }
99
+ if (key.delete) {
100
+ if (cursor >= value.length)
101
+ return;
102
+ return change(value.slice(0, cursor) + value.slice(cursor + 1));
103
+ }
104
+ if (key.ctrl) {
105
+ switch (input) {
106
+ case "a":
107
+ return setCursor(0);
108
+ case "e":
109
+ return setCursor(value.length);
110
+ case "u": // kill to start
111
+ change(value.slice(cursor));
112
+ return setCursor(0);
113
+ case "k": // kill to end
114
+ return change(value.slice(0, cursor));
115
+ case "w": {
116
+ // Rub out the word before the cursor, plus any spaces run into.
117
+ const head = value.slice(0, cursor);
118
+ const trimmed = head.replace(/\s*\S*$/, "");
119
+ change(trimmed + value.slice(cursor));
120
+ return setCursor(trimmed.length);
121
+ }
122
+ default:
123
+ return;
124
+ }
125
+ }
126
+ if (key.meta || !input)
127
+ return;
128
+ const printable = printableOf(input);
129
+ if (printable)
130
+ insert(printable);
131
+ }, { isActive });
132
+ const showPlaceholder = value.length === 0 && placeholder.length > 0;
133
+ const before = value.slice(0, cursor);
134
+ const at = value[cursor] ?? " ";
135
+ const after = value.slice(cursor + 1);
136
+ return (_jsxs(Text, { children: [prompt, showPlaceholder ? (_jsxs(_Fragment, { children: [isActive ? _jsx(Text, { inverse: true, children: placeholder[0] }) : _jsx(Text, { children: placeholder[0] }), _jsx(Text, { color: UI.dim, children: placeholder.slice(1) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: color, children: before }), isActive ? (_jsx(Text, { inverse: true, color: color, children: at })) : (_jsx(Text, { color: color, children: at === " " ? "" : at })), _jsx(Text, { color: color, children: after })] }))] }));
137
+ }
@@ -0,0 +1,19 @@
1
+ /** BEL, built from its code point so the source holds no control characters. */
2
+ const BELL = String.fromCharCode(7);
3
+ /**
4
+ * The terminal bell, for a decision that has just started blocking work.
5
+ *
6
+ * A bell is a single control character with no cursor movement, so it does not
7
+ * disturb the frame Ink is painting around it. It is the only way this app can
8
+ * reach someone whose window is behind something else, which is where an
9
+ * operator usually is when a decision arrives. `HIGHERDEV_NO_BELL` turns it
10
+ * off, since some terminals answer a bell by flashing the whole screen.
11
+ */
12
+ export function alertOnce(stream = process.stdout, env = process.env) {
13
+ if (env.HIGHERDEV_NO_BELL)
14
+ return false;
15
+ if (!stream.isTTY)
16
+ return false;
17
+ stream.write(BELL);
18
+ return true;
19
+ }
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { UI } from "./theme.js";
4
+ /**
5
+ * Panels that cannot draw more rows than they were given.
6
+ *
7
+ * Every panel in this app is handed a row budget and the live frame's height
8
+ * is the sum of those budgets, because past the window's height Ink stops
9
+ * erasing and appends a full copy of the screen on every keystroke. Getting
10
+ * that arithmetic right by hand has failed once per panel: a heading counted
11
+ * twice, an overflow row added after the budget was spent, a wrapped line
12
+ * costing two rows. So the bound is structural here rather than arithmetic.
13
+ * The slice is what makes it a guarantee.
14
+ */
15
+ export function BoundedPanel({ width, rows, children, }) {
16
+ return (_jsx(Box, { flexDirection: "column", width: width, children: children.filter(Boolean).slice(0, Math.max(0, rows)) }));
17
+ }
18
+ /**
19
+ * Rows left for content after the heading, and after the row that says what
20
+ * did not fit. The overflow line comes out of the budget rather than being
21
+ * added to it, which is the mistake worth naming: a panel that reports its own
22
+ * truncation in an extra row is one row taller than it claimed.
23
+ */
24
+ export function contentRows(rows, items) {
25
+ const forContent = Math.max(0, rows - 1);
26
+ return items <= forContent ? forContent : Math.max(0, forContent - 1);
27
+ }
28
+ /** A row saying what the panel could not fit, so nothing goes missing silently. */
29
+ export function More({ count }) {
30
+ if (count <= 0)
31
+ return null;
32
+ return _jsx(Text, { color: UI.dim, children: ` +${count} more` });
33
+ }
34
+ /** A panel's heading, which never wraps, so it always costs exactly one row. */
35
+ export function Heading({ text, note }) {
36
+ return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Text, { color: UI.text, bold: true, wrap: "truncate", children: text }), note ? (_jsxs(Text, { color: UI.dim, wrap: "truncate", children: [" ", note] })) : null] }));
37
+ }
@@ -0,0 +1,4 @@
1
+ /** Ink needs both terminal streams. Every caller shares this launch gate. */
2
+ export function canLaunchApp(streams = { stdin: process.stdin, stdout: process.stdout }) {
3
+ return streams.stdin.isTTY === true && streams.stdout.isTTY === true;
4
+ }
@@ -0,0 +1,165 @@
1
+ import { answerDecision, getStatus, listAgents, listEpics, listFeed, listMessages, listTicketEvents, listTickets, postMessage as sendMessage, showTicket, updateAgent as patchAgent, updateCaps, } from "../api.js";
2
+ import { loadConfig, loadStoredConfig, switchWorkspace as selectWorkspace, } from "../config.js";
3
+ export const POLL_MS = 5_000;
4
+ export const providers = ["claude", "codex", "gemini", "grok"];
5
+ export const efforts = ["low", "medium", "high"];
6
+ export const BOARD_COLUMNS = [
7
+ "backlog",
8
+ "ready",
9
+ "queued",
10
+ "running",
11
+ "in_review",
12
+ "changes_requested",
13
+ "approved",
14
+ "needs_decision",
15
+ "blocked",
16
+ "failed",
17
+ "merged",
18
+ "cancelled",
19
+ ];
20
+ export function configuredSlugs() {
21
+ return Object.keys(loadStoredConfig().workspaces).sort();
22
+ }
23
+ export async function loadSnapshot(config = loadConfig()) {
24
+ const [status, ticketData, agentData, epicData, feedData] = await Promise.all([
25
+ getStatus(config),
26
+ listTickets(config),
27
+ listAgents(config),
28
+ listEpics(config),
29
+ listFeed(config),
30
+ ]);
31
+ const byId = new Map(ticketData.tickets.map((ticket) => [ticket.id, ticket]));
32
+ const agents = new Map(agentData.agents.map((agent) => [agent.id, agent]));
33
+ const tickets = ticketData.tickets.map((ticket) => ({
34
+ ...ticket,
35
+ agent_name: ticket.agent_id ? agents.get(ticket.agent_id)?.display_name ?? null : null,
36
+ blocker_keys: ticket.blocked_by.map((id) => byId.get(id)?.key).filter((key) => Boolean(key)),
37
+ stuck: ticket.stuck_reason,
38
+ }));
39
+ const caps = status.workspace.provider_caps ?? {};
40
+ const availability = providers.map((provider) => {
41
+ const cap = Number(caps[provider] ?? 0);
42
+ return { provider, available: cap > 0, reason: cap > 0 ? "available" : "cap 0" };
43
+ });
44
+ return {
45
+ config,
46
+ workspace: status.workspace,
47
+ board: {
48
+ tickets,
49
+ agents: agentData.agents,
50
+ epics: epicData.epics,
51
+ decisions: status.decisions,
52
+ availability,
53
+ runs: status.live_runs,
54
+ },
55
+ feed: feedData.entries,
56
+ };
57
+ }
58
+ export function pollSnapshot(config, onSnapshot, onState, onError) {
59
+ let closed = false;
60
+ let active = false;
61
+ const refresh = async () => {
62
+ if (closed || active)
63
+ return;
64
+ active = true;
65
+ try {
66
+ const snapshot = await loadSnapshot(config);
67
+ if (closed)
68
+ return;
69
+ onSnapshot(snapshot);
70
+ onState("live");
71
+ }
72
+ catch (error) {
73
+ if (closed)
74
+ return;
75
+ onState("offline");
76
+ onError(error);
77
+ }
78
+ finally {
79
+ active = false;
80
+ }
81
+ };
82
+ const timer = setInterval(() => void refresh(), POLL_MS);
83
+ void refresh();
84
+ return { refresh, close: () => { closed = true; clearInterval(timer); } };
85
+ }
86
+ export async function switchWorkspace(slug) {
87
+ return loadSnapshot(selectWorkspace(slug));
88
+ }
89
+ export async function postOrchestrator(body, config) {
90
+ await sendMessage({ body_md: body, to_role: "orchestrator", delivery: "queue" }, config);
91
+ }
92
+ export async function loadTicketDetail(config, key) {
93
+ return showTicket(key, config);
94
+ }
95
+ export async function waitForReply(config, since, timeoutMs) {
96
+ const deadline = Date.now() + timeoutMs;
97
+ while (Date.now() <= deadline) {
98
+ const { messages } = await listMessages({ since, limit: 50 }, config);
99
+ const reply = messages.find((message) => ["orchestrator", "reviewer"].includes(message.from_role) &&
100
+ ["human", "operator", "all"].includes(message.to_role));
101
+ if (reply)
102
+ return reply.body_md;
103
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
104
+ }
105
+ return null;
106
+ }
107
+ export async function resolveDecision(config, id, answer) {
108
+ await answerDecision(id, answer, config);
109
+ }
110
+ export async function updateAgent(config, id, fields) {
111
+ return patchAgent(id, fields, config);
112
+ }
113
+ export async function updateProviderCap(config, provider, cap) {
114
+ return updateCaps({ [provider]: cap }, config);
115
+ }
116
+ export async function loadLiveEvents(config, board) {
117
+ const liveIds = new Set(board.runs.filter((run) => run.status === "running").map((run) => run.id));
118
+ const keys = new Set(board.runs
119
+ .filter((run) => liveIds.has(run.id) && run.ticket_id)
120
+ .map((run) => board.tickets.find((ticket) => ticket.id === run.ticket_id)?.key)
121
+ .filter((key) => Boolean(key)));
122
+ const batches = await Promise.all([...keys].map((key) => listTicketEvents(key, undefined, undefined, config)));
123
+ return batches
124
+ .flatMap((batch) => batch.events)
125
+ .filter((event) => liveIds.has(event.run_id))
126
+ .sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq);
127
+ }
128
+ export function decisionOptions(decision) {
129
+ if (Array.isArray(decision.options))
130
+ return decision.options.filter((value) => typeof value === "string");
131
+ return [];
132
+ }
133
+ export function statusLabel(status) {
134
+ return status.replaceAll("_", " ");
135
+ }
136
+ export function statusTone(status) {
137
+ if (["queued", "running", "in_review"].includes(status))
138
+ return "blue";
139
+ if (["ready", "approved", "merged"].includes(status))
140
+ return "success";
141
+ if (["changes_requested", "needs_decision", "blocked"].includes(status))
142
+ return "warning";
143
+ if (["failed", "cancelled"].includes(status))
144
+ return "danger";
145
+ return "muted";
146
+ }
147
+ export function availabilityLabel(row) {
148
+ return row.reason;
149
+ }
150
+ export function epicProgress(board) {
151
+ return board.epics.map((epic) => {
152
+ const tickets = board.tickets.filter((ticket) => ticket.epic_id === epic.id);
153
+ return {
154
+ epic,
155
+ merged: tickets.filter((ticket) => ticket.status === "merged").length,
156
+ cancelled: tickets.filter((ticket) => ticket.status === "cancelled").length,
157
+ total: tickets.filter((ticket) => ticket.status !== "cancelled").length,
158
+ };
159
+ });
160
+ }
161
+ export function epicProgressCaption(merged, total, cancelled = 0) {
162
+ if (total === 0 && cancelled === 0)
163
+ return "No tickets";
164
+ return `${merged}/${total} merged${cancelled ? ` +${cancelled} cancelled` : ""}`;
165
+ }