@bli-cockpit/cli 0.2.39 → 0.2.41

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.
@@ -23,8 +23,8 @@
23
23
  * run has nobody to ask and asking it to guess is worse than the flag it
24
24
  * already typed the command for.
25
25
  */
26
- import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
27
- import { NOTE_SLOW_UPLOAD_BYTES, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
26
+ import { isInteractiveStdin, readLine, readPipedText, writeLine, yesByDefault } from "./cli-io.js";
27
+ import { NOTE_SLOW_UPLOAD_BYTES, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
28
28
  import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
29
29
  import { readResponseJson } from "../upload-http.js";
30
30
  /**
@@ -261,15 +261,24 @@ async function pasteNote(command, door) {
261
261
  writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
262
262
  return fail(door, read.refusal, sentence);
263
263
  }
264
- text = read.bytes.toString("utf8");
264
+ const decoded = decodeTextBytes(read.bytes);
265
+ if (!decoded.ok) {
266
+ writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: decoded.reason })}`);
267
+ return fail(door, decoded.reason, "That file is not text this command can decode (it may be a binary or an unusual encoding). Nothing was sent.");
268
+ }
269
+ text = decoded.text;
265
270
  }
266
271
  else {
267
272
  if (isInteractiveStdin(door.io)) {
268
- return fail(door, "nothing_piped", "Pipe the note in (`pbpaste | cockpit notes paste` on macOS, "
269
- + "`Get-Clipboard | cockpit notes paste` on Windows) or pass --file <path>.");
273
+ return fail(door, "nothing_piped", "Pass --file <path> (safest on Windows), or pipe the note in "
274
+ + "(`pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 "
275
+ + "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes).");
270
276
  }
271
277
  try {
272
- text = await readAll(door.io.stdin);
278
+ text = await readPipedText(door.io.stdin, {
279
+ maxChars: PASTE_MAX_CHARS,
280
+ overflowMessage: `A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`,
281
+ });
273
282
  }
274
283
  catch (error) {
275
284
  return fail(door, "paste_too_long", errorText(error));
@@ -284,8 +293,14 @@ async function pasteNote(command, door) {
284
293
  form.set("name", command.name);
285
294
  if (command.exclude)
286
295
  form.set("exclusions", command.exclude);
287
- if (text.length >= NOTE_SLOW_UPLOAD_BYTES) {
288
- writeLine(door.io.stderr, `Reading ${Math.round(text.length / 1024)} KB of pasted text. This can take a couple of minutes.`);
296
+ // Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
297
+ // threshold, and the file path above already compares `bytes.byteLength`
298
+ // against it; `text.length` counts UTF-16 units, so a note in any non-Latin
299
+ // script was measured at a third to a half of the size actually being sent
300
+ // and the "this will take a while" line stayed silent through the wait.
301
+ const pastedBytes = Buffer.byteLength(text, "utf8");
302
+ if (pastedBytes >= NOTE_SLOW_UPLOAD_BYTES) {
303
+ writeLine(door.io.stderr, `Reading ${Math.round(pastedBytes / 1024)} KB of pasted text. This can take a couple of minutes.`);
289
304
  }
290
305
  const answer = await ask(door, {
291
306
  path: "/api/notes/upload",
@@ -302,6 +317,7 @@ async function pasteNote(command, door) {
302
317
  note_id: body.noteId ?? null,
303
318
  scope: body.scope ?? null,
304
319
  chars: text.length,
320
+ byte_size: pastedBytes,
305
321
  named_by_caller: Boolean(command.name),
306
322
  })}`);
307
323
  if (door.json)
@@ -474,17 +490,6 @@ function sayScope(door, body) {
474
490
  writeLine(door.io.stderr, `${TAG} narrowed ${JSON.stringify({ scope: body.scope ?? null, reason: body.degradedBecause })}`);
475
491
  writeLine(door.io.stderr, body.degradedNote ?? "This answer is narrower than the browser's.");
476
492
  }
477
- async function readAll(stream) {
478
- stream.setEncoding("utf8");
479
- let text = "";
480
- for await (const chunk of stream) {
481
- text += chunk;
482
- if (text.length > PASTE_MAX_CHARS) {
483
- throw new Error(`A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`);
484
- }
485
- }
486
- return text;
487
- }
488
493
  function errorText(error) {
489
494
  return error instanceof Error ? error.message : String(error);
490
495
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.39");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.41");
19
19
  return 0;
20
20
  }
21
21
 
@@ -15,12 +15,15 @@
15
15
  * **A bounded read says so.** The server returns at most 8 cards and 12
16
16
  * signals; when there are more, `truncationLines` prints how many. A quiet
17
17
  * board and a truncated board must never look alike.
18
+ *
19
+ * **Styling arrives as an argument, never as a global.** `styled` says whether
20
+ * this run's stdout is a terminal that asked for colour (BLI-3482); `scout.ts`
21
+ * decides it once with `colorEnabled(io)` and the layout stays deterministic.
18
22
  */
19
- const DIM = "\x1b[2m";
20
- const RESET = "\x1b[0m";
23
+ import { dim } from "./cli-io.js";
21
24
  // ------------------------------------------------------------------ rendering
22
25
  /** The whole board as terminal lines, in the page's three sections and order. */
23
- export function renderScoutBoard(payload) {
26
+ export function renderScoutBoard(payload, styled) {
24
27
  const board = payload.board ?? {};
25
28
  const lines = payload.lines ?? {};
26
29
  const out = [];
@@ -35,7 +38,7 @@ export function renderScoutBoard(payload) {
35
38
  }
36
39
  else {
37
40
  for (const card of experiments)
38
- out.push(...renderExperiment(card));
41
+ out.push(...renderExperiment(card, styled));
39
42
  }
40
43
  const settled = board.settled ?? [];
41
44
  if (settled.length > 0) {
@@ -51,14 +54,14 @@ export function renderScoutBoard(payload) {
51
54
  }
52
55
  else {
53
56
  for (const signal of signals)
54
- out.push(...renderSignal(signal));
57
+ out.push(...renderSignal(signal, styled));
55
58
  }
56
59
  const truncation = truncationLines(board);
57
60
  if (truncation.length > 0)
58
61
  out.push("", ...truncation);
59
62
  return out;
60
63
  }
61
- function renderExperiment(card) {
64
+ function renderExperiment(card, styled) {
62
65
  const sourceCount = card.sourceCount ?? 0;
63
66
  const head = [
64
67
  shortId(card.id ?? ""),
@@ -74,23 +77,25 @@ function renderExperiment(card) {
74
77
  if (card.claimSummary)
75
78
  out.push(` ${card.claimSummary}`);
76
79
  if (card.title)
77
- out.push(dim(` ${card.title}`));
80
+ out.push(dim(` ${card.title}`, styled));
78
81
  for (const row of card.crossref ?? []) {
79
- if (row.person)
80
- out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`));
82
+ if (row.person) {
83
+ out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`, styled));
84
+ }
81
85
  }
82
86
  if (card.expectedPayoff)
83
- out.push(dim(` Worth trying · ${card.expectedPayoff}`));
87
+ out.push(dim(` Worth trying · ${card.expectedPayoff}`, styled));
84
88
  for (const source of card.sources ?? []) {
85
- if (source.title)
86
- out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}`));
89
+ if (source.title) {
90
+ out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}`, styled));
91
+ }
87
92
  }
88
93
  return out;
89
94
  }
90
- function renderSignal(signal) {
95
+ function renderSignal(signal, styled) {
91
96
  const out = [` ⏺ ${signal.title ?? "untitled"} · ${dayStamp(signal.gatheredAt)}`];
92
97
  if (signal.synopsis)
93
- out.push(dim(` ${signal.synopsis}`));
98
+ out.push(dim(` ${signal.synopsis}`, styled));
94
99
  const foot = [
95
100
  signal.source,
96
101
  signal.evidenceStrength ?? undefined,
@@ -99,7 +104,7 @@ function renderSignal(signal) {
99
104
  signal.url,
100
105
  ].filter((part) => Boolean(part));
101
106
  if (foot.length > 0)
102
- out.push(dim(` ${foot.join(" · ")}`));
107
+ out.push(dim(` ${foot.join(" · ")}`, styled));
103
108
  return out;
104
109
  }
105
110
  /**
@@ -164,9 +169,6 @@ export function shortId(id) {
164
169
  function dayStamp(iso) {
165
170
  return iso ? iso.slice(0, 10) : "";
166
171
  }
167
- function dim(text) {
168
- return `${DIM}${text}${RESET}`;
169
- }
170
172
  function indent(text) {
171
173
  return ` ${text}`;
172
174
  }
@@ -16,7 +16,7 @@
16
16
  * as the page is; moving a card is super_admin whichever surface asks, and a
17
17
  * refusal never costs a person the board.
18
18
  */
19
- import { writeLine } from "./cli-io.js";
19
+ import { colorEnabled, writeLine } from "./cli-io.js";
20
20
  import { renderScoutBoard, resolveExperimentRef, refusalSentence, shortId, truncationLines, } from "./scout-render.js";
21
21
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
22
22
  /** The dashboard route caps nothing here, but a board read should never hang a shell. */
@@ -49,7 +49,7 @@ export async function runScout(command, io) {
49
49
  writeLine(io.stdout, JSON.stringify({ ok: true, audience: payload.audience ?? null, windowDays: payload.windowDays ?? board.windowDays ?? null, board, lines }));
50
50
  }
51
51
  else {
52
- for (const line of renderScoutBoard(payload))
52
+ for (const line of renderScoutBoard(payload, colorEnabled(io)))
53
53
  writeLine(io.stdout, line);
54
54
  }
55
55
  writeLine(io.stderr, `[scout cli] board read ${JSON.stringify({
@@ -78,7 +78,7 @@ export async function runScout(command, io) {
78
78
  }
79
79
  else {
80
80
  writeLine(io.stderr, sentence);
81
- for (const line of renderScoutBoard(payload))
81
+ for (const line of renderScoutBoard(payload, colorEnabled(io)))
82
82
  writeLine(io.stdout, line);
83
83
  }
84
84
  return 1;
@@ -119,7 +119,7 @@ export async function runScout(command, io) {
119
119
  if (applied.httpStatus === 403) {
120
120
  writeLine(io.stderr, "Deciding a Scout card is a super_admin action; reading the board is not.");
121
121
  }
122
- for (const line of renderScoutBoard(payload))
122
+ for (const line of renderScoutBoard(payload, colorEnabled(io)))
123
123
  writeLine(io.stdout, line);
124
124
  return 1;
125
125
  }
@@ -71,9 +71,16 @@ export async function runSessions(command, io) {
71
71
  reason: sidecar.skipped_reason,
72
72
  })),
73
73
  }));
74
+ // BLI-3483: the scanners have always counted the folders and files they
75
+ // could not read, and this command has always thrown those counts away
76
+ // except for one field in `--json`. So "No sessions observed" was printed on
77
+ // a machine where the scan had been blocked from looking — the operator's
78
+ // one diagnostic tool answering the question with the failure removed.
79
+ const readFailures = countReadFailures(codex, claude);
74
80
  if (command.json) {
75
81
  writeLine(io.stdout, JSON.stringify({
76
82
  window,
83
+ read_failures: readFailures,
77
84
  ...(codex
78
85
  ? { codex: { counts: codex.counts, sessions: codexRows } }
79
86
  : {}),
@@ -101,10 +108,47 @@ export async function runSessions(command, io) {
101
108
  }
102
109
  }
103
110
  if (codexRows.length === 0 && claudeRows.length === 0) {
104
- writeLine(io.stdout, "No sessions observed in the scan window.");
111
+ writeLine(io.stdout, readFailures.total > 0
112
+ ? `No sessions listed, but ${readFailures.total} read failure(s) mean the scan could not see everywhere — this is not proof there were none.`
113
+ : "No sessions observed in the scan window.");
114
+ }
115
+ if (readFailures.total > 0) {
116
+ for (const line of readFailureLines(readFailures)) {
117
+ writeLine(io.stdout, line);
118
+ }
105
119
  }
106
120
  return 0;
107
121
  }
122
+ /**
123
+ * Everything the two scanners looked at and could not read. Counts only — the
124
+ * project-dir slug encodes a local path and never leaves this machine.
125
+ */
126
+ function countReadFailures(codex, claude) {
127
+ const failures = {
128
+ codex_directory_read_failed: codex?.directory_read_failed_count ?? 0,
129
+ codex_stat_failed: codex?.stat_failed_count ?? 0,
130
+ codex_secret_path_skipped: codex?.secret_path_skipped_count ?? 0,
131
+ claude_project_dir_read_failed: claude?.project_dir_read_failed_count ?? 0,
132
+ claude_project_dirs_skipped: claude?.project_dirs_skipped ?? 0,
133
+ claude_session_stat_failed: claude?.session_stat_failed_count ?? 0,
134
+ claude_sidecar_dir_read_failed: claude?.sidecar_dir_read_failed_count ?? 0,
135
+ claude_sidecar_stat_failed: claude?.sidecar_stat_failed_count ?? 0,
136
+ };
137
+ return {
138
+ total: Object.values(failures).reduce((sum, count) => sum + count, 0),
139
+ ...failures,
140
+ };
141
+ }
142
+ function readFailureLines(failures) {
143
+ const named = Object.entries(failures)
144
+ .filter(([field, count]) => field !== "total" && Number(count) > 0)
145
+ .map(([field, count]) => ` ${field}: ${count}`);
146
+ return [
147
+ `read failures during this scan (${failures.total}) — sessions under these are not listed:`,
148
+ ...named,
149
+ " a permission wall is the usual cause; on macOS grant Full Disk Access, on Windows check the folder ACL",
150
+ ];
151
+ }
108
152
  /** `--all`, an explicit `--since-days` capped at pairing, or the default window. */
109
153
  async function sessionsScanWindow(command, now) {
110
154
  if (command.all) {
@@ -8,7 +8,12 @@
8
8
  * Every one of these is defensive about shape on purpose. These bodies come off
9
9
  * the wire from a dashboard that may be a release ahead or behind the installed
10
10
  * CLI, and a missing field should cost one line of output, not the command.
11
+ *
12
+ * Columns are padded in SCREEN CELLS, not UTF-16 code units (BLI-3482) — a
13
+ * display name written in another script is exactly the row where `padEnd`
14
+ * silently stops lining up.
11
15
  */
16
+ import { padEndDisplay } from "./text-width.js";
12
17
  const INDENT = " ";
13
18
  function text(value, fallback = "—") {
14
19
  if (typeof value === "string" && value.trim())
@@ -82,7 +87,7 @@ export function renderTeamMembers(body) {
82
87
  for (const person of people) {
83
88
  const standing = text(person.standing, "");
84
89
  const name = text(person.displayName, text(person.email));
85
- lines.push(`${INDENT}${name.padEnd(28)} ${text(person.role, "—").padEnd(20)} ${standing}`);
90
+ lines.push(`${INDENT}${padEndDisplay(name, 28)} ${padEndDisplay(text(person.role, "—"), 20)} ${standing}`);
86
91
  if (typeof person.userId === "string") {
87
92
  lines.push(`${INDENT}${INDENT}id ${person.userId}`);
88
93
  }
@@ -101,7 +106,7 @@ export function renderSwitches(body) {
101
106
  const options = Array.isArray(entry.options)
102
107
  ? entry.options.map((option) => text(record(option).value)).join(" | ")
103
108
  : "";
104
- return `${INDENT}${text(entry.key).padEnd(24)} ${text(entry.value).padEnd(12)} (${text(entry.source)}${options ? `; choices: ${options}` : ""})`;
109
+ return `${INDENT}${padEndDisplay(text(entry.key), 24)} ${padEndDisplay(text(entry.value), 12)} (${text(entry.source)}${options ? `; choices: ${options}` : ""})`;
105
110
  });
106
111
  if (typeof body.readFailureReason === "string") {
107
112
  lines.push(`${INDENT}values could not be read: ${body.readFailureReason}`);
@@ -114,7 +119,7 @@ export function renderModelRouting(body) {
114
119
  `${INDENT}memory ${text(body.memoryEffectiveLabel)} (${text(body.memorySource)}${typeof body.memoryVia === "string" ? ` via ${body.memoryVia}` : ""})`,
115
120
  ];
116
121
  for (const slot of list(body.displaySlots)) {
117
- lines.push(`${INDENT}${text(slot.name).padEnd(24)} ${text(slot.currentLabel)} — set in code (${text(slot.pointer)})`);
122
+ lines.push(`${INDENT}${padEndDisplay(text(slot.name), 24)} ${text(slot.currentLabel)} — set in code (${text(slot.pointer)})`);
118
123
  }
119
124
  if (typeof body.readFailureReason === "string") {
120
125
  lines.push(`${INDENT}settings could not be read: ${body.readFailureReason}`);
@@ -132,6 +137,6 @@ export function renderEnvBlobs(body) {
132
137
  const blobs = list(body.blobs);
133
138
  if (blobs.length === 0)
134
139
  return [`${INDENT}no env files stored`];
135
- return blobs.map((blob) => `${INDENT}${text(blob.project).padEnd(12)} ${text(blob.file_name).padEnd(16)} ` +
140
+ return blobs.map((blob) => `${INDENT}${padEndDisplay(text(blob.project), 12)} ${padEndDisplay(text(blob.file_name), 16)} ` +
136
141
  `updated ${text(blob.updated_at)} id ${text(blob.id)}`);
137
142
  }
@@ -213,6 +213,12 @@ async function setModels(command, tower, io) {
213
213
  return 0;
214
214
  }
215
215
  writeLine(io.stdout, `Saved: ${asList(body.saved).join(", ") || "nothing"}.`);
216
+ // BLI-3481: the save succeeded but the belt pre-flight could not reach the
217
+ // provider, so nobody has checked that the model just chosen will accept
218
+ // Tower's tool belt. A bare "Saved." there would be a silent success.
219
+ if (typeof body.warning === "string" && body.warning.length > 0) {
220
+ writeLine(io.stderr, body.warning);
221
+ }
216
222
  return 0;
217
223
  }
218
224
  // ── env files ─────────────────────────────────────────────────────────
@@ -239,8 +245,9 @@ async function setEnvBlob(command, tower, io) {
239
245
  const failure = {
240
246
  reason: "content_not_piped",
241
247
  detail: "settings env set reads the file from stdin. Pipe it in: " +
242
- "`cat <path> | cockpit settings env set --project <project> --file <name> --content-stdin` " +
243
- "(PowerShell: `Get-Content <path> -Raw | cockpit settings env set …`).",
248
+ "`cat <path> | cockpit settings env set --project <project> --file <name> --content-stdin`. " +
249
+ "On Windows use PowerShell 7 (`pwsh`): `Get-Content <path> -Raw | cockpit settings env set …`" +
250
+ "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes.",
244
251
  };
245
252
  return writeCommandFailure(io, command.json, failure);
246
253
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * How wide a string is ON SCREEN, in terminal cells (BLI-3482).
3
+ *
4
+ * `"".length` counts UTF-16 code units, which is the wrong number twice over
5
+ * for a column: an emoji costs two units and draws two cells, a CJK ideograph
6
+ * costs one unit and draws two, and a combining accent costs one unit and draws
7
+ * nothing. Padding by `.length` therefore ragged-edges any table whose cells are
8
+ * not plain ASCII — the failure a person sees is a column that no longer lines
9
+ * up, on exactly the rows that carry a name or a title in another script.
10
+ *
11
+ * This is a deliberately small wcwidth, not the real one, and its limits are
12
+ * honest:
13
+ *
14
+ * - The wide ranges below are the common East Asian Wide/Fullwidth blocks plus
15
+ * the main emoji planes. Ambiguous-width characters (box drawing, `⏺`, Greek,
16
+ * Cyrillic) are counted as ONE cell, which is what a Western-locale terminal
17
+ * draws; a CJK-locale terminal may draw some of them as two.
18
+ * - A ZWJ emoji sequence (👨‍👩‍👧) is measured as the sum of its parts, so it
19
+ * over-counts on terminals that draw the whole cluster in two cells. Nothing
20
+ * here inspects grapheme clusters; that needs `Intl.Segmenter` and a real
21
+ * emoji table, which is more machinery than a column deserves.
22
+ * - U+FE0F (emoji presentation) is counted as zero, so a text-default symbol
23
+ * promoted to emoji presentation is under-counted by one.
24
+ *
25
+ * Every one of those errors costs alignment, never content: nothing in this
26
+ * file may drop, cut or reorder text.
27
+ */
28
+ /** SGR colour sequences occupy no cells, so they are removed before counting. */
29
+ // eslint-disable-next-line no-control-regex
30
+ const ANSI_SGR = /\x1b\[[0-9;]*m/gu;
31
+ /** Zero-cell code points: combining marks, joiners, variation selectors. */
32
+ const ZERO_WIDTH = [
33
+ [0x0300, 0x036f], // combining diacritical marks
34
+ [0x0483, 0x0489],
35
+ [0x0591, 0x05bd],
36
+ [0x0610, 0x061a],
37
+ [0x064b, 0x065f],
38
+ [0x0670, 0x0670],
39
+ [0x06d6, 0x06dc],
40
+ [0x0e31, 0x0e31],
41
+ [0x0e34, 0x0e3a],
42
+ [0x0e47, 0x0e4e],
43
+ [0x1ab0, 0x1aff], // combining diacritical marks extended
44
+ [0x1dc0, 0x1dff], // combining diacritical marks supplement
45
+ [0x200b, 0x200f], // zero-width space through RTL mark (incl. ZWNJ, ZWJ)
46
+ [0x20d0, 0x20f0], // combining marks for symbols
47
+ [0xfe00, 0xfe0f], // variation selectors
48
+ [0xfe20, 0xfe2f], // combining half marks
49
+ [0xfeff, 0xfeff], // byte-order mark
50
+ ];
51
+ /** Two-cell code points: the common Wide / Fullwidth blocks and emoji planes. */
52
+ const WIDE = [
53
+ [0x1100, 0x115f], // Hangul Jamo
54
+ [0x2e80, 0x303e], // CJK radicals, Kangxi, CJK symbols and punctuation
55
+ [0x3041, 0x33ff], // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
56
+ [0x3400, 0x4dbf], // CJK Unified Ideographs Extension A
57
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
58
+ [0xa000, 0xa4cf], // Yi
59
+ [0xa960, 0xa97f], // Hangul Jamo Extended-A
60
+ [0xac00, 0xd7a3], // Hangul syllables
61
+ [0xf900, 0xfaff], // CJK compatibility ideographs
62
+ [0xfe10, 0xfe19], // vertical forms
63
+ [0xfe30, 0xfe6f], // CJK compatibility forms
64
+ [0xff00, 0xff60], // fullwidth forms
65
+ [0xffe0, 0xffe6], // fullwidth signs
66
+ [0x1f300, 0x1f64f], // misc symbols and pictographs, emoticons
67
+ [0x1f680, 0x1f6ff], // transport and map symbols
68
+ [0x1f900, 0x1f9ff], // supplemental symbols and pictographs
69
+ [0x20000, 0x3fffd], // CJK Unified Ideographs Extensions B onward
70
+ ];
71
+ function inRanges(code, ranges) {
72
+ for (const [low, high] of ranges) {
73
+ if (code < low)
74
+ return false; // ranges are ascending
75
+ if (code <= high)
76
+ return true;
77
+ }
78
+ return false;
79
+ }
80
+ /** Cells one code point draws: 0 for combining/invisible, 2 for wide, else 1. */
81
+ export function codePointWidth(code) {
82
+ if (code === 0x0a || code === 0x0d)
83
+ return 0;
84
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0))
85
+ return 0; // control characters
86
+ if (inRanges(code, ZERO_WIDTH))
87
+ return 0;
88
+ if (inRanges(code, WIDE))
89
+ return 2;
90
+ return 1;
91
+ }
92
+ /** Cells a string draws, ignoring any SGR colour sequences inside it. */
93
+ export function displayWidth(text) {
94
+ let total = 0;
95
+ for (const character of text.replace(ANSI_SGR, "")) {
96
+ total += codePointWidth(character.codePointAt(0) ?? 0);
97
+ }
98
+ return total;
99
+ }
100
+ /**
101
+ * `padEnd` that counts cells instead of code units. Like `padEnd`, a string
102
+ * already at or past the column is returned untouched — a column is a minimum,
103
+ * never a limit, because cutting a cell to fit loses content.
104
+ */
105
+ export function padEndDisplay(text, width) {
106
+ const missing = width - displayWidth(text);
107
+ return missing > 0 ? `${text}${" ".repeat(missing)}` : text;
108
+ }
@@ -17,7 +17,7 @@
17
17
  * - **stdout belongs to `--json`.** Every operational line goes to stderr, which
18
18
  * launchd captures; a `--json` run puts exactly one object on stdout.
19
19
  */
20
- import { writeLine } from "./cli-io.js";
20
+ import { readPipedText, writeLine } from "./cli-io.js";
21
21
  import { loadPairedSession, towerFailureDetail, towerJsonRequest, } from "../tower-client.js";
22
22
  /** Client-side ceiling. These routes are small reads and writes, not turns. */
23
23
  const REQUEST_DEADLINE_MS = 30_000;
@@ -101,12 +101,11 @@ export function parseModelKey(key) {
101
101
  }
102
102
  return { provider: key.slice(0, separator), model: key.slice(separator + 1) };
103
103
  }
104
- /** Reads piped stdin whole. Used only for env content, which is never echoed. */
104
+ /**
105
+ * Reads piped stdin whole, BOM-stripped (BLI-3480 — PowerShell redirection
106
+ * writes one, and stored verbatim it makes the first env line unreachable).
107
+ * Used only for env content, which is never echoed.
108
+ */
105
109
  export async function readAllStdin(stream) {
106
- stream.setEncoding("utf8");
107
- let text = "";
108
- for await (const chunk of stream) {
109
- text += chunk;
110
- }
111
- return text;
110
+ return readPipedText(stream);
112
111
  }
@@ -10,11 +10,18 @@
10
10
  * summarises, reorders or paraphrases — it wraps, and it strips the emphasis
11
11
  * markers a reader did not ask for. `--markdown` bypasses this file entirely
12
12
  * and prints the server's bytes, which is what lets this layout be opinionated.
13
+ *
14
+ * Two things arrive as arguments rather than being read from the process, so
15
+ * these functions stay pure and their tests stay deterministic (BLI-3482):
16
+ * `width` (the wrap column) and `styled` (whether stdout is a terminal that
17
+ * asked for colour — `workbook.ts` decides it once with `colorEnabled(io)`).
18
+ * Columns are measured in SCREEN CELLS via `displayWidth`, not in UTF-16 code
19
+ * units, so a table whose cells carry CJK or emoji still lines up.
13
20
  */
14
- const DIM = "\x1b[2m";
15
- const RESET = "\x1b[0m";
21
+ import { dim } from "./cli-io.js";
22
+ import { displayWidth, padEndDisplay } from "./text-width.js";
16
23
  /** One compartment per project, its documents underneath, addressed as typed. */
17
- export function renderShelf(projects, width) {
24
+ export function renderShelf(projects, width, styled) {
18
25
  const docs = projects.reduce((count, project) => count + (project.docs?.length ?? 0), 0);
19
26
  const out = [
20
27
  `WORKBOOK · ${projects.length} project${projects.length === 1 ? "" : "s"} · ${docs} document${docs === 1 ? "" : "s"}`,
@@ -27,25 +34,25 @@ export function renderShelf(projects, width) {
27
34
  out.push(` ⏺ cockpit workbook ${project.slug ?? ""} ${doc.slug ?? ""}`);
28
35
  out.push(` ${doc.title ?? ""} · ${doc.kind ?? ""}`);
29
36
  if (doc.line) {
30
- out.push(...wrap(doc.line, width - 4).map((line) => dim(` ${line}`)));
37
+ out.push(...wrap(doc.line, width - 4).map((line) => dim(` ${line}`, styled)));
31
38
  }
32
- out.push(dim(` ${[doc.author, doc.date].filter(Boolean).join(" · ")}`));
39
+ out.push(dim(` ${[doc.author, doc.date].filter(Boolean).join(" · ")}`, styled));
33
40
  }
34
41
  }
35
42
  return out;
36
43
  }
37
44
  /** The document's own header, then its blocks laid out for `width`. */
38
- export function renderDocText(payload, markdown, width) {
45
+ export function renderDocText(payload, markdown, width, styled) {
39
46
  const doc = payload.doc ?? {};
40
47
  const out = [];
41
48
  if (doc.title)
42
49
  out.push(...wrap(doc.title.toUpperCase(), width));
43
50
  const meta = [doc.kind, payload.project?.title, doc.author, doc.date].filter(Boolean);
44
51
  if (meta.length > 0)
45
- out.push(...wrap(meta.join(" · "), width).map((line) => dim(line)));
52
+ out.push(...wrap(meta.join(" · "), width).map((line) => dim(line, styled)));
46
53
  if (out.length > 0)
47
54
  out.push("");
48
- out.push(...renderMarkdownText(markdown, width));
55
+ out.push(...renderMarkdownText(markdown, width, styled));
49
56
  return out;
50
57
  }
51
58
  /**
@@ -54,7 +61,7 @@ export function renderDocText(payload, markdown, width) {
54
61
  * paragraphs — so there is no "unknown block" case to guess at; anything else
55
62
  * falls through as a wrapped paragraph rather than being dropped.
56
63
  */
57
- export function renderMarkdownText(markdown, width) {
64
+ export function renderMarkdownText(markdown, width, styled) {
58
65
  const out = [];
59
66
  for (const block of markdown.split("\n\n")) {
60
67
  const text = block.trim();
@@ -80,7 +87,7 @@ export function renderMarkdownText(markdown, width) {
80
87
  .map((line) => plain(line.replace(/^>\s?/, "")))
81
88
  .join(" ")
82
89
  .trim();
83
- out.push(...wrap(quote, width - 4).map((line) => dim(` ${line}`)));
90
+ out.push(...wrap(quote, width - 4).map((line) => dim(` ${line}`, styled)));
84
91
  continue;
85
92
  }
86
93
  if (text.startsWith("- ")) {
@@ -102,6 +109,11 @@ export function renderMarkdownText(markdown, width) {
102
109
  * width, each row is printed as `header: cell` lines instead — narrower, and
103
110
  * still every cell. Dropping columns to fit is never an option: a table with a
104
111
  * column missing looks complete and is not.
112
+ *
113
+ * Column sizes are SCREEN CELLS (BLI-3482). `.length` counts UTF-16 units, so a
114
+ * name in Japanese under-padded by one cell per character and a column of
115
+ * emoji over-padded by one — the fits/does-not-fit decision was measured in
116
+ * the same wrong unit, which is how a table that fits chose the narrow layout.
105
117
  */
106
118
  function renderTable(block, width) {
107
119
  const rows = block
@@ -121,12 +133,12 @@ function renderTable(block, width) {
121
133
  const columns = Math.max(...rows.map((cells) => cells.length));
122
134
  const widths = [];
123
135
  for (let index = 0; index < columns; index += 1) {
124
- widths.push(Math.max(...rows.map((cells) => (cells[index] ?? "").length)));
136
+ widths.push(Math.max(...rows.map((cells) => displayWidth(cells[index] ?? ""))));
125
137
  }
126
138
  const tableWidth = widths.reduce((sum, value) => sum + value, 0) + 2 * (columns - 1);
127
139
  if (tableWidth <= width) {
128
140
  return rows.map((cells) => cells
129
- .map((cell, index) => cell.padEnd(index === columns - 1 ? 0 : (widths[index] ?? 0)))
141
+ .map((cell, index) => padEndDisplay(cell, index === columns - 1 ? 0 : (widths[index] ?? 0)))
130
142
  .join(" ")
131
143
  .trimEnd());
132
144
  }
@@ -160,7 +172,10 @@ export function sectionSlice(markdown, sections, sectionId) {
160
172
  return blocks.slice(start, end).join("\n\n").trim();
161
173
  }
162
174
  // ----------------------------------------------------------------- small parts
163
- /** Greedy word wrap. A word longer than the column keeps its own line, uncut. */
175
+ /**
176
+ * Greedy word wrap, measured in screen cells. A word longer than the column
177
+ * keeps its own line, uncut.
178
+ */
164
179
  export function wrap(text, width) {
165
180
  const limit = Math.max(20, width);
166
181
  const words = text.split(/\s+/u).filter(Boolean);
@@ -168,17 +183,22 @@ export function wrap(text, width) {
168
183
  return [];
169
184
  const lines = [];
170
185
  let line = "";
186
+ let lineWidth = 0;
171
187
  for (const word of words) {
188
+ const wordWidth = displayWidth(word);
172
189
  if (!line) {
173
190
  line = word;
191
+ lineWidth = wordWidth;
174
192
  continue;
175
193
  }
176
- if (line.length + 1 + word.length <= limit) {
194
+ if (lineWidth + 1 + wordWidth <= limit) {
177
195
  line = `${line} ${word}`;
196
+ lineWidth += 1 + wordWidth;
178
197
  }
179
198
  else {
180
199
  lines.push(line);
181
200
  line = word;
201
+ lineWidth = wordWidth;
182
202
  }
183
203
  }
184
204
  lines.push(line);
@@ -190,7 +210,4 @@ function plain(text) {
190
210
  .replace(/\*\*(.+?)\*\*/gu, "$1")
191
211
  .replace(/\*(.+?)\*/gu, "$1")
192
212
  .replace(/\\\|/gu, "|");
193
- }
194
- function dim(text) {
195
- return `${DIM}${text}${RESET}`;
196
213
  }