@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
|
@@ -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
|
+
}
|