@north-light/crouter 0.3.274 → 0.3.275

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.
@@ -17,7 +17,7 @@ import { matchIndices } from '../../tui/fuzzy.js';
17
17
  // Span/color primitives live in core/tui/draw.ts (one copy, shared with the
18
18
  // `crtr view` host). Re-export the color caps so browse's importers + tests keep
19
19
  // resolving them from this module.
20
- import { clip, assemble, detectColorCaps } from '../../tui/draw.js';
20
+ import { clip, assemble, detectColorCaps, META_FG, RECESSIVE_FG } from '../../tui/draw.js';
21
21
  import { renderMarkdownSpans } from '../../tui/markdown.js';
22
22
  export { detectColorCaps };
23
23
  // Fixed chrome heights, shared with app.ts so its viewport math never drifts
@@ -85,20 +85,29 @@ const RESET = `${ESC}0m`;
85
85
  const REVERSE = `${ESC}7m`;
86
86
  const DIM = `${ESC}2m`;
87
87
  const BOLD = `${ESC}1m`;
88
- const CURSOR_BG = `${ESC}48;5;236m`; // subtle dark-gray cursor-row bg (256-color)
88
+ // Cursor-row band. Absolute (256-color) rather than theme-derived: the browser
89
+ // runs in its own process that deliberately never loads pi, so it cannot read the
90
+ // theme. It must therefore lift off BOTH planes it can land on — a bare terminal
91
+ // background when launched from a shell or a tmux keybinding, and the theme's
92
+ // card plane when the attach viewer opens it as a painted popup (see
93
+ // runtime/surface-bg.ts). 236 was chosen against a bare dark terminal alone and
94
+ // vanishes on a card (1.01:1 on gruvbox-material); 239 reads on both (1.6–1.8:1)
95
+ // while leaving body text above 4.5:1 on the band itself.
96
+ const CURSOR_BG = `${ESC}48;5;239m`;
89
97
  // Basic-16 ANSI fg codes used by the palette.
90
98
  const FG_YELLOW = '33';
91
99
  const FG_RED = '31';
92
100
  const FG_CYAN = '36';
93
- const FG_GRAY = '90'; // bright-black
101
+ // META_FG / RECESSIVE_FG (imported) carry every grey in this view. Bright-black
102
+ // and SGR DIM are deliberately NOT used for text — see their definition in draw.ts.
103
+ // DIM survives for exactly one job: the whole-row base on a non-matching ancestor
104
+ // during a search, where it is a RELATIVE step down from whatever the row already
105
+ // is, not an absolute colour choice.
106
+ const META = `${ESC}${META_FG}m`;
107
+ const RECESSIVE = `${ESC}${RECESSIVE_FG}m`;
94
108
  const FG_BRIGHT_YELLOW = '93';
95
109
  const FG_BRIGHT_CYAN = '96'; // query-match highlight (ties to the cyan search accent)
96
110
  const FG_MAGENTA = '35'; // pin star (distinct from the yellow ⚑ asks flag)
97
- // STATUS_GLYPH + STATUS_COLOR are imported from ../status-glyph.js (the shared
98
- // canvas-graph map). STATUS_COLOR there is NUMERIC SGR (e.g. 32) — wrap with
99
- // `sgr()` where a string ANSI code is needed (Span fg / template literals accept
100
- // the number directly).
101
- const FG = (code) => String(code);
102
111
  function fmtCtx(tokens) {
103
112
  if (tokens <= 0)
104
113
  return '0k';
@@ -173,17 +182,17 @@ function tabBar(active, color) {
173
182
  // Keep the [ ] brackets in BOTH paths so the active tab reads without color.
174
183
  return color ? `${BOLD}${ESC}${FG_CYAN}m[ ${t} ]${RESET}` : `${REVERSE}[ ${t} ]${RESET}`;
175
184
  }
176
- return color ? `${DIM} ${t} ${RESET}` : ` ${t} `;
185
+ return color ? `${META} ${t} ${RESET}` : ` ${t} `;
177
186
  }).join('');
178
187
  }
179
188
  const EMPTY_HI = new Set();
180
189
  /** Name → spans, splitting out the query-matched chars (bold + bright-cyan) so
181
- * matches are scannable. Non-matched chars carry the row's name style (dim for
182
- * terminal status, bold on the cursor row). */
190
+ * matches are scannable. Non-matched chars carry the row's name style (the
191
+ * recessive grey for terminal status, bold on the cursor row). */
183
192
  function nameSpans(name, query, style) {
184
193
  const hi = query === '' ? EMPTY_HI : matchIndices(query, name);
185
194
  if (hi.size === 0)
186
- return [{ text: name, style: { dim: style.dim, bold: style.bold } }];
195
+ return [{ text: name, style: { fg: style.fg, bold: style.bold } }];
187
196
  const out = [];
188
197
  let buf = '';
189
198
  let bufHi = false;
@@ -193,7 +202,7 @@ function nameSpans(name, query, style) {
193
202
  if (bufHi)
194
203
  out.push({ text: buf, style: { fg: FG_BRIGHT_CYAN, bold: true } });
195
204
  else
196
- out.push({ text: buf, style: { dim: style.dim, bold: style.bold } });
205
+ out.push({ text: buf, style: { fg: style.fg, bold: style.bold } });
197
206
  buf = '';
198
207
  };
199
208
  for (let i = 0; i < name.length; i++) {
@@ -229,7 +238,7 @@ function statusRail(r) {
229
238
  // STATUS_W — acceptable: the rare, attention-demanding row gets a slightly
230
239
  // wider rail (Math.max(1,…) keeps a gap), which draws the eye.
231
240
  const word = v.word ?? ({ active: 'active', idle: 'idle', done: 'done', dead: 'dead', canceled: 'cancel' }[r.status] ?? r.status);
232
- const fg = FG(v.color);
241
+ const fg = v.color;
233
242
  const text = `${v.glyph} ${word}`;
234
243
  const pad = Math.max(1, STATUS_W - [...text].length);
235
244
  return [
@@ -243,14 +252,14 @@ function statusRail(r) {
243
252
  function metaCluster(r, now, showCwd) {
244
253
  const ctxFg = ctxColorCode(r.ctx_tokens);
245
254
  const out = [
246
- { text: clipPad(`${r.kind}/${r.mode}`, KM_W), style: { fg: FG_GRAY } },
255
+ { text: clipPad(`${r.kind}/${r.mode}`, KM_W), style: { fg: META_FG } },
247
256
  { text: COL_GAP },
248
- { text: fmtCtx(r.ctx_tokens).padStart(CTX_W), style: { fg: ctxFg, dim: ctxFg === undefined } },
257
+ { text: fmtCtx(r.ctx_tokens).padStart(CTX_W), style: { fg: ctxFg ?? META_FG } },
249
258
  { text: COL_GAP },
250
- { text: relAge(r.created, now).padStart(AGE_W), style: { dim: true } },
259
+ { text: relAge(r.created, now).padStart(AGE_W), style: { fg: META_FG } },
251
260
  ];
252
261
  if (showCwd) {
253
- out.push({ text: COL_GAP }, { text: clipPad(`~${baseDir(r.cwd)}`, PROJ_W), style: { fg: FG_GRAY } });
262
+ out.push({ text: COL_GAP }, { text: clipPad(`~${baseDir(r.cwd)}`, PROJ_W), style: { fg: META_FG } });
254
263
  }
255
264
  return out;
256
265
  }
@@ -300,7 +309,7 @@ padToWidth = false) {
300
309
  // Hanging: the fault summary travels with the name (the ⚠ + kind
301
310
  // label live in the rail). Dim yellow so it reads as secondary detail.
302
311
  if (r.hanging != null)
303
- flags.push({ text: ` · ${faultSummary(r.hanging, now)}`, style: { fg: FG_YELLOW, dim: true } });
312
+ flags.push({ text: ` · ${faultSummary(r.hanging, now)}`, style: { fg: FG_YELLOW } });
304
313
  // Pin marker travels just before the name so a starred node is scannable in any
305
314
  // tab/sort. Magenta keeps it distinct from the yellow ⚑ asks flag.
306
315
  const pinMark = isPinned ? [{ text: '★ ', style: { fg: FG_MAGENTA, bold: true } }] : [];
@@ -312,11 +321,14 @@ padToWidth = false) {
312
321
  const fixed = spansWidth([gutter]) + spansWidth(status) + [...treeLead].length + spansWidth(pinMark) + spansWidth(flags);
313
322
  const rightW = spansWidth(right);
314
323
  const nameMax = Math.max(4, width - fixed - rightW - 1);
315
- const nameStyle = { dim: !isCursor && terminal, bold: isCursor }; // dim terminal names; bold the cursor row
324
+ // A finished node's name recedes to the grey ramp rather than to SGR DIM: dim
325
+ // is a multiplier on the theme's own foreground, and on a dark theme that lands
326
+ // where the canceled rows became unreadable.
327
+ const nameStyle = { fg: !isCursor && terminal ? RECESSIVE_FG : undefined, bold: isCursor };
316
328
  const left = [
317
329
  gutter,
318
330
  ...status,
319
- { text: treeLead, style: { dim: true } },
331
+ { text: treeLead, style: { fg: RECESSIVE_FG } },
320
332
  ...pinMark,
321
333
  ...nameSpans(clip(r.name, nameMax), query, nameStyle),
322
334
  ...flags,
@@ -334,32 +346,34 @@ padToWidth = false) {
334
346
  }
335
347
  return assembleRow(left, right, width, caps.color, lineBase, fill);
336
348
  }
337
- /** The column-header row — dim labels aligned to the same zones the rows use, so
338
- * the metadata columns read as a table. */
349
+ /** The column-header row — grey labels aligned to the same zones the rows use, so
350
+ * the metadata columns read as a table. Bold + the metadata grey: a header is a
351
+ * landmark you scan back to, so it sits at the same weight as the column under it. */
339
352
  function columnHeaderLine(width, showCwd, caps, showMeta = true, padToWidth = false) {
353
+ const hdr = { fg: META_FG, bold: true };
340
354
  const left = [
341
355
  { text: ' ' }, // viewer gutter (no label)
342
- { text: 'STATUS'.padEnd(STATUS_W), style: { dim: true, bold: true } },
343
- { text: 'NAME', style: { dim: true, bold: true } },
356
+ { text: 'STATUS'.padEnd(STATUS_W), style: hdr },
357
+ { text: 'NAME', style: hdr },
344
358
  ];
345
359
  if (!showMeta)
346
360
  return assemble(left, width, caps.color, '', padToWidth);
347
361
  const right = [
348
- { text: 'KIND/MODE'.padEnd(KM_W), style: { dim: true, bold: true } },
362
+ { text: 'KIND/MODE'.padEnd(KM_W), style: hdr },
349
363
  { text: COL_GAP },
350
- { text: 'CTX'.padStart(CTX_W), style: { dim: true, bold: true } },
364
+ { text: 'CTX'.padStart(CTX_W), style: hdr },
351
365
  { text: COL_GAP },
352
- { text: 'AGE'.padStart(AGE_W), style: { dim: true, bold: true } },
366
+ { text: 'AGE'.padStart(AGE_W), style: hdr },
353
367
  ];
354
368
  if (showCwd)
355
- right.push({ text: COL_GAP }, { text: 'PROJECT'.padEnd(PROJ_W), style: { dim: true, bold: true } });
369
+ right.push({ text: COL_GAP }, { text: 'PROJECT'.padEnd(PROJ_W), style: hdr });
356
370
  return assembleRow(left, right, width, caps.color, '', false);
357
371
  }
358
372
  /** The status line (always present): active profile/cwd scopes · sort mode ·
359
373
  * committed filter. The committed-filter indicator lives here now (not its own
360
374
  * line) so a filtered view always advertises its query without consuming a header row. */
361
375
  function statusLine(state) {
362
- const dim = (s) => `${DIM}${s}${RESET}`;
376
+ const dim = (s) => `${ESC}${META_FG}m${s}${RESET}`;
363
377
  const profile = state.profileScope === undefined ? 'all profiles' : 'current';
364
378
  const scope = state.cwdScope === null ? 'all dirs' : baseDir(state.cwdScope);
365
379
  const segs = [
@@ -413,14 +427,14 @@ function markedBlock(marker, text, empty, lines, width, caps, query) {
413
427
  if (lines <= 0)
414
428
  return [];
415
429
  const out = [];
416
- const mark = caps.color ? `${DIM}${marker}${RESET}` : marker;
430
+ const mark = caps.color ? `${RECESSIVE}${marker}${RESET}` : marker;
417
431
  const inner = Math.max(1, width - 2);
418
432
  // No query → render the text as markdown (see blockLines for why a live query
419
433
  // keeps the raw windowed path instead). No spacing: this panel is cramped.
420
434
  if (query === '') {
421
435
  const md = renderMarkdownSpans(text, inner, lines, false);
422
436
  if (md.length === 0)
423
- out.push(`${mark}${DIM}${clip(empty, inner)}${RESET}`);
437
+ out.push(`${mark}${RECESSIVE}${clip(empty, inner)}${RESET}`);
424
438
  else
425
439
  for (const spans of md)
426
440
  out.push(`${out.length === 0 ? mark : ' '}${assemble(spans, inner, caps.color, '', false)}`);
@@ -430,7 +444,7 @@ function markedBlock(marker, text, empty, lines, width, caps, query) {
430
444
  }
431
445
  const snippet = previewSnippet(query, text, inner, lines);
432
446
  if (snippet.length === 0) {
433
- out.push(`${mark}${DIM}${clip(empty, inner)}${RESET}`);
447
+ out.push(`${mark}${RECESSIVE}${clip(empty, inner)}${RESET}`);
434
448
  }
435
449
  else {
436
450
  for (const ln of snippet)
@@ -440,11 +454,11 @@ function markedBlock(marker, text, empty, lines, width, caps, query) {
440
454
  out.push('');
441
455
  return out;
442
456
  }
443
- /** One `Goal`/`State` briefing line: a dim fixed-width label then the text. */
457
+ /** One `Goal`/`State` briefing line: a grey fixed-width label then the text. */
444
458
  function recapLine(label, text, width, caps) {
445
459
  const lbl = `${label.padEnd(6)}`;
446
460
  const body = clip(text, Math.max(1, width - lbl.length));
447
- return caps.color ? `${DIM}${lbl}${RESET}${BOLD}${body}${RESET}` : `${lbl}${body}`;
461
+ return caps.color ? `${META}${lbl}${RESET}${BOLD}${body}${RESET}` : `${lbl}${body}`;
448
462
  }
449
463
  /** The bottom preview panel — exactly PREVIEW_HEIGHT lines, ordered by how fast
450
464
  * each answers "which node was this, and can I pick it back up?":
@@ -462,7 +476,7 @@ function recapLine(label, text, width, caps) {
462
476
  * The optional blocks yield their lines to the literal ones when absent, so the
463
477
  * panel is never padded with blanks. Always full height so viewport math holds. */
464
478
  function previewPanel(r, width, caps, now, query) {
465
- const out = [`${DIM}${'─'.repeat(width)}${RESET}`];
479
+ const out = [`${RECESSIVE}${'─'.repeat(width)}${RESET}`];
466
480
  if (r === undefined) {
467
481
  while (out.length < PREVIEW_HEIGHT)
468
482
  out.push('');
@@ -486,7 +500,7 @@ function previewPanel(r, width, caps, now, query) {
486
500
  if (r.viewed === true)
487
501
  metaPieces.push('\uf06e viewing');
488
502
  const metaText = clip(metaPieces.filter((p) => p !== '').join(' · '), Math.max(0, width - 2));
489
- out.push(caps.color ? `${glyph} ${DIM}${metaText}${RESET}` : `${glyph} ${metaText}`);
503
+ out.push(caps.color ? `${glyph} ${META}${metaText}${RESET}` : `${glyph} ${metaText}`);
490
504
  const alloc = previewAllocation(r.recap !== undefined, r.report !== undefined);
491
505
  // Goal / State — the generated briefing, the fastest "what is this and where is
492
506
  // it" in the panel. Absent for a node that never idled long enough to make one.
@@ -507,14 +521,14 @@ function previewPanel(r, width, caps, now, query) {
507
521
  const age = relAge(r.report.ts, now);
508
522
  const stamp = `${age === '' ? '' : `${age} `}${r.report.kind}`;
509
523
  const headline = r.report.heading === '' ? r.report.body : r.report.heading;
510
- const mark = caps.color ? `${DIM}▣ ${RESET}` : '▣ ';
524
+ const mark = caps.color ? `${RECESSIVE}▣ ${RESET}` : '▣ ';
511
525
  const inner = Math.max(1, width - 2);
512
- const stampText = caps.color ? `${ESC}${FG_CYAN}m${stamp}${RESET}${DIM} · ${RESET}` : `${stamp} · `;
526
+ const stampText = caps.color ? `${ESC}${FG_CYAN}m${stamp}${RESET}${RECESSIVE} · ${RESET}` : `${stamp} · `;
513
527
  out.push(`${mark}${stampText}${clip(headline, Math.max(1, inner - stamp.length - 3))}`);
514
528
  for (let i = 1; i < alloc.report; i++) {
515
529
  const detail = r.report.heading === '' ? '' : r.report.body;
516
530
  const ln = previewSnippet('', detail, inner, alloc.report - 1)[i - 1];
517
- out.push(ln === undefined ? '' : ` ${DIM}${clip(ln.text, inner)}${RESET}`);
531
+ out.push(ln === undefined ? '' : ` ${META}${clip(ln.text, inner)}${RESET}`);
518
532
  }
519
533
  }
520
534
  return out;
@@ -537,15 +551,16 @@ function previewPanel(r, width, caps, now, query) {
537
551
  // natural size, the largest gives a line back until it fits, and only then does the
538
552
  // least-valuable section drop out entirely (report → state → goal → reply; the ask
539
553
  // is the recognition anchor and never drops).
540
- /** A dim hairline with a small-caps section label — `── GOAL ────────`. The optional
541
- * suffix rides after the label in its own hue (the report's age · kind stamp). */
554
+ /** A hairline with a small-caps section label — `── GOAL ────────`. The optional
555
+ * suffix rides after the label in its own hue (the report's age · kind stamp).
556
+ * The label is a landmark (metadata grey + bold); the rule itself recedes. */
542
557
  function sectionRule(label, width, caps, suffix) {
543
558
  const suf = suffix === undefined ? '' : `${suffix.text} `;
544
559
  const tail = Math.max(0, width - 4 - label.length - suf.length);
545
560
  const sufStyled = suffix === undefined
546
561
  ? ''
547
- : caps.color ? `${ESC}${suffix.fg}m${suffix.text}${RESET}${DIM} ` : `${suffix.text} `;
548
- return `${DIM}── ${BOLD}${label}${RESET}${DIM} ${sufStyled}${'─'.repeat(tail)}${RESET}`;
562
+ : caps.color ? `${ESC}${suffix.fg}m${suffix.text}${RESET}${RECESSIVE} ` : `${suffix.text} `;
563
+ return `${RECESSIVE}── ${META}${BOLD}${label}${RESET}${RECESSIVE} ${sufStyled}${'─'.repeat(tail)}${RESET}`;
549
564
  }
550
565
  /** A marked body block with a hanging indent: `marker` leads the first line, `cont`
551
566
  * every wrapped line after it (`▌ ` keeps the accent bar running down a quote).
@@ -566,7 +581,7 @@ function blockLines(marker, cont, markerStyle, text, empty, maxLines, width, cap
566
581
  if (md.length === 0) {
567
582
  if (empty === undefined)
568
583
  return [];
569
- return [`${mk(marker, markerStyle)}${DIM}${clip(empty, inner)}${RESET}`];
584
+ return [`${mk(marker, markerStyle)}${RECESSIVE}${clip(empty, inner)}${RESET}`];
570
585
  }
571
586
  return md.map((spans, i) => `${mk(i === 0 ? marker : cont, markerStyle)}${assemble(spans, inner, caps.color, '', false)}`);
572
587
  }
@@ -574,17 +589,17 @@ function blockLines(marker, cont, markerStyle, text, empty, maxLines, width, cap
574
589
  if (snippet.length === 0) {
575
590
  if (empty === undefined)
576
591
  return [];
577
- return [`${mk(marker, markerStyle)}${DIM}${clip(empty, inner)}${RESET}`];
592
+ return [`${mk(marker, markerStyle)}${RECESSIVE}${clip(empty, inner)}${RESET}`];
578
593
  }
579
594
  return snippet.map((ln, i) => `${mk(i === 0 ? marker : cont, markerStyle)}${snippetLine(ln, inner, caps, bodyStyle)}`);
580
595
  }
581
- /** One `label value` fact cell: a 4-col right-aligned dim label, then the value in
596
+ /** One `label value` fact cell: a 4-col right-aligned grey label, then the value in
582
597
  * its own hue, padded so a two-column facts block aligns down both columns. */
583
598
  function factCell(label, value, w, style, pad) {
584
599
  const vw = Math.max(1, w - 5);
585
600
  const v = clip(value, vw);
586
601
  return [
587
- { text: `${label.padStart(4)} `, style: { dim: true } },
602
+ { text: `${label.padStart(4)} `, style: { fg: RECESSIVE_FG } },
588
603
  { text: v, style },
589
604
  ...(pad && v.length < vw ? [{ text: ' '.repeat(vw - v.length) }] : []),
590
605
  ];
@@ -594,10 +609,10 @@ function factCell(label, value, w, style, pad) {
594
609
  function factsBlock(r, width, now, caps) {
595
610
  const ctxFg = ctxColorCode(r.ctx_tokens);
596
611
  const facts = [
597
- ['kind', `${r.kind}/${r.mode}`, { fg: FG_GRAY }],
598
- ['ctx', fmtCtx(r.ctx_tokens), { fg: ctxFg, dim: ctxFg === undefined }],
599
- ['proj', `~${baseDir(r.cwd)}`, { fg: FG_GRAY }],
600
- ['age', relAge(r.created, now), { dim: true }],
612
+ ['kind', `${r.kind}/${r.mode}`, { fg: META_FG }],
613
+ ['ctx', fmtCtx(r.ctx_tokens), { fg: ctxFg ?? META_FG }],
614
+ ['proj', `~${baseDir(r.cwd)}`, { fg: META_FG }],
615
+ ['age', relAge(r.created, now), { fg: META_FG }],
601
616
  ];
602
617
  const out = [];
603
618
  // Two columns at any panel width the side layout allows; the column is capped so
@@ -628,7 +643,7 @@ function panelStatusLine(r, width, caps, now) {
628
643
  hanging: r.hanging ?? null,
629
644
  });
630
645
  const word = v.word ?? ({ active: 'active', idle: 'idle', done: 'done', dead: 'dead', canceled: 'canceled' }[r.status] ?? r.status);
631
- const spans = [{ text: `${v.glyph} ${word}`, style: { fg: FG(v.color), bold: true } }];
646
+ const spans = [{ text: `${v.glyph} ${word}`, style: { fg: v.color, bold: true } }];
632
647
  const sep = () => ({ text: ' ' });
633
648
  if (r.asks > 0)
634
649
  spans.push(sep(), { text: `⚑${r.asks} ${r.asks === 1 ? 'ask' : 'asks'}`, style: { fg: FG_BRIGHT_YELLOW, bold: true } });
@@ -642,13 +657,13 @@ function panelStatusLine(r, width, caps, now) {
642
657
  * beneath it: the pinned star, the node name in bold, its short id flush right. */
643
658
  function panelTitleLine(r, width, caps, isPinned) {
644
659
  if (r === undefined)
645
- return assemble([{ text: 'PREVIEW', style: { dim: true, bold: true } }], width, caps.color, '', false);
660
+ return assemble([{ text: 'PREVIEW', style: { fg: META_FG, bold: true } }], width, caps.color, '', false);
646
661
  const left = [];
647
662
  if (isPinned)
648
663
  left.push({ text: '★ ', style: { fg: FG_MAGENTA, bold: true } });
649
664
  const id = r.node_id.slice(0, 8);
650
665
  left.push({ text: clip(r.name, Math.max(4, width - id.length - 2 - spansWidth(left))), style: { bold: true } });
651
- return assembleRow(left, [{ text: id, style: { dim: true } }], width, caps.color, '', false);
666
+ return assembleRow(left, [{ text: id, style: { fg: META_FG } }], width, caps.color, '', false);
652
667
  }
653
668
  /** The side panel's body, at most `height` lines (never padded to it). */
654
669
  function sidePanelBody(r, width, height, caps, now, query) {
@@ -656,7 +671,7 @@ function sidePanelBody(r, width, height, caps, now, query) {
656
671
  if (height <= 0)
657
672
  return out;
658
673
  if (r === undefined) {
659
- out.push(`${DIM}(no selection)${RESET}`);
674
+ out.push(`${RECESSIVE}(no selection)${RESET}`);
660
675
  return out;
661
676
  }
662
677
  out.push(panelStatusLine(r, width, caps, now));
@@ -669,7 +684,7 @@ function sidePanelBody(r, width, height, caps, now, query) {
669
684
  frozen: isFrozenRow(r),
670
685
  hanging: r.hanging ?? null,
671
686
  });
672
- const accent = { fg: FG(v.color) };
687
+ const accent = { fg: v.color };
673
688
  const secs = [];
674
689
  if (r.recap !== undefined && r.recap.goal !== '') {
675
690
  const goal = r.recap.goal;
@@ -688,7 +703,7 @@ function sidePanelBody(r, width, height, caps, now, query) {
688
703
  lines: (n) => blockLines('❯ ', ' ', { fg: FG_BRIGHT_CYAN, bold: true }, askText, '(no spawn prompt)', n, width, caps, query) });
689
704
  const reply = r.lastAssistant ?? '';
690
705
  secs.push({ label: 'REPLY', drop: 1, min: 2, want: 9,
691
- lines: (n) => blockLines('↩ ', ' ', { dim: true }, reply, '(no reply yet)', n, width, caps, '') });
706
+ lines: (n) => blockLines('↩ ', ' ', { fg: META_FG }, reply, '(no reply yet)', n, width, caps, '') });
692
707
  if (r.report !== undefined) {
693
708
  const rep = r.report;
694
709
  const age = relAge(rep.ts, now);
@@ -699,7 +714,7 @@ function sidePanelBody(r, width, height, caps, now, query) {
699
714
  const headline = rep.heading === '' ? rep.body : rep.heading;
700
715
  const head = blockLines('▣ ', ' ', { fg: FG_CYAN, bold: true }, headline, undefined, Math.min(2, n), width, caps, '', { bold: true });
701
716
  if (rep.heading !== '' && n > head.length) {
702
- head.push(...blockLines(' ', ' ', undefined, rep.body, undefined, n - head.length, width, caps, '', { dim: true }));
717
+ head.push(...blockLines(' ', ' ', undefined, rep.body, undefined, n - head.length, width, caps, '', { fg: META_FG }));
703
718
  }
704
719
  return head;
705
720
  },
@@ -806,7 +821,7 @@ function renderHelp(size, caps) {
806
821
  const lines = [];
807
822
  lines.push(`${BOLD}Canvas navigator — keys${RESET}`);
808
823
  lines.push('');
809
- const dim = (s) => `${DIM}${s}${RESET}`;
824
+ const dim = (s) => `${META}${s}${RESET}`;
810
825
  for (const [group, keys, desc] of HELP_ROWS) {
811
826
  const g = group.padEnd(6);
812
827
  const k = keys.padEnd(12);
@@ -840,8 +855,8 @@ export function renderFrame(state, size, caps = { color: false, color256: false
840
855
  const parts = statusCounts(state.tree);
841
856
  const summaryPlain = parts.map((p) => `${STATUS_GLYPH[p.status]}${p.count}`).join(' ');
842
857
  const summaryColored = caps.color
843
- ? parts.map((p) => `${ESC}${STATUS_COLOR[p.status]}m${STATUS_GLYPH[p.status]}${RESET}${DIM}${p.count}${RESET}`).join(' ')
844
- : `${DIM}${summaryPlain}${RESET}`;
858
+ ? parts.map((p) => `${ESC}${STATUS_COLOR[p.status]}m${STATUS_GLYPH[p.status]}${RESET}${META}${p.count}${RESET}`).join(' ')
859
+ : `${META}${summaryPlain}${RESET}`;
845
860
  const gap = width - titlePlainLen - summaryPlain.length;
846
861
  lines.push(gap > 1 ? `${title}${' '.repeat(gap)}${summaryColored}` : title);
847
862
  // line 2 — tab bar.
@@ -862,7 +877,7 @@ export function renderFrame(state, size, caps = { color: false, color256: false
862
877
  const layout = previewLayout(cols, previewOn);
863
878
  const side = layout.mode === 'side';
864
879
  const rowsW = side ? layout.rowsW : width;
865
- const gutter = `${DIM}${SIDE_GUTTER}${RESET}`;
880
+ const gutter = `${RECESSIVE}${SIDE_GUTTER}${RESET}`;
866
881
  const sel = state.visible[state.cursor];
867
882
  const selRow = sel !== undefined ? state.tree.nodes.get(sel.id)?.row : undefined;
868
883
  // column header (table labels, plus the panel title in side mode) + separator.
@@ -872,8 +887,8 @@ export function renderFrame(state, size, caps = { color: false, color256: false
872
887
  ? `${colHeader}${gutter}${panelTitleLine(selRow, layout.panelW, caps, sel !== undefined && state.pinned.has(sel.id))}`
873
888
  : colHeader);
874
889
  lines.push(side
875
- ? `${DIM}${'─'.repeat(rowsW)}─┼─${'─'.repeat(layout.panelW)}${RESET}`
876
- : `${DIM}${'─'.repeat(width)}${RESET}`);
890
+ ? `${RECESSIVE}${'─'.repeat(rowsW)}─┼─${'─'.repeat(layout.panelW)}${RESET}`
891
+ : `${RECESSIVE}${'─'.repeat(width)}${RESET}`);
877
892
  // body — windowed visible rows, leaving room for the (optional) bottom panel.
878
893
  const head = lines.length; // === headerHeight(state.search)
879
894
  const previewH = layout.mode === 'bottom' ? PREVIEW_HEIGHT : 0;
@@ -886,7 +901,7 @@ export function renderFrame(state, size, caps = { color: false, color256: false
886
901
  const compose = (row, i) => (side ? `${row}${gutter}${panel[i] ?? ''}` : row);
887
902
  const blankRow = side ? ' '.repeat(rowsW) : '';
888
903
  if (state.visible.length === 0) {
889
- lines.push(`${DIM} (no nodes match this view)${RESET}`);
904
+ lines.push(`${META} (no nodes match this view)${RESET}`);
890
905
  for (let i = 1; i < viewport; i++)
891
906
  lines.push('');
892
907
  }
@@ -923,7 +938,7 @@ export function renderFrame(state, size, caps = { color: false, color256: false
923
938
  : state.readOnly === true
924
939
  ? '↑↓ move →/← tree / search p pin v preview Tab tabs ? more q quit — read-only remote canvas'
925
940
  : `${hangingHint}↑↓ move ⏎ open O bg / search p pin v preview m msg x close ? more q quit`;
926
- lines.push(`${DIM}${clip(footer, width)}${RESET}`);
941
+ lines.push(`${META}${clip(footer, width)}${RESET}`);
927
942
  }
928
943
  // Assemble: home, each line cleared to EOL, then clear below.
929
944
  const body = lines.map((l) => `${l}${ESC}K`).join('\r\n');
@@ -1,15 +1,16 @@
1
1
  import type { NodeStatus } from './types.js';
2
2
  import { type Fault, type FaultKind } from '../runtime/fault.js';
3
3
  export declare const STATUS_GLYPH: Record<NodeStatus, string>;
4
- export declare const STATUS_COLOR: Record<NodeStatus, number>;
4
+ export declare const STATUS_COLOR: Record<NodeStatus, string>;
5
5
  export declare const HANGING_GLYPH = "\u26A0";
6
- export declare const HANGING_COLOR = 33;
6
+ export declare const HANGING_COLOR = "33";
7
7
  /** Short label for the fault kind. The overlay keeps one glyph and lets the
8
8
  * label carry the distinction. */
9
9
  export declare function hangingLabel(kind: FaultKind): string;
10
10
  export interface NodeVisual {
11
11
  glyph: string;
12
- color: number;
12
+ /** SGR fg parameters, e.g. '32' or '38;5;243' — interpolate as `\x1b[${color}m`. */
13
+ color: string;
13
14
  /** Overlay word (hanging kind label / 'live'); undefined for a plain status
14
15
  * (the caller supplies its own status word). */
15
16
  word?: string;
@@ -6,6 +6,7 @@
6
6
  // because a frozen row has no process at all: any streaming read against it is
7
7
  // a stale marker, and showing it as active is the lie the freeze state exists
8
8
  // to stop telling.
9
+ import { RECESSIVE_FG } from '../tui/draw.js';
9
10
  import { FAULT_QUIET_MS } from '../runtime/fault.js';
10
11
  // ── Status glyphs (the mono, color-free primary encoding) ─────────────────────
11
12
  export const STATUS_GLYPH = {
@@ -15,24 +16,31 @@ export const STATUS_GLYPH = {
15
16
  dead: '✗',
16
17
  canceled: '⊘',
17
18
  };
18
- // ── Status hue: numeric SGR codes per status. ─────────────────────────────────
19
+ // ── Status hue: SGR fg parameters per status. ─────────────────────────────────
19
20
  // Reinforces the glyph; the glyph SHAPE stays the NO_COLOR carrier.
21
+ //
22
+ // The four live statuses use basic-16 hues, which every terminal palette renders
23
+ // as a saturated colour. `canceled` cannot: it wants GREY, and basic-16 grey is
24
+ // bright-black — a slot themes routinely set to a value barely off their own
25
+ // background, which is what made every canceled row unreadable. Grey is therefore
26
+ // an explicit 256-colour step (see RECESSIVE_FG) chosen for measured contrast
27
+ // rather than delegated to the palette.
20
28
  export const STATUS_COLOR = {
21
- active: 32, // green
22
- idle: 33, // yellow
23
- done: 36, // cyan
24
- dead: 31, // red
25
- canceled: 90, // grey (bright-black)
29
+ active: '32', // green
30
+ idle: '33', // yellow
31
+ done: '36', // cyan
32
+ dead: '31', // red
33
+ canceled: RECESSIVE_FG,
26
34
  };
27
35
  // ── Streaming overlay (genuinely mid-turn) — browse's existing statusRail visual.
28
36
  const STREAMING_GLYPH = '⟳';
29
- const STREAMING_COLOR = 92; // bright green (brighter than active-status green)
37
+ const STREAMING_COLOR = '92'; // bright green (brighter than active-status green)
30
38
  // ── Frozen overlay (waiting on a broker slot: marked, but no process). ───────
31
39
  const FROZEN_GLYPH = '◌';
32
- const FROZEN_COLOR = 94; // bright blue
40
+ const FROZEN_COLOR = '94'; // bright blue
33
41
  // ── Active-fault overlay (one glyph, kind label carries the detail). ─────────
34
42
  export const HANGING_GLYPH = '⚠';
35
- export const HANGING_COLOR = 33; // yellow
43
+ export const HANGING_COLOR = '33'; // yellow
36
44
  /** Short label for the fault kind. The overlay keeps one glyph and lets the
37
45
  * label carry the distinction. */
38
46
  export function hangingLabel(kind) {
@@ -1,10 +1,12 @@
1
- /** The SGR escape that turns ON the distinct-surface background (theme
2
- * `selectedBg`), e.g. `\x1b[48;2;58;58;74m`. Pair with `\x1b[49m` to reset just
1
+ /** The SGR escape that turns ON the distinct-surface background (the theme's
2
+ * card plane), e.g. `\x1b[48;2;52;53;65m`. Pair with `\x1b[49m` to reset just
3
3
  * the background. Throws if no process has initialized the theme — callers on a
4
4
  * render path (the attach overlay) always have, by construction. */
5
5
  export declare function surfaceBgAnsi(): string;
6
- /** tmux flags that frame a `display-menu` / `display-popup` on the distinct
7
- * surface background — a rounded border + the surface bg on the body and border.
6
+ /** tmux flags that frame a `display-menu` / `display-popup` as a distinct
7
+ * surface — a rounded border in the theme accent around a body painted with the
8
+ * theme's card plane. The accent border is what carries the "you are somewhere
9
+ * else" signal, because a card sits close to the page by design.
8
10
  * Empty when the theme is unavailable in this process (chrome stays default; the
9
11
  * attach viewer re-installs the menu with these once it has themed). One knob:
10
12
  * every crtr tmux float shares this single definition. */
@@ -3,10 +3,24 @@
3
3
  // crtr's overlays and popups must read as a SEPARATE surface from the normal one
4
4
  // (CTO ruling, insights/terminal-ui/inline-ui-placement #11): a popup whose background matches
5
5
  // the surface has invisible edges, so you can't tell where the float begins. We
6
- // paint them with the active pi theme's `selectedBg` role — theme-derived, so it
7
- // adapts to light/dark and is ONE centralized knob, not a scattered hardcode.
6
+ // paint them from the active pi theme — theme-derived, so it adapts to light/dark
7
+ // and is ONE centralized knob, not a scattered hardcode.
8
8
  //
9
- // pi 0.79 does not re-export `selectedBg` through its public API (the `.` entry
9
+ // WHICH ROLE: `userMessageBg`, the theme's CARD plane — not `selectedBg`. Every
10
+ // theme places its card one step off the page background (gruvbox-material
11
+ // #32302f vs #282828; pi's own dark #343541, light #e8e8e8) while `selectedBg` is
12
+ // a SELECTION BAND, deliberately pushed far off the page so a highlighted row
13
+ // lifts. Painting a 90%×85% takeover with the selection band made the whole
14
+ // screen that band: every dim/muted role lost the contrast it was designed
15
+ // against (gruvbox dim text fell to 2.1:1), and the row highlight drawn ON TOP
16
+ // had nowhere left to go. The card plane keeps content legibility and leaves the
17
+ // selection band free to do its actual job inside the popup.
18
+ //
19
+ // A card sits close to the page by design, so the EDGE signal rule 11 demands
20
+ // comes from the border, not the fill: we paint the rounded border in the theme
21
+ // accent so the float's boundary is unmistakable.
22
+ //
23
+ // pi 0.79 does not re-export these roles through its public API (the `.` entry
10
24
  // omits `getResolvedThemeColors` and the live `theme` singleton). It DOES publish
11
25
  // the live `Theme` instance on a process-global symbol once `initTheme()` has run
12
26
  // — the same hook pi's own SDK reads (see broker.ts `ctx.ui.theme`). We read it
@@ -26,43 +40,46 @@ function liveTheme() {
26
40
  const t = globalThis[THEME_SYMBOL];
27
41
  return (t ?? undefined);
28
42
  }
29
- /** The SGR escape that turns ON the distinct-surface background (theme
30
- * `selectedBg`), e.g. `\x1b[48;2;58;58;74m`. Pair with `\x1b[49m` to reset just
43
+ /** The SGR escape that turns ON the distinct-surface background (the theme's
44
+ * card plane), e.g. `\x1b[48;2;52;53;65m`. Pair with `\x1b[49m` to reset just
31
45
  * the background. Throws if no process has initialized the theme — callers on a
32
46
  * render path (the attach overlay) always have, by construction. */
33
47
  export function surfaceBgAnsi() {
34
48
  const t = liveTheme();
35
49
  if (t === undefined)
36
50
  throw new Error('surfaceBgAnsi: pi theme not initialized in this process');
37
- return t.getBgAnsi('selectedBg');
51
+ return t.getBgAnsi('userMessageBg');
38
52
  }
39
- /** The distinct-surface background as a tmux colour token (`#rrggbb` on a
40
- * truecolor theme, `colourN` on a 256-colour theme), or undefined when the theme
41
- * is not yet loaded in this process OR defines no selection background. tmux
42
- * cannot read pi's theme, so we translate the SGR pi resolved for us. */
43
- function surfaceBgTmux() {
44
- const t = liveTheme();
45
- if (t === undefined)
46
- return undefined;
47
- const ansi = t.getBgAnsi('selectedBg');
48
- const tc = /48;2;(\d+);(\d+);(\d+)/.exec(ansi);
53
+ /** An SGR colour parameter (`48;2;r;g;b` or `38;5;n`) as a tmux colour token —
54
+ * `#rrggbb` on a truecolor theme, `colourN` on a 256-colour one. tmux cannot
55
+ * read pi's theme, so we translate the SGR pi already resolved for us.
56
+ * undefined when the role is empty (the SGR is a bare default-colour reset). */
57
+ function tmuxColor(ansi) {
58
+ const tc = /[34]8;2;(\d+);(\d+);(\d+)/.exec(ansi);
49
59
  if (tc !== null) {
50
60
  const hex = (n) => Number(n).toString(16).padStart(2, '0');
51
61
  return `#${hex(tc[1])}${hex(tc[2])}${hex(tc[3])}`;
52
62
  }
53
- const c256 = /48;5;(\d+)/.exec(ansi);
63
+ const c256 = /[34]8;5;(\d+)/.exec(ansi);
54
64
  if (c256 !== null)
55
65
  return `colour${c256[1]}`;
56
- return undefined; // empty/default selectedBg → nothing distinct to paint
66
+ return undefined;
57
67
  }
58
- /** tmux flags that frame a `display-menu` / `display-popup` on the distinct
59
- * surface background — a rounded border + the surface bg on the body and border.
68
+ /** tmux flags that frame a `display-menu` / `display-popup` as a distinct
69
+ * surface — a rounded border in the theme accent around a body painted with the
70
+ * theme's card plane. The accent border is what carries the "you are somewhere
71
+ * else" signal, because a card sits close to the page by design.
60
72
  * Empty when the theme is unavailable in this process (chrome stays default; the
61
73
  * attach viewer re-installs the menu with these once it has themed). One knob:
62
74
  * every crtr tmux float shares this single definition. */
63
75
  export function surfaceTmuxStyleArgs() {
64
- const bg = surfaceBgTmux();
76
+ const t = liveTheme();
77
+ if (t === undefined)
78
+ return [];
79
+ const bg = tmuxColor(t.getBgAnsi('userMessageBg'));
65
80
  if (bg === undefined)
66
81
  return [];
67
- return ['-b', 'rounded', '-s', `bg=${bg}`, '-S', `bg=${bg}`];
82
+ const accent = tmuxColor(t.getFgAnsi('accent'));
83
+ const border = accent === undefined ? `bg=${bg}` : `fg=${accent},bg=${bg}`;
84
+ return ['-b', 'rounded', '-s', `bg=${bg}`, '-S', border];
68
85
  }
@@ -1,3 +1,5 @@
1
+ export declare const META_FG = "38;5;246";
2
+ export declare const RECESSIVE_FG = "38;5;243";
1
3
  export interface Size {
2
4
  cols: number;
3
5
  rows: number;