@north-light/crouter 0.3.214 → 0.3.216

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 (99) hide show
  1. package/dist/api/dto/nodes.d.ts +2 -1
  2. package/dist/api/dto/profiles.d.ts +13 -1
  3. package/dist/builtin-memory/00-runtime-base.md +12 -11
  4. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -11
  5. package/dist/builtin-memory/02-lifecycle/00-terminal.md +4 -10
  6. package/dist/builtin-memory/04-orchestration-kernel.md +9 -35
  7. package/dist/builtin-memory/05-kinds/advisor/{00-base.md → advice-contract.md} +1 -1
  8. package/dist/builtin-memory/05-kinds/design/00-base.md +2 -2
  9. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +1 -1
  10. package/dist/builtin-memory/05-kinds/developer/00-base.md +2 -2
  11. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -1
  12. package/dist/builtin-memory/05-kinds/general/00-base.md +2 -0
  13. package/dist/builtin-memory/05-kinds/plan/00-base.md +1 -1
  14. package/dist/builtin-memory/05-kinds/plan/reviewers/{00-base.md → lens-contract.md} +1 -1
  15. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -1
  16. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -3
  17. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  18. package/dist/builtin-memory/05-kinds/review/security-findings.md +12 -0
  19. package/dist/builtin-memory/05-kinds/spec/00-base.md +1 -1
  20. package/dist/clients/attach/__tests__/context-message.test.js +28 -1
  21. package/dist/clients/attach/chrome/canvas-panels.d.ts +0 -4
  22. package/dist/clients/attach/chrome/canvas-panels.js +5 -7
  23. package/dist/clients/attach/chrome/inbox-strip.d.ts +31 -0
  24. package/dist/clients/attach/chrome/inbox-strip.js +187 -0
  25. package/dist/clients/attach/chrome/roster.d.ts +6 -2
  26. package/dist/clients/attach/chrome/roster.js +13 -52
  27. package/dist/clients/attach/chrome/ticket-panel.d.ts +29 -0
  28. package/dist/clients/attach/chrome/ticket-panel.js +236 -0
  29. package/dist/clients/attach/render/card-presentation.d.ts +8 -3
  30. package/dist/clients/attach/render/card-presentation.js +48 -10
  31. package/dist/clients/attach/render/context-message.d.ts +5 -0
  32. package/dist/clients/attach/render/context-message.js +20 -14
  33. package/dist/clients/attach/session/context.d.ts +3 -0
  34. package/dist/clients/attach/session/frame.d.ts +4 -0
  35. package/dist/clients/attach/session/frame.js +8 -3
  36. package/dist/clients/attach/session/keys.d.ts +7 -0
  37. package/dist/clients/attach/session/keys.js +7 -0
  38. package/dist/clients/attach/session/layout.js +6 -3
  39. package/dist/clients/attach/session/pane-focus.d.ts +8 -0
  40. package/dist/clients/attach/session/pane-focus.js +42 -0
  41. package/dist/clients/attach/viewer.js +793 -789
  42. package/dist/clients/inbox/controller.d.ts +9 -0
  43. package/dist/clients/inbox/controller.js +57 -2
  44. package/dist/clients/inbox/surface.d.ts +2 -0
  45. package/dist/clients/inbox/surface.js +18 -3
  46. package/dist/clients/inbox/tui.d.ts +2 -0
  47. package/dist/clients/inbox/tui.js +11 -1
  48. package/dist/commands/node/create.js +3 -3
  49. package/dist/commands/profile/kind.d.ts +1 -0
  50. package/dist/commands/profile/kind.js +39 -0
  51. package/dist/commands/profile/list.js +5 -1
  52. package/dist/commands/profile/meta.d.ts +4 -0
  53. package/dist/commands/profile/meta.js +69 -0
  54. package/dist/commands/profile/new.js +34 -4
  55. package/dist/commands/profile/pause.d.ts +2 -0
  56. package/dist/commands/profile/pause.js +60 -0
  57. package/dist/commands/profile/show.js +10 -2
  58. package/dist/commands/profile.js +5 -8
  59. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +40 -0
  60. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +28 -0
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -0
  62. package/dist/core/command.js +23 -23
  63. package/dist/core/config.d.ts +5 -0
  64. package/dist/core/config.js +15 -0
  65. package/dist/core/help.d.ts +1 -1
  66. package/dist/core/keybindings/attach-control.d.ts +3 -0
  67. package/dist/core/keybindings/attach-control.js +1 -0
  68. package/dist/core/keybindings/catalog.js +1 -0
  69. package/dist/core/profiles/deletion-reservation.d.ts +7 -3
  70. package/dist/core/profiles/deletion-reservation.js +9 -5
  71. package/dist/core/profiles/manifest.d.ts +39 -1
  72. package/dist/core/profiles/manifest.js +171 -4
  73. package/dist/core/runtime/boot-root.js +3 -3
  74. package/dist/core/runtime/close.js +10 -5
  75. package/dist/core/runtime/revive.js +2 -0
  76. package/dist/core/runtime/spawn-env.d.ts +9 -1
  77. package/dist/core/runtime/spawn-env.js +17 -1
  78. package/dist/core/termrender/termrender.d.ts +7 -2
  79. package/dist/core/termrender/termrender.js +11 -6
  80. package/dist/core/termrender/version.d.ts +1 -1
  81. package/dist/core/termrender/version.js +1 -1
  82. package/dist/daemon/api/__tests__/profile-launch-gates.test.d.ts +1 -0
  83. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +111 -0
  84. package/dist/daemon/api/handlers/messages.js +9 -2
  85. package/dist/daemon/api/handlers/nodes.d.ts +2 -0
  86. package/dist/daemon/api/handlers/nodes.js +15 -8
  87. package/dist/daemon/api/handlers/profiles.js +12 -2
  88. package/dist/daemon/api/map.js +4 -0
  89. package/dist/daemon/fleet.js +1 -6
  90. package/dist/daemon/reconcilers/broker-supervision.js +15 -25
  91. package/dist/daemon/reconcilers/dormant-inbox.js +10 -10
  92. package/dist/daemon/reconcilers/live-obligation.d.ts +5 -1
  93. package/dist/daemon/reconcilers/live-obligation.js +11 -19
  94. package/dist/pi-extensions/canvas-inbox-watcher.js +15 -0
  95. package/dist/types.d.ts +9 -0
  96. package/package.json +1 -1
  97. package/runtime.lock.json +2 -2
  98. package/dist/builtin-memory/01-spine/01-no-manager.md +0 -11
  99. package/dist/builtin-memory/05-kinds/general/01-orchestrator.md +0 -8
@@ -24,17 +24,55 @@ function extractCardSection(body, section) {
24
24
  const match = new RegExp(`<${section}\\b[^>]*>([\\s\\S]*?)</${section}>`).exec(body);
25
25
  return match ? match[1].trim() : '';
26
26
  }
27
+ /** Body with one whole-body wrapping element removed. Envelope bodies carry
28
+ * prose; a tag around all of it is framing the reader does not need. */
29
+ const WHOLE_BODY_ELEMENT = /^<([A-Za-z][\w.:-]*)\b[^>]*>([\s\S]*)<\/\1>$/;
30
+ function unwrapWholeBody(body) {
31
+ const trimmed = body.trim();
32
+ return (WHOLE_BODY_ELEMENT.exec(trimmed)?.[2] ?? trimmed).trim();
33
+ }
27
34
  /** The disclosure a card actually offers, given its policy and body. A kind
28
35
  * whose section is missing from an old transcript offers nothing to expand. */
29
- export function cardDisclosure(presentation, body) {
36
+ export function cardDisclosure(presentation, card) {
30
37
  switch (presentation.expanded.disclose) {
31
38
  case 'never': return '';
32
- case 'payload': return body;
33
- case 'section': return extractCardSection(body, presentation.expanded.section);
39
+ case 'payload': return card.body;
40
+ case 'section': return extractCardSection(card.body, presentation.expanded.section);
41
+ case 'render': return presentation.expanded.render(card);
34
42
  }
35
43
  }
36
- export function cardIsExpandable(presentation, body) {
37
- return cardDisclosure(presentation, body).length > 0;
44
+ export function cardIsExpandable(presentation, card) {
45
+ return cardDisclosure(presentation, card).trim().length > 0;
46
+ }
47
+ /** A sender's entries read by what they are, not by the wire kind, for the two
48
+ * dispositions whose kind describes the delivery rather than the message. */
49
+ function entryLabel(entry) {
50
+ switch (entry.disposition) {
51
+ case 'human-answer': return 'answer';
52
+ case 'human-canceled': return 'canceled';
53
+ default: return entry.kind;
54
+ }
55
+ }
56
+ /** The inbox digest as Markdown: a heading per sender, then each entry's kind
57
+ * and report path above its body. Both levels are headings because the theme
58
+ * paints those and leaves bold at body color, so an entry label set in bold
59
+ * would not separate from the prose under it. Pre-cut scrollback whose
60
+ * `<from>` grammar did not parse keeps its raw body — there is nothing else
61
+ * to show. */
62
+ function inboxDisclosure(card) {
63
+ if (card.senders.length === 0)
64
+ return card.body;
65
+ return card.senders.map((sender) => {
66
+ const blocks = [`## ${sender.id}${sender.finished ? ' · finished' : ''}`];
67
+ for (const entry of sender.entries) {
68
+ const ref = entry.ref === undefined ? '' : ` · \`${entry.ref}\``;
69
+ blocks.push(`### ${entryLabel(entry)}${ref}`);
70
+ const body = entry.body.trim();
71
+ if (body !== '')
72
+ blocks.push(body);
73
+ }
74
+ return blocks.join('\n\n');
75
+ }).join('\n\n');
38
76
  }
39
77
  function inboxSummary(card) {
40
78
  if (card.senders.length === 0)
@@ -74,7 +112,7 @@ const CORE_CARD_PRESENTATIONS = {
74
112
  inbox: {
75
113
  subject: 'inbox',
76
114
  summary: inboxSummary,
77
- expanded: PAYLOAD,
115
+ expanded: { disclose: 'render', render: inboxDisclosure },
78
116
  groupFolded: 'senders',
79
117
  },
80
118
  revive: {
@@ -94,19 +132,19 @@ const CORE_CARD_PRESENTATIONS = {
94
132
  'stop-guard': {
95
133
  subject: 'stop guard',
96
134
  summary: stopGuardSummary,
97
- expanded: NEVER,
135
+ expanded: PAYLOAD,
98
136
  groupFolded: 'event',
99
137
  },
100
138
  recovery: {
101
139
  subject: 'recovery',
102
140
  summary: recoverySummary,
103
- expanded: NEVER,
141
+ expanded: PAYLOAD,
104
142
  groupFolded: 'event',
105
143
  },
106
144
  'persona-transition': {
107
145
  subject: 'persona',
108
146
  summary: () => 'role updated',
109
- expanded: NEVER,
147
+ expanded: { disclose: 'render', render: (card) => unwrapWholeBody(card.body) },
110
148
  groupFolded: 'event',
111
149
  },
112
150
  'review-approval': {
@@ -130,7 +168,7 @@ const CORE_CARD_PRESENTATIONS = {
130
168
  'context-nudge': {
131
169
  subject: 'context',
132
170
  summary: () => 'context-window guidance',
133
- expanded: NEVER,
171
+ expanded: PAYLOAD,
134
172
  groupFolded: 'omit',
135
173
  },
136
174
  bearings: {
@@ -7,11 +7,16 @@ export declare class ContextMessageComponent implements Component {
7
7
  private expanded;
8
8
  private readonly expandable;
9
9
  private readonly labelTag;
10
+ private readonly summaryText;
10
11
  private readonly collapsedHint;
11
12
  private readonly expandedBody;
12
13
  constructor(card: GeneratedCard, presentation: CardPresentation, expanded: boolean, dim: (s: string) => string, label: (s: string) => string);
13
14
  /** Ctrl+O global tool-output toggle (ChatView.isExpandable duck-type). */
14
15
  setExpanded(expanded: boolean): void;
16
+ /** Paint the [label] tag in the accent color and the rest dim. Truncate
17
+ * BEFORE painting so the ANSI wrappers never inflate the measured width — an
18
+ * over-wide line aborts the whole TUI render. */
19
+ private headerRow;
15
20
  render(width: number): string[];
16
21
  invalidate(): void;
17
22
  }
@@ -17,39 +17,45 @@ export class ContextMessageComponent {
17
17
  expanded;
18
18
  expandable;
19
19
  labelTag;
20
+ summaryText;
20
21
  collapsedHint;
21
22
  expandedBody;
22
23
  constructor(card, presentation, expanded, dim, label) {
23
24
  this.dim = dim;
24
25
  this.label = label;
25
- this.expandable = cardIsExpandable(presentation, card.body);
26
+ this.expandable = cardIsExpandable(presentation, card);
26
27
  this.expanded = this.expandable && expanded;
27
28
  this.labelTag = `[${cardLabel(presentation.subject)}]`;
29
+ this.summaryText = ` ${presentation.summary(card)}`;
28
30
  // A card with nothing to disclose must not advertise the toggle.
29
- this.collapsedHint = ` ${presentation.summary(card)}${this.expandable ? ' — ctrl+o to expand' : ''}`;
30
- this.expandedBody = new UserMessageComponent(styleAttachMarkdownSource(cardDisclosure(presentation, card.body)), attachMarkdownTheme());
31
+ this.collapsedHint = `${this.summaryText}${this.expandable ? ' — ctrl+o to expand' : ''}`;
32
+ this.expandedBody = new UserMessageComponent(styleAttachMarkdownSource(cardDisclosure(presentation, card)), attachMarkdownTheme());
31
33
  }
32
34
  /** Ctrl+O global tool-output toggle (ChatView.isExpandable duck-type). */
33
35
  setExpanded(expanded) {
34
36
  if (this.expandable)
35
37
  this.expanded = expanded;
36
38
  }
39
+ /** Paint the [label] tag in the accent color and the rest dim. Truncate
40
+ * BEFORE painting so the ANSI wrappers never inflate the measured width — an
41
+ * over-wide line aborts the whole TUI render. */
42
+ headerRow(w, hint) {
43
+ const labelW = visibleWidth(this.labelTag);
44
+ if (labelW >= w)
45
+ return this.label(truncateToWidth(this.labelTag, w, '…'));
46
+ return this.label(this.labelTag) + this.dim(truncateToWidth(hint, w - labelW, '…'));
47
+ }
37
48
  render(width) {
38
49
  const w = width > 0 ? width : 80;
39
- if (!this.expanded) {
40
- // Paint the [label] tag in the accent color and the rest dim, so the label
41
- // reads distinctly in BOTH states. Truncate BEFORE painting so the ANSI
42
- // wrappers never inflate the measured width (an over-wide line aborts the
43
- // whole TUI render).
44
- const labelW = visibleWidth(this.labelTag);
45
- if (labelW >= w)
46
- return ['', this.label(truncateToWidth(this.labelTag, w, '…')), ''];
47
- return ['', this.label(this.labelTag) + this.dim(truncateToWidth(this.collapsedHint, w - labelW, '…')), ''];
48
- }
50
+ // The header stays in BOTH states: expanding replaces the card's body, not
51
+ // its identity, and consecutive disclosed cards are otherwise a run of
52
+ // unattributed bubbles.
53
+ if (!this.expanded)
54
+ return ['', this.headerRow(w, this.collapsedHint), ''];
49
55
  const lines = this.expandedBody.render(w);
50
56
  if (lines.at(-1) !== '')
51
57
  lines.push('');
52
- return lines;
58
+ return ['', this.headerRow(w, this.summaryText), ...lines];
53
59
  }
54
60
  invalidate() {
55
61
  /* stateless — nothing to clear */
@@ -22,6 +22,9 @@ export interface AttachContainers {
22
22
  readonly queued: Container;
23
23
  /** Native pickers (/model etc.), inline directly under the editor. */
24
24
  readonly pickerPanel: Container;
25
+ /** The inline ticket page (chrome/ticket-panel.ts), below the recap header
26
+ * — the expanded form of the strip's notification row. */
27
+ readonly ticketPanel: Container;
25
28
  /** Extension widgets placed `belowEditor`. */
26
29
  readonly belowWidgets: Container;
27
30
  /** The status line. */
@@ -12,6 +12,10 @@ export declare function cropAroundCursor(lines: readonly string[], alloc: number
12
12
  export interface ViewerFrameParts {
13
13
  /** Rendered above the transcript; 0 rows when empty. */
14
14
  header: Component;
15
+ /** The inline ticket page, directly beneath the header — the expanded form
16
+ * of the strip's notification row. It grows downward to whatever the page
17
+ * needs, squeezing the transcript to zero before the cockpit gives a row. */
18
+ ticket: Component;
15
19
  viewport: TranscriptViewport;
16
20
  /** The bottom slots, in VISUAL order, tagged with their allocation identity
17
21
  * so the allocator can apply the §2.1 priority table. */
@@ -112,17 +112,21 @@ export class ViewerFrame {
112
112
  let headerLines = this.parts.header.render(width).slice(0, HEADER_MAX_ROWS);
113
113
  if (rows - bottomLines.length - headerLines.length < MIN_TRANSCRIPT_ROWS)
114
114
  headerLines = [];
115
- const viewportRows = Math.max(0, rows - headerLines.length - bottomLines.length);
115
+ // The ticket page takes every row the header and cockpit left, before the
116
+ // transcript sees any — a tall page legitimately drives the viewport to 0.
117
+ const ticketAll = this.parts.ticket.render(width);
118
+ const ticketLines = ticketAll.slice(0, Math.max(0, rows - headerLines.length - bottomLines.length));
119
+ const viewportRows = Math.max(0, rows - headerLines.length - ticketLines.length - bottomLines.length);
116
120
  this.viewportRows = viewportRows;
117
121
  let viewportLines = this.parts.viewport.renderSlice(width, viewportRows);
118
- let frame = [...headerLines, ...viewportLines, ...bottomLines];
122
+ let frame = [...headerLines, ...ticketLines, ...viewportLines, ...bottomLines];
119
123
  // A misbehaving viewport is repaired in its own region only: transcript
120
124
  // padding goes directly beneath the header, while excess drops from its top.
121
125
  // The cockpit, including the editor's marker row, is never sliced here.
122
126
  if (frame.length !== rows) {
123
127
  reportInvariant(`frame rendered ${frame.length} rows; expected ${rows}`);
124
128
  viewportLines = fitTranscript(viewportLines, viewportRows);
125
- frame = [...headerLines, ...viewportLines, ...bottomLines];
129
+ frame = [...headerLines, ...ticketLines, ...viewportLines, ...bottomLines];
126
130
  }
127
131
  if (frame.length !== rows)
128
132
  reportInvariant(`frame correction rendered ${frame.length} rows; expected ${rows}`);
@@ -152,6 +156,7 @@ export class ViewerFrame {
152
156
  }
153
157
  invalidate() {
154
158
  this.parts.header.invalidate();
159
+ this.parts.ticket.invalidate();
155
160
  for (const { component } of this.parts.bottom)
156
161
  component.invalidate();
157
162
  this.parts.viewport.invalidate();
@@ -14,6 +14,11 @@ export interface KeyHooks {
14
14
  /** Transcript search's chance at a chunk; true when it consumed it. It owns
15
15
  * typed characters while editing a query, so it runs ahead of the editor. */
16
16
  searchHandleKey: (data: string) => boolean;
17
+ /** The inline ticket panel: while open it consumes EVERY chunk — pages
18
+ * carry text inputs — so it runs ahead of search, scroll, and the lists. */
19
+ panelHandleKey: (data: string) => boolean;
20
+ /** The inbox strip's chance at a chunk; true when it consumed it. */
21
+ stripHandleKey: (data: string) => boolean;
17
22
  /** The roster's chance at a chunk; true when it consumed it. */
18
23
  rosterHandleKey: (data: string) => boolean;
19
24
  setNotice: (message: string) => void;
@@ -22,6 +27,8 @@ export interface KeyHooks {
22
27
  toggleHelp: () => void;
23
28
  /** Toggle the mounted inbox surface. */
24
29
  toggleInbox: () => void;
30
+ /** Enter or leave the top-of-pane pending-request strip. */
31
+ toggleInboxStrip: () => void;
25
32
  cycleModelLadder: (direction: 'forward' | 'backward') => void;
26
33
  inspectLoadedCommand: () => void;
27
34
  /** The file-review chord — open the transcript-file picker + review surface. */
@@ -33,6 +33,7 @@ export function installGlobalKeys(ctx, hooks) {
33
33
  'crtr.attach.graph.toggle': hooks.toggleGraph,
34
34
  'crtr.attach.help.toggle': hooks.toggleHelp,
35
35
  'crtr.attach.inbox.toggle': hooks.toggleInbox,
36
+ 'crtr.attach.inbox-strip': hooks.toggleInboxStrip,
36
37
  // A plain broker frame, so this works locally and remotely alike; the broker gates observers on controller role.
37
38
  'crtr.attach.model-ladder.next': () => hooks.cycleModelLadder('forward'),
38
39
  'crtr.attach.model-ladder.previous': () => hooks.cycleModelLadder('backward'),
@@ -57,12 +58,18 @@ export function installGlobalKeys(ctx, hooks) {
57
58
  }
58
59
  if (hooks.hasExclusiveSurface())
59
60
  return undefined;
61
+ if (hooks.panelHandleKey(data))
62
+ return { consume: true };
60
63
  if (hooks.searchHandleKey(data))
61
64
  return { consume: true };
62
65
  // Let focused pickers and overlays retain their native page navigation.
63
66
  if (editor.focused && dispatchAttachViewportBinding(hooks.bindings(), data, hooks.viewportActions()))
64
67
  return { consume: true };
65
68
  // Inline subagent scroll+swap: once the editor hands off at its ↓ boundary, ↑/↓ move over the manager/report roster and Enter dives into a node. An inactive roster never intercepts editor input.
69
+ // The inbox strip while it holds the highlight — ahead of the roster, whose
70
+ // rows the same arrows would otherwise move.
71
+ if (hooks.stripHandleKey(data))
72
+ return { consume: true };
66
73
  if (hooks.rosterHandleKey(data))
67
74
  return { consume: true };
68
75
  return dispatchAttachBinding(hooks.bindings(), data, actions) ? { consume: true } : undefined;
@@ -6,9 +6,10 @@
6
6
  // reader asking "where does this line get painted?" needs only this one.
7
7
  //
8
8
  // The render order is asserted in ONE place (`ViewerFrame`'s bottom list).
9
- // Top to bottom: pinned recap header · virtualized chat · cockpit padding · fixed activity ·
10
- // above-editor extension widgets · queued · editor · picker panel · below-editor
11
- // extension widgets · roster · search bar · status bar. The frame owns the pane rectangle,
9
+ // Top to bottom: pinned recap header · inline ticket page · virtualized chat ·
10
+ // cockpit padding · fixed activity · above-editor extension widgets · queued ·
11
+ // editor · picker panel · below-editor extension widgets · roster · search bar ·
12
+ // status bar. The frame owns the pane rectangle,
12
13
  // so every region remains on screen and none can enter terminal scrollback.
13
14
  import { Container, Spacer } from '@earendil-works/pi-tui';
14
15
  import { getSelectListTheme } from '@earendil-works/pi-coding-agent';
@@ -25,6 +26,7 @@ export function createLayout(procTerminal, tui, chat, activity, pal, km, viewpor
25
26
  reports: new Container(),
26
27
  queued: new Container(),
27
28
  pickerPanel: new Container(),
29
+ ticketPanel: new Container(),
28
30
  belowWidgets: new Container(),
29
31
  footer: new Container(),
30
32
  };
@@ -34,6 +36,7 @@ export function createLayout(procTerminal, tui, chat, activity, pal, km, viewpor
34
36
  const editor = new TitledEditor(tui, editorTheme, km, { paddingX: 1 });
35
37
  const frame = new ViewerFrame(procTerminal, {
36
38
  header: containers.header,
39
+ ticket: containers.ticketPanel,
37
40
  viewport,
38
41
  bottom: [
39
42
  { slot: 'cockpitPadding', component: new Spacer(1) },
@@ -0,0 +1,8 @@
1
+ import type { TitledEditor } from '../input/titled-editor.js';
2
+ export interface PaneFocus {
3
+ /** Recompute the editor's cursor style from pane focus + the holders. */
4
+ sync(): void;
5
+ /** Report real tmux-pane focus (DECSET 1004). Repaints the editor cursor. */
6
+ setPaneFocused(focused: boolean): void;
7
+ }
8
+ export declare function createPaneFocus(editor: TitledEditor, holders: ReadonlyArray<() => boolean>): PaneFocus;
@@ -0,0 +1,42 @@
1
+ // session/pane-focus.ts — the ONE owner of the editor's hollow-cursor state.
2
+ //
3
+ // The cursor is dimmed whenever the tmux pane is blurred OR any chrome list
4
+ // (the below-editor roster, the top-of-pane inbox strip) holds the highlight:
5
+ // while a list is scrolled the editor is "away", so it must read exactly like
6
+ // an unfocused pane. Both lists report their active state through `holders`;
7
+ // they call `sync()` whenever that state may have flipped, and the viewer
8
+ // feeds real tmux focus (DECSET 1004) in through `setPaneFocused`.
9
+ import { execFileSync } from 'node:child_process';
10
+ /** Seed pane focus from the pane's REAL active state, not an unconditional
11
+ * `true`. tmux only emits DECSET 1004 focus events on a TRANSITION, so a viewer
12
+ * spawned into a non-active pane (a `split -d` beside another node — the
13
+ * auto-viewer for a background child) starts unfocused and NEVER receives a
14
+ * FOCUS_OUT, leaving a solid cursor drawn until the user swaps in and back out.
15
+ * Query `#{pane_active}` for our own pane once at startup so an unfocused split
16
+ * renders hollow immediately; default true when not in tmux or the query fails. */
17
+ function initialPaneActive() {
18
+ const pane = process.env['TMUX_PANE'];
19
+ if (pane === undefined || pane === '')
20
+ return true;
21
+ try {
22
+ const out = execFileSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_active}'], { encoding: 'utf8' }).trim();
23
+ return out !== '0';
24
+ }
25
+ catch {
26
+ return true;
27
+ }
28
+ }
29
+ export function createPaneFocus(editor, holders) {
30
+ let paneFocused = initialPaneActive();
31
+ const sync = () => {
32
+ editor.paneFocused = paneFocused && !holders.some((holds) => holds());
33
+ };
34
+ sync();
35
+ return {
36
+ sync,
37
+ setPaneFocused: (focused) => {
38
+ paneFocused = focused;
39
+ sync();
40
+ },
41
+ };
42
+ }