@tryinget/pi-activity-strip 0.9.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 -22
- package/bin/pi-activity-strip.mjs +32 -4
- package/native/bin/linux-x64-gnu/artifact.json +3 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +572 -266
- 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 +79 -17
- 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 +4 -0
- package/native/panel/src/protocol.rs +22 -8
- package/native/panel/src/runtime.rs +43 -2
- package/package.json +5 -5
- 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 +1 -0
- package/src/common/contracts.ts +38 -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 +54 -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 +77 -71
- 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 +338 -24
- 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
|
@@ -3,23 +3,39 @@
|
|
|
3
3
|
// read_when:
|
|
4
4
|
// - "changing native panel card membership, focus, or transport revisions"
|
|
5
5
|
// ---
|
|
6
|
-
|
|
7
6
|
import { joinAkTaskChips } from "../common/ak-tasks.mjs";
|
|
8
7
|
import { resolveSnapshotSession } from "../common/niri-focus.mjs";
|
|
9
8
|
import { projectSessionCards, sessionRecordKey } from "../common/session-cards.mjs";
|
|
9
|
+
import { classifyAdmittedWindowFocus } from "../common/workspace-view.mjs";
|
|
10
|
+
import { createFocusVisibility } from "./focus-visibility.mjs";
|
|
11
|
+
import {
|
|
12
|
+
agentSourceMatches,
|
|
13
|
+
publisherSet,
|
|
14
|
+
selectionContradictsSource,
|
|
15
|
+
sourceIdentity,
|
|
16
|
+
visibleCardsForWindow,
|
|
17
|
+
} from "./interaction-context.mjs";
|
|
18
|
+
import { createNeutralSource } from "./panel-neutral-source.mjs";
|
|
19
|
+
import { createPanelPresentation } from "./panel-presentation.mjs";
|
|
20
|
+
import { PANEL_PROTOCOL } from "./panel-protocol.mjs";
|
|
10
21
|
|
|
11
22
|
/** @typedef {Record<string, unknown>} SessionRecord */
|
|
12
23
|
/** @typedef {{generatedAt: number; sessions: SessionRecord[]}} Snapshot */
|
|
13
|
-
/** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null}} WorkspaceView */
|
|
24
|
+
/** @typedef {{workspace: Record<string, unknown> | null; sessions: SessionRecord[]; focusedSessionId: string | null; focusedCardId?: string | null; focusWindowId?: number | null; focusKind?: import("./focus-visibility.mjs").FocusKind}} WorkspaceView */
|
|
14
25
|
/** @typedef {{placement: string; surfaceVisible: boolean; windowId: number | null; workspaceIdx: number | null}} CardPlacement */
|
|
15
|
-
/** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void}} NativePanelProjectionOptions */
|
|
26
|
+
/** @typedef {{isNiriSession: () => boolean; publish: (view: Record<string, unknown> & {type: string; sessions: SessionRecord[]}) => void; getWindowFacts?: (id: number) => Record<string, unknown> | null; env?: NodeJS.ProcessEnv; hotkey?: string | null}} NativePanelProjectionOptions */
|
|
16
27
|
|
|
17
28
|
/** @param {NativePanelProjectionOptions} options */
|
|
18
|
-
export function createNativePanelProjection({
|
|
29
|
+
export function createNativePanelProjection({
|
|
30
|
+
isNiriSession,
|
|
31
|
+
publish,
|
|
32
|
+
getWindowFacts = () => null,
|
|
33
|
+
env = process.env,
|
|
34
|
+
hotkey = null,
|
|
35
|
+
}) {
|
|
19
36
|
/** @type {Snapshot} */
|
|
20
37
|
let snapshot = { generatedAt: Date.now(), sessions: [] };
|
|
21
|
-
/**
|
|
22
|
-
/** @type {SessionRecord[]} */
|
|
38
|
+
/** Process-discovered agent tabs publish no telemetry. @type {SessionRecord[]} */
|
|
23
39
|
let agentSessions = [];
|
|
24
40
|
/** @type {string | null} */
|
|
25
41
|
let focusedSessionId = null;
|
|
@@ -33,15 +49,79 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
33
49
|
let workspaceRecordKeys = new Set();
|
|
34
50
|
/** @type {Map<string, CardPlacement>} */
|
|
35
51
|
let workspacePlacements = new Map();
|
|
36
|
-
/** Read-only AK
|
|
37
|
-
/** @type {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
|
|
52
|
+
/** Read-only AK data; absent until first read. @type {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
|
|
38
53
|
let akTasks = null;
|
|
39
|
-
/**
|
|
40
|
-
/** @type {import("../common/workspace-view.mjs").WindowDirectoryEntry[]} */
|
|
54
|
+
/** All-workspace window list. @type {import("../common/workspace-view.mjs").WindowDirectoryEntry[]} */
|
|
41
55
|
let directory = [];
|
|
42
56
|
/** @type {Map<string, SessionRecord>} */
|
|
43
57
|
let directoryCards = new Map();
|
|
44
58
|
let revision = 0;
|
|
59
|
+
let nextKeyboardRequest = 0;
|
|
60
|
+
let interactionEpoch = 0;
|
|
61
|
+
let intentGeneration = 0;
|
|
62
|
+
let consumedJumpRequest = -1;
|
|
63
|
+
/** @type {string | null} */
|
|
64
|
+
let interactionContext = null;
|
|
65
|
+
const visibility = createFocusVisibility();
|
|
66
|
+
// Passive desktop presence is not permission to capture keys or activate a stale source.
|
|
67
|
+
const persistent = env.PI_ACTIVITY_STRIP_VISIBILITY !== "focus";
|
|
68
|
+
const keepsSurface = () => persistent && isNiriSession() && workspace?.is_focused === true;
|
|
69
|
+
/** @type {import("./focus-visibility.mjs").FocusKind} */
|
|
70
|
+
let focusKind = "unknown";
|
|
71
|
+
/** Exact source, retained across explicit panel NULL focus. @type {number | null} */
|
|
72
|
+
let focusWindowId = null;
|
|
73
|
+
/** One exact card, or a presence app owning none. @type {"agent" | "neutral" | null} */
|
|
74
|
+
let focusSourceKind = null;
|
|
75
|
+
let focusContextValid = false;
|
|
76
|
+
|
|
77
|
+
const neutralSource = createNeutralSource(getWindowFacts, env);
|
|
78
|
+
const presentation = createPanelPresentation({
|
|
79
|
+
getWindowFacts,
|
|
80
|
+
getCards: getDisplaySessions,
|
|
81
|
+
getRawSessions: allSessions,
|
|
82
|
+
env,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function ensureNeutralSource() {
|
|
86
|
+
if (
|
|
87
|
+
focusSourceKind !== "neutral" ||
|
|
88
|
+
neutralSource.holds(workspace?.id, focusWindowId, getDisplaySessions())
|
|
89
|
+
)
|
|
90
|
+
return true;
|
|
91
|
+
invalidateFocus();
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clearFocusState() {
|
|
96
|
+
focusContextValid = false;
|
|
97
|
+
focusKind = "unknown";
|
|
98
|
+
focusWindowId = null;
|
|
99
|
+
focusSourceKind = null;
|
|
100
|
+
neutralSource.clear();
|
|
101
|
+
focusedCardId = null;
|
|
102
|
+
focusedSessionId = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invalidateFocus() {
|
|
106
|
+
presentation.clear();
|
|
107
|
+
clearFocusState();
|
|
108
|
+
send();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Recheck exact ownership: revoke only, never admit new membership from event facts.
|
|
112
|
+
* @param {(workspace: Record<string, unknown>) => WorkspaceView | null} resolveView
|
|
113
|
+
*/
|
|
114
|
+
function focusProofMatches(resolveView) {
|
|
115
|
+
if (focusWindowId === null || !focusContextValid) return true;
|
|
116
|
+
const current = workspace ? resolveView(workspace) : null;
|
|
117
|
+
if (focusSourceKind === "neutral") {
|
|
118
|
+
return (
|
|
119
|
+
current?.workspace?.id === workspace?.id &&
|
|
120
|
+
neutralSource.holds(workspace?.id, focusWindowId, current?.sessions ?? [])
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return agentSourceMatches(getDisplaySessions(), current, workspace?.id, focusWindowId);
|
|
124
|
+
}
|
|
45
125
|
|
|
46
126
|
function allSessions() {
|
|
47
127
|
return agentSessions.length > 0 ? [...snapshot.sessions, ...agentSessions] : snapshot.sessions;
|
|
@@ -62,8 +142,7 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
62
142
|
});
|
|
63
143
|
}
|
|
64
144
|
if (!akTasks) return cards;
|
|
65
|
-
//
|
|
66
|
-
// other workspaces, so a claim is only orphaned when no live session anywhere holds it.
|
|
145
|
+
// Claims are orphaned only when no live session anywhere (including other workspaces) holds them.
|
|
67
146
|
const liveSessionIds = new Set(
|
|
68
147
|
rawSessions.map((session) => String(session.sessionId ?? "")).filter(Boolean),
|
|
69
148
|
);
|
|
@@ -79,16 +158,66 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
79
158
|
function currentView() {
|
|
80
159
|
revision += 1;
|
|
81
160
|
const sessions = getDisplaySessions();
|
|
161
|
+
if (
|
|
162
|
+
selectionContradictsSource(workspace, focusWindowId) ||
|
|
163
|
+
(focusSourceKind === "neutral" &&
|
|
164
|
+
!neutralSource.holds(workspace?.id, focusWindowId, sessions))
|
|
165
|
+
)
|
|
166
|
+
clearFocusState();
|
|
167
|
+
// NULL continues only an exact admitted owner, never another card after owner loss/ambiguity.
|
|
168
|
+
if (
|
|
169
|
+
focusSourceKind !== "neutral" &&
|
|
170
|
+
(focusKind === "agent" || (focusKind === "none" && focusWindowId !== null)) &&
|
|
171
|
+
(focusWindowId === null || visibleCardsForWindow(sessions, focusWindowId).length !== 1)
|
|
172
|
+
) {
|
|
173
|
+
clearFocusState();
|
|
174
|
+
}
|
|
175
|
+
let owner =
|
|
176
|
+
focusSourceKind === "neutral"
|
|
177
|
+
? undefined
|
|
178
|
+
: sessions.find((card) => card.windowId === focusWindowId && card.surfaceVisible !== false);
|
|
179
|
+
const rawOwner = owner
|
|
180
|
+
? projectSessionCards(allSessions(), null).find((card) => card.cardId === owner?.cardId)
|
|
181
|
+
: undefined;
|
|
182
|
+
// Newly added publishers cannot inherit an admission established for a smaller set.
|
|
183
|
+
if (owner && JSON.stringify(publisherSet(owner)) !== JSON.stringify(publisherSet(rawOwner))) {
|
|
184
|
+
clearFocusState();
|
|
185
|
+
owner = undefined;
|
|
186
|
+
}
|
|
187
|
+
const facts = {
|
|
188
|
+
workspaceId: workspace?.id ?? null,
|
|
189
|
+
focus: isNiriSession() ? focusKind : "unknown",
|
|
190
|
+
hasCards: workspace?.is_focused === true && sessions.length > 0,
|
|
191
|
+
};
|
|
192
|
+
// Source identity survives own release/NULL, not app switch/admission loss; presence is separate.
|
|
193
|
+
const sourceHeld = focusSourceKind === "neutral" ? focusWindowId !== null : owner !== undefined;
|
|
194
|
+
const context =
|
|
195
|
+
focusContextValid &&
|
|
196
|
+
workspace?.is_focused === true &&
|
|
197
|
+
["agent", "neutral", "none"].includes(focusKind) &&
|
|
198
|
+
sourceHeld
|
|
199
|
+
? focusSourceKind === "neutral"
|
|
200
|
+
? neutralSource.context(workspace?.id, focusWindowId)
|
|
201
|
+
: sourceIdentity(workspace?.id, focusWindowId, owner)
|
|
202
|
+
: null;
|
|
203
|
+
if (context !== interactionContext) {
|
|
204
|
+
interactionEpoch += 1;
|
|
205
|
+
visibility.resetKeyboard();
|
|
206
|
+
interactionContext = context;
|
|
207
|
+
}
|
|
208
|
+
const focusVisible = presentation.visible(visibility.observe(facts), workspace, focusWindowId);
|
|
82
209
|
return {
|
|
83
|
-
protocol:
|
|
210
|
+
protocol: PANEL_PROTOCOL,
|
|
84
211
|
type: "view",
|
|
85
212
|
revision,
|
|
86
|
-
|
|
213
|
+
interactionEpoch,
|
|
214
|
+
visible: keepsSurface() || focusVisible,
|
|
87
215
|
focusedSessionId,
|
|
88
216
|
focusedCardId,
|
|
89
217
|
generatedAt: snapshot.generatedAt,
|
|
90
218
|
sessions,
|
|
91
219
|
windows: directory,
|
|
220
|
+
hotkey,
|
|
92
221
|
};
|
|
93
222
|
}
|
|
94
223
|
|
|
@@ -106,6 +235,159 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
106
235
|
|
|
107
236
|
return {
|
|
108
237
|
getDisplaySessions,
|
|
238
|
+
// Observe existing fields only: no publish, epoch advancement, admission or query access.
|
|
239
|
+
diagnostics: () => ({
|
|
240
|
+
persistentPresentation: keepsSurface(),
|
|
241
|
+
focus: focusKind,
|
|
242
|
+
source: focusSourceKind,
|
|
243
|
+
contextValid: focusContextValid,
|
|
244
|
+
eligible: visibility.isEligible(),
|
|
245
|
+
interactionEpoch,
|
|
246
|
+
keyboardAuthorization: visibility.keyboardMode(),
|
|
247
|
+
}),
|
|
248
|
+
/** Reserve ownership before sending; cancel on a failed write or panel exit. */
|
|
249
|
+
requestKeyboard: (clickThrough = false) => {
|
|
250
|
+
if (!ensureNeutralSource()) return null;
|
|
251
|
+
const request = ++nextKeyboardRequest;
|
|
252
|
+
if (!visibility.requestKeyboard(request, clickThrough)) return null;
|
|
253
|
+
intentGeneration += 1;
|
|
254
|
+
return request;
|
|
255
|
+
},
|
|
256
|
+
/** @param {number} epoch @param {number} token @param {boolean} clickThrough */
|
|
257
|
+
requestHoverKeyboard(epoch, token, clickThrough) {
|
|
258
|
+
if (!ensureNeutralSource() || epoch !== interactionEpoch) return null;
|
|
259
|
+
const request = ++nextKeyboardRequest;
|
|
260
|
+
if (!visibility.requestKeyboard(request, clickThrough, "hover", token)) return null;
|
|
261
|
+
intentGeneration += 1;
|
|
262
|
+
return request;
|
|
263
|
+
},
|
|
264
|
+
getInteractionEpoch: () => interactionEpoch,
|
|
265
|
+
keyboardMode: () => visibility.keyboardMode(),
|
|
266
|
+
/** @param {number} request */
|
|
267
|
+
ownsKeyboardRequest: (request) => visibility.ownsRequest(request),
|
|
268
|
+
/** @param {number} token */
|
|
269
|
+
cancelHoverKeyboard(token) {
|
|
270
|
+
const cancelled = visibility.cancelHover(token);
|
|
271
|
+
if (cancelled) intentGeneration += 1;
|
|
272
|
+
return cancelled;
|
|
273
|
+
},
|
|
274
|
+
cancelKeyboard() {
|
|
275
|
+
intentGeneration += 1;
|
|
276
|
+
visibility.resetKeyboard();
|
|
277
|
+
},
|
|
278
|
+
/** One accepted action, not a lease to capture keys. Own release must not cancel it.
|
|
279
|
+
* @param {number} request @param {number} epoch */
|
|
280
|
+
acceptJumpIntent(request, epoch) {
|
|
281
|
+
if (!ensureNeutralSource()) return null;
|
|
282
|
+
if (
|
|
283
|
+
!visibility.ownsRequest(request) ||
|
|
284
|
+
!visibility.isEligible() ||
|
|
285
|
+
epoch !== interactionEpoch ||
|
|
286
|
+
!interactionContext ||
|
|
287
|
+
!focusContextValid ||
|
|
288
|
+
focusWindowId === null ||
|
|
289
|
+
consumedJumpRequest === request
|
|
290
|
+
)
|
|
291
|
+
return null;
|
|
292
|
+
consumedJumpRequest = request;
|
|
293
|
+
const context = interactionContext;
|
|
294
|
+
const generation = intentGeneration;
|
|
295
|
+
const sourceWorkspaceId = workspace?.id;
|
|
296
|
+
const sourceWindowId = focusWindowId;
|
|
297
|
+
const sourceKind = focusSourceKind;
|
|
298
|
+
return {
|
|
299
|
+
workspaceId: sourceWorkspaceId,
|
|
300
|
+
windowId: sourceWindowId,
|
|
301
|
+
allowUnlisted: visibility.keyboardMode() === "explicit",
|
|
302
|
+
isCurrent: () =>
|
|
303
|
+
generation === intentGeneration &&
|
|
304
|
+
epoch === interactionEpoch &&
|
|
305
|
+
focusContextValid &&
|
|
306
|
+
context === interactionContext &&
|
|
307
|
+
sourceKind === focusSourceKind &&
|
|
308
|
+
["agent", "neutral", "none"].includes(focusKind),
|
|
309
|
+
/** @param {WorkspaceView | null} view */
|
|
310
|
+
matchesSource(view) {
|
|
311
|
+
if (!view || view.workspace?.id !== sourceWorkspaceId) return false;
|
|
312
|
+
if (selectionContradictsSource(view.workspace, sourceWindowId)) return false;
|
|
313
|
+
const kind = view.focusKind ?? "unknown";
|
|
314
|
+
const owned = visibleCardsForWindow(view.sessions, sourceWindowId);
|
|
315
|
+
if (sourceKind === "neutral")
|
|
316
|
+
return (
|
|
317
|
+
(kind === "none" || (kind === "neutral" && view.focusWindowId === sourceWindowId)) &&
|
|
318
|
+
neutralSource.holds(sourceWorkspaceId, sourceWindowId, view.sessions) &&
|
|
319
|
+
neutralSource.context(sourceWorkspaceId, sourceWindowId) === context
|
|
320
|
+
);
|
|
321
|
+
if (!["agent", "none"].includes(kind)) return false;
|
|
322
|
+
return (
|
|
323
|
+
owned.length === 1 &&
|
|
324
|
+
(view.focusKind !== "agent" || view.focusedCardId === owned[0]?.cardId) &&
|
|
325
|
+
sourceIdentity(sourceWorkspaceId, sourceWindowId, owned[0]) === context
|
|
326
|
+
);
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
},
|
|
330
|
+
/** @param {number} request @param {boolean} active */
|
|
331
|
+
keyboardChanged: (request, active) => visibility.keyboardChanged(request, active),
|
|
332
|
+
isEligible: () => visibility.isEligible(),
|
|
333
|
+
invalidateFocus,
|
|
334
|
+
/** Revocation-only layout metadata; never admit seat focus or a replacement source.
|
|
335
|
+
* @param {number} id @param {number | null} windowId
|
|
336
|
+
* @param {(workspace: Record<string, unknown>) => WorkspaceView | null} resolveView */
|
|
337
|
+
observeWorkspaceSelection(id, windowId, resolveView) {
|
|
338
|
+
if (workspace?.id !== id) return;
|
|
339
|
+
workspace = { ...workspace, active_window_id: windowId };
|
|
340
|
+
presentation.prepare(workspace, windowId, resolveView);
|
|
341
|
+
send();
|
|
342
|
+
},
|
|
343
|
+
/** @param {number} windowId */
|
|
344
|
+
invalidateWindowFocus(windowId) {
|
|
345
|
+
if (windowId === focusWindowId || presentation.involves(windowId)) invalidateFocus();
|
|
346
|
+
},
|
|
347
|
+
/** @param {(workspace: Record<string, unknown>) => WorkspaceView | null} resolveView */
|
|
348
|
+
revalidateWindowFocus(resolveView) {
|
|
349
|
+
const valid = focusProofMatches(resolveView) && presentation.holds(workspace, resolveView);
|
|
350
|
+
if (!valid) invalidateFocus();
|
|
351
|
+
return valid;
|
|
352
|
+
},
|
|
353
|
+
/** Authoritative focus-event ids, not app-id/title guesses or delayed panel acknowledgements.
|
|
354
|
+
* @param {number | null} windowId
|
|
355
|
+
* @param {(workspace: Record<string, unknown>) => WorkspaceView | null} [resolveView]
|
|
356
|
+
*/
|
|
357
|
+
publishWindowFocus(windowId, resolveView) {
|
|
358
|
+
if (windowId !== null && resolveView) presentation.prepare(workspace, windowId, resolveView);
|
|
359
|
+
const cards = getDisplaySessions();
|
|
360
|
+
const facts = windowId === null ? null : getWindowFacts(windowId);
|
|
361
|
+
focusKind = !focusContextValid
|
|
362
|
+
? "unknown"
|
|
363
|
+
: windowId === null
|
|
364
|
+
? "none"
|
|
365
|
+
: classifyAdmittedWindowFocus(
|
|
366
|
+
windowId,
|
|
367
|
+
cards,
|
|
368
|
+
facts?.workspace_id === workspace?.id ? facts : null,
|
|
369
|
+
env,
|
|
370
|
+
);
|
|
371
|
+
// NULL may be a layer surface: retain the source ring and scroll position, not input authority.
|
|
372
|
+
if (focusKind !== "none") {
|
|
373
|
+
const current =
|
|
374
|
+
focusKind === "agent"
|
|
375
|
+
? cards.find((card) => card.windowId === windowId && card.surfaceVisible !== false)
|
|
376
|
+
: null;
|
|
377
|
+
focusedCardId = current ? String(current.cardId) : null;
|
|
378
|
+
focusedSessionId = current ? String(current.sessionId) : null;
|
|
379
|
+
}
|
|
380
|
+
if (focusKind === "agent" || focusKind === "neutral") {
|
|
381
|
+
focusWindowId = windowId;
|
|
382
|
+
focusSourceKind = focusKind;
|
|
383
|
+
neutralSource.admit(focusKind === "neutral" ? facts : null);
|
|
384
|
+
} else if (focusKind !== "none") {
|
|
385
|
+
focusWindowId = null;
|
|
386
|
+
focusSourceKind = null;
|
|
387
|
+
}
|
|
388
|
+
if (resolveView && !focusProofMatches(resolveView)) clearFocusState();
|
|
389
|
+
send();
|
|
390
|
+
},
|
|
109
391
|
getRawSessions: allSessions,
|
|
110
392
|
/** Broker-published records only, for comparisons against a broker snapshot. */
|
|
111
393
|
getBrokerSessions: () => snapshot.sessions,
|
|
@@ -113,28 +395,29 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
113
395
|
setAgentSessions(records) {
|
|
114
396
|
agentSessions = Array.isArray(records) ? records : [];
|
|
115
397
|
},
|
|
116
|
-
/**
|
|
117
|
-
* Replace the read-only AK task data joined onto cards. An empty refresh clears every chip;
|
|
118
|
-
* `null` data (AK never read) leaves cards untouched, which is how the strip stays unchanged
|
|
119
|
-
* when AK is absent.
|
|
398
|
+
/** Replace read-only AK chips: empty clears them; null (never read) leaves cards untouched.
|
|
120
399
|
* @param {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} state
|
|
121
400
|
*/
|
|
122
401
|
setAkTasks(state) {
|
|
123
402
|
akTasks = state;
|
|
124
403
|
send();
|
|
125
404
|
},
|
|
126
|
-
/**
|
|
127
|
-
*
|
|
128
|
-
* @param {string} targetId
|
|
129
|
-
*/
|
|
405
|
+
/** Resolve a current card or a cross-workspace directory entry.
|
|
406
|
+
* @param {string} targetId */
|
|
130
407
|
resolveTarget: (targetId) =>
|
|
131
408
|
resolveSnapshotSession(getDisplaySessions(), targetId) ??
|
|
132
409
|
directoryCards.get(targetId) ??
|
|
133
410
|
null,
|
|
134
411
|
/** @param {number} windowId */
|
|
135
412
|
hasDirectoryWindow: (windowId) => directory.some((entry) => entry.windowId === windowId),
|
|
136
|
-
/**
|
|
137
|
-
|
|
413
|
+
/** @param {number} windowId */
|
|
414
|
+
getDirectoryWindowIdentities: (windowId) =>
|
|
415
|
+
directory
|
|
416
|
+
.filter((entry) => entry.windowId === windowId)
|
|
417
|
+
.map((entry) =>
|
|
418
|
+
sourceIdentity(entry.workspaceId, windowId, directoryCards.get(entry.cardId)),
|
|
419
|
+
),
|
|
420
|
+
/** Replace the window list, published with the next view.
|
|
138
421
|
* @param {{entries: import("../common/workspace-view.mjs").WindowDirectoryEntry[]; cards: Map<string, SessionRecord>}} next
|
|
139
422
|
*/
|
|
140
423
|
setDirectory(next) {
|
|
@@ -143,7 +426,17 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
143
426
|
},
|
|
144
427
|
/** @param {WorkspaceView} view */
|
|
145
428
|
publishWorkspaceView(view) {
|
|
429
|
+
presentation.prepareView(view, focusWindowId);
|
|
430
|
+
const sameWorkspace = Number.isInteger(workspace?.id) && view.workspace?.id === workspace?.id;
|
|
431
|
+
const retainedCardId = sameWorkspace ? focusedCardId : null;
|
|
432
|
+
const retainedSessionId = sameWorkspace ? focusedSessionId : null;
|
|
433
|
+
if (!sameWorkspace) {
|
|
434
|
+
focusWindowId = null;
|
|
435
|
+
focusSourceKind = null;
|
|
436
|
+
}
|
|
146
437
|
workspace = view.workspace ?? null;
|
|
438
|
+
focusContextValid = Number.isInteger(workspace?.id) && workspace?.is_focused === true;
|
|
439
|
+
focusKind = view.focusKind ?? "unknown";
|
|
147
440
|
workspaceCardIds = new Set(view.sessions.map((session) => String(session.cardId ?? "")));
|
|
148
441
|
workspaceRecordKeys = new Set(
|
|
149
442
|
view.sessions.flatMap((session) =>
|
|
@@ -167,6 +460,27 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
|
|
|
167
460
|
);
|
|
168
461
|
focusedSessionId = view.focusedSessionId;
|
|
169
462
|
focusedCardId = view.focusedCardId ?? null;
|
|
463
|
+
// Under NULL the established source keeps its current card (see publishWindowFocus).
|
|
464
|
+
if (
|
|
465
|
+
focusKind === "none" &&
|
|
466
|
+
focusWindowId !== null &&
|
|
467
|
+
retainedCardId &&
|
|
468
|
+
workspaceCardIds.has(retainedCardId)
|
|
469
|
+
) {
|
|
470
|
+
focusedCardId = retainedCardId;
|
|
471
|
+
focusedSessionId = retainedSessionId;
|
|
472
|
+
}
|
|
473
|
+
if (focusKind === "agent") {
|
|
474
|
+
focusWindowId = workspacePlacements.get(focusedCardId ?? "")?.windowId ?? null;
|
|
475
|
+
focusSourceKind = focusWindowId === null ? null : "agent";
|
|
476
|
+
} else if (focusKind === "neutral") {
|
|
477
|
+
focusWindowId = Number.isInteger(view.focusWindowId) ? Number(view.focusWindowId) : null;
|
|
478
|
+
focusSourceKind = focusWindowId === null ? null : "neutral";
|
|
479
|
+
neutralSource.admit(focusWindowId === null ? null : getWindowFacts(focusWindowId));
|
|
480
|
+
} else if (focusKind !== "none") {
|
|
481
|
+
focusWindowId = null;
|
|
482
|
+
focusSourceKind = null;
|
|
483
|
+
}
|
|
170
484
|
send();
|
|
171
485
|
},
|
|
172
486
|
send,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Private controller/rendering protocol ONLY. Pi publisher/broker message envelopes are unchanged.
|
|
2
|
+
export const PANEL_PROTOCOL = 2;
|
|
3
|
+
/** Fatal readiness/event gate, even for unverified development overrides. No view or command
|
|
4
|
+
* may be sent to an incompatible child, and a late ready cannot undo the rejection.
|
|
5
|
+
* @param {import("node:child_process").ChildProcessWithoutNullStreams & {_protocolError?: string}} child
|
|
6
|
+
* @param {{protocol?: unknown}} event
|
|
7
|
+
* @param {{state?: string; error?: string | null}} status @param {() => void} revoke */
|
|
8
|
+
export function admitPanelEvent(child, event, status, revoke) {
|
|
9
|
+
if (child._protocolError) return false;
|
|
10
|
+
if (event.protocol === PANEL_PROTOCOL) return true;
|
|
11
|
+
child._protocolError =
|
|
12
|
+
"Native panel private protocol mismatch: expected " +
|
|
13
|
+
PANEL_PROTOCOL +
|
|
14
|
+
", received " +
|
|
15
|
+
String(event.protocol) +
|
|
16
|
+
". Rebuild/use a matching controller and panel.";
|
|
17
|
+
revoke();
|
|
18
|
+
status.state = "error";
|
|
19
|
+
status.error = child._protocolError;
|
|
20
|
+
child.stdin.end();
|
|
21
|
+
child.kill("SIGTERM");
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { resolveFocusedWorkspaceView, resolveWindowDirectory } from "../common/workspace-view.mjs";
|
|
2
|
+
import { sourceIdentity } from "./interaction-context.mjs";
|
|
3
|
+
import { createWindowJump } from "./window-jump.mjs";
|
|
4
|
+
/** The SAME production binding is used by native event consumption and controlled pipe-race tests.
|
|
5
|
+
* @param {{projection: ReturnType<typeof import("./panel-projection.mjs").createNativePanelProjection>;
|
|
6
|
+
* getWindowFacts: (id: number) => Record<string, unknown> | null;
|
|
7
|
+
* placementOptions: import("../common/workspace-view.mjs").ResolveOptions;
|
|
8
|
+
* isNiriSession: () => boolean; readWindows: () => Promise<Record<string, unknown>[]>;
|
|
9
|
+
* readWorkspaces: () => Promise<Record<string, unknown>[]>;
|
|
10
|
+
* focusWindow: (id: number) => Promise<unknown>; reconcile: () => void}} options */
|
|
11
|
+
export function createPanelWindowJump({
|
|
12
|
+
projection,
|
|
13
|
+
placementOptions,
|
|
14
|
+
getWindowFacts,
|
|
15
|
+
...options
|
|
16
|
+
}) {
|
|
17
|
+
/** @param {{windowId?: unknown; request?: unknown; epoch?: unknown}} event */
|
|
18
|
+
return async function requestJump(event) {
|
|
19
|
+
const intent = projection.acceptJumpIntent(Number(event.request), Number(event.epoch));
|
|
20
|
+
if (!intent)
|
|
21
|
+
return { ok: false, error: "Jump has no current authorized source intent; nothing moved." };
|
|
22
|
+
const sourceFacts = getWindowFacts(intent.windowId);
|
|
23
|
+
const windowId = Number(event.windowId);
|
|
24
|
+
const expectedTarget = projection.getDirectoryWindowIdentities(windowId);
|
|
25
|
+
/** @type {Record<string, unknown>[]} */
|
|
26
|
+
let workspaces = [];
|
|
27
|
+
const jump = createWindowJump({
|
|
28
|
+
...options,
|
|
29
|
+
readWindows: async () => {
|
|
30
|
+
const [windows, freshWorkspaces] = await Promise.all([
|
|
31
|
+
options.readWindows(),
|
|
32
|
+
options.readWorkspaces(),
|
|
33
|
+
]);
|
|
34
|
+
workspaces = freshWorkspaces;
|
|
35
|
+
return windows;
|
|
36
|
+
},
|
|
37
|
+
isAdmittedWindow: (id, windows) => {
|
|
38
|
+
if (!projection.hasDirectoryWindow(id)) return false;
|
|
39
|
+
const fresh = resolveWindowDirectory(
|
|
40
|
+
windows,
|
|
41
|
+
[...new Set(windows.map((w) => w.workspace_id))].map((id) => ({ id })),
|
|
42
|
+
projection.getRawSessions(),
|
|
43
|
+
placementOptions,
|
|
44
|
+
);
|
|
45
|
+
return fresh.entries.some(
|
|
46
|
+
(entry) =>
|
|
47
|
+
entry.windowId === id &&
|
|
48
|
+
expectedTarget.includes(
|
|
49
|
+
sourceIdentity(entry.workspaceId, id, fresh.cards.get(entry.cardId)),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return jump(
|
|
55
|
+
Number(event.windowId),
|
|
56
|
+
intent.allowUnlisted && expectedTarget.length === 0,
|
|
57
|
+
(windows) =>
|
|
58
|
+
intent.isCurrent() &&
|
|
59
|
+
Array.isArray(windows) &&
|
|
60
|
+
Array.isArray(workspaces) &&
|
|
61
|
+
workspaces.every(
|
|
62
|
+
(w) =>
|
|
63
|
+
w &&
|
|
64
|
+
Number.isInteger(w.id) &&
|
|
65
|
+
typeof w.is_focused === "boolean" &&
|
|
66
|
+
(typeof w.name === "string" || Number.isInteger(w.idx)),
|
|
67
|
+
) &&
|
|
68
|
+
new Set(workspaces.map((w) => w.id)).size === workspaces.length &&
|
|
69
|
+
sourceFacts !== null &&
|
|
70
|
+
windows.some(
|
|
71
|
+
(w) =>
|
|
72
|
+
w?.id === intent.windowId &&
|
|
73
|
+
w?.workspace_id === intent.workspaceId &&
|
|
74
|
+
w?.workspace_id === sourceFacts.workspace_id &&
|
|
75
|
+
w?.pid === sourceFacts.pid &&
|
|
76
|
+
w?.app_id === sourceFacts.app_id,
|
|
77
|
+
) &&
|
|
78
|
+
windows.every((w) => w && Number.isInteger(w.id) && typeof w.is_focused === "boolean") &&
|
|
79
|
+
new Set(windows.map((w) => w.id)).size === windows.length &&
|
|
80
|
+
intent.matchesSource(
|
|
81
|
+
resolveFocusedWorkspaceView(
|
|
82
|
+
windows,
|
|
83
|
+
workspaces,
|
|
84
|
+
projection.getRawSessions(),
|
|
85
|
+
placementOptions,
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
}
|