@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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "renders only allowlisted ribbon observation fields, never arbitrary diagnostic text"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing diagnostic output or its redaction boundary"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import { normalizeKeyboardObservation } from "./keyboard-observation.mjs";
|
|
8
|
+
|
|
9
|
+
/** @param {unknown} value @param {string[]} allowed */
|
|
10
|
+
const enumValue = (value, allowed) =>
|
|
11
|
+
typeof value === "string" && allowed.includes(value) ? value : "unknown";
|
|
12
|
+
/** @param {unknown} value */
|
|
13
|
+
const hashValue = (value) =>
|
|
14
|
+
typeof value === "string" && /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
|
|
15
|
+
|
|
16
|
+
/** @param {import("./contracts.ts").ActivityStripRuntimeStatus["ribbonDiagnostics"]} diagnostics */
|
|
17
|
+
export function formatRibbonDiagnostics(diagnostics) {
|
|
18
|
+
if (!diagnostics) return ["ribbon diagnostics: not reported by this controller"];
|
|
19
|
+
const build = diagnostics.panelBuild;
|
|
20
|
+
const native = normalizeKeyboardObservation({
|
|
21
|
+
...diagnostics.nativeKeyboardObservation,
|
|
22
|
+
protocol: 2,
|
|
23
|
+
type: "keyboard-observation",
|
|
24
|
+
});
|
|
25
|
+
return [
|
|
26
|
+
`visibility reason: ${enumValue(diagnostics.visibilityReason, ["starting", "error", "unproven-focus", "non-agent-focus", "no-admitted-owner", "awaiting-panel-visibility", "eligible-agent-context", "eligible-presence-context", "persistent-workspace"])}`,
|
|
27
|
+
`keyboard authorization: ${enumValue(diagnostics.keyboardAuthorization, ["none", "hover", "explicit"])} (not proof of capture)`,
|
|
28
|
+
...(native
|
|
29
|
+
? [
|
|
30
|
+
`capture expiry: last native observation (${native.origin}; ${native.reason}), not current authority`,
|
|
31
|
+
`native monotonic ms: ${native.nativeMs}; idle deadline ms: ${native.deadlineMs ?? "not applicable (explicit)"}; request: ${native.request} (same native process clock only; not a countdown)`,
|
|
32
|
+
]
|
|
33
|
+
: ["capture expiry: not observed (native monotonic clock)"]),
|
|
34
|
+
`private protocol: expected ${Number.isSafeInteger(diagnostics.expectedPrivateProtocol) ? diagnostics.expectedPrivateProtocol : "unknown"}, receipt ${Number.isSafeInteger(build?.privateProtocol) ? build.privateProtocol : "unknown"}`,
|
|
35
|
+
`panel receipt: ${enumValue(build?.state, ["pending", "unavailable", "changed-during-observation", "incompatible", "receipt-matched", "receipt-mismatch"])}`,
|
|
36
|
+
`panel PID executable sha256: ${hashValue(build?.binarySha256)} (sampled; not custody proof)`,
|
|
37
|
+
`panel build sampled at: ${Number.isSafeInteger(diagnostics.panelBuildObservedAt) ? diagnostics.panelBuildObservedAt : "unknown"} (stale after process changes)`,
|
|
38
|
+
`receipted native source sha256: ${hashValue(build?.receiptedSourceSha256)}`,
|
|
39
|
+
`controller startup source sha256: ${hashValue(diagnostics.controllerStartupSourceSha256)} (observation, not loaded-byte proof)`,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
/** @typedef {import("./contracts.ts").BrokerResponse} BrokerResponse */
|
|
8
8
|
|
|
9
|
+
import { formatRibbonDiagnostics } from "./ribbon-status.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* When the AK deferral list was read and how long it may be reused; empty for unusable values.
|
|
13
|
+
* @param {{akTaskDeferredReadAt?: number | null; akTaskDeferredMaxAgeMs?: number}} runtime
|
|
14
|
+
*/
|
|
15
|
+
function deferralListAge(runtime) {
|
|
16
|
+
const readAt = runtime.akTaskDeferredReadAt;
|
|
17
|
+
if (typeof readAt !== "number" || !Number.isFinite(readAt) || Math.abs(readAt) > 8.64e15)
|
|
18
|
+
return "";
|
|
19
|
+
const maxAge = runtime.akTaskDeferredMaxAgeMs;
|
|
20
|
+
const reuse =
|
|
21
|
+
typeof maxAge === "number" && Number.isFinite(maxAge) && maxAge > 0
|
|
22
|
+
? `, reused up to ${Math.round(maxAge / 1000)} s`
|
|
23
|
+
: "";
|
|
24
|
+
return `; deferral list read ${new Date(readAt).toISOString()}${reuse}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
/** @param {BrokerResponse | null | undefined} result */
|
|
10
28
|
export function formatBrokerRuntimeStatus(result) {
|
|
11
29
|
if (!result?.ok) return "stopped";
|
|
@@ -26,6 +44,14 @@ export function formatBrokerRuntimeStatus(result) {
|
|
|
26
44
|
if (typeof runtime.windowVisible === "boolean") {
|
|
27
45
|
lines.push(`surface visible: ${runtime.windowVisible ? "yes" : "no"}`);
|
|
28
46
|
}
|
|
47
|
+
if (typeof runtime.panelExpanded === "boolean") {
|
|
48
|
+
lines.push(
|
|
49
|
+
runtime.panelExpanded && runtime.windowVisible === false
|
|
50
|
+
? "expansion: stale (hidden surface)"
|
|
51
|
+
: `expansion: ${runtime.panelExpanded ? "expanded" : "compact"} (reported; cause not observed)`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
lines.push(...formatRibbonDiagnostics(runtime.ribbonDiagnostics));
|
|
29
55
|
if (typeof runtime.rendererCardCount === "number") {
|
|
30
56
|
const hidden =
|
|
31
57
|
typeof runtime.rendererHiddenTabCardCount === "number"
|
|
@@ -64,7 +90,7 @@ export function formatBrokerRuntimeStatus(result) {
|
|
|
64
90
|
if (runtime.akTaskState === "ok") {
|
|
65
91
|
const orphaned = Number(runtime.akTaskOrphanCount ?? 0);
|
|
66
92
|
lines.push(
|
|
67
|
-
`AK task refs: ok (${runtime.akTaskClaimCount ?? 0} live claims, ${runtime.akTaskDeferredCount ?? 0} deferred${orphaned > 0 ? `, ${orphaned} orphaned claims` : ""})`,
|
|
93
|
+
`AK task refs: ok (${runtime.akTaskClaimCount ?? 0} live claims, ${runtime.akTaskDeferredCount ?? 0} deferred${orphaned > 0 ? `, ${orphaned} orphaned claims` : ""}${deferralListAge(runtime)})`,
|
|
68
94
|
);
|
|
69
95
|
} else if (runtime.akTaskState !== "disabled") {
|
|
70
96
|
lines.push(
|
|
@@ -101,10 +127,19 @@ export function summarizeBrokerRuntimeStatus(result) {
|
|
|
101
127
|
|
|
102
128
|
const runtime = result.runtimeStatus;
|
|
103
129
|
if (!runtime) {
|
|
104
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
headline: "Activity strip broker is running; overlay readiness is unknown",
|
|
132
|
+
level: "warning",
|
|
133
|
+
};
|
|
105
134
|
}
|
|
106
135
|
|
|
107
136
|
if (runtime.state === "ready") {
|
|
137
|
+
if (runtime.tabInventoryState === "failed" || runtime.tabInventoryState === "unavailable") {
|
|
138
|
+
return {
|
|
139
|
+
headline: `Activity strip overlay is ready; tab inventory ${runtime.tabInventoryState}`,
|
|
140
|
+
level: "warning",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
108
143
|
return { headline: "Activity strip is running and ready", level: "info" };
|
|
109
144
|
}
|
|
110
145
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// ---
|
|
6
6
|
|
|
7
7
|
import { canVerifyTabVisibility, placeSessionWindow } from "./niri-focus.mjs";
|
|
8
|
+
import { isPresenceApp } from "./presence-apps.mjs";
|
|
8
9
|
import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
|
|
9
10
|
import { parseTerminalTitleBinding } from "./surface-bindings.mjs";
|
|
10
11
|
import { canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
|
|
@@ -40,6 +41,50 @@ export function resolveSurfaceVisibility(window, session, placement, options = {
|
|
|
40
41
|
return "unknown";
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** @typedef {"agent" | "neutral" | "non-agent" | "unknown" | "none"} FocusKind */
|
|
45
|
+
/**
|
|
46
|
+
* Classify exact admitted Niri ownership, never app-id/title heuristics. Card ownership alone
|
|
47
|
+
* makes a window "agent"; a focused presence application that owns no exact card is "neutral":
|
|
48
|
+
* the strip stays on screen over it and the window itself, not a card, is the interaction source.
|
|
49
|
+
* @param {Array<Record<string, unknown>>} windows
|
|
50
|
+
* @param {Record<string, unknown>} workspace
|
|
51
|
+
* @param {Array<Record<string, unknown>>} cards
|
|
52
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
53
|
+
* @returns {FocusKind}
|
|
54
|
+
*/
|
|
55
|
+
export function classifyWorkspaceFocus(windows, workspace, cards, env = process.env) {
|
|
56
|
+
if (
|
|
57
|
+
windows.some(
|
|
58
|
+
(window) => !Number.isInteger(window?.id) || typeof window?.is_focused !== "boolean",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return "unknown";
|
|
62
|
+
const focused = windows.filter((window) => window.is_focused === true);
|
|
63
|
+
// Niri retains an active window while an exclusive layer surface owns the keyboard. NULL
|
|
64
|
+
// alone is not ownership: only the controller's pending explicit request can admit it.
|
|
65
|
+
if (focused.length === 0) return windows.length > 0 ? "none" : "unknown";
|
|
66
|
+
if (focused.length !== 1) return "unknown";
|
|
67
|
+
const window = focused[0];
|
|
68
|
+
if (!Number.isInteger(window.id) || window.workspace_id !== workspace.id) return "unknown";
|
|
69
|
+
return classifyAdmittedWindowFocus(Number(window.id), cards, window, env);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Exact Niri focus events may update eligibility before a slower full projection finishes.
|
|
73
|
+
* Without the window's own facts presence cannot be proven, so the strict result stands.
|
|
74
|
+
* @param {number} windowId @param {Array<Record<string, unknown>>} cards
|
|
75
|
+
* @param {Record<string, unknown> | null} [window] @param {NodeJS.ProcessEnv} [env]
|
|
76
|
+
* @returns {FocusKind}
|
|
77
|
+
*/
|
|
78
|
+
export function classifyAdmittedWindowFocus(windowId, cards, window = null, env = process.env) {
|
|
79
|
+
if (!Number.isInteger(windowId)) return "unknown";
|
|
80
|
+
const visible = cards.filter(
|
|
81
|
+
(card) => card.windowId === windowId && card.surfaceVisible !== false,
|
|
82
|
+
).length;
|
|
83
|
+
if (visible === 1) return "agent";
|
|
84
|
+
if (window?.id === windowId && isPresenceApp(window.app_id, env)) return "neutral";
|
|
85
|
+
return visible === 0 ? "non-agent" : "unknown";
|
|
86
|
+
}
|
|
87
|
+
|
|
43
88
|
/**
|
|
44
89
|
* Project the global broker snapshot onto one exact Niri workspace. Membership requires an exact
|
|
45
90
|
* window placement: a visible title, or a hidden tab attributed through its Ghostty host process.
|
|
@@ -129,8 +174,17 @@ export function resolveWorkspaceView(windows, workspace, sessions, options = {})
|
|
|
129
174
|
windows.some((window) => window?.id === session.windowId && window?.is_focused === true),
|
|
130
175
|
);
|
|
131
176
|
const focusedCard = focusedCandidates.length === 1 ? focusedCandidates[0] : null;
|
|
177
|
+
const focusedWindows = windows.filter((window) => window?.is_focused === true);
|
|
178
|
+
const focusWindow =
|
|
179
|
+
focusedWindows.length === 1 &&
|
|
180
|
+
Number.isInteger(focusedWindows[0]?.id) &&
|
|
181
|
+
focusedWindows[0].workspace_id === workspace.id
|
|
182
|
+
? Number(focusedWindows[0].id)
|
|
183
|
+
: null;
|
|
132
184
|
return {
|
|
133
185
|
workspace,
|
|
186
|
+
focusKind: classifyWorkspaceFocus(windows, workspace, projectedSessions, options.env),
|
|
187
|
+
focusWindowId: focusWindow,
|
|
134
188
|
sessions: projectedSessions,
|
|
135
189
|
focusedSessionId: focusedCard ? String(focusedCard.sessionId ?? "") || null : null,
|
|
136
190
|
focusedCardId: focusedCard ? String(focusedCard.cardId ?? "") || null : null,
|
|
@@ -149,6 +203,70 @@ export function resolveFocusedWorkspaceView(windows, workspaces, sessions, optio
|
|
|
149
203
|
return workspace ? resolveWorkspaceView(windows, workspace, sessions, options) : null;
|
|
150
204
|
}
|
|
151
205
|
|
|
206
|
+
/**
|
|
207
|
+
* @typedef {{
|
|
208
|
+
* cardId: string;
|
|
209
|
+
* label: string;
|
|
210
|
+
* windowId: number;
|
|
211
|
+
* workspaceId: number;
|
|
212
|
+
* workspaceIdx: number | null;
|
|
213
|
+
* workspaceName: string | null;
|
|
214
|
+
* hiddenTab: boolean;
|
|
215
|
+
* current: boolean;
|
|
216
|
+
* }} WindowDirectoryEntry
|
|
217
|
+
*/
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Every agent window on every workspace, for the ribbon's window list. It reuses the per-workspace
|
|
221
|
+
* card projection, so an entry exists exactly when that workspace would show the card. Ordered
|
|
222
|
+
* alphabetically by project name, then by window number; each entry carries its workspace.
|
|
223
|
+
* @param {Array<Record<string, unknown>>} windows
|
|
224
|
+
* @param {Array<Record<string, unknown>>} workspaces
|
|
225
|
+
* @param {Array<Record<string, unknown>>} sessions
|
|
226
|
+
* @param {ResolveOptions} [options]
|
|
227
|
+
* @returns {{entries: WindowDirectoryEntry[]; cards: Map<string, Record<string, unknown>>}}
|
|
228
|
+
*/
|
|
229
|
+
export function resolveWindowDirectory(windows, workspaces, sessions, options = {}) {
|
|
230
|
+
const ordered = workspaces
|
|
231
|
+
.filter((workspace) => Number.isInteger(workspace?.id))
|
|
232
|
+
.sort(
|
|
233
|
+
(a, b) =>
|
|
234
|
+
String(a.output ?? "").localeCompare(String(b.output ?? "")) ||
|
|
235
|
+
Number(a.idx ?? 0) - Number(b.idx ?? 0),
|
|
236
|
+
);
|
|
237
|
+
/** @type {WindowDirectoryEntry[]} */
|
|
238
|
+
const entries = [];
|
|
239
|
+
/** @type {Map<string, Record<string, unknown>>} */
|
|
240
|
+
const cards = new Map();
|
|
241
|
+
for (const workspace of ordered) {
|
|
242
|
+
const view = resolveWorkspaceView(windows, workspace, sessions, options);
|
|
243
|
+
if (!view) continue;
|
|
244
|
+
const placed = view.sessions
|
|
245
|
+
.filter((card) => Number.isInteger(card.windowId))
|
|
246
|
+
.sort((a, b) => Number(a.windowId) - Number(b.windowId));
|
|
247
|
+
for (const card of placed) {
|
|
248
|
+
const cardId = String(card.cardId ?? "");
|
|
249
|
+
if (!cardId || cards.has(cardId)) continue;
|
|
250
|
+
cards.set(cardId, card);
|
|
251
|
+
entries.push({
|
|
252
|
+
cardId,
|
|
253
|
+
label: String(card.repoLabel || "session"),
|
|
254
|
+
windowId: Number(card.windowId),
|
|
255
|
+
workspaceId: Number(workspace.id),
|
|
256
|
+
workspaceIdx: Number.isInteger(card.workspaceIdx) ? Number(card.workspaceIdx) : null,
|
|
257
|
+
workspaceName: typeof workspace.name === "string" ? workspace.name : null,
|
|
258
|
+
hiddenTab: card.surfaceVisible === false,
|
|
259
|
+
current: cardId === view.focusedCardId,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
entries.sort(
|
|
264
|
+
(a, b) =>
|
|
265
|
+
a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.windowId - b.windowId,
|
|
266
|
+
);
|
|
267
|
+
return { entries, cards };
|
|
268
|
+
}
|
|
269
|
+
|
|
152
270
|
/** @param {Array<Record<string, unknown>>} workspaces */
|
|
153
271
|
export function resolveFocusedNiriWorkspace(workspaces) {
|
|
154
272
|
const matches = workspaces.filter(
|
|
@@ -13,6 +13,14 @@ import {
|
|
|
13
13
|
|
|
14
14
|
/** AK state changes are rare; a calm clock keeps the ribbon cheap. */
|
|
15
15
|
export const AK_TASK_REFRESH_MS = 15_000;
|
|
16
|
+
/**
|
|
17
|
+
* The full deferral list held the workstation-wide AK lock 3.3 s of every 15 s (AK7943): reuse the
|
|
18
|
+
* last good list for up to five minutes while claims keep the 15 s clock. It is read only after the
|
|
19
|
+
* claims read succeeds, is dropped when its own read fails, expires when the clock steps back, and is
|
|
20
|
+
* reread at once when a newer live claim names a task it still holds as deferred (AK refuses to
|
|
21
|
+
* claim a task with an active deferral, so that claim proves the list stale).
|
|
22
|
+
*/
|
|
23
|
+
export const AK_DEFERRED_REFRESH_MS = 300_000;
|
|
16
24
|
const AK_EXEC_TIMEOUT_MS = 8000;
|
|
17
25
|
const AK_EXEC_MAX_BUFFER = 4 * 1024 * 1024;
|
|
18
26
|
|
|
@@ -40,6 +48,8 @@ export function createAkTaskRuntime({
|
|
|
40
48
|
/** @type {NodeJS.Timeout | null} */
|
|
41
49
|
let timer = null;
|
|
42
50
|
let inFlight = false;
|
|
51
|
+
/** @type {{at: number; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
|
|
52
|
+
let deferredCache = null;
|
|
43
53
|
|
|
44
54
|
runtimeStatus.akTaskState = disabled ? "disabled" : (runtimeStatus.akTaskState ?? "pending");
|
|
45
55
|
runtimeStatus.akTaskError = runtimeStatus.akTaskError ?? null;
|
|
@@ -63,32 +73,61 @@ export function createAkTaskRuntime({
|
|
|
63
73
|
/**
|
|
64
74
|
* @param {string} error
|
|
65
75
|
*/
|
|
66
|
-
function failClosed(error) {
|
|
76
|
+
function failClosed(error, { keepDeferred = false } = {}) {
|
|
67
77
|
runtimeStatus.akTaskState = "unavailable";
|
|
68
78
|
runtimeStatus.akTaskError = error;
|
|
69
79
|
runtimeStatus.akTaskClaimCount = 0;
|
|
70
80
|
runtimeStatus.akTaskDeferredCount = 0;
|
|
81
|
+
runtimeStatus.akTaskDeferredReadAt = null;
|
|
82
|
+
if (!keepDeferred) deferredCache = null;
|
|
71
83
|
onChange({ claims: [], deferred: [] });
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
async function refresh() {
|
|
75
87
|
if (disabled || inFlight) return false;
|
|
76
88
|
inFlight = true;
|
|
89
|
+
let phase = "claims";
|
|
77
90
|
try {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
const readAt = now();
|
|
92
|
+
// Claims first: a failing or contended claim read never also spends the deferral read.
|
|
93
|
+
const claims = parseAkClaimList(
|
|
94
|
+
await readCli([
|
|
95
|
+
"task",
|
|
96
|
+
"list",
|
|
97
|
+
"--status",
|
|
98
|
+
"claimed",
|
|
99
|
+
"--format",
|
|
100
|
+
"json",
|
|
101
|
+
"--all",
|
|
102
|
+
"--verbose",
|
|
103
|
+
]),
|
|
104
|
+
);
|
|
84
105
|
if (!claims.ok) {
|
|
85
|
-
failClosed(claims.error);
|
|
106
|
+
failClosed(claims.error, { keepDeferred: true });
|
|
86
107
|
return false;
|
|
87
108
|
}
|
|
109
|
+
const cache = deferredCache;
|
|
110
|
+
const cachedIds = new Set(cache ? cache.deferred.map((task) => task.id) : []);
|
|
111
|
+
const contradicted =
|
|
112
|
+
cache !== null &&
|
|
113
|
+
claims.claims.some(
|
|
114
|
+
(claim) =>
|
|
115
|
+
cachedIds.has(claim.id) && claim.claimedAt > cache.at && claimIsLive(claim, readAt),
|
|
116
|
+
);
|
|
117
|
+
const age = cache ? readAt - cache.at : Number.NaN;
|
|
118
|
+
const fresh =
|
|
119
|
+
cache && !contradicted && age >= 0 && age < AK_DEFERRED_REFRESH_MS ? cache : null;
|
|
120
|
+
phase = "deferred";
|
|
121
|
+
const deferred = fresh
|
|
122
|
+
? { ok: /** @type {const} */ (true), deferred: fresh.deferred }
|
|
123
|
+
: parseAkDeferredList(await readCli(["task", "deferred", "--format", "json", "--all"]));
|
|
88
124
|
if (!deferred.ok) {
|
|
89
125
|
failClosed(deferred.error);
|
|
90
126
|
return false;
|
|
91
127
|
}
|
|
128
|
+
deferredCache = fresh ?? { at: readAt, deferred: deferred.deferred };
|
|
129
|
+
runtimeStatus.akTaskDeferredReadAt = deferredCache.at;
|
|
130
|
+
runtimeStatus.akTaskDeferredMaxAgeMs = AK_DEFERRED_REFRESH_MS;
|
|
92
131
|
runtimeStatus.akTaskState = "ok";
|
|
93
132
|
runtimeStatus.akTaskError = null;
|
|
94
133
|
runtimeStatus.akTaskClaimCount = claims.claims.filter((claim) =>
|
|
@@ -98,7 +137,9 @@ export function createAkTaskRuntime({
|
|
|
98
137
|
onChange({ claims: claims.claims, deferred: deferred.deferred });
|
|
99
138
|
return true;
|
|
100
139
|
} catch (error) {
|
|
101
|
-
failClosed(`ak CLI unavailable: ${error instanceof Error ? error.message : String(error)}
|
|
140
|
+
failClosed(`ak CLI unavailable: ${error instanceof Error ? error.message : String(error)}`, {
|
|
141
|
+
keepDeferred: phase === "claims",
|
|
142
|
+
});
|
|
102
143
|
return false;
|
|
103
144
|
} finally {
|
|
104
145
|
inFlight = false;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** @typedef {import("../common/workspace-view.mjs").FocusKind} FocusKind */
|
|
2
|
+
/** Controller-authorized hover/explicit ownership across transient Niri NULL focus. */
|
|
3
|
+
export function createFocusVisibility() {
|
|
4
|
+
/** @type {FocusKind} */
|
|
5
|
+
let focus = "unknown";
|
|
6
|
+
/** @type {unknown} */
|
|
7
|
+
let workspaceId = null;
|
|
8
|
+
/** @type {number | null} */
|
|
9
|
+
let keyboardRequest = null;
|
|
10
|
+
let eligible = false;
|
|
11
|
+
/** @type {"none" | "hover" | "explicit"} */
|
|
12
|
+
let mode = "none";
|
|
13
|
+
/** @type {number | null} */
|
|
14
|
+
let hoverToken = null;
|
|
15
|
+
/** A window the strip may stay present over and take an interaction source from. */
|
|
16
|
+
const isSource = (/** @type {FocusKind} */ kind) => kind === "agent" || kind === "neutral";
|
|
17
|
+
function reset() {
|
|
18
|
+
keyboardRequest = null;
|
|
19
|
+
mode = "none";
|
|
20
|
+
hoverToken = null;
|
|
21
|
+
if (!isSource(focus)) eligible = false;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
/** @param {{workspaceId: unknown; focus: FocusKind; hasCards: boolean}} next */
|
|
25
|
+
observe(next) {
|
|
26
|
+
if (
|
|
27
|
+
next.workspaceId !== workspaceId ||
|
|
28
|
+
!next.hasCards ||
|
|
29
|
+
!(isSource(next.focus) || next.focus === "none")
|
|
30
|
+
)
|
|
31
|
+
reset();
|
|
32
|
+
workspaceId = next.workspaceId;
|
|
33
|
+
focus = next.focus;
|
|
34
|
+
eligible =
|
|
35
|
+
next.hasCards && (isSource(focus) || (focus === "none" && keyboardRequest !== null));
|
|
36
|
+
return eligible;
|
|
37
|
+
},
|
|
38
|
+
/** Reserve BEFORE the panel write: NULL may precede the acknowledgement.
|
|
39
|
+
* @param {number} request @param {boolean} clickThrough
|
|
40
|
+
* @param {"hover" | "explicit"} [kind] @param {number | null} [token]
|
|
41
|
+
*/
|
|
42
|
+
requestKeyboard(request, clickThrough, kind = "explicit", token = null) {
|
|
43
|
+
if (!eligible || clickThrough || (kind === "hover" && mode !== "none")) return false;
|
|
44
|
+
keyboardRequest = request;
|
|
45
|
+
mode = kind;
|
|
46
|
+
hoverToken = kind === "hover" ? token : null;
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
/** Late acknowledgements cannot revive invalidated ownership. @param {number} request @param {boolean} active */
|
|
50
|
+
keyboardChanged(request, active) {
|
|
51
|
+
if (request !== keyboardRequest) return false;
|
|
52
|
+
if (!active) reset();
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
resetKeyboard: reset,
|
|
56
|
+
/** Never cancel a newer hover or explicit upgrade. @param {number} token */
|
|
57
|
+
cancelHover(token) {
|
|
58
|
+
if (mode !== "hover" || token !== hoverToken) return false;
|
|
59
|
+
reset();
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
keyboardMode: () => mode,
|
|
63
|
+
/** @param {number} request */
|
|
64
|
+
ownsRequest: (request) => request === keyboardRequest,
|
|
65
|
+
isEligible: () => eligible,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
Usage: ghostty-tab-inventory.py <pid> [<pid> ...]
|
|
10
10
|
|
|
11
11
|
Output: {"ok": true, "frames": [{"pid": int, "name": str, "tabs": [str, ...]}, ...]}
|
|
12
|
-
|
|
12
|
+
Failures emit {"ok": false, "stage": fixed_label, "reason": fixed_label}.
|
|
13
|
+
Exit 3 only when the AT-SPI Python bindings are unavailable; enumeration failures exit 1.
|
|
13
14
|
The script only reads accessibility state; it never activates, selects, or focuses anything.
|
|
14
15
|
"""
|
|
15
16
|
|
|
@@ -20,15 +21,19 @@ MAX_NODES_PER_FRAME = 20000
|
|
|
20
21
|
MAX_DEPTH = 40
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
def fail(stage, reason, code=1):
|
|
25
|
+
print(json.dumps({"ok": False, "stage": stage, "reason": reason}))
|
|
26
|
+
return code
|
|
27
|
+
|
|
28
|
+
|
|
23
29
|
def main() -> int:
|
|
24
30
|
try:
|
|
25
31
|
import gi
|
|
26
32
|
|
|
27
33
|
gi.require_version("Atspi", "2.0")
|
|
28
34
|
from gi.repository import Atspi
|
|
29
|
-
except Exception
|
|
30
|
-
|
|
31
|
-
return 3
|
|
35
|
+
except Exception: # noqa: BLE001 - any import failure means the capability is absent
|
|
36
|
+
return fail("import", "bindings-unavailable", 3)
|
|
32
37
|
|
|
33
38
|
pids = {int(argument) for argument in sys.argv[1:] if argument.isdigit()}
|
|
34
39
|
if not pids:
|
|
@@ -68,29 +73,41 @@ def main() -> int:
|
|
|
68
73
|
return labels
|
|
69
74
|
|
|
70
75
|
frames = []
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
for window_index in range(application.get_child_count()):
|
|
83
|
-
frame = application.get_child_at_index(window_index)
|
|
84
|
-
if frame is None:
|
|
76
|
+
stage = "desktop-lookup"
|
|
77
|
+
try:
|
|
78
|
+
desktop = Atspi.get_desktop(0)
|
|
79
|
+
if desktop is None:
|
|
80
|
+
return fail(stage, "desktop-unavailable")
|
|
81
|
+
stage = "desktop-count"
|
|
82
|
+
application_count = desktop.get_child_count()
|
|
83
|
+
for index in range(application_count):
|
|
84
|
+
stage = "desktop-index"
|
|
85
|
+
application = desktop.get_child_at_index(index)
|
|
86
|
+
if application is None:
|
|
85
87
|
continue
|
|
86
88
|
try:
|
|
87
|
-
|
|
88
|
-
continue
|
|
89
|
-
name = frame.get_name() or ""
|
|
89
|
+
pid = application.get_process_id()
|
|
90
90
|
except Exception: # noqa: BLE001
|
|
91
91
|
continue
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
if pid not in pids:
|
|
93
|
+
continue
|
|
94
|
+
stage = "application-count"
|
|
95
|
+
window_count = application.get_child_count()
|
|
96
|
+
for window_index in range(window_count):
|
|
97
|
+
stage = "application-index"
|
|
98
|
+
frame = application.get_child_at_index(window_index)
|
|
99
|
+
if frame is None:
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
if frame.get_role() != Atspi.Role.FRAME:
|
|
103
|
+
continue
|
|
104
|
+
name = frame.get_name() or ""
|
|
105
|
+
except Exception: # noqa: BLE001
|
|
106
|
+
continue
|
|
107
|
+
frames.append({"pid": pid, "name": name, "tabs": tab_labels(frame)})
|
|
108
|
+
except Exception as error: # noqa: BLE001 - never report a partial top-level inventory
|
|
109
|
+
reason = "access-denied" if isinstance(error, PermissionError) else "enumeration-failed"
|
|
110
|
+
return fail(stage, reason)
|
|
94
111
|
print(json.dumps({"ok": True, "frames": frames}))
|
|
95
112
|
return 0
|
|
96
113
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Bounded panel-request/controller-grant handshake, using the production admission state.
|
|
2
|
+
* No GTK focus signal is an authority source. No deferred command is queued on write failure.
|
|
3
|
+
* @param {{
|
|
4
|
+
* projection: ReturnType<typeof import("./panel-projection.mjs").createNativePanelProjection>;
|
|
5
|
+
* runtimeStatus: {clickThrough?: boolean; windowVisible?: boolean};
|
|
6
|
+
* writePanel: (message: {type: string; token: number; epoch: number; request?: number}) => boolean;
|
|
7
|
+
* }} options
|
|
8
|
+
*/
|
|
9
|
+
export function createHoverKeyboard({ projection, runtimeStatus, writePanel }) {
|
|
10
|
+
// Tokens are unique for the lifetime of one panel. This binding is recreated on panel restart.
|
|
11
|
+
let lastToken = 0;
|
|
12
|
+
return {
|
|
13
|
+
/** @param {number} token @param {number} epoch */
|
|
14
|
+
request(token, epoch) {
|
|
15
|
+
if (!Number.isSafeInteger(token) || token <= lastToken) return false;
|
|
16
|
+
lastToken = token;
|
|
17
|
+
const request =
|
|
18
|
+
runtimeStatus.windowVisible && !runtimeStatus.clickThrough
|
|
19
|
+
? projection.requestHoverKeyboard(epoch, token, false)
|
|
20
|
+
: null;
|
|
21
|
+
if (request !== null) {
|
|
22
|
+
if (writePanel({ type: "hover-grant", token, epoch, request })) return true;
|
|
23
|
+
projection.cancelHoverKeyboard(token);
|
|
24
|
+
} else {
|
|
25
|
+
writePanel({ type: "hover-denied", token, epoch });
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
},
|
|
29
|
+
/** @param {number} token */
|
|
30
|
+
cancel: (token) => projection.cancelHoverKeyboard(token),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Exact-window ownership cardinality shared by every projection proof.
|
|
2
|
+
* @param {Record<string, unknown>[]} sessions @param {number | null} windowId */
|
|
3
|
+
export function visibleCardsForWindow(sessions, windowId) {
|
|
4
|
+
return sessions.filter((card) => card.windowId === windowId && card.surfaceVisible !== false);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Recheck the same exact agent card/publisher set against a complete cached view.
|
|
8
|
+
* @param {Record<string, unknown>[]} sessions
|
|
9
|
+
* @param {{workspace: Record<string, unknown> | null; sessions: Record<string, unknown>[]} | null} current
|
|
10
|
+
* @param {unknown} workspaceId @param {number | null} windowId */
|
|
11
|
+
export function agentSourceMatches(sessions, current, workspaceId, windowId) {
|
|
12
|
+
const established = visibleCardsForWindow(sessions, windowId);
|
|
13
|
+
const owned = visibleCardsForWindow(current?.sessions ?? [], windowId);
|
|
14
|
+
return (
|
|
15
|
+
current?.workspace?.id === workspaceId &&
|
|
16
|
+
established.length === 1 &&
|
|
17
|
+
owned.length === 1 &&
|
|
18
|
+
owned[0]?.cardId === established[0]?.cardId &&
|
|
19
|
+
JSON.stringify(publisherSet(owned[0])) === JSON.stringify(publisherSet(established[0]))
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Layout selection can contradict a retained source but never establish seat focus.
|
|
24
|
+
* Missing fields support older snapshots; an explicit NULL or malformed id fails closed.
|
|
25
|
+
* @param {Record<string, unknown> | null} workspace @param {number | null} sourceId */
|
|
26
|
+
export function selectionContradictsSource(workspace, sourceId) {
|
|
27
|
+
return (
|
|
28
|
+
sourceId !== null &&
|
|
29
|
+
Object.hasOwn(workspace ?? {}, "active_window_id") &&
|
|
30
|
+
workspace?.active_window_id !== sourceId
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Publisher order is presentation, never identity. @param {Record<string, unknown> | undefined} card */
|
|
35
|
+
export function publisherSet(card) {
|
|
36
|
+
return [
|
|
37
|
+
...new Set(
|
|
38
|
+
Array.isArray(card?.publisherRecordKeys) ? card.publisherRecordKeys.map(String) : [],
|
|
39
|
+
),
|
|
40
|
+
].sort();
|
|
41
|
+
}
|
|
42
|
+
/** @param {unknown} workspaceId @param {number | null} windowId @param {Record<string, unknown> | undefined} card */
|
|
43
|
+
export function sourceIdentity(workspaceId, windowId, card) {
|
|
44
|
+
return JSON.stringify([workspaceId, windowId, card?.cardId, publisherSet(card)]);
|
|
45
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admit against the current compositor projection/event facts, not panel activity or card count.
|
|
3
|
+
* Reserve before writing: Niri's NULL focus event may arrive before the panel acknowledgement.
|
|
4
|
+
* No asynchronous queue may take the keyboard after this command has reported failure.
|
|
5
|
+
* @param {{
|
|
6
|
+
* projection: ReturnType<typeof import("./panel-projection.mjs").createNativePanelProjection>;
|
|
7
|
+
* runtimeStatus: {clickThrough?: boolean; windowVisible?: boolean};
|
|
8
|
+
* writePanel: (message: {type: string; revision: number; epoch: number}) => boolean;
|
|
9
|
+
* }} options
|
|
10
|
+
*/
|
|
11
|
+
export function createKeyboardEntry({ projection, runtimeStatus, writePanel }) {
|
|
12
|
+
return function focusStrip() {
|
|
13
|
+
if (runtimeStatus.clickThrough) {
|
|
14
|
+
return { ok: false, error: "Keyboard entry is disabled in click-through mode." };
|
|
15
|
+
}
|
|
16
|
+
if (!runtimeStatus.windowVisible || !projection.isEligible()) {
|
|
17
|
+
return { ok: false, error: "No eligible visible native strip exists on this workspace." };
|
|
18
|
+
}
|
|
19
|
+
const request = projection.requestKeyboard();
|
|
20
|
+
if (
|
|
21
|
+
request !== null &&
|
|
22
|
+
writePanel({
|
|
23
|
+
type: "focus-strip",
|
|
24
|
+
revision: request,
|
|
25
|
+
epoch: projection.getInteractionEpoch(),
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
return { ok: true };
|
|
29
|
+
projection.cancelKeyboard();
|
|
30
|
+
return { ok: false, error: "Native panel is not ready for keyboard entry." };
|
|
31
|
+
};
|
|
32
|
+
}
|