@tryinget/pi-activity-strip 0.8.0 → 0.10.0

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 (58) hide show
  1. package/README.md +59 -21
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +4 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +1141 -84
  6. package/native/panel/src/app_gui_tests.rs +730 -0
  7. package/native/panel/src/app_hover_gui_tests.rs +917 -0
  8. package/native/panel/src/card_view.rs +19 -4
  9. package/native/panel/src/directory_view.rs +471 -0
  10. package/native/panel/src/hints.rs +65 -0
  11. package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
  12. package/native/panel/src/hover_keyboard.rs +237 -0
  13. package/native/panel/src/interaction.rs +156 -0
  14. package/native/panel/src/keyboard-observation-fixtures.json +241 -0
  15. package/native/panel/src/keyboard_observation.rs +70 -0
  16. package/native/panel/src/main.rs +5 -0
  17. package/native/panel/src/protocol.rs +93 -8
  18. package/native/panel/src/runtime.rs +85 -12
  19. package/native/panel/src/style.css +85 -0
  20. package/package.json +6 -7
  21. package/src/broker/server.mjs +31 -14
  22. package/src/client/launcher.mjs +3 -7
  23. package/src/client/runtime-lock.mjs +2 -1
  24. package/src/common/agent-identity.mjs +6 -1
  25. package/src/common/contracts.d.ts +2 -0
  26. package/src/common/contracts.ts +39 -0
  27. package/src/common/keyboard-observation.mjs +35 -0
  28. package/src/common/overlay-readiness.mjs +11 -0
  29. package/src/common/presence-apps.mjs +57 -0
  30. package/src/common/recovery-identity.mjs +85 -0
  31. package/src/common/recovery-manifest.mjs +417 -0
  32. package/src/common/recovery-types.ts +75 -0
  33. package/src/common/ribbon-status.mjs +41 -0
  34. package/src/common/status-report.mjs +37 -2
  35. package/src/common/workspace-view.mjs +118 -0
  36. package/src/native/ak-runtime.mjs +50 -9
  37. package/src/native/focus-visibility.mjs +67 -0
  38. package/src/native/ghostty-tab-inventory.py +40 -23
  39. package/src/native/hover-keyboard.mjs +32 -0
  40. package/src/native/interaction-context.mjs +45 -0
  41. package/src/native/keyboard-entry.mjs +32 -0
  42. package/src/native/main.mjs +91 -42
  43. package/src/native/panel-binary.mjs +25 -8
  44. package/src/native/panel-neutral-source.mjs +53 -0
  45. package/src/native/panel-presentation.mjs +137 -0
  46. package/src/native/panel-projection.mjs +354 -18
  47. package/src/native/panel-protocol.mjs +23 -0
  48. package/src/native/panel-window-jump.mjs +90 -0
  49. package/src/native/placement.mjs +83 -20
  50. package/src/native/recovery-inventory.mjs +209 -0
  51. package/src/native/recovery-proc.mjs +178 -0
  52. package/src/native/recovery-sources.mjs +358 -0
  53. package/src/native/runtime-diagnostics.mjs +242 -0
  54. package/src/native/strip-hotkey-kdl.mjs +252 -0
  55. package/src/native/strip-hotkey.mjs +62 -0
  56. package/src/native/tab-inventory.mjs +62 -11
  57. package/src/native/window-jump.mjs +43 -0
  58. package/src/native/workspace-events.mjs +123 -2
@@ -13,18 +13,30 @@ import { pruneClaudeEventRecords } from "../common/claude-events.mjs";
13
13
  import { ACTIVITY_STRIP_WORKSPACE_SYNC_MS } from "../common/constants.mjs";
14
14
  import { focusNiriSession, readNiriWindows, readNiriWorkspaces } from "../common/niri-focus.mjs";
15
15
  import { haveSameRecordMembership } from "../common/session-cards.mjs";
16
- import { resolveFocusedWorkspaceView } from "../common/workspace-view.mjs";
16
+ import { resolveFocusedWorkspaceView, resolveWindowDirectory } from "../common/workspace-view.mjs";
17
17
  import { discoverAgentTabs } from "./agent-discovery.mjs";
18
18
  import { startAkTaskProjection } from "./ak-runtime.mjs";
19
19
  import { createHeightRepair } from "./height-repair.mjs";
20
+ import { createHoverKeyboard } from "./hover-keyboard.mjs";
21
+ import { createKeyboardEntry } from "./keyboard-entry.mjs";
20
22
  import { resolvePanelBinary } from "./panel-binary.mjs";
21
23
  import { createNativePanelProjection } from "./panel-projection.mjs";
24
+ import { admitPanelEvent, PANEL_PROTOCOL } from "./panel-protocol.mjs";
25
+ import { createPanelWindowJump } from "./panel-window-jump.mjs";
22
26
  import { createPlacementRuntime } from "./placement.mjs";
27
+ import {
28
+ createRuntimeStatusReader,
29
+ recordNativeKeyboardObservation,
30
+ } from "./runtime-diagnostics.mjs";
31
+ import { resolveStripHotkey } from "./strip-hotkey.mjs";
23
32
  import { createThemeRuntime, THEME_POLL_INTERVAL_MS } from "./theme-runtime.mjs";
24
- import { createNiriWorkspaceEventWatcher } from "./workspace-events.mjs";
33
+ import {
34
+ createNiriWorkspaceEventWatcher,
35
+ createWorkspaceProjectionHandlers,
36
+ } from "./workspace-events.mjs";
25
37
 
26
38
  /** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
27
- /** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
39
+ /** @typedef {import("node:child_process").ChildProcessWithoutNullStreams & {_protocolError?: string; _activityBuffer?: string; _readyTimer?: NodeJS.Timeout; _stableTimer?: NodeJS.Timeout}} PanelChild */
28
40
  /** @typedef {{type: string; protocol?: number; revision?: number; visible?: boolean; sessions?: Array<Record<string, unknown>>; [key: string]: unknown}} PanelMessage */
29
41
 
30
42
  const execFileAsync = promisify(execFile);
@@ -80,8 +92,6 @@ function isNiriSession() {
80
92
  return Boolean(process.env.NIRI_SOCKET);
81
93
  }
82
94
 
83
- // Hidden Ghostty tabs are placed by their own runtime: host-process containment plus the window
84
- // memory learned from visible titles and the bounded AT-SPI tab inventory.
85
95
  const placement = createPlacementRuntime({
86
96
  env: process.env,
87
97
  runtimeStatus,
@@ -96,7 +106,9 @@ function writePanel(message) {
96
106
  return false;
97
107
  }
98
108
  try {
99
- const accepted = panel.stdin.write(`${JSON.stringify(message)}\n`);
109
+ const accepted = panel.stdin.write(
110
+ `${JSON.stringify({ ...message, protocol: PANEL_PROTOCOL })}\n`,
111
+ );
100
112
  if (!accepted) {
101
113
  panelWriteReady = false;
102
114
  const child = panel;
@@ -119,6 +131,11 @@ function writePanel(message) {
119
131
 
120
132
  const projection = createNativePanelProjection({
121
133
  isNiriSession,
134
+ // Presence ("neutral") focus needs the focused window's app id from the event cache.
135
+ getWindowFacts: (id) => placement.getObservedWindow(id),
136
+ env: process.env,
137
+ // Read once: the panel's progressive hints name the shortcut the compositor really binds.
138
+ hotkey: resolveStripHotkey({ env: process.env }),
122
139
  publish(view) {
123
140
  runtimeStatus.rendererCardCount = view.sessions.length;
124
141
  runtimeStatus.rendererCardIds = view.sessions.map((session) => String(session.cardId ?? ""));
@@ -133,8 +150,7 @@ const projection = createNativePanelProjection({
133
150
  },
134
151
  });
135
152
 
136
- // AK task references are a read-only projection of claims held by live sessions; the strip
137
- // never writes AK state and renders nothing when the read fails.
153
+ // Read-only AK task projection: never writes AK state or invents tasks on read failure.
138
154
  startAkTaskProjection({ execFileAsync, env: process.env, runtimeStatus, projection });
139
155
 
140
156
  /** @param {string} targetId @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>} */
@@ -158,17 +174,27 @@ async function focusSession(targetId) {
158
174
  return result;
159
175
  }
160
176
 
161
- async function focusStrip() {
162
- if (runtimeStatus.clickThrough) {
163
- return { ok: false, error: "Keyboard entry is disabled in click-through mode." };
164
- }
165
- if (!runtimeStatus.windowVisible || runtimeStatus.rendererCardCount === 0) {
166
- return { ok: false, error: "No visible native strip exists on this workspace." };
167
- }
168
- return writePanel({ type: "focus-strip" })
169
- ? { ok: true }
170
- : { ok: false, error: "Native panel is not ready for keyboard entry." };
171
- }
177
+ const requestWindowJump = createPanelWindowJump({
178
+ projection,
179
+ placementOptions: placement.options,
180
+ getWindowFacts: placement.getObservedWindow,
181
+ isNiriSession,
182
+ readWindows: () => getNiriWindows(),
183
+ readWorkspaces: () => getNiriWorkspaces(),
184
+ focusWindow: (id) =>
185
+ execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(id)], {
186
+ env: process.env,
187
+ }),
188
+ reconcile: () => reconcileRunner.request(),
189
+ });
190
+
191
+ const requestKeyboardEntry = createKeyboardEntry({
192
+ projection,
193
+ runtimeStatus,
194
+ writePanel,
195
+ });
196
+ const focusStrip = async () => requestKeyboardEntry();
197
+ let hoverKeyboard = createHoverKeyboard({ projection, runtimeStatus, writePanel });
172
198
 
173
199
  /** @param {PanelChild} child @param {unknown} chunk */
174
200
  function consumePanelEvents(child, chunk) {
@@ -181,13 +207,14 @@ function consumePanelEvents(child, chunk) {
181
207
  if (line) {
182
208
  try {
183
209
  const event = JSON.parse(line);
184
- if (event.protocol !== 1) throw new Error("unsupported native panel protocol");
210
+ if (!admitPanelEvent(child, event, runtimeStatus, () => projection.cancelKeyboard()))
211
+ return;
185
212
  if (event.type === "ready") {
186
213
  clearTimeout(child._readyTimer);
187
214
  panelReady = true;
188
215
  runtimeStatus.state = "ready";
189
216
  runtimeStatus.readyAt = Date.now();
190
- runtimeStatus.panelPid = Number(event.pid) || child.pid || null;
217
+ runtimeStatus.panelPid = child.pid ?? null; // Supervised child, never a reported PID.
191
218
  runtimeStatus.error = null;
192
219
  clearTimeout(child._stableTimer);
193
220
  child._stableTimer = setTimeout(() => {
@@ -209,9 +236,20 @@ function consumePanelEvents(child, chunk) {
209
236
  const visible = event.visible === true;
210
237
  const changed = runtimeStatus.windowVisible !== visible;
211
238
  runtimeStatus.windowVisible = visible;
212
- // Showing or hiding the surface is the only moment the exclusive zone changes, so it is
213
- // the only moment a tiled window can be left at the previous working area's height.
239
+ // Zone changes can leave tiled window heights stale.
214
240
  if (changed) void heightRepair.repairAfterZoneChange();
241
+ } else if (event.type === "hover-request") {
242
+ hoverKeyboard.request(Number(event.token), Number(event.epoch));
243
+ } else if (event.type === "hover-cancel") {
244
+ if (hoverKeyboard.cancel(Number(event.token))) reconcileRunner.request();
245
+ } else if (event.type === "keyboard-observation") {
246
+ recordNativeKeyboardObservation(runtimeStatus, event, child.pid);
247
+ } else if (event.type === "keyboard-active") {
248
+ const accepted = projection.keyboardChanged(
249
+ Number(event.revision),
250
+ event.active === true,
251
+ );
252
+ if (accepted && event.active !== true) reconcileRunner.request();
215
253
  } else if (event.type === "expanded") {
216
254
  runtimeStatus.panelExpanded = event.expanded === true;
217
255
  } else if (event.type === "activate") {
@@ -231,6 +269,17 @@ function consumePanelEvents(child, chunk) {
231
269
  : result.error || "Focus failed; nothing moved.",
232
270
  }),
233
271
  );
272
+ } else if (event.type === "jump") {
273
+ runtimeStatus.panelJumpCount = (runtimeStatus.panelJumpCount ?? 0) + 1;
274
+ const windowId = Number(event.windowId);
275
+ void requestWindowJump(event).then((result) =>
276
+ writePanel({
277
+ type: "jump-result",
278
+ windowId,
279
+ ok: result.ok === true,
280
+ message: result.ok ? `Jumped to #${windowId}.` : result.error || "Nothing moved.",
281
+ }),
282
+ );
234
283
  } else if (event.type === "moved") {
235
284
  runtimeStatus.panelMoveCount = (runtimeStatus.panelMoveCount ?? 0) + 1;
236
285
  runtimeStatus.lastMovedCardId = String(event.cardId ?? "") || null;
@@ -255,10 +304,12 @@ function startPanel() {
255
304
  stdio: ["pipe", "pipe", "pipe"],
256
305
  });
257
306
  panel = child;
307
+ hoverKeyboard = createHoverKeyboard({ projection, runtimeStatus, writePanel });
258
308
  panelReady = false;
259
309
  panelWriteReady = true;
260
310
  panelStderr = "";
261
311
  runtimeStatus.panelPid = child.pid ?? null;
312
+ runtimeStatus.nativeKeyboardObservation = null; // New supervised child, new native clock domain.
262
313
  runtimeStatus.state = "starting";
263
314
  child.stdout.setEncoding("utf8");
264
315
  child.stdin.on("error", () => {
@@ -281,9 +332,15 @@ function startPanel() {
281
332
  clearTimeout(child._stableTimer);
282
333
  if (panel !== child) return;
283
334
  panelReady = false;
335
+ projection.cancelKeyboard();
284
336
  runtimeStatus.windowVisible = false;
285
337
  runtimeStatus.panelPid = null;
286
338
  if (shuttingDown) return;
339
+ if (child._protocolError) {
340
+ runtimeStatus.state = "error";
341
+ runtimeStatus.error = child._protocolError;
342
+ return;
343
+ }
287
344
  if (panelRestartCount < 3) {
288
345
  panelRestartCount += 1;
289
346
  runtimeStatus.panelRestartCount = panelRestartCount;
@@ -337,7 +394,7 @@ const theme = createThemeRuntime({
337
394
  execFileAsync,
338
395
  env: process.env,
339
396
  runtimeStatus,
340
- publish: (definitions) => writePanel({ protocol: 1, type: "theme", definitions }),
397
+ publish: (definitions) => writePanel({ protocol: PANEL_PROTOCOL, type: "theme", definitions }),
341
398
  });
342
399
 
343
400
  const heightRepair = createHeightRepair({
@@ -361,7 +418,8 @@ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
361
418
  const sessions = projection.getRawSessions();
362
419
  runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
363
420
  const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
364
- if (view) placement.learnFromPlacements(view.sessions, windows);
421
+ placement.learnFromPlacements(view?.sessions ?? [], windows, sessions);
422
+ projection.setDirectory(resolveWindowDirectory(windows, workspaces, sessions, placement.options));
365
423
  projection.publishWorkspaceView(
366
424
  view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
367
425
  );
@@ -374,7 +432,7 @@ async function shutdown(exitCode = 0) {
374
432
  watcher?.stop();
375
433
  placement.persist();
376
434
  writePanel({
377
- protocol: 1,
435
+ protocol: PANEL_PROTOCOL,
378
436
  type: "view",
379
437
  revision: Number.MAX_SAFE_INTEGER,
380
438
  visible: false,
@@ -394,14 +452,10 @@ async function main() {
394
452
  broker = await createActivityStripBroker({
395
453
  focusSession,
396
454
  focusStrip,
397
- getRuntimeStatus: () => ({
398
- ...runtimeStatus,
399
- warnings: [...(runtimeStatus.warnings ?? [])],
400
- }),
455
+ getRuntimeStatus: createRuntimeStatusReader({ runtimeStatus, projection }),
401
456
  });
402
457
  broker.on("snapshot", (snapshot) => {
403
- // Compare like with like: the raw set also holds discovered agent tabs, which the broker
404
- // never publishes, so comparing it to a broker snapshot would always differ and reconcile.
458
+ // Compare broker-only membership; discovered agent tabs must not force reconciliation.
405
459
  const membershipChanged = !haveSameRecordMembership(
406
460
  projection.getBrokerSessions(),
407
461
  snapshot.sessions,
@@ -417,16 +471,11 @@ async function main() {
417
471
  watcher = createNiriWorkspaceEventWatcher({
418
472
  spawn,
419
473
  env: process.env,
420
- onFocusedWorkspace: () => reconcileRunner.request(),
421
- onFallback: () => reconcileRunner.request(),
422
- onWindowChanged: (window) => {
423
- if (placement.observeWindowEvent(window)) reconcileRunner.request();
424
- },
425
- onWindowClosed: (windowId) => {
426
- if (placement.forgetWindow(windowId)) reconcileRunner.request();
427
- },
428
- onWindowFocusChanged: () => reconcileRunner.request(),
429
- onWorkspacesChanged: () => reconcileRunner.request(),
474
+ ...createWorkspaceProjectionHandlers({
475
+ projection,
476
+ placement,
477
+ reconcile: () => reconcileRunner.request(),
478
+ }),
430
479
  fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
431
480
  });
432
481
  }
@@ -8,6 +8,7 @@ import { createHash } from "node:crypto";
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { PANEL_PROTOCOL } from "./panel-protocol.mjs";
11
12
 
12
13
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
13
14
 
@@ -15,13 +16,24 @@ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "
15
16
  export function resolvePanelBinary(env) {
16
17
  const override = env.PI_ACTIVITY_STRIP_NATIVE_PANEL_BIN?.trim();
17
18
  const allowUnverified = env.PI_ACTIVITY_STRIP_ALLOW_UNVERIFIED_PANEL === "1";
18
- const candidates = [
19
- ...(override ? [override] : []),
20
- path.join(packageRoot, "native", "bin", "linux-x64-gnu", "pi-activity-strip-panel"),
21
- ...(allowUnverified
22
- ? [path.join(packageRoot, "native", "panel", "target", "release", "pi-activity-strip-panel")]
23
- : []),
24
- ].filter(Boolean);
19
+ // An explicit selection is binding: losing it must not silently choose another build.
20
+ const candidates = override
21
+ ? [override]
22
+ : [
23
+ path.join(packageRoot, "native", "bin", "linux-x64-gnu", "pi-activity-strip-panel"),
24
+ ...(allowUnverified
25
+ ? [
26
+ path.join(
27
+ packageRoot,
28
+ "native",
29
+ "panel",
30
+ "target",
31
+ "release",
32
+ "pi-activity-strip-panel",
33
+ ),
34
+ ]
35
+ : []),
36
+ ];
25
37
  const binary = candidates.find((candidate) => {
26
38
  try {
27
39
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -33,7 +45,6 @@ export function resolvePanelBinary(env) {
33
45
  if (!binary) {
34
46
  throw new Error(`Native panel binary is unavailable. Checked: ${candidates.join(", ")}`);
35
47
  }
36
- if (allowUnverified) return binary;
37
48
  if (process.platform !== "linux" || process.arch !== "x64") {
38
49
  throw new Error(`Native panel requires Linux x64, got ${process.platform} ${process.arch}.`);
39
50
  }
@@ -44,6 +55,12 @@ export function resolvePanelBinary(env) {
44
55
  } catch {
45
56
  throw new Error(`Native panel receipt is unavailable: ${artifactPath}`);
46
57
  }
58
+ if (artifact.panelProtocol !== PANEL_PROTOCOL) {
59
+ throw new Error(
60
+ `Native panel private protocol mismatch in receipt: expected ${PANEL_PROTOCOL}, received ${String(artifact.panelProtocol)} (${artifactPath}). Rebuild/use a matching panel.`,
61
+ );
62
+ }
63
+ if (allowUnverified) return binary;
47
64
  const digest = createHash("sha256").update(fs.readFileSync(binary)).digest("hex");
48
65
  if (
49
66
  artifact.schema !== "pi-activity-strip-native-artifact.v1" ||
@@ -0,0 +1,53 @@
1
+ // ---
2
+ // summary: "pins and rechecks the exact presence-app window behind neutral keyboard authorization"
3
+ // read_when:
4
+ // - "changing neutral-source lifetime across panel NULL focus or delayed jumps"
5
+ // ---
6
+
7
+ import { classifyAdmittedWindowFocus } from "../common/workspace-view.mjs";
8
+ import { sourceIdentity } from "./interaction-context.mjs";
9
+
10
+ /** A presence app is a source window, not a card. Copy its process/app facts at admission so a
11
+ * fallback refresh cannot silently transfer an existing grant to a reused or relocated window.
12
+ * Niri reports `pid: null` for a client whose process it does not know; that null is pinned like a
13
+ * number, so the window keeps its presence and a pid that later differs still revokes it.
14
+ * @param {(id: number) => Record<string, unknown> | null} getWindowFacts
15
+ * @param {NodeJS.ProcessEnv} env
16
+ */
17
+ export function createNeutralSource(getWindowFacts, env) {
18
+ /** @type {Record<string, unknown> | null} */
19
+ let source = null;
20
+ return {
21
+ /** @param {Record<string, unknown> | null} facts */
22
+ admit(facts) {
23
+ source = facts ? { ...facts } : null;
24
+ },
25
+ clear() {
26
+ source = null;
27
+ },
28
+ /** @param {unknown} workspaceId @param {number | null} windowId
29
+ * @param {Record<string, unknown>[]} sessions */
30
+ holds(workspaceId, windowId, sessions) {
31
+ if (!source || windowId === null) return false;
32
+ const current = getWindowFacts(windowId);
33
+ return (
34
+ source.id === windowId &&
35
+ source.workspace_id === workspaceId &&
36
+ (source.pid === null || Number.isInteger(source.pid)) &&
37
+ current?.id === windowId &&
38
+ current.workspace_id === workspaceId &&
39
+ current.pid === source.pid &&
40
+ current.app_id === source.app_id &&
41
+ classifyAdmittedWindowFocus(windowId, sessions, current, env) === "neutral"
42
+ );
43
+ },
44
+ /** @param {unknown} workspaceId @param {number | null} windowId */
45
+ context(workspaceId, windowId) {
46
+ return JSON.stringify([
47
+ sourceIdentity(workspaceId, windowId, undefined),
48
+ source?.pid,
49
+ source?.app_id,
50
+ ]);
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,137 @@
1
+ // ---
2
+ // summary: "keeps an already-visible ribbon mapped across a proven eligible handoff, without input authority"
3
+ // read_when:
4
+ // - "Changing passive presentation during selected-window / seat-focus event ordering."
5
+ // ---
6
+ import { projectSessionCards } from "../common/session-cards.mjs";
7
+ import { classifyAdmittedWindowFocus } from "../common/workspace-view.mjs";
8
+ import {
9
+ publisherSet,
10
+ selectionContradictsSource,
11
+ sourceIdentity,
12
+ visibleCardsForWindow,
13
+ } from "./interaction-context.mjs";
14
+
15
+ /** Presentation only: no request, epoch, source admission, or keyboard eligibility is stored here.
16
+ * Both ends must retain exact complete-window admission and process/publisher identities.
17
+ * @param {{getWindowFacts: (id: number) => Record<string, unknown> | null;
18
+ * getCards: () => Record<string, unknown>[]; getRawSessions: () => Record<string, unknown>[];
19
+ * env: NodeJS.ProcessEnv}} options
20
+ */
21
+ export function createPanelPresentation({ getWindowFacts, getCards, getRawSessions, env }) {
22
+ /** @type {ReturnType<typeof proof>} */
23
+ let source = null;
24
+ /** @type {{source: NonNullable<ReturnType<typeof proof>>; target: NonNullable<ReturnType<typeof proof>>} | null} */
25
+ let handoff = null;
26
+ /** @type {((workspace: Record<string, unknown>) => {sessions: Record<string, unknown>[]; focusKind?: string} | null) | null} */
27
+ let resolveView = null;
28
+
29
+ /** @param {Record<string, unknown> | null} workspace @param {number | null} id
30
+ * @param {Record<string, unknown>[]} cards */
31
+ function proof(workspace, id, cards) {
32
+ if (workspace?.is_focused !== true || id === null) return null;
33
+ const facts = getWindowFacts(id);
34
+ if (
35
+ facts?.id !== id ||
36
+ facts.workspace_id !== workspace.id ||
37
+ !(facts.pid === null || Number.isInteger(facts.pid)) ||
38
+ typeof facts.app_id !== "string"
39
+ )
40
+ return null;
41
+ const kind = classifyAdmittedWindowFocus(id, cards, facts, env);
42
+ if (kind !== "agent" && kind !== "neutral") return null;
43
+ const owner = visibleCardsForWindow(cards, id)[0];
44
+ if (kind === "agent") {
45
+ // A complete admission result, not an arbitrary legacy display card, is required to bridge.
46
+ if (owner?.surfaceVisible !== true || typeof owner.placement !== "string") return null;
47
+ const raw = projectSessionCards(getRawSessions(), null).find(
48
+ (card) => card.cardId === owner.cardId,
49
+ );
50
+ if (JSON.stringify(publisherSet(owner)) !== JSON.stringify(publisherSet(raw))) return null;
51
+ }
52
+ return {
53
+ id,
54
+ workspaceId: workspace.id,
55
+ pid: facts.pid,
56
+ appId: facts.app_id,
57
+ kind,
58
+ identity: sourceIdentity(workspace.id, id, owner),
59
+ };
60
+ }
61
+
62
+ function clear() {
63
+ source = null;
64
+ handoff = null;
65
+ resolveView = null;
66
+ }
67
+
68
+ /** @param {Record<string, unknown> | null} workspace
69
+ * @param {NonNullable<typeof resolveView>} [resolver] */
70
+ function holds(workspace, resolver) {
71
+ if (resolver) resolveView = resolver;
72
+ if (!handoff) return true;
73
+ if (
74
+ Object.hasOwn(workspace ?? {}, "active_window_id") &&
75
+ ![handoff.source.id, handoff.target.id].some((id) => id === workspace?.active_window_id)
76
+ )
77
+ return false;
78
+ const cached = workspace && resolveView?.(workspace);
79
+ if (!cached || !["agent", "neutral", "none"].includes(cached.focusKind ?? "unknown"))
80
+ return false;
81
+ return [handoff.source, handoff.target].every((pinned) =>
82
+ [getCards(), cached.sessions].every(
83
+ (cards) => JSON.stringify(proof(workspace, pinned.id, cards)) === JSON.stringify(pinned),
84
+ ),
85
+ );
86
+ }
87
+
88
+ /** A selection can keep an existing surface, never create a surface or input grant.
89
+ * @param {Record<string, unknown> | null} workspace @param {number | null} id
90
+ * @param {NonNullable<typeof resolveView>} resolver */
91
+ function prepare(workspace, id, resolver) {
92
+ resolveView = resolver;
93
+ if (source?.id === id) return;
94
+ const cached = workspace && resolver(workspace);
95
+ const target = cached ? proof(workspace, id, cached.sessions) : null;
96
+ if (!source || !target) {
97
+ clear();
98
+ return;
99
+ }
100
+ handoff = { source, target };
101
+ if (!holds(workspace)) clear();
102
+ }
103
+
104
+ return {
105
+ clear,
106
+ holds,
107
+ prepare,
108
+ /** Full reads can interleave the same way as incremental focus/selection events.
109
+ * @param {{workspace: Record<string, unknown> | null; sessions: Record<string, unknown>[]; focusKind?: string}} view
110
+ * @param {number | null} sourceId */
111
+ prepareView(view, sourceId) {
112
+ if (!["agent", "neutral", "none"].includes(view.focusKind ?? "unknown")) clear();
113
+ else if (selectionContradictsSource(view.workspace, sourceId))
114
+ prepare(
115
+ view.workspace,
116
+ Number.isSafeInteger(view.workspace?.active_window_id)
117
+ ? Number(view.workspace?.active_window_id)
118
+ : null,
119
+ () => view,
120
+ );
121
+ },
122
+ /** @param {number} id */
123
+ involves: (id) => handoff?.source.id === id || handoff?.target.id === id,
124
+ /** @param {boolean} eligible @param {Record<string, unknown> | null} workspace
125
+ * @param {number | null} id */
126
+ visible(eligible, workspace, id) {
127
+ if (eligible) {
128
+ source = proof(workspace, id, getCards());
129
+ handoff = null;
130
+ return true;
131
+ }
132
+ if (handoff && holds(workspace)) return true;
133
+ clear();
134
+ return false;
135
+ },
136
+ };
137
+ }