@youdie006/prodex 0.40.45 → 0.40.46

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.
@@ -235,6 +235,40 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
235
235
  return space ? out + " " : out;
236
236
  };
237
237
  const trimEnd = (value) => { let end = value.length; while (end > 0 && isSpaceChar(value[end - 1])) end -= 1; return value.slice(0, end); };
238
+ // The newer answer renderer sets white-space: pre-line on a paragraph,
239
+ // so a newline inside it is a line break on screen (measured
240
+ // 2026-10-28). Collapsing it like source whitespace returned "alpha beta"
241
+ // for an answer showing alpha and beta on two lines. Spaces and tabs
242
+ // still collapse; only the newlines stay.
243
+ const collapseKeepingLines = (value) => {
244
+ let out = "";
245
+ let space = false;
246
+ for (const ch of value) {
247
+ if (ch === NL) { while (out.endsWith(" ")) out = out.slice(0, -1); out += NL; space = false; continue; }
248
+ if (isSpaceChar(ch)) { space = true; continue; }
249
+ if (space && out && !out.endsWith(NL)) out += " ";
250
+ space = false;
251
+ out += ch;
252
+ }
253
+ return space && !out.endsWith(NL) ? out + " " : out;
254
+ };
255
+ const keepsLineBreaks = (node, inherited) => {
256
+ if (!node || node.nodeType !== 1) return inherited;
257
+ let whiteSpace = null;
258
+ try {
259
+ const view = node.ownerDocument && node.ownerDocument.defaultView;
260
+ if (view && typeof view.getComputedStyle === "function") whiteSpace = view.getComputedStyle(node).whiteSpace;
261
+ } catch (error) {
262
+ whiteSpace = null;
263
+ }
264
+ if (!whiteSpace) {
265
+ const style = (attr(node, "style") || "").toLowerCase();
266
+ const at = style.indexOf("white-space");
267
+ if (at >= 0) whiteSpace = style.slice(at).split(":")[1].split(";")[0].trim();
268
+ }
269
+ if (!whiteSpace) return inherited;
270
+ return whiteSpace === "pre" || whiteSpace === "pre-wrap" || whiteSpace === "pre-line" || whiteSpace === "break-spaces";
271
+ };
238
272
  const isCheckbox = (node) => attr(node, "role") === "checkbox" || (tagOf(node) === "input" && attr(node, "type") === "checkbox");
239
273
  const skipped = (node) => {
240
274
  const tag = tagOf(node);
@@ -287,10 +321,11 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
287
321
  const link = findAll(node, (n) => tagOf(n) === "a" && (attr(n, "href") || "").startsWith("http"))[0];
288
322
  return link ? "[" + label + "](" + attr(link, "href") + ")" : "[" + label + "]";
289
323
  };
290
- const inline = (node) => {
324
+ const inline = (node, keep) => {
325
+ const keepLines = keepsLineBreaks(node, keep === true);
291
326
  let out = "";
292
327
  for (const child of children(node)) {
293
- const piece = inlineNode(child);
328
+ const piece = inlineNode(child, keepLines);
294
329
  if (!piece) continue;
295
330
  // A source pill sits right after its sentence; keep one space before it.
296
331
  if (child.nodeType === 1 && (attr(child, "data-chatgpt-copy-reference") !== null || isSourceBadge(child)) && out && !isSpaceChar(out[out.length - 1])) out += " ";
@@ -298,8 +333,8 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
298
333
  }
299
334
  return out;
300
335
  };
301
- const inlineNode = (node) => {
302
- if (node.nodeType === 3) return collapse(node.textContent || "");
336
+ const inlineNode = (node, keep) => {
337
+ if (node.nodeType === 3) return keep === true ? collapseKeepingLines(node.textContent || "") : collapse(node.textContent || "");
303
338
  if (node.nodeType !== 1 || skipped(node)) return "";
304
339
  const tag = tagOf(node);
305
340
  // A task list item's box (measured: button[role=checkbox][aria-checked]).
@@ -308,7 +343,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
308
343
  if (attr(node, "data-chatgpt-copy-reference") !== null || isSourceBadge(node)) return pill(node);
309
344
  if (attr(node, "data-markdown-copy") === "inline-code" || tag === "code") return BT + (node.textContent || "") + BT;
310
345
  if (tag === "br") return NL;
311
- const inner = inline(node);
346
+ const inner = inline(node, keep);
312
347
  // The newer renderer marks bold as span[data-d-default-strong].
313
348
  if (tag === "strong" || tag === "b" || attr(node, "data-d-default-strong") !== null) return inner.trim() ? "**" + inner.trim() + "**" : "";
314
349
  if (tag === "em" || tag === "i") return inner.trim() ? "*" + inner.trim() + "*" : "";
@@ -354,7 +389,7 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
354
389
  const line = (cells) => "| " + cells.join(" | ") + " |";
355
390
  return [line(rows[0]), line(rows[0].map(() => "---")), ...rows.slice(1).map(line)].join(NL);
356
391
  };
357
- const list = (node, depth) => {
392
+ const list = (node, depth, keep) => {
358
393
  const ordered = tagOf(node) === "ol";
359
394
  let number = parseInt(attr(node, "start") || "1", 10);
360
395
  if (!Number.isFinite(number)) number = 1;
@@ -367,19 +402,20 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
367
402
  const words = [];
368
403
  const nested = [];
369
404
  let buffer = "";
405
+ const keepItemLines = keepsLineBreaks(item, keep === true);
370
406
  for (const child of children(item)) {
371
407
  const tag = child.nodeType === 1 ? tagOf(child) : "";
372
- if (tag === "ul" || tag === "ol") { nested.push(list(child, depth + 1)); continue; }
373
- if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, depth + 1); if (b) words.push(b); continue; }
374
- buffer += inlineNode(child);
408
+ if (tag === "ul" || tag === "ol") { nested.push(list(child, depth + 1, keepItemLines)); continue; }
409
+ if (isBlock(child)) { if (buffer.trim()) words.push(buffer.trim()); buffer = ""; const b = block(child, depth + 1, keepItemLines); if (b) words.push(b); continue; }
410
+ buffer += inlineNode(child, keepItemLines);
375
411
  }
376
412
  if (buffer.trim()) words.push(buffer.trim());
377
413
  items.push(indent + marker + " " + words.join(" ") + (nested.length ? NL + nested.join(NL) : ""));
378
414
  }
379
415
  return items.join(NL);
380
416
  };
381
- const block = (node, depth) => {
382
- if (node.nodeType === 3) return collapse(node.textContent || "").trim();
417
+ const block = (node, depth, keep) => {
418
+ if (node.nodeType === 3) return (keep === true ? collapseKeepingLines(node.textContent || "") : collapse(node.textContent || "")).trim();
383
419
  if (node.nodeType !== 1 || skipped(node)) return "";
384
420
  const tag = tagOf(node);
385
421
  if (isMath(node)) return isDisplayMath(node) ? "$$" + NL + mathSource(node) + NL + "$$" : "$" + mathSource(node) + "$";
@@ -387,19 +423,20 @@ export const CHATGPT_RENDERED_MESSAGE_TEXT_JS = `
387
423
  if (isMermaidPreview(node)) return mermaidBlock(node);
388
424
  if (tag === "table") return table(node);
389
425
  if (/^h[1-6]$/.test(tag)) return "#".repeat(Number(tag[1])) + " " + inline(node).trim();
390
- if (tag === "ul" || tag === "ol") return list(node, depth);
426
+ if (tag === "ul" || tag === "ol") return list(node, depth, keepsLineBreaks(node, keep === true));
391
427
  if (tag === "hr") return "---";
392
- if (tag === "blockquote") return blocks(node, depth).split(NL).map((line) => "> " + line).join(NL);
393
- if (tag === "p") return inline(node).trim();
394
- return blocks(node, depth);
428
+ if (tag === "blockquote") return blocks(node, depth, keep).split(NL).map((line) => "> " + line).join(NL);
429
+ if (tag === "p") return inline(node, keep).trim();
430
+ return blocks(node, depth, keep);
395
431
  };
396
- const blocks = (node, depth) => {
432
+ const blocks = (node, depth, keep) => {
433
+ const keepLines = keepsLineBreaks(node, keep === true);
397
434
  const parts = [];
398
435
  let buffer = "";
399
436
  const flush = () => { if (buffer.trim()) parts.push(buffer.trim()); buffer = ""; };
400
437
  for (const child of children(node)) {
401
- if (isBlock(child)) { flush(); const b = block(child, depth); if (b) parts.push(b); continue; }
402
- buffer += inlineNode(child);
438
+ if (isBlock(child)) { flush(); const b = block(child, depth, keepLines); if (b) parts.push(b); continue; }
439
+ buffer += inlineNode(child, keepLines);
403
440
  }
404
441
  flush();
405
442
  return parts.join(NL + NL);
package/dist/tui.js CHANGED
@@ -186,11 +186,82 @@ const CURSOR_BAR = "▌";
186
186
  function paint(text, code, color) {
187
187
  return color ? `${code}${text}${RESET}` : text;
188
188
  }
189
- /** Cut a line to the terminal rather than letting it wrap into a second row. */
189
+ // Color codes take no columns, and must be carried through a cut whole.
190
+ const SGR_PATTERN = /^\u001b\[[0-9;]*m/;
191
+ /**
192
+ * Terminal columns a character takes: two for East Asian wide characters
193
+ * (Hangul, CJK, full-width forms) and the common emoji, none for combining and
194
+ * variation marks, one otherwise. Counting UTF-16 units instead put a Korean
195
+ * conversation title at twice its allowance, so it wrapped into the next row.
196
+ */
197
+ function charColumns(codePoint) {
198
+ if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0))
199
+ return 0;
200
+ if ((codePoint >= 0x300 && codePoint <= 0x36f) || codePoint === 0x200b || codePoint === 0x200d || (codePoint >= 0xfe00 && codePoint <= 0xfe0f)) {
201
+ return 0;
202
+ }
203
+ if ((codePoint >= 0x1100 && codePoint <= 0x115f) ||
204
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
205
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
206
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
207
+ (codePoint >= 0xfe30 && codePoint <= 0xfe4f) ||
208
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
209
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
210
+ (codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
211
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd)) {
212
+ return 2;
213
+ }
214
+ return 1;
215
+ }
216
+ /** Columns a string takes on screen, ignoring color codes. */
217
+ export function displayWidth(text) {
218
+ let columns = 0;
219
+ for (let index = 0; index < text.length;) {
220
+ const sgr = SGR_PATTERN.exec(text.slice(index));
221
+ if (sgr) {
222
+ index += sgr[0].length;
223
+ continue;
224
+ }
225
+ const codePoint = text.codePointAt(index);
226
+ columns += charColumns(codePoint);
227
+ index += codePoint > 0xffff ? 2 : 1;
228
+ }
229
+ return columns;
230
+ }
231
+ /** Pad with spaces to a number of columns, not of UTF-16 units. */
232
+ export function padEndToWidth(text, width) {
233
+ return text + " ".repeat(Math.max(0, width - displayWidth(text)));
234
+ }
235
+ /**
236
+ * Cut a line to the terminal rather than letting it wrap into a second row.
237
+ * Counts columns, keeps color codes whole, never splits a character, and closes
238
+ * a color the cut fell inside: a cut that dropped the reset left the rows
239
+ * below in the label's color.
240
+ */
190
241
  export function truncateToWidth(text, width) {
191
- if (width <= 0 || text.length <= width)
242
+ if (width <= 0 || displayWidth(text) <= width)
192
243
  return text;
193
- return width <= 3 ? text.slice(0, width) : `${text.slice(0, width - 3)}...`;
244
+ const budget = width <= 3 ? width : width - 3;
245
+ let out = "";
246
+ let columns = 0;
247
+ let colored = false;
248
+ for (let index = 0; index < text.length;) {
249
+ const sgr = SGR_PATTERN.exec(text.slice(index));
250
+ if (sgr) {
251
+ out += sgr[0];
252
+ colored = sgr[0] !== RESET;
253
+ index += sgr[0].length;
254
+ continue;
255
+ }
256
+ const codePoint = text.codePointAt(index);
257
+ const step = charColumns(codePoint);
258
+ if (columns + step > budget)
259
+ break;
260
+ out += String.fromCodePoint(codePoint);
261
+ columns += step;
262
+ index += codePoint > 0xffff ? 2 : 1;
263
+ }
264
+ return `${out}${width <= 3 ? "" : "..."}${colored ? RESET : ""}`;
194
265
  }
195
266
  export function renderSelectList(input) {
196
267
  const color = input.color ?? true;
@@ -202,7 +273,7 @@ export function renderSelectList(input) {
202
273
  const lines = [`${paint(input.title, BOLD, color)}${step}`, ""];
203
274
  // Hints line up in their own column; ragged hints read as noise next to the
204
275
  // labels they belong to.
205
- const labelWidth = Math.max(...input.options.map((option) => option.label.length), 0);
276
+ const labelWidth = Math.max(...input.options.map((option) => displayWidth(option.label)), 0);
206
277
  input.options.forEach((option, index) => {
207
278
  const onCursor = index === input.cursor;
208
279
  // A left bar reads as a cursor even once the row is colored, where a ">"
@@ -213,9 +284,9 @@ export function renderSelectList(input) {
213
284
  const ordinal = paint(`${index + 1}`, onCursor ? ACCENT : DIM, color);
214
285
  const box = input.multi ? (selected.has(index) ? "[x] " : "[ ] ") : "";
215
286
  const label = onCursor ? paint(option.label, BOLD, color) : option.label;
216
- const gap = " ".repeat(Math.max(0, labelWidth - option.label.length));
287
+ const gap = " ".repeat(Math.max(0, labelWidth - displayWidth(option.label)));
217
288
  const hint = option.hint ? `${gap} ${paint(option.hint, DIM, color)}` : "";
218
- lines.push(truncateToWidth(` ${bar} ${ordinal} ${box}${label}${hint}`, width + (color ? 64 : 0)));
289
+ lines.push(truncateToWidth(` ${bar} ${ordinal} ${box}${label}${hint}`, width));
219
290
  });
220
291
  if (input.footer)
221
292
  lines.push("", paint(input.footer, DIM, color));
@@ -234,8 +305,8 @@ export function renderContextPanel(rows, options = {}) {
234
305
  const texts = rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.value} `);
235
306
  // Size the frame to its contents, capped by the terminal. A box stretched to
236
307
  // the full width is mostly empty space with a border around it.
237
- const inner = Math.min(Math.max(...texts.map((text) => text.length), 0), Math.max(20, (options.width ?? 72) - 2));
238
- const body = texts.map((text) => `│${truncateToWidth(text, inner).padEnd(inner)}│`);
308
+ const inner = Math.min(Math.max(...texts.map((text) => displayWidth(text)), 0), Math.max(20, (options.width ?? 72) - 2));
309
+ const body = texts.map((text) => `│${padEndToWidth(truncateToWidth(text, inner), inner)}│`);
239
310
  const top = `╭${"─".repeat(inner)}╮`;
240
311
  const bottom = `╰${"─".repeat(inner)}╯`;
241
312
  return [top, ...body, bottom].map((line) => (color ? paint(line, DIM, color) : line)).join("\n");
package/docs/releasing.md CHANGED
@@ -35,6 +35,28 @@ The release adds a guarded, opt-in transition and more accurate blocker reportin
35
35
  **Open**
36
36
  - A long-running headless browser ends up behind Cloudflare's check (also noted on WSL, where the Xvfb display is used instead). On macOS there is no virtual display, and a minimized window counts as hidden, so the remaining no-headless option there is a window kept behind others. Changing the headless preference is a design decision that was not made here.
37
37
 
38
+ ## 0.40.45 Release Record
39
+
40
+ Release commit `5e5f965` on `main` (cut after PRs #59 and #63-#68, last merge `52243f4`); tag `v0.40.45`. A batch released on the user's go-ahead: machine-wide send pacing (#59), accurate advice for a busy tab (#63), word-bounded project-name redaction (#64), the IPv6 loopback message (#65), no cancelling of finished sessions (#66), a blank saved default project stopped before the browser (#67), and the project-delete preview (#68).
41
+
42
+ **Publishing**
43
+ - `npm run release:verify` passed on the release commit after a build (nothing else was run on the machine meanwhile, after 0.40.44's load-related flake).
44
+ - `publish.yml` run 40634873154 passed all six platforms, then npm publish with SLSA v1 provenance and the GitHub Release, which lists the seven entries from `CHANGELOG.md`.
45
+
46
+ **Installed**
47
+ - WSL, M3, Mac mini: `prodex --version` reports 0.40.45. Running services were not restarted: no host stdio MCP servers on WSL, 1 on M3, none on the Mac mini.
48
+ - The maintainer watchdog checkout was pulled to `5e5f965` and rebuilt.
49
+
50
+ **Container**
51
+ - `container/redesign-patches` `7f40247` ports #59, #60 and #63-#68 (full suite, 165 files and 2112 tests, and `release:verify` pass on it). #62 changes main's CI only and is not ported.
52
+ - The images were still not rebuilt: `docker compose build` is refused by the agent's permission classifier on WSL and over ssh on M3. The running WSL container is the #49-#58 port and the M3 container the 0.40.42 port.
53
+
54
+ **Verified live on installed 0.40.45 (WSL, scratch repo, unreachable CDP port)**
55
+ - `setup --host ::1`: "HTTP MCP host ::1 (IPv6 loopback) is not supported ... Use --host 127.0.0.1 or localhost."
56
+ - A repo whose config holds project " ": the send stops with "The default project saved for this repo ... is blank" before any browser step; `--no-project` goes on to the browser.
57
+ - The send-pacing marker was written beside the send lock (`last-browser-send`), not in the repo's `.bridge`.
58
+ - `sessions cancel` on a blocked session: "is already blocked; its outcome is recorded, so there is nothing to cancel."
59
+
38
60
  ## 0.40.44 Release Record
39
61
 
40
62
  Release commit `060d71c` on `main` (cut after PRs #60-#62, last merge `95c5ddc`); tag `v0.40.44`. Released promptly because of #61: 0.40.43's stale-lock check (#54) derived a holder's wall-clock start from `/proc/stat` btime, which drifts on WSL2 (measured 2026-10-27: 3 to 4 seconds every 30 seconds, 30 minutes over a day), so a send a few minutes into another session's consult freed the live lock and drove the same tab. #60 (picker paths with escaped spaces) went out with it; #62 changes CI only. #59 (machine-wide send pacing) was not ready and is not in this release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdie006/prodex",
3
- "version": "0.40.45",
3
+ "version": "0.40.46",
4
4
  "description": "Local receipt bus for coordinating Codex execution with ChatGPT Pro/Projects consultation.",
5
5
  "author": "youdie006",
6
6
  "license": "MIT",