@higherdev/cli 0.4.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,301 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { Box, Text, useStdout } from "ink";
4
+ import { UI } from "./theme.js";
5
+ export const WORD = "HigherDEV";
6
+ /**
7
+ * HigherDEV as a bitmap. Drawing the word as pixels rather than as whole block
8
+ * characters is what buys the resolution: a terminal cell is about twice as
9
+ * tall as it is wide, so a half block renders two pixel rows in one text row
10
+ * and the curves get somewhere to go.
11
+ *
12
+ * Rows 0-9 are cap height, 3-9 is the x-height, 10-11 is the descender.
13
+ */
14
+ const GLYPHS = {
15
+ H: [
16
+ "##...##",
17
+ "##...##",
18
+ "##...##",
19
+ "##...##",
20
+ "#######",
21
+ "#######",
22
+ "##...##",
23
+ "##...##",
24
+ "##...##",
25
+ "##...##",
26
+ ".......",
27
+ ".......",
28
+ ],
29
+ i: ["##", "##", "..", "##", "##", "##", "##", "##", "##", "##", "..", ".."],
30
+ g: [
31
+ ".......",
32
+ ".......",
33
+ ".......",
34
+ ".######",
35
+ "##...##",
36
+ "##...##",
37
+ "##...##",
38
+ "##...##",
39
+ "##...##",
40
+ ".######",
41
+ ".....##",
42
+ ".#####.",
43
+ ],
44
+ h: [
45
+ "##.....",
46
+ "##.....",
47
+ "##.....",
48
+ "##.###.",
49
+ "###..##",
50
+ "##...##",
51
+ "##...##",
52
+ "##...##",
53
+ "##...##",
54
+ "##...##",
55
+ ".......",
56
+ ".......",
57
+ ],
58
+ e: [
59
+ ".......",
60
+ ".......",
61
+ ".......",
62
+ ".#####.",
63
+ "##...##",
64
+ "##...##",
65
+ "#######",
66
+ "##.....",
67
+ "##...##",
68
+ ".#####.",
69
+ ".......",
70
+ ".......",
71
+ ],
72
+ r: [
73
+ "......",
74
+ "......",
75
+ "......",
76
+ "######",
77
+ "#####.",
78
+ "###...",
79
+ "##....",
80
+ "##....",
81
+ "##....",
82
+ "##....",
83
+ "......",
84
+ "......",
85
+ ],
86
+ D: [
87
+ "#####..",
88
+ "##..##.",
89
+ "##...##",
90
+ "##...##",
91
+ "##...##",
92
+ "##...##",
93
+ "##...##",
94
+ "##...##",
95
+ "##..##.",
96
+ "#####..",
97
+ ".......",
98
+ ".......",
99
+ ],
100
+ E: [
101
+ "######",
102
+ "######",
103
+ "##....",
104
+ "##....",
105
+ "#####.",
106
+ "#####.",
107
+ "##....",
108
+ "##....",
109
+ "######",
110
+ "######",
111
+ "......",
112
+ "......",
113
+ ],
114
+ V: [
115
+ "##...##",
116
+ "##...##",
117
+ "##...##",
118
+ "##...##",
119
+ "##...##",
120
+ ".##.##.",
121
+ ".##.##.",
122
+ ".##.##.",
123
+ "..###..",
124
+ "..###..",
125
+ ".......",
126
+ ".......",
127
+ ],
128
+ " ": ["..", "..", "..", "..", "..", "..", "..", "..", "..", "..", "..", ".."],
129
+ };
130
+ const PIXEL_ROWS = 12;
131
+ /** Columns each size needs. Anything wider than the terminal wraps, and a
132
+ * wrapped banner is worse than a small one. */
133
+ export const BANNER_WIDTH = {
134
+ // 56 pixels of letter and 8 of gap, doubled, with the gaps left single.
135
+ large: 128,
136
+ small: 64,
137
+ text: WORD.length,
138
+ };
139
+ /** Rows each size occupies, so a caller can budget the space before drawing. */
140
+ export const BANNER_HEIGHT = {
141
+ large: PIXEL_ROWS,
142
+ small: PIXEL_ROWS / 2,
143
+ text: 1,
144
+ };
145
+ /**
146
+ * The largest banner the terminal can hold. The large one is deliberately
147
+ * fussy: it is a third of a short window's height, so it waits for a terminal
148
+ * with room to spare rather than crowding out the board.
149
+ */
150
+ export function bannerSize(columns, rows) {
151
+ if (columns >= BANNER_WIDTH.large + 2 && rows >= BANNER_HEIGHT.large + 26)
152
+ return "large";
153
+ // Height matters as well as width. Six rows of wordmark is most of a very
154
+ // short window, and a splash that fills the window is one Ink cannot repaint
155
+ // in place.
156
+ if (columns >= BANNER_WIDTH.small + 2 && rows >= BANNER_HEIGHT.small + 8)
157
+ return "small";
158
+ return "text";
159
+ }
160
+ /** The word as a pixel grid, one string of `#` and `.` per pixel row. */
161
+ export function pixelRows(word = WORD, gap = 1) {
162
+ const rows = Array.from({ length: PIXEL_ROWS }, () => "");
163
+ const letters = [...word].filter((letter) => GLYPHS[letter]);
164
+ letters.forEach((letter, index) => {
165
+ const glyph = GLYPHS[letter];
166
+ const width = Math.max(...glyph.map((row) => row.length));
167
+ const spacer = index === letters.length - 1 ? 0 : gap;
168
+ for (let i = 0; i < PIXEL_ROWS; i += 1) {
169
+ rows[i] += (glyph[i] ?? "").padEnd(width, ".") + ".".repeat(spacer);
170
+ }
171
+ });
172
+ return rows;
173
+ }
174
+ const HALF = { "00": " ", "10": "▀", "01": "▄", "11": "█" };
175
+ /**
176
+ * Pixels to text. `small` packs two pixel rows into one text row with a half
177
+ * block, which is the shape that keeps the letters square. `large` spends two
178
+ * columns and a whole row on each pixel, which is the same word at twice the
179
+ * size and the same proportions.
180
+ */
181
+ export function bannerRows(size = "small", word = WORD) {
182
+ if (size === "text")
183
+ return [word];
184
+ if (size === "large") {
185
+ return pixelRows(word, 1).map((row) => [...row].map((pixel) => (pixel === "#" ? "██" : " ")).join(""));
186
+ }
187
+ const pixels = pixelRows(word, 1);
188
+ const rows = [];
189
+ for (let i = 0; i < pixels.length; i += 2) {
190
+ const top = pixels[i] ?? "";
191
+ const bottom = pixels[i + 1] ?? "";
192
+ let row = "";
193
+ for (let x = 0; x < top.length; x += 1) {
194
+ const key = `${top[x] === "#" ? "1" : "0"}${bottom[x] === "#" ? "1" : "0"}`;
195
+ row += HALF[key];
196
+ }
197
+ rows.push(row);
198
+ }
199
+ return rows;
200
+ }
201
+ /**
202
+ * How bright the sky is. 0 is the dark between flashes, 1 the blue afterglow
203
+ * a strike leaves behind, 2 and 3 the flash itself.
204
+ */
205
+ export const LEVELS = [
206
+ { color: UI.dim, dimColor: true, bold: false },
207
+ { color: UI.accent, dimColor: false, bold: false },
208
+ { color: UI.text, dimColor: false, bold: false },
209
+ { color: UI.text, dimColor: false, bold: true },
210
+ ];
211
+ /** Where it comes to rest once the storm passes, and how it looks unanimated. */
212
+ const SETTLED = { color: UI.text, dimColor: false, bold: false };
213
+ /** Four minutes. Long enough that the terminal keeps flickering while you work. */
214
+ export const STORM_MS = 240_000;
215
+ function mulberry32(seed) {
216
+ let a = seed >>> 0;
217
+ return () => {
218
+ a = (a + 0x6d2b79f5) | 0;
219
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
220
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
221
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
222
+ };
223
+ }
224
+ /**
225
+ * A storm, as a list of frames to hold. Sheet lightning does not fade evenly:
226
+ * it fires two or three times in a fifth of a second, leaves the sky blue for
227
+ * a moment, then does nothing at all for several seconds. The gaps are what
228
+ * make it read as weather rather than as a blinking cursor, so they are the
229
+ * longest thing in here.
230
+ */
231
+ export function stormSchedule(opts = {}) {
232
+ const durationMs = opts.durationMs ?? STORM_MS;
233
+ const random = mulberry32(opts.seed ?? 1);
234
+ const between = (min, max) => min + Math.floor(random() * (max - min + 1));
235
+ const frames = [];
236
+ let elapsed = 0;
237
+ const push = (frame) => {
238
+ frames.push(frame);
239
+ elapsed += frame.ms;
240
+ };
241
+ while (elapsed < durationMs) {
242
+ const strikes = between(2, 5);
243
+ for (let strike = 0; strike < strikes; strike += 1) {
244
+ const last = strike === strikes - 1;
245
+ // The final flash of a burst holds, then fades down through the levels
246
+ // instead of being cut off. A real strike leaves the sky lit for a
247
+ // moment after it, and each step of the way out is slower than the last.
248
+ push({ level: 3, ms: last ? between(110, 170) : between(35, 70) });
249
+ if (last) {
250
+ push({ level: 2, ms: between(120, 180) });
251
+ push({ level: 1, ms: between(180, 260) });
252
+ break;
253
+ }
254
+ push({ level: 1, ms: between(25, 55) });
255
+ // Some strikes flare twice before the sky lets go of them.
256
+ if (random() < 0.35)
257
+ push({ level: 2, ms: between(30, 60) });
258
+ push({ level: 0, ms: between(45, 160) });
259
+ }
260
+ push({ level: 0, ms: between(600, 3000), quiet: true });
261
+ }
262
+ // The last quiet stretch is cut short rather than left to run past the hour
263
+ // it was asked for, so the storm lasts what the caller said it would.
264
+ const last = frames[frames.length - 1];
265
+ if (last && elapsed > durationMs)
266
+ last.ms = Math.max(1, last.ms - (elapsed - durationMs));
267
+ return frames;
268
+ }
269
+ export function Banner({ animate = true, onDone, columns, rows, seed, durationMs, }) {
270
+ const { stdout } = useStdout();
271
+ // A pty with no size reports 0. Guessing small keeps the word inside it.
272
+ const wide = columns ?? (stdout?.columns && stdout.columns > 0 ? stdout.columns : 80);
273
+ const tall = rows ?? (stdout?.rows && stdout.rows > 0 ? stdout.rows : 24);
274
+ const size = bannerSize(wide, tall);
275
+ // Built once and only paused by `animate`, never rebuilt: a frame that grows
276
+ // past the window stops the flashing, and a frame that shrinks again picks up
277
+ // where the storm left off rather than starting a second one.
278
+ const storm = useMemo(() => stormSchedule({ seed: seed ?? Math.floor(Math.random() * 2 ** 31), durationMs }), [seed, durationMs]);
279
+ const [frame, setFrame] = useState(0);
280
+ const announced = useRef(false);
281
+ // The word settles into its readable state long before the storm ends, so
282
+ // whatever is waiting on the banner never waits four minutes for it.
283
+ useEffect(() => {
284
+ if (announced.current)
285
+ return;
286
+ if (!animate || storm.length === 0 || storm[frame]?.quiet) {
287
+ announced.current = true;
288
+ onDone?.();
289
+ }
290
+ }, [animate, storm, frame, onDone]);
291
+ useEffect(() => {
292
+ if (!animate || frame >= storm.length)
293
+ return;
294
+ const timer = setTimeout(() => setFrame((current) => current + 1), storm[frame].ms);
295
+ return () => clearTimeout(timer);
296
+ }, [animate, storm, frame]);
297
+ const over = !animate || frame >= storm.length;
298
+ const style = over ? SETTLED : LEVELS[storm[frame].level];
299
+ const lines = bannerRows(size);
300
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: lines.map((line, index) => (_jsx(Text, { color: style.color, dimColor: style.dimColor, bold: style.bold, children: line }, index))) }));
301
+ }
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { UI, speakerStyle } from "./theme.js";
4
+ /**
5
+ * One turn. The frame says who is speaking, so the text can stay white
6
+ * throughout and still be attributable at a glance.
7
+ */
8
+ export function Bubble({ message, width }) {
9
+ const style = speakerStyle(message.speaker);
10
+ const body = message.body.replace(/\s+$/, "");
11
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, width: width, children: _jsxs(Box, { borderStyle: style.borderStyle, borderColor: style.borderColor, ...(style.backgroundColor ? { backgroundColor: style.backgroundColor } : {}), flexDirection: "column", paddingX: 1, children: [style.label ? (_jsx(Text, { color: UI.text, bold: true, children: style.label })) : null, (message.steps ?? []).map((step, index) => (_jsx(Text, { color: UI.dim, children: step }, index))), body ? (_jsx(Text, { color: UI.text, wrap: "wrap", children: body })) : message.pending ? (_jsx(Text, { color: UI.dim, children: "thinking\u2026" })) : null] }) }));
12
+ }
@@ -0,0 +1,206 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { BOARD_COLUMNS, availabilityLabel, epicProgressCaption, epicProgress, statusLabel, statusTone, } from "./data.js";
4
+ import { elapsed, 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 width at which the board and the agents stop competing for the same
11
+ * columns and can sit next to each other. Below it they stack, because two
12
+ * half-width columns of truncated text read worse than one of each.
13
+ */
14
+ export const SPLIT_AT = 96;
15
+ /** How the cockpit divides the row it is given. */
16
+ export function splitWidths(width) {
17
+ if (width < SPLIT_AT)
18
+ return null;
19
+ // The board carries a key, a title and an owner; the agents carry a name and
20
+ // a state. Three fifths and two fifths is what stops the titles truncating
21
+ // long before the agent rows need the room.
22
+ const agents = Math.max(30, Math.min(42, Math.round(width * 0.4)));
23
+ return { board: width - agents - 1, agents };
24
+ }
25
+ /**
26
+ * The board as one flat list, headings included. Everything that has to agree
27
+ * about position reads this: the cursor moves through it, the scroll window is
28
+ * computed over it, and the panel draws a slice of it.
29
+ */
30
+ export function boardEntries(board) {
31
+ const entries = [];
32
+ for (const status of BOARD_COLUMNS) {
33
+ const tickets = board.tickets.filter((ticket) => ticket.status === status);
34
+ if (tickets.length === 0)
35
+ continue;
36
+ entries.push({ key: `h:${status}`, kind: "heading", status: status, count: tickets.length });
37
+ for (const ticket of tickets)
38
+ entries.push({ key: ticket.id, kind: "ticket", ticket });
39
+ }
40
+ return entries;
41
+ }
42
+ /** Ticket ids in the order they are drawn, which is the order the cursor moves. */
43
+ export function boardTicketIds(board) {
44
+ return boardEntries(board)
45
+ .filter((entry) => entry.kind === "ticket")
46
+ .map((entry) => entry.key);
47
+ }
48
+ /**
49
+ * Where the cursor lands after a move. Null when there is nothing to select.
50
+ * It stops at both ends rather than wrapping, so holding a key down settles
51
+ * somewhere predictable instead of cycling.
52
+ */
53
+ export function nextCursor(order, current, delta) {
54
+ if (order.length === 0)
55
+ return null;
56
+ const at = current ? order.indexOf(current) : -1;
57
+ // No selection yet: a step down starts at the top, a step up at the bottom.
58
+ if (at < 0)
59
+ return delta > 0 ? order[0] : order[order.length - 1];
60
+ return order[Math.min(order.length - 1, Math.max(0, at + delta))];
61
+ }
62
+ /**
63
+ * Which slice of a longer list to draw. Computed from the cursor alone rather
64
+ * than remembered, so the window cannot drift out of step with the selection.
65
+ * The cursor sits mid panel except near either end, where the list stops
66
+ * moving and the cursor travels instead.
67
+ */
68
+ export function scrollWindow(count, rows, focus) {
69
+ if (rows >= count)
70
+ return { start: 0, end: count };
71
+ if (rows <= 0)
72
+ return { start: 0, end: 0 };
73
+ if (focus < 0)
74
+ return { start: 0, end: rows };
75
+ const start = Math.max(0, Math.min(focus - Math.floor(rows / 2), count - rows));
76
+ return { start, end: start + rows };
77
+ }
78
+ /**
79
+ * What a windowed panel is hiding, said in its heading rather than in a row.
80
+ * Counted in tickets, not in entries: the entry list carries a heading per
81
+ * group, and counting those would put numbers beside the ticket total that do
82
+ * not add up to it.
83
+ */
84
+ export function hiddenTickets(entries, start, end) {
85
+ const tickets = (from, to) => entries.slice(from, to).filter((entry) => entry.kind === "ticket").length;
86
+ return { above: tickets(0, start), below: tickets(end, entries.length) };
87
+ }
88
+ function hiddenNote(total, hidden) {
89
+ if (hidden.above === 0 && hidden.below === 0)
90
+ return `${total}`;
91
+ return `${total} ${hidden.above > 0 ? `${hidden.above}↑ ` : ""}${hidden.below > 0 ? `${hidden.below}↓` : ""}`.trimEnd();
92
+ }
93
+ export function BoardColumn({ board, width, rows, cursor, }) {
94
+ const entries = boardEntries(board);
95
+ const inner = Math.max(0, rows - 1);
96
+ const focus = cursor ? entries.findIndex((entry) => entry.key === cursor) : -1;
97
+ const { start, end } = scrollWindow(entries.length, inner, focus);
98
+ const title = Math.max(8, width - 10);
99
+ return (_jsx(Panel, { width: width, rows: rows, children: [
100
+ _jsx(Heading, { text: "Board", note: hiddenNote(board.tickets.length, hiddenTickets(entries, start, end)) }, "h"),
101
+ ...(entries.length === 0
102
+ ? [
103
+ _jsx(Text, { color: UI.dim, children: "No tickets yet." }, "empty"),
104
+ ]
105
+ : entries.slice(start, end).map((entry) => entry.kind === "heading" ? (_jsxs(Text, { color: inkColor(statusTone(entry.status)), wrap: "truncate", children: [statusLabel(entry.status), " ", _jsxs(Text, { color: UI.dim, children: ["(", entry.count, ")"] })] }, entry.key)) : (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: UI.accent, children: entry.key === cursor ? "›" : " " }) }), _jsx(Box, { width: 8, flexShrink: 0, children: _jsx(Text, { color: UI.text, bold: true, wrap: "truncate", children: entry.ticket.key }) }), _jsx(Text, { color: entry.ticket.stuck ? UI.warn : UI.text, inverse: entry.key === cursor, wrap: "truncate", children: truncate(entry.ticket.title, title - 2) })] }, entry.key)))),
106
+ ] }));
107
+ }
108
+ export function AgentsColumn({ board, width, rows, }) {
109
+ // Whoever is working comes first. An idle registry is what you scroll past.
110
+ const ordered = [...board.agents].sort((left, right) => {
111
+ const busy = (id) => board.runs.some((run) => run.agent_id === id && run.status === "running") ? 0 : 1;
112
+ return busy(left.id) - busy(right.id) || left.display_name.localeCompare(right.display_name);
113
+ });
114
+ const shown = ordered.slice(0, contentRows(rows, ordered.length));
115
+ return (_jsx(Panel, { width: width, rows: rows, children: [
116
+ _jsx(Heading, { text: "Agents", note: `${board.agents.filter((a) => a.enabled).length} on` }, "h"),
117
+ ...(board.agents.length === 0
118
+ ? [
119
+ _jsx(Text, { color: UI.dim, children: "No agents yet." }, "empty"),
120
+ ]
121
+ : []),
122
+ ...shown.map((agent) => {
123
+ const run = board.runs.find((row) => row.agent_id === agent.id && row.status === "running");
124
+ const ticket = run?.ticket_id
125
+ ? board.tickets.find((item) => item.id === run.ticket_id)
126
+ : undefined;
127
+ const availability = board.availability.find((row) => row.provider === agent.provider);
128
+ const blocked = availability && !availability.available ? availabilityLabel(availability) : "";
129
+ const tone = !agent.enabled ? "muted" : run ? "blue" : blocked ? "warning" : "muted";
130
+ const name = Math.max(8, Math.min(22, width - 14));
131
+ return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Box, { width: name, flexShrink: 0, children: _jsx(Text, { color: UI.text, wrap: "truncate", children: truncate(agent.display_name, name - 1) }) }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: run
132
+ ? `${ticket ? `${ticket.key} ` : ""}${elapsed(run.started_at ?? run.created_at)}`
133
+ : blocked || (agent.enabled ? "idle" : "off") })] }, agent.id));
134
+ }),
135
+ _jsx(More, { count: ordered.length - shown.length }, "more"),
136
+ ] }));
137
+ }
138
+ const KIND_COLOR = {
139
+ error: UI.danger,
140
+ tool: UI.dim,
141
+ status: UI.dim,
142
+ text: UI.text,
143
+ };
144
+ /**
145
+ * What the agents are doing, as it arrives. The panel is a fixed height on
146
+ * purpose: it is the one thing here that grows without limit, and a live frame
147
+ * that outgrows the window stops repainting in place.
148
+ */
149
+ export function StreamPanel({ lines, width, rows, live, }) {
150
+ const name = Math.max(8, Math.min(18, Math.round(width / 5)));
151
+ const budget = Math.max(0, rows - 1);
152
+ return (_jsx(Panel, { width: width, rows: rows, children: [
153
+ _jsx(Heading, { text: "Activity", note: live ? "" : "idle" }, "h"),
154
+ ...(lines.length === 0
155
+ ? [
156
+ _jsx(Text, { color: UI.dim, children: live ? "Waiting for the first step." : "Nothing running." }, "empty"),
157
+ ]
158
+ : []),
159
+ ...lines.slice(-budget).map((line) => (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: name, flexShrink: 0, children: _jsx(Text, { color: UI.dim, wrap: "truncate", children: truncate(line.agent, name - 1) }) }), _jsxs(Text, { color: KIND_COLOR[line.kind], wrap: "truncate", children: [line.kind === "tool" ? "· " : "", truncate(line.title, Math.max(12, width - name - 3))] })] }, line.id))),
160
+ ] }));
161
+ }
162
+ /** How many rows the epics strip wants, heading included, or none. */
163
+ export function epicsRows(board, cap = 3) {
164
+ const open = epicProgress(board).filter((row) => row.epic.status !== "done");
165
+ return open.length === 0 ? 0 : Math.min(open.length, cap) + 1;
166
+ }
167
+ /**
168
+ * Epic progress, above the board it explains. Capped hard: this is the summary
169
+ * line, and the tickets underneath are what you are here to read.
170
+ */
171
+ export function EpicsStrip({ board, width, rows }) {
172
+ const open = epicProgress(board).filter((row) => row.epic.status !== "done");
173
+ if (open.length === 0 || rows < 2)
174
+ return null;
175
+ const shown = open.slice(0, contentRows(rows, open.length));
176
+ const title = Math.max(12, Math.min(52, width - 24));
177
+ return (_jsx(Panel, { width: width, rows: rows, children: [
178
+ _jsx(Heading, { text: "Epics", note: `${open.length}` }, "h"),
179
+ ...shown.map((row) => (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: title, flexShrink: 0, children: _jsx(Text, { color: UI.text, wrap: "truncate", children: truncate(row.epic.title, title - 1) }) }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: epicProgressCaption(row.merged, row.total, row.cancelled) })] }, row.epic.id))),
180
+ _jsx(More, { count: open.length - shown.length }, "more"),
181
+ ] }));
182
+ }
183
+ /** The board and the agents together, side by side when there is room. */
184
+ export function Cockpit({ board, width, rows, cursor, }) {
185
+ // The epics strip is spent out of the same budget, so adding it shortens the
186
+ // board rather than making the frame taller.
187
+ const epics = Math.min(epicsRows(board), Math.max(0, rows - 4));
188
+ const rest = rows - (epics > 0 ? epics + 1 : 0);
189
+ const columns = renderColumns(board, width, rest, cursor);
190
+ if (epics === 0)
191
+ return columns;
192
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(EpicsStrip, { board: board, width: width, rows: epics }), _jsx(Box, { height: 1 }), columns] }));
193
+ }
194
+ function renderColumns(board, width, rows, cursor) {
195
+ const split = splitWidths(width);
196
+ if (!split) {
197
+ // Stacked, so the two share the budget instead of each taking all of it,
198
+ // and the blank row between them is spent out of the same allowance. When
199
+ // there is not enough for both, the agents win: a board cropped to one
200
+ // heading says less than a short list of who is working.
201
+ const agents = Math.max(1, Math.min(board.agents.length + 1, Math.floor(rows / 3)));
202
+ const boardRows = Math.max(0, rows - agents - 1);
203
+ return (_jsxs(Box, { flexDirection: "column", children: [boardRows > 0 ? (_jsx(BoardColumn, { board: board, width: width, rows: boardRows, cursor: cursor })) : null, boardRows > 0 ? _jsx(Box, { height: 1 }) : null, _jsx(AgentsColumn, { board: board, width: width, rows: agents })] }));
204
+ }
205
+ return (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", children: [_jsx(BoardColumn, { board: board, width: split.board, rows: rows, cursor: cursor }), _jsx(Box, { width: 1, flexShrink: 0 }), _jsx(AgentsColumn, { board: board, width: split.agents, rows: rows })] }));
206
+ }
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { relativeTime, truncate } from "../out/format.js";
4
+ import { clipToRows } from "./Panels.js";
5
+ import { decisionOptions } from "./data.js";
6
+ import { UI } from "./theme.js";
7
+ /**
8
+ * Everything the panel spends that is not question or options: two rows of
9
+ * frame, the heading, the line saying how to answer, and the blank row after.
10
+ */
11
+ const DECISION_CHROME = 5;
12
+ /**
13
+ * Rows the flag wants, so the caller can take them out of the panel budget
14
+ * before anything is drawn. Zero when nothing is waiting.
15
+ */
16
+ export function decisionRows(decisions, cap = 9) {
17
+ if (decisions.length === 0)
18
+ return 0;
19
+ // Two rows of question is the least worth reading, and the options after it.
20
+ return Math.min(cap, DECISION_CHROME + 2 + decisionOptions(decisions[0]).length);
21
+ }
22
+ /**
23
+ * A decision waiting on you, in the way of the prompt rather than counted in
24
+ * the status line. This is the one thing in the frame that is not a report:
25
+ * nothing moves on the ticket it blocks until it is answered, so it stays in
26
+ * front of you until it is.
27
+ */
28
+ export function DecisionPanel({ decisions, board, width, rows, }) {
29
+ if (decisions.length === 0)
30
+ return null;
31
+ const decision = decisions[0];
32
+ const ticket = board?.tickets.find((row) => row.id === decision.ticket_id);
33
+ const options = decisionOptions(decision);
34
+ const inner = Math.max(8, width - 4);
35
+ const forBody = rows - DECISION_CHROME;
36
+ // Too little room for a frame at all. The flag still has to appear, so it
37
+ // shrinks to the one line that says something is waiting and how to answer.
38
+ if (forBody < 1) {
39
+ return (_jsxs(Box, { width: width, flexWrap: "nowrap", children: [_jsx(Text, { color: UI.warn, bold: true, wrap: "truncate", children: decisions.length > 1 ? `${decisions.length} decisions waiting` : "Decision waiting" }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: ` ${truncate(decision.question_md, Math.max(8, width - 26))} /decide` })] }));
40
+ }
41
+ // The question gets a row before any option does, and the options take what
42
+ // is left after it.
43
+ const optionRows = Math.min(options.length, Math.max(0, forBody - 1));
44
+ const question = clipToRows(decision.question_md.trim(), inner, forBody - optionRows);
45
+ return (_jsxs(Box, { borderStyle: "single", borderColor: UI.warn, flexDirection: "column", paddingX: 1, width: width, marginBottom: 1, children: [_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Text, { color: UI.warn, bold: true, wrap: "truncate", children: decisions.length > 1 ? `Decision 1 of ${decisions.length}` : "Decision" }), _jsxs(Text, { color: UI.dim, wrap: "truncate", children: [ticket ? ` ${ticket.key}` : "", ` asked by ${decision.asked_by_role}`, ` ${relativeTime(decision.created_at)}`] })] }), _jsx(Text, { color: UI.text, wrap: "wrap", children: question }), options.slice(0, optionRows).map((option, index) => (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: 3, flexShrink: 0, children: _jsxs(Text, { color: UI.accent, children: [index + 1, ")"] }) }), _jsx(Text, { color: UI.text, wrap: "truncate", children: truncate(option, Math.max(8, inner - 4)) })] }, index))), _jsx(Text, { color: UI.dim, wrap: "truncate", children: options.length
46
+ ? `/decide 1 to ${options.length}, or /decide <your answer>`
47
+ : "/decide <your answer>" })] }));
48
+ }
@@ -0,0 +1,30 @@
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
+ import { helpNameColumn } from "./height.js";
5
+ export const HELP_FOOTER = "Anything not starting with / goes to whoever you are talking to. Ctrl-C leaves.";
6
+ /** Everything you can type. The app is driven from here, not from flags. */
7
+ export const COMMANDS = [
8
+ { name: "/board", help: "the kanban board" },
9
+ { name: "/inbox", help: "decisions and messages waiting on you" },
10
+ { name: "/ticket", args: "HD-12", help: "open one ticket" },
11
+ { name: "/decide", args: "2 | text", help: "answer the decision on screen" },
12
+ { name: "/agents", help: "every agent in full, and the live run stream" },
13
+ { name: "/settings", help: "change provider caps and agent settings" },
14
+ { name: "/workspace", args: "[slug]", help: "switch workspace, or list the ones you can reach" },
15
+ { name: "/feed", help: "what just happened" },
16
+ { name: "/orchestrator", help: "talk to the agent that gets work in flight finished" },
17
+ { name: "/refresh", help: "reload the board now" },
18
+ { name: "/help", help: "this list" },
19
+ { name: "/exit", help: "leave" },
20
+ ];
21
+ /**
22
+ * One line saying the list exists, for a window too short to hold it. `/help`
23
+ * prints the list into the log, which scrolls, so nothing is out of reach.
24
+ */
25
+ export function HelpHint({ width }) {
26
+ return (_jsx(Box, { width: width, marginBottom: 1, flexWrap: "nowrap", children: _jsx(Text, { color: UI.dim, wrap: "truncate", children: `${COMMANDS.length} commands. Type /help for the list, or just say what you want.` }) }));
27
+ }
28
+ export function Help({ width }) {
29
+ return (_jsxs(Box, { borderStyle: "single", borderColor: UI.text, flexDirection: "column", paddingX: 2, width: width, children: [_jsx(Text, { color: UI.text, bold: true, children: "Commands" }), COMMANDS.map((command) => (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: helpNameColumn(COMMANDS), flexShrink: 0, children: _jsxs(Text, { color: UI.text, wrap: "truncate", children: [command.name, command.args ? ` ${command.args}` : ""] }) }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: command.help })] }, command.name))), _jsx(Text, { color: UI.dim, children: HELP_FOOTER })] }));
30
+ }