@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.
- package/README.md +59 -21
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +4 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +1141 -84
- package/native/panel/src/app_gui_tests.rs +730 -0
- package/native/panel/src/app_hover_gui_tests.rs +917 -0
- package/native/panel/src/card_view.rs +19 -4
- package/native/panel/src/directory_view.rs +471 -0
- package/native/panel/src/hints.rs +65 -0
- package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
- package/native/panel/src/hover_keyboard.rs +237 -0
- package/native/panel/src/interaction.rs +156 -0
- package/native/panel/src/keyboard-observation-fixtures.json +241 -0
- package/native/panel/src/keyboard_observation.rs +70 -0
- package/native/panel/src/main.rs +5 -0
- package/native/panel/src/protocol.rs +93 -8
- package/native/panel/src/runtime.rs +85 -12
- package/native/panel/src/style.css +85 -0
- package/package.json +6 -7
- package/src/broker/server.mjs +31 -14
- package/src/client/launcher.mjs +3 -7
- package/src/client/runtime-lock.mjs +2 -1
- package/src/common/agent-identity.mjs +6 -1
- package/src/common/contracts.d.ts +2 -0
- package/src/common/contracts.ts +39 -0
- package/src/common/keyboard-observation.mjs +35 -0
- package/src/common/overlay-readiness.mjs +11 -0
- package/src/common/presence-apps.mjs +57 -0
- package/src/common/recovery-identity.mjs +85 -0
- package/src/common/recovery-manifest.mjs +417 -0
- package/src/common/recovery-types.ts +75 -0
- package/src/common/ribbon-status.mjs +41 -0
- package/src/common/status-report.mjs +37 -2
- package/src/common/workspace-view.mjs +118 -0
- package/src/native/ak-runtime.mjs +50 -9
- package/src/native/focus-visibility.mjs +67 -0
- package/src/native/ghostty-tab-inventory.py +40 -23
- package/src/native/hover-keyboard.mjs +32 -0
- package/src/native/interaction-context.mjs +45 -0
- package/src/native/keyboard-entry.mjs +32 -0
- package/src/native/main.mjs +91 -42
- package/src/native/panel-binary.mjs +25 -8
- package/src/native/panel-neutral-source.mjs +53 -0
- package/src/native/panel-presentation.mjs +137 -0
- package/src/native/panel-projection.mjs +354 -18
- package/src/native/panel-protocol.mjs +23 -0
- package/src/native/panel-window-jump.mjs +90 -0
- package/src/native/placement.mjs +83 -20
- package/src/native/recovery-inventory.mjs +209 -0
- package/src/native/recovery-proc.mjs +178 -0
- package/src/native/recovery-sources.mjs +358 -0
- package/src/native/runtime-diagnostics.mjs +242 -0
- package/src/native/strip-hotkey-kdl.mjs +252 -0
- package/src/native/strip-hotkey.mjs +62 -0
- package/src/native/tab-inventory.mjs +62 -11
- package/src/native/window-jump.mjs +43 -0
- package/src/native/workspace-events.mjs +123 -2
package/src/native/main.mjs
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|