@north-light/crouter 0.3.176 → 0.3.178

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.
Files changed (152) hide show
  1. package/dist/api/client.d.ts +4 -4
  2. package/dist/api/client.js +2 -2
  3. package/dist/api/dto/lifecycle.d.ts +1 -1
  4. package/dist/api/dto/reports.d.ts +1 -1
  5. package/dist/clients/attach/__tests__/frame-selection.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/frame-selection.test.js +84 -0
  7. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  8. package/dist/clients/attach/ansi-cells.d.ts +31 -0
  9. package/dist/clients/attach/ansi-cells.js +120 -0
  10. package/dist/clients/attach/config.js +5 -5
  11. package/dist/clients/attach/input/clipboard-image.js +2 -2
  12. package/dist/clients/attach/input/ref-autocomplete.js +1 -1
  13. package/dist/clients/attach/input/titled-editor.d.ts +12 -13
  14. package/dist/clients/attach/input/titled-editor.js +14 -15
  15. package/dist/clients/attach/overlays/dialogs.d.ts +1 -1
  16. package/dist/clients/attach/overlays/dialogs.js +2 -2
  17. package/dist/clients/attach/overlays/help.js +2 -1
  18. package/dist/clients/attach/overlays/mcp.js +5 -12
  19. package/dist/clients/attach/render/chat-view.d.ts +1 -1
  20. package/dist/clients/attach/render/chat-view.js +2 -2
  21. package/dist/clients/attach/render/group-recap.js +7 -0
  22. package/dist/clients/attach/session/context.js +3 -3
  23. package/dist/clients/attach/session/editor-inventory.js +6 -6
  24. package/dist/clients/attach/session/file-links.js +15 -50
  25. package/dist/clients/attach/session/frame.d.ts +5 -1
  26. package/dist/clients/attach/session/frame.js +19 -4
  27. package/dist/clients/attach/session/input-wiring.js +2 -2
  28. package/dist/clients/attach/session/mouse.d.ts +4 -0
  29. package/dist/clients/attach/session/mouse.js +14 -3
  30. package/dist/clients/attach/session/selection.d.ts +28 -0
  31. package/dist/clients/attach/session/selection.js +108 -0
  32. package/dist/clients/attach/session/teardown.js +7 -9
  33. package/dist/clients/attach/slash/dispatch.d.ts +1 -2
  34. package/dist/clients/attach/slash/dispatch.js +5 -9
  35. package/dist/clients/attach/viewer.js +552 -550
  36. package/dist/clients/inbox/controller.js +3 -2
  37. package/dist/clients/inbox/deck-adapter.js +3 -2
  38. package/dist/clients/surfaces/host.d.ts +1 -1
  39. package/dist/commands/api-client.d.ts +12 -13
  40. package/dist/commands/api-client.js +15 -16
  41. package/dist/commands/chord.js +1 -2
  42. package/dist/commands/human/queue.js +6 -5
  43. package/dist/commands/memory/find.js +3 -3
  44. package/dist/commands/node/lifecycle.js +1 -1
  45. package/dist/commands/node-lifecycle-revive.d.ts +1 -1
  46. package/dist/commands/node-lifecycle-revive.js +3 -3
  47. package/dist/commands/push.js +1 -1
  48. package/dist/commands/surface/node/placement.d.ts +1 -1
  49. package/dist/commands/surface/node/placement.js +1 -1
  50. package/dist/commands/surface-edit.js +1 -4
  51. package/dist/commands/sys/panels/panel.d.ts +3 -3
  52. package/dist/commands/sys/panels/panel.js +3 -3
  53. package/dist/commands/sys/setup-core.js +1 -7
  54. package/dist/commands/sys/sync-deps.js +1 -3
  55. package/dist/commands/sys/sync-project-guidance.js +2 -11
  56. package/dist/commands/sys/sysprompt.js +1 -3
  57. package/dist/core/__tests__/fixtures/fake-engine.js +1 -1
  58. package/dist/core/__tests__/helpers/harness.d.ts +1 -1
  59. package/dist/core/__tests__/helpers/harness.js +1 -1
  60. package/dist/core/canvas/boot.js +1 -1
  61. package/dist/core/canvas/browse/app.js +2 -2
  62. package/dist/core/canvas/canvas.d.ts +3 -3
  63. package/dist/core/canvas/canvas.js +3 -3
  64. package/dist/core/canvas/daemon-owner.js +1 -2
  65. package/dist/core/canvas/db.js +7 -8
  66. package/dist/core/canvas/index.js +2 -2
  67. package/dist/core/canvas/pid.d.ts +12 -14
  68. package/dist/core/canvas/pid.js +16 -20
  69. package/dist/core/canvas/remote-transport.js +2 -2
  70. package/dist/core/canvas/status-glyph.d.ts +1 -1
  71. package/dist/core/canvas/status-glyph.js +1 -1
  72. package/dist/core/canvas/types.d.ts +1 -1
  73. package/dist/core/command-manifests/manifest.js +4 -6
  74. package/dist/core/command-manifests/schema.js +10 -12
  75. package/dist/core/command-plugins/transport/exec-invoke.js +5 -7
  76. package/dist/core/command-plugins/transport/http-invoke.js +4 -6
  77. package/dist/core/config.d.ts +1 -1
  78. package/dist/core/config.js +2 -2
  79. package/dist/core/events/read.js +1 -3
  80. package/dist/core/fault-classifier.js +1 -3
  81. package/dist/core/fs-utils.d.ts +7 -0
  82. package/dist/core/fs-utils.js +29 -2
  83. package/dist/core/host-exports/export.d.ts +4 -5
  84. package/dist/core/host-exports/export.js +4 -5
  85. package/dist/core/human/claim.js +3 -2
  86. package/dist/core/human/convention.d.ts +0 -1
  87. package/dist/core/human/convention.js +1 -9
  88. package/dist/core/human/scan.js +5 -4
  89. package/dist/core/profiles/manifest.d.ts +1 -1
  90. package/dist/core/profiles/manifest.js +1 -1
  91. package/dist/core/review/types.d.ts +1 -1
  92. package/dist/core/review/types.js +1 -1
  93. package/dist/core/runtime/broker/frame-dispatch.js +4 -4
  94. package/dist/core/runtime/broker-cli.js +1 -1
  95. package/dist/core/runtime/broker-protocol.d.ts +9 -10
  96. package/dist/core/runtime/broker-protocol.js +9 -10
  97. package/dist/core/runtime/broker-sdk.d.ts +1 -1
  98. package/dist/core/runtime/broker.js +6 -6
  99. package/dist/core/runtime/host.js +14 -14
  100. package/dist/core/runtime/launch.d.ts +1 -1
  101. package/dist/core/runtime/launch.js +1 -1
  102. package/dist/core/runtime/lifecycle.js +16 -21
  103. package/dist/core/runtime/node-read.js +2 -3
  104. package/dist/core/runtime/package-health.js +1 -8
  105. package/dist/core/runtime/persona.js +2 -2
  106. package/dist/core/runtime/pi-vendored.d.ts +4 -2
  107. package/dist/core/runtime/pi-vendored.js +5 -16
  108. package/dist/core/runtime/promote.js +1 -1
  109. package/dist/core/runtime/reopen.js +2 -2
  110. package/dist/core/runtime/revive-all.d.ts +3 -3
  111. package/dist/core/runtime/revive-all.js +6 -6
  112. package/dist/core/runtime/revive.js +1 -1
  113. package/dist/core/runtime/spawn.d.ts +1 -2
  114. package/dist/core/runtime/spawn.js +2 -3
  115. package/dist/core/runtime/structured-output.js +4 -6
  116. package/dist/core/runtime/tmux-driver.d.ts +2 -2
  117. package/dist/core/runtime/tmux-driver.js +3 -5
  118. package/dist/core/runtime/tool-group-summary.js +5 -4
  119. package/dist/core/runtime/warm-pool.js +1 -1
  120. package/dist/core/scope.js +2 -9
  121. package/dist/core/self-update.js +3 -3
  122. package/dist/core/spawn.d.ts +0 -1
  123. package/dist/core/spawn.js +1 -4
  124. package/dist/core/substrate/on-read.js +1 -9
  125. package/dist/core/wake.js +2 -2
  126. package/dist/core/worktree.d.ts +3 -3
  127. package/dist/core/worktree.js +5 -7
  128. package/dist/daemon/api/bridge.js +1 -1
  129. package/dist/daemon/api/handlers/broker-ops.js +1 -2
  130. package/dist/daemon/api/handlers/messages.js +2 -2
  131. package/dist/daemon/api/handlers/nodes.js +1 -1
  132. package/dist/daemon/api/handlers/reports.js +1 -1
  133. package/dist/daemon/api/handlers/validate.d.ts +2 -1
  134. package/dist/daemon/api/handlers/validate.js +2 -3
  135. package/dist/daemon/cron-run.js +7 -7
  136. package/dist/daemon/human/finish.js +3 -2
  137. package/dist/daemon/manage.d.ts +6 -6
  138. package/dist/daemon/manage.js +15 -16
  139. package/dist/pi-extensions/canvas-context-intro.js +1 -14
  140. package/dist/pi-extensions/canvas-doc-substrate.js +1 -1
  141. package/dist/pi-extensions/canvas-review-boundary.js +1 -14
  142. package/dist/pi-extensions/truncate.d.ts +5 -0
  143. package/dist/pi-extensions/truncate.js +14 -0
  144. package/dist/shared/predicates.d.ts +2 -0
  145. package/dist/shared/predicates.js +4 -0
  146. package/dist/shared/shell-quote.d.ts +3 -0
  147. package/dist/shared/shell-quote.js +5 -0
  148. package/dist/shared/tool-groups.d.ts +4 -2
  149. package/dist/shared/tool-groups.js +3 -3
  150. package/dist/types.d.ts +4 -5
  151. package/package.json +1 -1
  152. package/runtime.lock.json +2 -2
@@ -33,7 +33,7 @@ export interface CrtrClientOptions {
33
33
  * autostart is on; after it resolves, the client polls `/healthz` and retries
34
34
  * the original request once. Absent → a cold socket throws `daemon_unavailable`. */
35
35
  onColdSocket?: () => Promise<void>;
36
- /** Injected cold-start diagnostic (issue #516). Called ONLY when the bounded
36
+ /** Injected cold-start diagnostic. Called ONLY when the bounded
37
37
  * `/healthz` poll times out after `onColdSocket`, so the caller can attach
38
38
  * operator-useful context (e.g. a bounded tail of crtrd's stderr log) to the
39
39
  * `daemon_unavailable` error instead of it staying a bare message. Must
@@ -43,7 +43,7 @@ export interface CrtrClientOptions {
43
43
  /** Bounded window (ms) to poll `/healthz` after `onColdSocket` resolves
44
44
  * before giving up with `daemon_unavailable`. Defaults to
45
45
  * `HEALTHZ_POLL_WINDOW_MS`. This must NOT silently drift from whatever
46
- * window actually governs "did the daemon start" (issue #508 follow-up):
46
+ * window actually governs "did the daemon start":
47
47
  * `onColdSocket` is fire-and-forget, so this poll is the ONLY deadline that
48
48
  * determines whether the CLI reports success. A caller whose `onColdSocket`
49
49
  * hook triggers a differently-windowed startup verifier (e.g. the CLI's
@@ -253,8 +253,8 @@ export declare class CrtrClient {
253
253
  private handleColdSocket;
254
254
  }
255
255
  /** Compose the cold-start `/healthz`-timeout `daemon_unavailable` message,
256
- * appending the injected diagnostic (issue #516) when one is present instead
257
- * of discarding the real startup failure behind a bare message. Exported (not
256
+ * appending the injected diagnostic when one is present instead of discarding
257
+ * the real startup failure behind a bare message. Exported (not
258
258
  * from the package's public `index.ts` surface, which is dependency-light by
259
259
  * design) purely so the regression test can assert the composition without
260
260
  * waiting out the real poll window. */
@@ -655,8 +655,8 @@ function sleep(ms) {
655
655
  return new Promise((resolve) => setTimeout(resolve, ms));
656
656
  }
657
657
  /** Compose the cold-start `/healthz`-timeout `daemon_unavailable` message,
658
- * appending the injected diagnostic (issue #516) when one is present instead
659
- * of discarding the real startup failure behind a bare message. Exported (not
658
+ * appending the injected diagnostic when one is present instead of discarding
659
+ * the real startup failure behind a bare message. Exported (not
660
660
  * from the package's public `index.ts` surface, which is dependency-light by
661
661
  * design) purely so the regression test can assert the composition without
662
662
  * waiting out the real poll window. */
@@ -3,7 +3,7 @@ import type { NodeIdDTO, NodeStatusDTO } from './common.js';
3
3
  export interface ReviveRequest {
4
4
  /** Resume the saved conversation (true) vs a fresh launch (false). */
5
5
  resume?: boolean;
6
- /** Clear the finalization latch before reviving (#339 `--reopen` gate). The
6
+ /** Clear the finalization latch before reviving (the `--reopen` gate). The
7
7
  * latch clear is a canvas write, so the gate is enforced server-side: with
8
8
  * `reopen:false` (default) a finalized node is REJECTED (`node_finalized`);
9
9
  * with `reopen:true` a node that is NOT finalized is rejected
@@ -20,7 +20,7 @@ export interface PushReportResultDTO {
20
20
  /** Present (and true) only for a `final` push whose reporting node owned a
21
21
  * managed worktree with zero commits ahead of its base and no uncommitted
22
22
  * changes: the server auto-dropped it instead of blocking on `node worktree
23
- * close` (#327/#333). A worktree with real unlanded work instead blocks with
23
+ * close`. A worktree with real unlanded work instead blocks with
24
24
  * an `open_managed_worktree` error. */
25
25
  worktree_auto_dropped?: boolean;
26
26
  /** Present only alongside `worktree_auto_dropped`: the checkout path dropped. */
@@ -0,0 +1,84 @@
1
+ // Mouse drag-select copies out of the PHYSICAL frame rows, which carry SGR
2
+ // color, OSC-8 link boundaries, pi-tui's APC cursor marker, and wide glyphs. A
3
+ // column is a terminal cell, never a string index — get that wrong and the copy
4
+ // silently slices mid-escape or off-by-N, with the highlight still looking right
5
+ // on screen.
6
+ import assert from 'node:assert/strict';
7
+ import test from 'node:test';
8
+ import { highlightCells, rowCells, sliceCells } from '../ansi-cells.js';
9
+ import { createFrameSelection } from '../session/selection.js';
10
+ const CURSOR_MARKER = '\x1b_pi-cursor\x1b\\';
11
+ const LINK = (uri, text) => `\x1b]8;;${uri}\x07${text}\x1b]8;;\x07`;
12
+ test('cells are counted as the terminal counts them', () => {
13
+ assert.equal(rowCells('plain'), 5);
14
+ assert.equal(rowCells('\x1b[31mred\x1b[0m'), 3);
15
+ assert.equal(rowCells(LINK('file:///tmp/a.ts', 'a.ts')), 4);
16
+ assert.equal(rowCells(`ab${CURSOR_MARKER}cd`), 4);
17
+ assert.equal(rowCells('日本'), 4);
18
+ });
19
+ test('a slice returns the plain text under those cells', () => {
20
+ assert.equal(sliceCells('\x1b[31mhello\x1b[0m world', 0, 5), 'hello');
21
+ assert.equal(sliceCells('\x1b[31mhello\x1b[0m world', 6, 11), 'world');
22
+ assert.equal(sliceCells(`src/${LINK('file:///tmp/a.ts', 'a.ts')}:12`, 0, 8), 'src/a.ts');
23
+ assert.equal(sliceCells(`ab${CURSOR_MARKER}cd`, 0, 4), 'abcd');
24
+ // A wide glyph belongs to the cell it starts at and is never split in half.
25
+ assert.equal(sliceCells('日本語', 2, 4), '本');
26
+ });
27
+ test('highlighting restyles without changing what the row says', () => {
28
+ const row = '\x1b[31mhello\x1b[0m world';
29
+ const painted = highlightCells(row, 0, 5, 80);
30
+ assert.equal(sliceCells(painted, 0, rowCells(row)), 'hello world');
31
+ assert.equal(rowCells(painted), rowCells(row));
32
+ assert.ok(painted.includes('\x1b[7m'));
33
+ // A reset inside the span would drop the highlight, so inverse is re-asserted.
34
+ assert.equal(highlightCells(row, 0, 11, 80).split('\x1b[7m').length - 1, 2);
35
+ });
36
+ test('the cursor marker survives a highlight that starts on its cell', () => {
37
+ const painted = highlightCells(`ab${CURSOR_MARKER}cd`, 2, 4, 80);
38
+ assert.ok(painted.includes(CURSOR_MARKER));
39
+ assert.equal(sliceCells(painted, 0, 4), 'abcd');
40
+ });
41
+ test('a short row pads so a multi-row selection reads as one block, never past the pane', () => {
42
+ assert.equal(rowCells(highlightCells('ab', 0, 6, 80)), 6);
43
+ // Padding past the pane width would wrap the row and break the frame's row count.
44
+ assert.equal(rowCells(highlightCells('ab', 0, 6, 4)), 4);
45
+ });
46
+ test('a press that never moves stays a click, so file links still open', () => {
47
+ const selection = createFrameSelection({ frame: () => ['src/a.ts'], width: () => 40 });
48
+ selection.begin(3, 1);
49
+ assert.deepEqual(selection.release(), { kind: 'click', column: 3, row: 1 });
50
+ assert.equal(selection.active(), false);
51
+ });
52
+ test('a drag across rows selects terminal-style: to the edge, then whole rows', () => {
53
+ const frame = ['first row', '\x1b[32msecond row\x1b[0m', 'third row'];
54
+ const selection = createFrameSelection({ frame: () => frame, width: () => 40 });
55
+ selection.begin(7, 1);
56
+ assert.equal(selection.extend(5, 3), true);
57
+ // Same cell twice is not a change — no wasted render.
58
+ assert.equal(selection.extend(5, 3), false);
59
+ const painted = selection.apply(frame);
60
+ assert.equal(painted.length, 3);
61
+ assert.ok(painted[0].includes('\x1b[7m'));
62
+ assert.equal(sliceCells(painted[1], 0, rowCells(frame[1])), 'second row');
63
+ assert.equal(selection.active(), true);
64
+ assert.equal(selection.clear(), true);
65
+ assert.equal(selection.active(), false);
66
+ });
67
+ test('a backwards drag selects the same span as a forwards one', () => {
68
+ const frame = ['abcdef', 'ghijkl'];
69
+ const forwards = createFrameSelection({ frame: () => frame, width: () => 6 });
70
+ forwards.begin(3, 1);
71
+ forwards.extend(4, 2);
72
+ const backwards = createFrameSelection({ frame: () => frame, width: () => 6 });
73
+ backwards.begin(4, 2);
74
+ backwards.extend(3, 1);
75
+ assert.deepEqual(backwards.apply(frame), forwards.apply(frame));
76
+ });
77
+ test('an empty drag over blank rows reports nothing to copy', () => {
78
+ // Releasing over content would shell out to the clipboard; blank rows settle
79
+ // the geometry without leaving the process.
80
+ const selection = createFrameSelection({ frame: () => ['', '', ''], width: () => 40 });
81
+ selection.begin(2, 1);
82
+ selection.extend(5, 3);
83
+ assert.deepEqual(selection.release(), { kind: 'selection', notice: 'Nothing to copy — selection is empty' });
84
+ });
@@ -30,7 +30,7 @@ async function summarizedGroup(messages, key) {
30
30
  stats: {},
31
31
  state: { isStreaming: false },
32
32
  toolGroupSummaries: {
33
- [key]: { bullets: ['broker recap'], nodesSpawned: 0, filesEdited: 0 },
33
+ [key]: { bullets: ['broker recap'], nodesSpawned: 0, filesDeleted: 0 },
34
34
  },
35
35
  });
36
36
  const group = internals.groups.find((candidate) => candidate.key === key);
@@ -70,7 +70,7 @@ test('a folded recap carries paths and counts, never diff or write payload bodie
70
70
  bashMode: tone('bashMode'), bashModeAlt: tone('bashModeAlt'),
71
71
  };
72
72
  const recap = new GroupRecapComponent();
73
- recap.setRecap({ bullets: ['finished'], nodesSpawned: 0, filesEdited: 2 }, 2, { memories: [{ action: 'read', name: 'alpha', path: '/tmp/memories/alpha.md' }], files: recorder.activity().files }, true, palette);
73
+ recap.setRecap({ bullets: ['finished'], nodesSpawned: 0, filesDeleted: 2 }, 2, { memories: [{ action: 'read', name: 'alpha', path: '/tmp/memories/alpha.md' }], files: recorder.activity().files }, true, palette);
74
74
  const text = recap.render(120).join('\n');
75
75
  assert.match(text, /overwritten\.ts/, 'the recap still names what changed');
76
76
  assert.doesNotMatch(text, /sensitive-old-diff|sensitive-new-diff|sensitive-write-payload/, 'payload bodies never leak into a recap');
@@ -0,0 +1,31 @@
1
+ /** Read one terminal control sequence so non-printing ANSI never takes a cell.
2
+ * `undefined` means the sequence runs off the end of the row. */
3
+ export declare function readEscape(line: string, start: number): {
4
+ text: string;
5
+ end: number;
6
+ } | undefined;
7
+ /** One printable grapheme, with the escape sequences that preceded it. */
8
+ export type RowGlyph = {
9
+ /** The grapheme itself, with no ANSI attached. */
10
+ text: string;
11
+ /** 0-indexed terminal cell this grapheme starts at. */
12
+ cell: number;
13
+ /** Cells occupied — 2 for a wide grapheme, 0 for a zero-width one. */
14
+ width: number;
15
+ };
16
+ /** Walk `line` as the terminal does, calling `onGlyph` per printable grapheme
17
+ * and `onEscape` per control sequence, in row order. Returns the cell count. */
18
+ export declare function walkRow(line: string, visit: {
19
+ onGlyph?: (glyph: RowGlyph) => void;
20
+ onEscape?: (escape: string) => void;
21
+ }): number;
22
+ /** Cells the row occupies on screen. */
23
+ export declare function rowCells(line: string): number;
24
+ /** Plain text of cells `[from, to)` — every escape, link boundary, and cursor
25
+ * marker dropped, exactly what a terminal copies out of that span. */
26
+ export declare function sliceCells(line: string, from: number, to: number): string;
27
+ /** Reverse-video cells `[from, to)`, re-asserting inverse after any SGR inside
28
+ * the span (a reset there would otherwise drop the highlight mid-selection).
29
+ * A short row is padded with spaces so a multi-row selection reads as one
30
+ * block, never past `maxCells`. */
31
+ export declare function highlightCells(line: string, from: number, to: number, maxCells: number): string;
@@ -0,0 +1,120 @@
1
+ // ansi-cells.ts — cell arithmetic over a rendered terminal row.
2
+ //
3
+ // A composed frame row mixes printable graphemes with SGR colors, OSC-8 link
4
+ // boundaries, and pi-tui's APC cursor marker. Anything that addresses a row by
5
+ // terminal column — link hit tests, mouse selection — has to walk it the way the
6
+ // terminal does: escapes take no cell, wide graphemes take two. Both the attach
7
+ // session and the renderers need that walk, so it lives at the client root
8
+ // rather than inside either peer.
9
+ import stringWidth from 'string-width';
10
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
11
+ const INVERSE_ON = '\x1b[7m';
12
+ const INVERSE_OFF = '\x1b[27m';
13
+ /** Read one terminal control sequence so non-printing ANSI never takes a cell.
14
+ * `undefined` means the sequence runs off the end of the row. */
15
+ export function readEscape(line, start) {
16
+ const kind = line[start + 1];
17
+ if (kind === undefined)
18
+ return undefined;
19
+ // OSC, APC, DCS, and PM are string controls terminated by BEL or ST. pi-tui's
20
+ // cursor marker uses APC, so it must consume no display cells here.
21
+ if (kind === ']' || kind === '_' || kind === 'P' || kind === '^') {
22
+ const bel = line.indexOf('\x07', start + 2);
23
+ const st = line.indexOf('\x1b\\', start + 2);
24
+ const end = bel === -1 ? st : st === -1 ? bel : Math.min(bel, st);
25
+ if (end === -1)
26
+ return undefined;
27
+ return { text: line.slice(start, end + (end === bel ? 1 : 2)), end: end + (end === bel ? 1 : 2) };
28
+ }
29
+ if (kind === '[') {
30
+ let end = start + 2;
31
+ while (end < line.length && (line.charCodeAt(end) < 0x40 || line.charCodeAt(end) > 0x7e))
32
+ end++;
33
+ if (end >= line.length)
34
+ return undefined;
35
+ return { text: line.slice(start, end + 1), end: end + 1 };
36
+ }
37
+ return { text: line.slice(start, start + 2), end: start + 2 };
38
+ }
39
+ /** Walk `line` as the terminal does, calling `onGlyph` per printable grapheme
40
+ * and `onEscape` per control sequence, in row order. Returns the cell count. */
41
+ export function walkRow(line, visit) {
42
+ let cell = 0;
43
+ let index = 0;
44
+ while (index < line.length) {
45
+ if (line[index] === '\x1b') {
46
+ const escape = readEscape(line, index);
47
+ if (escape === undefined)
48
+ break;
49
+ visit.onEscape?.(escape.text);
50
+ index = escape.end;
51
+ continue;
52
+ }
53
+ const nextEscape = line.indexOf('\x1b', index);
54
+ const text = line.slice(index, nextEscape === -1 ? undefined : nextEscape);
55
+ for (const segment of GRAPHEMES.segment(text)) {
56
+ const width = stringWidth(segment.segment);
57
+ visit.onGlyph?.({ text: segment.segment, cell, width });
58
+ cell += width;
59
+ }
60
+ index = nextEscape === -1 ? line.length : nextEscape;
61
+ }
62
+ return cell;
63
+ }
64
+ /** Cells the row occupies on screen. */
65
+ export function rowCells(line) {
66
+ return walkRow(line, {});
67
+ }
68
+ /** Plain text of cells `[from, to)` — every escape, link boundary, and cursor
69
+ * marker dropped, exactly what a terminal copies out of that span. */
70
+ export function sliceCells(line, from, to) {
71
+ let text = '';
72
+ walkRow(line, {
73
+ onGlyph: (glyph) => {
74
+ if (glyph.cell >= from && glyph.cell < to)
75
+ text += glyph.text;
76
+ },
77
+ });
78
+ return text;
79
+ }
80
+ /** Reverse-video cells `[from, to)`, re-asserting inverse after any SGR inside
81
+ * the span (a reset there would otherwise drop the highlight mid-selection).
82
+ * A short row is padded with spaces so a multi-row selection reads as one
83
+ * block, never past `maxCells`. */
84
+ export function highlightCells(line, from, to, maxCells) {
85
+ const end = Math.min(to, maxCells);
86
+ if (end <= from || from < 0)
87
+ return line;
88
+ let out = '';
89
+ let inside = false;
90
+ const open = () => { if (!inside) {
91
+ out += INVERSE_ON;
92
+ inside = true;
93
+ } };
94
+ const close = () => { if (inside) {
95
+ out += INVERSE_OFF;
96
+ inside = false;
97
+ } };
98
+ const cells = walkRow(line, {
99
+ onEscape: (escape) => {
100
+ out += escape;
101
+ // An SGR inside the span may have reset every attribute, including ours.
102
+ if (inside && escape.endsWith('m'))
103
+ out += INVERSE_ON;
104
+ },
105
+ onGlyph: (glyph) => {
106
+ if (glyph.cell >= from && glyph.cell < end)
107
+ open();
108
+ else
109
+ close();
110
+ out += glyph.text;
111
+ },
112
+ });
113
+ close();
114
+ if (cells < end) {
115
+ const gap = Math.max(0, from - cells);
116
+ const highlighted = end - Math.max(from, cells);
117
+ out += ' '.repeat(gap) + INVERSE_ON + ' '.repeat(highlighted) + INVERSE_OFF;
118
+ }
119
+ return out;
120
+ }
@@ -29,10 +29,10 @@ export function defaultAgentDir() {
29
29
  }
30
30
  /**
31
31
  * pi's `app.*` editor keybindings — VENDORED from pi `core/keybindings.ts`
32
- * `KEYBINDINGS` (the const is not exported; review-C1 pattern). This is the
33
- * subset CustomEditor's main editor consults; the selector-internal bindings
34
- * (`app.tree.*`, `app.models.*`, session delete/rename) are omitted because the
35
- * attach viewer does not open those selectors in Phase 4. Re-sync on each SDK
32
+ * `KEYBINDINGS` (the const is not exported). This is the subset CustomEditor's
33
+ * main editor consults; the selector-internal bindings (`app.tree.*`,
34
+ * `app.models.*`, session delete/rename) are omitted because the attach viewer
35
+ * does not open those selectors. Re-sync on each SDK
36
36
  * bump against `core/keybindings.js`.
37
37
  */
38
38
  const APP_KEYBINDINGS = {
@@ -148,7 +148,7 @@ export async function mirrorKittyProtocolToEditor(active) {
148
148
  * Resolve the `fd` binary path the @-mention file picker needs. pi-tui's
149
149
  * `CombinedAutocompleteProvider` only returns file suggestions when handed an
150
150
  * `fdPath` (its third ctor arg) — without it, typing `@` triggers the picker but
151
- * `getFuzzyFileSuggestions` returns `[]`, so the picker never appears (issue #6).
151
+ * `getFuzzyFileSuggestions` returns `[]`, so the picker never appears.
152
152
  * pi's own interactive mode resolves this via `ensureTool('fd')` from its
153
153
  * internal `utils/tools-manager` (checks pi's managed bin dir + system PATH,
154
154
  * downloading the binary if missing). That module is NOT on pi-coding-agent's
@@ -1,4 +1,4 @@
1
- // input/clipboard-image.ts — read the clipboard image for `crtr surface attach` (T6, review M1).
1
+ // input/clipboard-image.ts — read the clipboard image for `crtr surface attach`.
2
2
  //
3
3
  // pi's `readClipboardImage` is NOT re-exported (it lives behind a native
4
4
  // clipboard binding + Photon worker the package gates off `.`-only exports), so
@@ -21,7 +21,7 @@
21
21
  // JPEG and picks the smaller, so it doubles as format normalization;
22
22
  // `convertToPng` is the fallback when the Photon resizer is unavailable. The
23
23
  // fallbacks (convertToPng / raw bytes) are NOT size-bounded by `resizeImage`, so
24
- // each is gated on the SAME MAX_BYTES base64 ceiling (review M1): over it → the
24
+ // each is gated on the SAME MAX_BYTES base64 ceiling: over it → the
25
25
  // image is DROPPED with a user-visible note rather than shipped as an over-cap
26
26
  // frame that would overflow BROKER_READ_CAPS.maxLineBytes (24 MiB) and destroy
27
27
  // the viewer socket. The result is an `ImageContent` (pi-ai) ready to drop
@@ -94,7 +94,7 @@ export class RefAwareAutocompleteProvider {
94
94
  }));
95
95
  // Filtering above used the full slash-form `prefix` (context.prefix always
96
96
  // starts with '/' for a matched token), but the RETURNED prefix strips that
97
- // leading '/' (review Major 3). pi-tui's `handleInput` (tui.select.confirm
97
+ // leading '/'. pi-tui's `handleInput` (tui.select.confirm
98
98
  // branch) falls through to `submitValue()` whenever `autocompletePrefix`
99
99
  // (set verbatim from this return value) starts with '/' — that's its own
100
100
  // leading-command-completion submit shortcut, and a non-leading ref
@@ -91,7 +91,7 @@ export declare class TitledEditor extends CustomEditor {
91
91
  * The base `Editor` resolves newLine against `getKeybindings()` on ITS pi-tui
92
92
  * instance, which in a non-deduped install is a DIFFERENT module than the one
93
93
  * carrying crtr's `alt+enter` override — so the override can silently never
94
- * reach it and Alt+Enter falls through to submit (issue #11). crtr's `km` (the
94
+ * reach it and Alt+Enter falls through to submit. crtr's `km` (the
95
95
  * same manager CustomEditor matches `app.*` against) always carries the
96
96
  * override, so matching the chord here makes the newline behavior independent
97
97
  * of cross-instance keybinding mirroring. Skipped while the autocomplete popup
@@ -159,8 +159,8 @@ export declare class TitledEditor extends CustomEditor {
159
159
  paneFocused: boolean;
160
160
  /** Canonical names the broker currently resolves — the exact-membership set
161
161
  * a candidate ref token must be in to render as a resolved highlight.
162
- * Empty by default (no decoration) until the caller (T3, out of scope for
163
- * this wave) supplies the broker-supplied inventory. */
162
+ * Empty by default (no decoration) until the caller supplies the
163
+ * broker-supplied inventory. */
164
164
  private resolvedRefNames;
165
165
  /** Command names the VIEWER dispatches ENTIRELY locally (never reach the
166
166
  * engine as prose) — checked against the TRIMMED leading-name rule
@@ -169,15 +169,14 @@ export declare class TitledEditor extends CustomEditor {
169
169
  * `locallyConsumedCommandNames()` from `slash/dispatch.ts` — independent
170
170
  * of `remote` and of the broker's `get_commands` reply (see that
171
171
  * function's doc comment). Deliberately NEVER the merged display/
172
- * completion list (review Major 6 — `slashCommandList` is a different,
173
- * remote-filtered, broker-augmented list, not a dispatch predicate). */
172
+ * completion list (`slashCommandList` is a different, remote-filtered,
173
+ * broker-augmented list, not a dispatch predicate). */
174
174
  private localCommandNames;
175
175
  /** Leading tokens the ENGINE (broker) independently consumes as a leading
176
176
  * command/skill/template dispatch — the broker's own
177
- * `engineLeadingCommandNames` (design: inline memory references, review
178
- * Major 6), checked against the UNTRIMMED leading-name rule
179
- * (`matchesEngineLeadingCommand`, mirroring the broker's own
180
- * `isLeadingEngineCommand` — review Minor 10). Populated from each
177
+ * `engineLeadingCommandNames`, checked against the UNTRIMMED leading-name
178
+ * rule (`matchesEngineLeadingCommand`, mirroring the broker's own
179
+ * `isLeadingEngineCommand`). Populated from each
181
180
  * `list_memory_refs` reply, alongside `resolvedRefNames`. */
182
181
  private engineCommandNames;
183
182
  setResolvedRefNames(names: Iterable<string>): void;
@@ -192,12 +191,12 @@ export declare class TitledEditor extends CustomEditor {
192
191
  * is simply left undecorated. Suppressed entirely (command-wide) whenever
193
192
  * the composer's own leading token dispatches — as a LOCAL viewer command
194
193
  * (`localCommandNames`, TRIMMED rule) or as an ENGINE-consumed leading
195
- * command/skill/template (`engineCommandNames`, UNTRIMMED rule, review
196
- * Minor 10) — the ref grammar and slash-dispatch's name charset differ, so
194
+ * command/skill/template (`engineCommandNames`, UNTRIMMED rule) — the ref
195
+ * grammar and slash-dispatch's name charset differ, so
197
196
  * highlighting a ref inside a command that's about to dispatch would be
198
197
  * misleading chrome for text the command, not memory resolution, is about
199
- * to consume. The two rules are kept deliberately SEPARATE (review Major
200
- * 6) rather than collapsed into one set/predicate: they classify against
198
+ * to consume. The two rules are kept deliberately SEPARATE rather than
199
+ * collapsed into one set/predicate: they classify against
201
200
  * different text (trimmed vs. raw) and different authorities (this
202
201
  * viewer's own dispatch vs. the broker's). */
203
202
  private decorateResolvedRefs;
@@ -401,7 +401,7 @@ function extractLeadingCommandName(text) {
401
401
  return m ? m[1] : null;
402
402
  }
403
403
  /** Mirrors the broker's `isLeadingEngineCommand` (`core/runtime/broker.ts`)
404
- * leading-token rule EXACTLY for classification purposes (review Minor 10):
404
+ * leading-token rule EXACTLY for classification purposes:
405
405
  * the RAW, un-trimmed `text` must itself start with `/` — no `.trim()` — for
406
406
  * ANY token to be a candidate, matching the broker's own untrimmed
407
407
  * `text.startsWith('/')` checks byte-for-byte (space-based name split,
@@ -447,7 +447,7 @@ export class TitledEditor extends CustomEditor {
447
447
  constructor(tui, theme, keybindings, options) {
448
448
  super(tui, theme, keybindings, options);
449
449
  this.km = keybindings;
450
- // Preserve raw (untrimmed) submit text end-to-end (review Major 4). The base
450
+ // Preserve raw (untrimmed) submit text end-to-end. The base
451
451
  // `Editor.submitValue()` — private in the `.d.ts` (blocking a normal TS
452
452
  // `override`), hence the structural cast, same pattern as the other private-
453
453
  // internals casts throughout this file (`scrollOffset`, `historyIndex`,
@@ -497,7 +497,7 @@ export class TitledEditor extends CustomEditor {
497
497
  * The base `Editor` resolves newLine against `getKeybindings()` on ITS pi-tui
498
498
  * instance, which in a non-deduped install is a DIFFERENT module than the one
499
499
  * carrying crtr's `alt+enter` override — so the override can silently never
500
- * reach it and Alt+Enter falls through to submit (issue #11). crtr's `km` (the
500
+ * reach it and Alt+Enter falls through to submit. crtr's `km` (the
501
501
  * same manager CustomEditor matches `app.*` against) always carries the
502
502
  * override, so matching the chord here makes the newline behavior independent
503
503
  * of cross-instance keybinding mirroring. Skipped while the autocomplete popup
@@ -655,8 +655,8 @@ export class TitledEditor extends CustomEditor {
655
655
  paneFocused = true;
656
656
  /** Canonical names the broker currently resolves — the exact-membership set
657
657
  * a candidate ref token must be in to render as a resolved highlight.
658
- * Empty by default (no decoration) until the caller (T3, out of scope for
659
- * this wave) supplies the broker-supplied inventory. */
658
+ * Empty by default (no decoration) until the caller supplies the
659
+ * broker-supplied inventory. */
660
660
  resolvedRefNames = new Set();
661
661
  /** Command names the VIEWER dispatches ENTIRELY locally (never reach the
662
662
  * engine as prose) — checked against the TRIMMED leading-name rule
@@ -665,15 +665,14 @@ export class TitledEditor extends CustomEditor {
665
665
  * `locallyConsumedCommandNames()` from `slash/dispatch.ts` — independent
666
666
  * of `remote` and of the broker's `get_commands` reply (see that
667
667
  * function's doc comment). Deliberately NEVER the merged display/
668
- * completion list (review Major 6 — `slashCommandList` is a different,
669
- * remote-filtered, broker-augmented list, not a dispatch predicate). */
668
+ * completion list (`slashCommandList` is a different, remote-filtered,
669
+ * broker-augmented list, not a dispatch predicate). */
670
670
  localCommandNames = new Set();
671
671
  /** Leading tokens the ENGINE (broker) independently consumes as a leading
672
672
  * command/skill/template dispatch — the broker's own
673
- * `engineLeadingCommandNames` (design: inline memory references, review
674
- * Major 6), checked against the UNTRIMMED leading-name rule
675
- * (`matchesEngineLeadingCommand`, mirroring the broker's own
676
- * `isLeadingEngineCommand` — review Minor 10). Populated from each
673
+ * `engineLeadingCommandNames`, checked against the UNTRIMMED leading-name
674
+ * rule (`matchesEngineLeadingCommand`, mirroring the broker's own
675
+ * `isLeadingEngineCommand`). Populated from each
677
676
  * `list_memory_refs` reply, alongside `resolvedRefNames`. */
678
677
  engineCommandNames = new Set();
679
678
  setResolvedRefNames(names) {
@@ -694,12 +693,12 @@ export class TitledEditor extends CustomEditor {
694
693
  * is simply left undecorated. Suppressed entirely (command-wide) whenever
695
694
  * the composer's own leading token dispatches — as a LOCAL viewer command
696
695
  * (`localCommandNames`, TRIMMED rule) or as an ENGINE-consumed leading
697
- * command/skill/template (`engineCommandNames`, UNTRIMMED rule, review
698
- * Minor 10) — the ref grammar and slash-dispatch's name charset differ, so
696
+ * command/skill/template (`engineCommandNames`, UNTRIMMED rule) — the ref
697
+ * grammar and slash-dispatch's name charset differ, so
699
698
  * highlighting a ref inside a command that's about to dispatch would be
700
699
  * misleading chrome for text the command, not memory resolution, is about
701
- * to consume. The two rules are kept deliberately SEPARATE (review Major
702
- * 6) rather than collapsed into one set/predicate: they classify against
700
+ * to consume. The two rules are kept deliberately SEPARATE rather than
701
+ * collapsed into one set/predicate: they classify against
703
702
  * different text (trimmed vs. raw) and different authorities (this
704
703
  * viewer's own dispatch vs. the broker's). */
705
704
  decorateResolvedRefs(lines, width) {
@@ -19,7 +19,7 @@ export declare function isBlockingDialog(req: RpcExtensionUIRequest): boolean;
19
19
  * `keybindings.matches(key, "app.editor.external")` for the Ctrl+G external-editor
20
20
  * shortcut. Defaults to a fresh `KeybindingsManager(TUI_KEYBINDINGS)` — that
21
21
  * knows the base TUI bindings but not pi's app-level `app.editor.external`, so
22
- * Ctrl+G is inert until the input controller (T6) passes the app keybindings
22
+ * Ctrl+G is inert until the input controller passes the app keybindings
23
23
  * manager. `ExtensionEditorComponent` is typed against pi's CORE
24
24
  * KeybindingsManager (a subclass of pi-tui's), so we widen via the constructor's
25
25
  * own parameter type rather than importing the core class (type-only export).
@@ -1,4 +1,4 @@
1
- // overlays/dialogs.ts — the blocking-dialog renderer for `crtr surface attach` (T5).
1
+ // overlays/dialogs.ts — the blocking-dialog renderer for `crtr surface attach`.
2
2
  //
3
3
  // The broker forwards an extension's user-blocking UI request as an
4
4
  // `extension_ui_request` frame (RpcExtensionUIRequest). The viewer renders it
@@ -45,7 +45,7 @@ const OVERLAY_OPTIONS = { anchor: 'center', width: '70%', maxHeight: '80%' };
45
45
  * `keybindings.matches(key, "app.editor.external")` for the Ctrl+G external-editor
46
46
  * shortcut. Defaults to a fresh `KeybindingsManager(TUI_KEYBINDINGS)` — that
47
47
  * knows the base TUI bindings but not pi's app-level `app.editor.external`, so
48
- * Ctrl+G is inert until the input controller (T6) passes the app keybindings
48
+ * Ctrl+G is inert until the input controller passes the app keybindings
49
49
  * manager. `ExtensionEditorComponent` is typed against pi's CORE
50
50
  * KeybindingsManager (a subclass of pi-tui's), so we widen via the constructor's
51
51
  * own parameter type rather than importing the core class (type-only export).
@@ -135,6 +135,7 @@ export class HelpOverlay {
135
135
  muted('mouse wheel (when reporting is on)'),
136
136
  blank,
137
137
  heading('Selection and copy'),
138
+ { text: 'Drag with the mouse to select; releasing copies the selection to the clipboard (and the tmux buffer). Any key clears the highlight.', style: 'plain' },
138
139
  { text: 'prefix + [ tmux copy-mode selects and copies the visible screenful; it works even while mouse reporting is on.', style: 'plain' },
139
140
  { text: 'Option-drag (iTerm2, Terminal.app) or Shift-drag (Ghostty, Alacritty, xterm) makes a true terminal selection that bypasses mouse reporting.', style: 'plain' },
140
141
  { text: 'Block-copy commands copy transcript content and are not capped at one screenful.', style: 'plain' },
@@ -146,7 +147,7 @@ export class HelpOverlay {
146
147
  { text: "/copy-transcript every copyable block's text, oldest to newest, joined by blank lines.", style: 'plain' },
147
148
  blank,
148
149
  heading('Mouse'),
149
- { text: `/mouse or ${this.binding('crtr.attach.mouse.toggle')} toggle mouse wheel scrolling. Turning it off restores tmux drag-select and copy-mode mouse.`, style: 'plain' },
150
+ { text: `/mouse or ${this.binding('crtr.attach.mouse.toggle')} toggle mouse reporting — wheel scrolling and viewer drag-select. Turning it off restores tmux drag-select and copy-mode mouse.`, style: 'plain' },
150
151
  blank,
151
152
  heading('Other attach keys'),
152
153
  ...otherKeys,
@@ -40,12 +40,13 @@
40
40
  // installed adapter (pi-mcp-adapter index.ts: "auth-start" / "auth-complete" /
41
41
  // "ui-messages"; `connect: <name>`).
42
42
  import { createHash } from 'node:crypto';
43
- import { existsSync, readFileSync } from 'node:fs';
43
+ import { existsSync } from 'node:fs';
44
44
  import { homedir } from 'node:os';
45
45
  import { join, resolve } from 'node:path';
46
46
  import { piAgentDir } from '../../../core/runtime/package-health.js';
47
47
  import { SelectList, visibleWidth } from '@earendil-works/pi-tui';
48
48
  import { getSelectListTheme } from '@earendil-works/pi-coding-agent';
49
+ import { readJsonOrNull } from '../../../core/fs-utils.js';
49
50
  /** Adapter metadata-cache.ts CACHE_MAX_AGE_MS — entries older than this are misses. */
50
51
  const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
51
52
  /** The adapter's import-source table (pi-mcp-adapter config.ts IMPORT_PATHS).
@@ -72,14 +73,6 @@ function importPathCandidates(kind, home) {
72
73
  return [];
73
74
  }
74
75
  }
75
- function readJson(path) {
76
- try {
77
- return JSON.parse(readFileSync(path, 'utf-8'));
78
- }
79
- catch {
80
- return null; // missing or corrupt → empty state, never a crash
81
- }
82
- }
83
76
  /** Mirror of the adapter's validateConfig (config.ts): a source file's own
84
77
  * servers may live under `mcpServers` OR the `mcp-servers` alias — but a
85
78
  * non-object value invalidates the WHOLE file, dropping its `imports` too. */
@@ -130,7 +123,7 @@ function mergeServerMaps(base, next) {
130
123
  /** Read one config file: its own servers layered OVER any `imports` servers
131
124
  * (first import wins per name, own config wins overall — adapter expandImports). */
132
125
  function readConfigSource(path, cwd, home) {
133
- const raw = readJson(path);
126
+ const raw = readJsonOrNull(path);
134
127
  if (raw === null)
135
128
  return {};
136
129
  const { servers: own, imports } = validateConfigSource(raw);
@@ -144,7 +137,7 @@ function readConfigSource(path, cwd, home) {
144
137
  .find((candidate) => existsSync(candidate));
145
138
  if (full === undefined)
146
139
  continue;
147
- const importedRaw = readJson(full);
140
+ const importedRaw = readJsonOrNull(full);
148
141
  if (importedRaw === null)
149
142
  continue;
150
143
  for (const [name, spec] of Object.entries(extractImportedServers(importedRaw, kind))) {
@@ -228,7 +221,7 @@ export function readMcpServers(home = homedir(), cwd = process.cwd()) {
228
221
  for (const path of sourcePaths) {
229
222
  servers = mergeServerMaps(servers, readConfigSource(path, cwd, home));
230
223
  }
231
- const cache = readJson(join(agentDir, 'mcp-cache.json'));
224
+ const cache = readJsonOrNull(join(agentDir, 'mcp-cache.json'));
232
225
  return Object.entries(servers).map(([name, spec]) => {
233
226
  const transport = spec.type ?? (spec.url !== undefined ? 'http' : 'stdio');
234
227
  const target = spec.url ?? [spec.command, ...(spec.args ?? [])].filter(Boolean).join(' ');
@@ -35,7 +35,7 @@ export interface ChatViewOptions {
35
35
  toolOutputExpanded?: boolean;
36
36
  /** Sink for footer/header-only events ChatView does not itself render
37
37
  * (queue_update, session_info_changed, thinking_level_changed). The attach
38
- * command (T7) wires a footer here; absent → these events are no-ops. */
38
+ * command wires a footer here; absent → these events are no-ops. */
39
39
  onFooterEvent?: (event: AgentSessionEvent) => void;
40
40
  /** Theme palette for the activity spinner / dim status / error lines. Absent →
41
41
  * the module defaults (faint + ANSI red) are used. */
@@ -1,4 +1,4 @@
1
- // render/chat-view.ts — the render half of `crtr surface attach` (Phase 4, T5).
1
+ // render/chat-view.ts — the render half of `crtr surface attach`.
2
2
  //
3
3
  // A direct port of pi's `InteractiveMode.handleEvent()`
4
4
  // (interactive-mode.js:2169-2462) + its rebuild path
@@ -11,7 +11,7 @@
11
11
  // Its activity state (working spinner, compaction/retry indicators) is signaled
12
12
  // to the viewer-owned fixed slot when one is supplied. It does NOT own the
13
13
  // editor, the footer, or the terminal title — those are chrome laid out by the
14
- // attach command (T7). Events that only affect that chrome
14
+ // attach command. Events that only affect that chrome
15
15
  // (queue_update / session_info_changed / thinking_level_changed) are forwarded
16
16
  // to the optional `onFooterEvent` sink instead of rendered here.
17
17
  //