@north-light/crouter 0.3.273 → 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.
@@ -9,6 +9,7 @@
9
9
  import { test } from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
11
  import { classify } from '../fault-classifier.js';
12
+ import { __classifyProviderErrorForTest } from '../../builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js';
12
13
  test('invalid_grant text classifies as fatal auth (issue #94)', () => {
13
14
  const c = classify('pi→provider', 'Anthropic token refresh failed: invalid_grant');
14
15
  assert.equal(c.kind, 'auth');
@@ -89,3 +90,49 @@ test('"terminated" matches as a whole word only (no false positive on substrings
89
90
  // guard the word-boundary choice explicitly.
90
91
  assert.notEqual(classify('pi→provider', 'preterminated').kind, 'connection');
91
92
  });
93
+ test('shared recognition preserves each existing policy eligibility, except bare quota', () => {
94
+ const assertPair = (raw, faultKind, rotationKind, observed = {}) => {
95
+ assert.equal(classify('pi→provider', raw).kind, faultKind, `fault: ${JSON.stringify(raw)}`);
96
+ const error = typeof raw === 'string' ? { message: raw } : raw;
97
+ assert.equal(__classifyProviderErrorForTest(error, observed).kind, rotationKind, `rotation: ${JSON.stringify(raw)}`);
98
+ };
99
+ for (const [status, faultKind, rotationKind] of [
100
+ [401, 'auth', 'fatal'], [403, 'auth', 'fatal'], [429, 'rate-limit', 'rate_limit'],
101
+ [500, 'overloaded', 'transient'], [502, 'overloaded', 'transient'], [503, 'overloaded', 'transient'], [504, 'overloaded', 'transient'],
102
+ [529, 'overloaded', 'fatal'], [501, 'overloaded', 'fatal'], [400, 'protocol', 'fatal'], [413, 'protocol', 'fatal'],
103
+ ])
104
+ assertPair({ status }, faultKind, rotationKind);
105
+ assertPair({ status: 503 }, 'overloaded', 'rate_limit', { retryAfterMs: 1 });
106
+ for (const code of ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOENT', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH']) {
107
+ assertPair({ code }, 'connection', 'fatal');
108
+ }
109
+ assertPair({ code: 'ELOCKED' }, 'overloaded', 'fatal');
110
+ assertPair({ code: 1008 }, 'protocol', 'fatal');
111
+ assertPair({ code: 1006 }, 'connection', 'fatal');
112
+ assertPair({ code: 1011, reason: 'no running broker' }, 'connection', 'fatal');
113
+ assertPair({ code: 1011, reason: 'unrelated failure' }, 'other', 'fatal');
114
+ for (const text of ['rate limit', '429', 'too many requests', 'usage limit', 'usage_not_included', 'usagelimiterror', 'insufficient_quota', 'quota exceeded']) {
115
+ assertPair(text, 'rate-limit', 'rate_limit');
116
+ }
117
+ assertPair('quota', 'other', 'fatal');
118
+ assertPair('ordinary usage discussion', 'other', 'fatal');
119
+ for (const text of ['connection', 'network', 'fetch failed', 'econnreset', 'econnrefused', 'enotfound', 'etimedout', 'timed out', 'timeout', 'WebSocket closed', 'WebSocket stream closed before response.completed', 'WebSocket error', 'WebSocket interruption', 'socket hang', 'overloaded', 'temporarily unavailable']) {
120
+ const faultKind = text === 'overloaded' || text === 'temporarily unavailable' ? 'overloaded' : 'connection';
121
+ assertPair(text, faultKind, 'transient');
122
+ }
123
+ for (const text of ['socket', 'service unavailable'])
124
+ assertPair(text, 'other', 'transient');
125
+ for (const text of ['network error: rate limit exceeded', 'service unavailable: too many requests'])
126
+ assertPair(text, 'rate-limit', 'transient');
127
+ for (const text of ['413 Payload Too Large', 'invalid_grant', 'context overflow recovery failed']) {
128
+ const faultKind = text.includes('413') ? 'protocol' : text.includes('invalid_grant') ? 'auth' : 'context-overflow';
129
+ assertPair(text, faultKind, 'fatal');
130
+ }
131
+ assertPair('WebSocket closed: 1008 policy violation', 'protocol', 'transient');
132
+ for (const text of ['lock file is already being held', 'Internal server error', 'APIError', '500', 'capacity', 'server busy']) {
133
+ assertPair(text, 'overloaded', 'fatal');
134
+ }
135
+ for (const text of ['EPIPE', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'terminated', 'other side closed', 'premature close']) {
136
+ assertPair(text, 'connection', 'fatal');
137
+ }
138
+ });
@@ -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) {
@@ -25,19 +25,4 @@ export interface FaultClassification {
25
25
  disposition: FaultRetryDisposition;
26
26
  }
27
27
  export declare function errorClassFromFault(fault: Pick<Fault, 'kind' | 'retry'>): ErrorClass;
28
- export interface FaultErrnoLike {
29
- code?: unknown;
30
- errno?: unknown;
31
- message?: unknown;
32
- }
33
- export interface FaultHttpLike {
34
- status?: unknown;
35
- statusText?: unknown;
36
- ok?: unknown;
37
- message?: unknown;
38
- }
39
- export interface FaultWsCloseLike {
40
- code?: unknown;
41
- reason?: unknown;
42
- }
43
28
  export declare function classify(link: FaultLink, raw: unknown): FaultClassification;