ccdeck 1.43.0 → 1.45.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,570 @@
1
+ // Everything the deck knows about the terminal it is printing into.
2
+ //
3
+ // The terminal is the first surface of this product and, for the seconds before
4
+ // the browser opens, the only one — so it gets the same treatment the canvas
5
+ // got. The rules below are the whole of it: detect what this terminal can do,
6
+ // then degrade, rather than picking the floor and printing the same 16 colours
7
+ // into a pipe, a CI log and a truecolor emulator alike.
8
+ //
9
+ // It lives here, apart from bin/deck.js, for the reason supervisor.mjs and
10
+ // npx.mjs do: bin/deck.js installs hooks and binds a port the moment it is
11
+ // imported, so none of this could be checked any other way — and "what does the
12
+ // output look like at 40 columns, with NO_COLOR, on a Windows console" is
13
+ // exactly the kind of question that is only ever answered by asserting it.
14
+ //
15
+ // Nothing here writes anything or reads process state on its own: every input
16
+ // (env, isTTY, platform, columns) is a parameter, defaulted from the real thing.
17
+ // The two exceptions are the cursor constants, which are strings the caller
18
+ // writes, because who restores the cursor and when is a lifecycle question and
19
+ // belongs with the lifecycle.
20
+
21
+ // ── colour profile ───────────────────────────────────────────────────────────
22
+ //
23
+ // Detected once, in the order the conventions themselves establish:
24
+ // NO_COLOR (no-color.org — present and non-empty means no colour, whatever the
25
+ // value) beats FORCE_COLOR beats what the terminal advertises. Truecolor is
26
+ // effectively universal now, and it is the only tier on which the deck's accent
27
+ // is actually the deck's accent rather than whatever the user's theme decided
28
+ // cyan is — so it is worth asking for, and worth degrading from cleanly.
29
+
30
+ /** Present and non-empty, which is what every one of these variables means. */
31
+ const set = (v) => v != null && v !== "";
32
+
33
+ /** Anything below a space, or DEL. A URL is going inside an escape sequence, so
34
+ * a control character in it would close that sequence early. */
35
+ const hasControl = (s) => [...String(s)].some((c) => c.charCodeAt(0) <= 0x20 || c.charCodeAt(0) === 0x7f);
36
+
37
+ /** Terminals that do truecolor without saying so in COLORTERM. */
38
+ const TRUECOLOR_PROGRAMS = new Set(["iTerm.app", "vscode", "WezTerm", "ghostty", "Hyper", "rio", "Tabby"]);
39
+
40
+ /** FORCE_COLOR's levels, as every CLI that honours it reads them. `undefined`
41
+ * means the variable is absent and detection continues. */
42
+ function forcedProfile(raw) {
43
+ if (raw == null) return undefined;
44
+ const v = String(raw).trim().toLowerCase();
45
+ if (v === "0" || v === "false") return "none";
46
+ if (v === "2") return "ansi256";
47
+ if (v === "3") return "truecolor";
48
+ return "ansi16"; // "", "1", "true", anything else: colour, at the safe floor
49
+ }
50
+
51
+ /**
52
+ * What this terminal can be asked for: "none" | "ansi16" | "ansi256" | "truecolor".
53
+ *
54
+ * A Windows TTY floors at ansi16 rather than none: libuv translates SGR into
55
+ * console API calls for consoles that cannot parse escapes themselves, so the
56
+ * 16 colours arrive on legacy conhost too. Nothing above that tier is assumed
57
+ * there — Windows Terminal says so through WT_SESSION, and conhost never does.
58
+ */
59
+ export function colorProfile({ env = process.env, isTTY = false, platform = process.platform } = {}) {
60
+ if (set(env.NO_COLOR)) return "none";
61
+ const forced = forcedProfile(env.FORCE_COLOR);
62
+ if (forced !== undefined) return forced;
63
+ if (!isTTY) return "none";
64
+
65
+ const term = String(env.TERM ?? "").toLowerCase();
66
+ if (term === "dumb") return "none";
67
+
68
+ const colorterm = String(env.COLORTERM ?? "").toLowerCase();
69
+ if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor";
70
+ if (set(env.WT_SESSION)) return "truecolor";
71
+ if (TRUECOLOR_PROGRAMS.has(String(env.TERM_PROGRAM ?? ""))) return "truecolor";
72
+ if (term === "xterm-kitty" || term === "alacritty" || term.includes("truecolor")) return "truecolor";
73
+ if (term.includes("256")) return "ansi256";
74
+ // A TTY that advertises nothing still takes colour on every platform we
75
+ // support; 16 is the tier nothing has to advertise to have.
76
+ return "ansi16";
77
+ }
78
+
79
+ // ── colours ──────────────────────────────────────────────────────────────────
80
+
81
+ /** rgb → the nearest xterm-256 index: the 6×6×6 cube, or the 24-step grey ramp
82
+ * for anything neutral, which is where the muted tones land. */
83
+ function to256(r, g, b) {
84
+ if (r === g && g === b) {
85
+ if (r < 8) return 16;
86
+ if (r > 248) return 231;
87
+ return Math.round(((r - 8) / 247) * 24) + 232;
88
+ }
89
+ return 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5);
90
+ }
91
+
92
+ /**
93
+ * One colour, expressed as far as this profile reaches.
94
+ *
95
+ * `fallback` is the 16-colour SGR to use when that is all there is — chosen per
96
+ * colour rather than derived, because the nearest of eight hues to a pastel is
97
+ * a question with a taste answer, not an arithmetic one.
98
+ */
99
+ export function fg([r, g, b], profile, fallback) {
100
+ if (profile === "truecolor") return `\x1b[38;2;${r};${g};${b}m`;
101
+ if (profile === "ansi256") return `\x1b[38;5;${to256(r, g, b)}m`;
102
+ if (profile === "ansi16") return fallback;
103
+ return "";
104
+ }
105
+
106
+ // The canvas' own tokens (src/web/styles.css), so the terminal and the page are
107
+ // one product rather than two. The ramp is the wordmark's vertical gradient:
108
+ // lit at the top, settling into the deeper tone at the baseline.
109
+ const BRAND = {
110
+ accentSoft: [[186, 230, 253], "\x1b[96m"],
111
+ accent: [[125, 211, 252], "\x1b[96m"],
112
+ accentDeep: [[56, 189, 248], "\x1b[36m"],
113
+ ok: [[134, 239, 172], "\x1b[32m"],
114
+ warn: [[252, 211, 77], "\x1b[33m"],
115
+ err: [[252, 165, 165], "\x1b[31m"],
116
+ muted: [[126, 130, 140], "\x1b[2m"],
117
+ };
118
+
119
+ /**
120
+ * The semantic names every call site uses — no raw escape ever appears in
121
+ * bin/deck.js again. Empty strings under "none", so the same template literals
122
+ * produce escape-free output in a pipe without a branch at each one.
123
+ */
124
+ export function palette(profile) {
125
+ const on = profile !== "none";
126
+ const out = {
127
+ reset: on ? "\x1b[0m" : "",
128
+ bold: on ? "\x1b[1m" : "",
129
+ dim: on ? "\x1b[2m" : "",
130
+ };
131
+ for (const [name, [rgb, fallback]] of Object.entries(BRAND)) out[name] = fg(rgb, profile, fallback);
132
+ return out;
133
+ }
134
+
135
+ // ── hyperlinks ───────────────────────────────────────────────────────────────
136
+ //
137
+ // OSC 8 is an allowlist rather than a probe: a terminal that does not know the
138
+ // sequence is supposed to ignore it, and most do, but "most" is not a promise
139
+ // worth making to a legacy Windows console — and tmux below 3.4 passes the
140
+ // escape through mangled, which is worse than plain text. So links are emitted
141
+ // only where they are known to arrive.
142
+
143
+ const HYPERLINK_PROGRAMS = new Set(["iTerm.app", "vscode", "WezTerm", "ghostty", "Hyper", "rio", "Tabby"]);
144
+
145
+ export function supportsHyperlinks({ env = process.env, profile = "truecolor" } = {}) {
146
+ // NO_COLOR takes hyperlinks with it: the convention is about decoration, and
147
+ // an escape sequence wrapped around a path is decoration.
148
+ if (profile === "none") return false;
149
+ const term = String(env.TERM ?? "").toLowerCase();
150
+ if (set(env.TMUX) || term.startsWith("screen")) return false;
151
+ if (HYPERLINK_PROGRAMS.has(String(env.TERM_PROGRAM ?? ""))) return true;
152
+ if (set(env.WT_SESSION)) return true;
153
+ if (set(env.KITTY_WINDOW_ID) || term === "xterm-kitty") return true;
154
+ if (set(env.KONSOLE_VERSION) || set(env.DOMTERM)) return true;
155
+ const vte = Number.parseInt(String(env.VTE_VERSION ?? ""), 10);
156
+ return Number.isFinite(vte) && vte >= 5000; // GNOME Terminal 3.26+
157
+ }
158
+
159
+ /** `text` as a link to `url`, or just `text`. BEL-terminated, which is the form
160
+ * the widest set of terminals accepts. A url carrying a control character is
161
+ * dropped rather than escaped — it could only have come from a path we should
162
+ * not be linking anyway. */
163
+ export function link(text, url, enabled) {
164
+ if (!enabled || !url || hasControl(url)) return text;
165
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
166
+ }
167
+
168
+ // ── glyphs ───────────────────────────────────────────────────────────────────
169
+ //
170
+ // Windows Terminal renders all of these; `cmd.exe` on a non-UTF-8 code page
171
+ // renders none of them, and this project's rule is that everything works on
172
+ // Windows. So there are two tiers and every glyph the deck prints — punctuation
173
+ // included, since an em dash is as absent from CP437 as a check mark — comes
174
+ // from one of them.
175
+
176
+ const UNICODE_GLYPHS = {
177
+ ok: "✓", fail: "✗", warn: "⚠", stop: "◉", restart: "↻", cancel: "✕",
178
+ up: "↑", play: "▶", pulse: "●",
179
+ arrow: "→", ellipsis: "…", dash: "—", bullet: "·",
180
+ };
181
+
182
+ const ASCII_GLYPHS = {
183
+ ok: "+", fail: "x", warn: "!", stop: "*", restart: "~", cancel: "x",
184
+ up: "^", play: ">", pulse: "*",
185
+ arrow: "->", ellipsis: "...", dash: "-", bullet: "-",
186
+ };
187
+
188
+ /**
189
+ * Whether this terminal can be trusted with the box-drawing and arrow glyphs.
190
+ *
191
+ * Everywhere but Windows, yes — except the Linux virtual console, whose font is
192
+ * 256 glyphs and none of them are these. On Windows it is the other way round:
193
+ * an allowlist of the emulators that ship a real font and a UTF-8 code page,
194
+ * because the default console does neither.
195
+ */
196
+ export function unicodeOK({ env = process.env, platform = process.platform } = {}) {
197
+ const term = String(env.TERM ?? "").toLowerCase();
198
+ if (platform !== "win32") return term !== "linux" && term !== "dumb";
199
+ if (set(env.WT_SESSION)) return true;
200
+ if (String(env.TERM_PROGRAM ?? "") === "vscode") return true;
201
+ if (set(env.ConEmuTask) || set(env.WSL_DISTRO_NAME)) return true;
202
+ if (String(env.TERMINAL_EMULATOR ?? "") === "JetBrains-JediTerm") return true;
203
+ return term.startsWith("xterm") || term === "alacritty";
204
+ }
205
+
206
+ export function glyphs(unicode) {
207
+ return unicode ? { ...UNICODE_GLYPHS } : { ...ASCII_GLYPHS };
208
+ }
209
+
210
+ // One definition, for the banner, the step spinner and anything later. The
211
+ // braille frames are the nicest thing a terminal can do with one cell; the
212
+ // ASCII tier gets the rotating bar, which is the only spinner CP437 has.
213
+ const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
214
+ const ASCII_FRAMES = ["|", "/", "-", "\\"];
215
+
216
+ export function spinnerFrames(unicode) {
217
+ return unicode ? BRAILLE_FRAMES.slice() : ASCII_FRAMES.slice();
218
+ }
219
+
220
+ // ── motion ───────────────────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * Whether anything is allowed to move — the terminal's `prefers-reduced-motion`.
224
+ *
225
+ * A pipe, a file, a CI log and a NO_COLOR terminal all get the same treatment:
226
+ * no sleeps, no `\r` repaints, no spinner, no pulse. Every one of those either
227
+ * cannot show motion or is somebody's log file, and a log file full of carriage
228
+ * returns is the artefact this rule exists to prevent.
229
+ */
230
+ export function motionOK({ env = process.env, isTTY = false, profile = colorProfile({ env, isTTY }) } = {}) {
231
+ if (!isTTY || profile === "none") return false;
232
+ const ci = String(env.CI ?? "").trim().toLowerCase();
233
+ if (set(ci) && ci !== "0" && ci !== "false") return false;
234
+ return true;
235
+ }
236
+
237
+ // ── width ────────────────────────────────────────────────────────────────────
238
+
239
+ /** The visible text: SGR colour and OSC 8 hyperlinks removed. Everything that
240
+ * measures a line for layout measures it through here — a padded line that
241
+ * counted its own escapes is how a `\r` repaint starts leaving debris. */
242
+ export function stripAnsi(s) {
243
+ return String(s)
244
+ .replace(/\x1b\]8;;.*?(?:\x07|\x1b\\)/g, "")
245
+ .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
246
+ }
247
+
248
+ export function visibleWidth(s) {
249
+ return stripAnsi(s).length;
250
+ }
251
+
252
+ /** How wide the terminal is, or 80 when it will not say — a pipe, a CI runner,
253
+ * a terminal that never sent SIGWINCH. 80 is the assumption every one of them
254
+ * is already built around, and the layout below never needs more than 40. */
255
+ export function termColumns(stream = process.stdout) {
256
+ const c = stream?.columns;
257
+ return Number.isInteger(c) && c > 0 ? c : 80;
258
+ }
259
+
260
+ /** The status column, computed from the labels rather than counted into each
261
+ * string by hand. A label one character longer used to break the alignment of
262
+ * every row silently. */
263
+ export function labelColumn(labels) {
264
+ return labels.reduce((w, l) => Math.max(w, String(l).length), 0);
265
+ }
266
+
267
+ /**
268
+ * `s` trimmed to `max` columns, from whichever end matters least.
269
+ *
270
+ * A path, a URL or a version has no spaces in it and is identified by its tail
271
+ * — `…/agent-dag/hook.js` still says which file — so the head goes. A sentence
272
+ * is identified by its head, and `…nel enabled)` says nothing at all, so its
273
+ * tail goes instead. Below the width where the ellipsis plus a few characters
274
+ * would still mean something, the detail is dropped rather than reduced to
275
+ * punctuation; the label beside it already says which row this is.
276
+ */
277
+ export function fit(s, max, ellipsis = "…") {
278
+ const text = String(s);
279
+ if (text.length <= max) return text;
280
+ if (max < ellipsis.length + 3) return "";
281
+ const room = max - ellipsis.length;
282
+ return /\s/.test(text) ? text.slice(0, room) + ellipsis : ellipsis + text.slice(text.length - room);
283
+ }
284
+
285
+ /**
286
+ * A failure, reduced to the one line it is safe to print while the deck is
287
+ * painting.
288
+ *
289
+ * Everything bin/deck.js writes after the banner is a `\r`-rewritten row — the
290
+ * spinner in `step`, the pulse line, which repaints every 800ms — so a write
291
+ * that arrives from an async callback lands in the middle of one of them. A
292
+ * single short line is survivable: it ends in a newline, the next repaint
293
+ * starts on a fresh row, and the report above it is still on screen. A
294
+ * subprocess's stack trace is not: fifteen lines scroll the entire startup
295
+ * report away and leave the pulse mid-frame. Reported from Windows (#432),
296
+ * where a broken npm shim put two Node stack traces over the boot output.
297
+ *
298
+ * Three things happen here, and the order matters.
299
+ *
300
+ * The most informative line is chosen rather than the first one, because the
301
+ * first line of a Node stack trace is `node:internal/modules/cjs/loader:1573`
302
+ * and the line that names the actual failure — `Error: Cannot find module …` —
303
+ * is five lines below it.
304
+ *
305
+ * Every control character is replaced, not just the newlines. A lone `\r` is a
306
+ * cursor jump to column 0, so a CRLF stack trace that only had its `\n`
307
+ * removed would still overwrite whatever the deck had drawn on that row — and
308
+ * CRLF is what a Windows child writes, which is the platform this exists for.
309
+ * An ESC would open an escape sequence out of a string the deck did not write.
310
+ *
311
+ * And the result is fitted to the room the caller has left, so the one line
312
+ * stays one line: a message that wraps is two rows, and the `\r` that follows
313
+ * only ever reaches the second of them. The ellipsis defaults to the ASCII one
314
+ * rather than `…`, because callers of this are error paths in modules that
315
+ * know nothing about the terminal's glyph tier — and a `…` on a cmd.exe code
316
+ * page is a question mark in the middle of the only clue the user got.
317
+ *
318
+ * @param room how many columns are free after whatever prefix the caller prints.
319
+ */
320
+ export function oneLine(text, room = 80, ellipsis = "...") {
321
+ const lines = stripAnsi(String(text ?? ""))
322
+ .split("\n")
323
+ // eslint-disable-next-line no-control-regex
324
+ .map((l) => l.replace(/[\x00-\x1f\x7f]/g, " ").trim())
325
+ .filter(Boolean);
326
+ if (!lines.length) return "";
327
+ // "Error:", "TypeError:", "Error [ERR_MODULE_NOT_FOUND]:" — the line a reader
328
+ // would have picked out of the dump themselves. The optional prefix is what
329
+ // makes a bare "Error:" match as well as a named subclass.
330
+ const named = lines.find((l) => /^[\w$]*Error\b/.test(l));
331
+ return fit(named ?? lines[0], Math.max(8, room), ellipsis);
332
+ }
333
+
334
+ const same = (s) => s;
335
+
336
+ /**
337
+ * One aligned status row: ` ✓ label detail`.
338
+ *
339
+ * Laid out on the plain text and painted afterwards, so colour and hyperlinks
340
+ * never enter the arithmetic. The last cell of the line is left empty: a
341
+ * terminal that fills its final column either wraps or leaves the cursor in a
342
+ * place the next `\r` cannot recover from.
343
+ *
344
+ * `keep` is for the one detail nobody can act on a fragment of — the URL. When
345
+ * it will not fit beside its label it moves to its own line under the gutter
346
+ * rather than losing its port to an ellipsis; a truncated address is not a
347
+ * shorter address, it is no address. The result then contains a newline, which
348
+ * is why callers write it whole.
349
+ */
350
+ export function statusLine({
351
+ mark = " ", label = "", detail = "", labelWidth = 0, columns = 80, indent = 2, paint = {},
352
+ ellipsis = "…", keep = false,
353
+ } = {}) {
354
+ // A blank gutter is painted by nobody: colouring a space costs two escapes
355
+ // and shows nothing, and rows with no mark are the majority of the quiet ones.
356
+ const paintMark = stripAnsi(mark).trim() ? (paint.mark ?? same) : same;
357
+ const paintLabel = paint.label ?? same;
358
+ const paintDetail = paint.detail ?? same;
359
+ const pad = " ".repeat(indent);
360
+ const head = `${pad}${paintMark(mark)} ${paintLabel(label)}`;
361
+
362
+ if (!detail) return head;
363
+
364
+ const padded = label.padEnd(Math.max(labelWidth, label.length));
365
+ const used = indent + stripAnsi(mark).length + 2 + padded.length + 2;
366
+ const room = columns - used - 1;
367
+ const plain = stripAnsi(detail);
368
+ if (keep && plain.length > room) {
369
+ // Under the gutter if that still fits, then flush left, then wrapped —
370
+ // there is nothing else to try when the address is wider than the terminal.
371
+ const under = [indent + 3, indent, 0].find(n => n + plain.length < columns) ?? 0;
372
+ return `${head}\n${" ".repeat(under)}${paintDetail(detail)}`;
373
+ }
374
+ const fitted = plain.length <= room ? detail : fit(plain, room, ellipsis);
375
+ if (!fitted) return head;
376
+ return `${pad}${paintMark(mark)} ${paintLabel(padded)} ${paintDetail(fitted)}`;
377
+ }
378
+
379
+ // ── wordmark ─────────────────────────────────────────────────────────────────
380
+ //
381
+ // Drawn, not pasted out of a figlet font — six letters on a 4×6 pixel grid,
382
+ // printed two pixel rows to a text row with the half-block characters, which is
383
+ // what gives it a lowercase with real ascenders in three lines instead of six.
384
+ // The old banner said `agents-deck`, a name the repo, the command, the README
385
+ // and the docs stopped using; this one says what the product is called.
386
+
387
+ const LETTERS = {
388
+ c: ["....", "....", ".###", "#...", "#...", ".###"],
389
+ d: ["...#", "...#", ".###", "#..#", "#..#", ".###"],
390
+ e: ["....", "....", ".###", "####", "#...", ".###"],
391
+ k: ["#...", "#...", "#.##", "##..", "#.#.", "#..#"],
392
+ };
393
+
394
+ /** Two pixel rows into one text row: ▀ upper only, ▄ lower only, █ both. */
395
+ function squash(top, bottom) {
396
+ let out = "";
397
+ for (let i = 0; i < top.length; i++) {
398
+ const t = top[i] === "#";
399
+ const b = bottom[i] === "#";
400
+ out += t && b ? "█" : t ? "▀" : b ? "▄" : " ";
401
+ }
402
+ return out;
403
+ }
404
+
405
+ function renderWord(word) {
406
+ const rows = ["", "", ""];
407
+ for (const [n, ch] of [...word].entries()) {
408
+ const px = LETTERS[ch];
409
+ for (let r = 0; r < 3; r++) rows[r] += (n ? " " : "") + squash(px[r * 2], px[r * 2 + 1]);
410
+ }
411
+ return rows.map((r) => r.replace(/\s+$/, ""));
412
+ }
413
+
414
+ /** The drawn mark itself, three text rows of half-blocks.
415
+ *
416
+ * Exported for one reader, and it is a test rather than a caller (#383). This
417
+ * is the ONLY place the art's true width can be measured: `renderWord` trims
418
+ * each row's trailing blanks, so the rows are not all the same length, and the
419
+ * arithmetic in WORDMARK_WIDTH below is a hand-computed prediction of the
420
+ * widest of them. Nothing in the banner path compares the two — `wordmark()`
421
+ * gates on WORDMARK_WIDTH and then prints THESE lines — so an under-counting
422
+ * width lets the art render into a terminal too narrow to hold it and the
423
+ * three rows wrap into six. See wordmark-art-width.test.ts, which is the
424
+ * comparison this export exists to make possible. */
425
+ export const WORDMARK_LINES = renderWord("ccdeck");
426
+ /** 29 columns of art, plus the two-space indent every other line uses. */
427
+ export const WORDMARK_WIDTH = 2 + LETTERS.c[0].length * 6 + 5;
428
+
429
+ /**
430
+ * The tagline, longest version that fits — the name and version are the part
431
+ * that must survive, the rest is context.
432
+ *
433
+ * `prefix` is how many columns are already spent on the line the tagline lands
434
+ * on, and it is a parameter because the three layouts below do not agree on it
435
+ * (#383). In the full banner the tagline gets a line of its own behind a
436
+ * two-space indent; in the compact and plain ones it shares the line with the
437
+ * product name, which costs eight or nine columns more. This budget was
438
+ * hard-coded at 2 for all three, so between 21 and 31 columns — the widths where
439
+ * the compact form is chosen and the medium tagline still looks affordable —
440
+ * the banner printed a 36-column line into a terminal that could not hold it and
441
+ * the first thing a user saw wrapped. `columns - 1` keeps the last cell empty,
442
+ * because a line ending exactly at the right margin makes some terminals wrap
443
+ * anyway.
444
+ */
445
+ function tagline(version, columns, bullet, prefix) {
446
+ const v = `v${version}`;
447
+ const options = [
448
+ `${v} ${bullet} live agent DAG ${bullet} Claude Code + Codex`,
449
+ `${v} ${bullet} live agent DAG`,
450
+ v,
451
+ ];
452
+ return options.find((o) => o.length + prefix <= columns - 1) ?? v;
453
+ }
454
+
455
+ // What each layout spends before the tagline starts, counted off the strings
456
+ // built in `wordmark` below: " ccdeck " for plain, " ccdeck " for compact,
457
+ // and the bare two-space indent for the full banner's own tagline line.
458
+ const PLAIN_PREFIX_W = " ccdeck ".length;
459
+ const COMPACT_PREFIX_W = " ccdeck ".length;
460
+ const TAG_INDENT_W = " ".length;
461
+
462
+ /**
463
+ * The wordmark, in the largest form this terminal can hold.
464
+ *
465
+ * "full" — the drawn mark, three rows, on a vertical gradient
466
+ * "compact" — the name in the accent, for a terminal too narrow for the art
467
+ * or a console without the half-block glyphs
468
+ * "plain" — no escapes at all: a pipe, a CI log, NO_COLOR
469
+ *
470
+ * Returns the lines to print, blank lines included, so the caller writes them
471
+ * and nothing else decides how much air the banner gets.
472
+ */
473
+ export function wordmark({
474
+ columns = 80, version = "0.0.0", profile = "none", unicode = true, pal = palette(profile),
475
+ } = {}) {
476
+ const g = glyphs(unicode);
477
+ // Each layout asks for its own tagline, because each leaves it a different
478
+ // amount of room — see the prefix constants above.
479
+ const tag = (prefix) => tagline(version, columns, g.bullet, prefix);
480
+
481
+ if (profile === "none") return { kind: "plain", lines: ["", ` ccdeck ${tag(PLAIN_PREFIX_W)}`, ""] };
482
+ if (!unicode || columns < WORDMARK_WIDTH + 1) {
483
+ return { kind: "compact", lines: ["", ` ${pal.bold}${pal.accent}ccdeck${pal.reset} ${pal.muted}${tag(COMPACT_PREFIX_W)}${pal.reset}`, ""] };
484
+ }
485
+
486
+ const ramp = [pal.accentSoft, pal.accent, pal.accentDeep];
487
+ return {
488
+ kind: "full",
489
+ lines: [
490
+ "",
491
+ ...WORDMARK_LINES.map((l, i) => ` ${ramp[i]}${l}${pal.reset}`),
492
+ "",
493
+ ` ${pal.muted}${tag(TAG_INDENT_W)}${pal.reset}`,
494
+ "",
495
+ ],
496
+ };
497
+ }
498
+
499
+ // ── the pulse line ───────────────────────────────────────────────────────────
500
+
501
+ /**
502
+ * The one line that stays on screen for hours, sized to the terminal it is on.
503
+ *
504
+ * It is redrawn over itself with `\r`, so both messages are padded to one width
505
+ * — the shorter has to cover the longer — and that width is clamped to the real
506
+ * terminal: at 40 columns the old 58-character message wrapped, after which the
507
+ * `\r` only ever reached the second row and every repaint left the first one
508
+ * behind. A deck no hook can find is not listening in any sense the user cares
509
+ * about, which is why that state has a message here at all.
510
+ *
511
+ * `claude` is what makes that last sentence conditional. A deck watching only
512
+ * Codex is not waiting on a hook: startCodexWatcher is started by startServer
513
+ * and never consults the discovery file, and writesCodexLog keeps a deck with no
514
+ * record on disk writing its log. Capture and persistence both work perfectly
515
+ * there, while this line used to repaint "hooks cannot find this deck" every
516
+ * 800ms, forever (#404) — a permanent alarm about a mechanism that deck does not
517
+ * use. The one-time report at boot still says what IS lost; see
518
+ * unregisteredDetail.
519
+ */
520
+ export function pulseText({ registered = true, claude = true, columns = 80, unicode = true, indent = 2 } = {}) {
521
+ const g = glyphs(unicode);
522
+ const room = Math.max(4, columns - indent - 3 - 1);
523
+ const pick = (options) => options.find((o) => o.length <= room) ?? options[options.length - 1].slice(0, room);
524
+
525
+ const ok = pick([`listening ${g.dash} Ctrl+C to stop`, "listening"]);
526
+ const bad = pick([
527
+ `listening, but not registered ${g.dash} hooks cannot find this deck`,
528
+ `not registered ${g.dash} hooks cannot find this deck`,
529
+ "not registered",
530
+ ]);
531
+ // Both branches are still measured, registered or not, because the line is
532
+ // redrawn over itself and the shorter message has to cover the longer one on
533
+ // the beat after a deck loses its registration.
534
+ const width = Math.min(room, Math.max(ok.length, bad.length));
535
+ return (registered || !claude ? ok : bad).padEnd(width);
536
+ }
537
+
538
+ /**
539
+ * The second line of the "not registered" report, which bin/deck.js prints once
540
+ * per change of state rather than every beat.
541
+ *
542
+ * What a missing discovery file costs is not the same on the two capture paths,
543
+ * and the old sentence — "hooks find this deck through <file>, so until that
544
+ * file exists no events arrive" — stated the Claude Code answer as if it were
545
+ * both. On a Codex-only deck it is false twice over: no hook is looking for this
546
+ * deck, and events do keep arriving, because the rollout watcher reads the files
547
+ * directly. What that deck really loses is the writer election in
548
+ * log-writer.mjs, which is how several decks tailing one rollout agree on which
549
+ * of them appends it to a shared events log. A deck with no record on disk is
550
+ * assumed to be writing, so nothing is dropped — the log can gain the same line
551
+ * twice instead.
552
+ *
553
+ * @param file the discovery file that could not be written.
554
+ * @param claude whether this deck is watching Claude Code at all.
555
+ */
556
+ export function unregisteredDetail({ file, claude = true }) {
557
+ if (claude) {
558
+ return `Claude Code hooks find this deck through ${file}, so until that file exists no Claude Code events arrive.`;
559
+ }
560
+ return `Codex capture does not use ${file} — the deck tails the rollout files itself — so events still arrive. Only decks sharing one events log need it, to agree on which of them records.`;
561
+ }
562
+
563
+ // ── cursor ───────────────────────────────────────────────────────────────────
564
+ //
565
+ // Strings rather than writers: hiding the cursor is trivial and restoring it is
566
+ // not — it has to happen on the normal exit, on SIGINT/SIGTERM/SIGHUP and after
567
+ // an uncaught throw, and getting that half-right leaves the user's shell with
568
+ // no cursor after the deck is gone. The lifecycle owns it; see bin/deck.js.
569
+ export const CURSOR_HIDE = "\x1b[?25l";
570
+ export const CURSOR_SHOW = "\x1b[?25h";