@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
|
@@ -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
|
+
}
|
package/src/native/main.mjs
CHANGED
|
@@ -17,14 +17,26 @@ import { resolveFocusedWorkspaceView, resolveWindowDirectory } from "../common/w
|
|
|
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,48 +174,27 @@ async function focusSession(targetId) {
|
|
|
158
174
|
return result;
|
|
159
175
|
}
|
|
160
176
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return { ok: false, error: "Not a window number; nothing moved." };
|
|
171
|
-
}
|
|
172
|
-
let windows;
|
|
173
|
-
try {
|
|
174
|
-
windows = await getNiriWindows();
|
|
175
|
-
} catch {
|
|
176
|
-
return { ok: false, error: "Could not read Niri windows; nothing moved." };
|
|
177
|
-
}
|
|
178
|
-
if (!windows.some((window) => window?.id === windowId)) {
|
|
179
|
-
return { ok: false, error: `No window #${windowId}; nothing moved.` };
|
|
180
|
-
}
|
|
181
|
-
try {
|
|
182
|
-
await execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(windowId)], {
|
|
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)], {
|
|
183
186
|
env: process.env,
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
reconcileRunner.request();
|
|
189
|
-
return { ok: true };
|
|
190
|
-
}
|
|
187
|
+
}),
|
|
188
|
+
reconcile: () => reconcileRunner.request(),
|
|
189
|
+
});
|
|
191
190
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return writePanel({ type: "focus-strip" })
|
|
200
|
-
? { ok: true }
|
|
201
|
-
: { ok: false, error: "Native panel is not ready for keyboard entry." };
|
|
202
|
-
}
|
|
191
|
+
const requestKeyboardEntry = createKeyboardEntry({
|
|
192
|
+
projection,
|
|
193
|
+
runtimeStatus,
|
|
194
|
+
writePanel,
|
|
195
|
+
});
|
|
196
|
+
const focusStrip = async () => requestKeyboardEntry();
|
|
197
|
+
let hoverKeyboard = createHoverKeyboard({ projection, runtimeStatus, writePanel });
|
|
203
198
|
|
|
204
199
|
/** @param {PanelChild} child @param {unknown} chunk */
|
|
205
200
|
function consumePanelEvents(child, chunk) {
|
|
@@ -212,13 +207,14 @@ function consumePanelEvents(child, chunk) {
|
|
|
212
207
|
if (line) {
|
|
213
208
|
try {
|
|
214
209
|
const event = JSON.parse(line);
|
|
215
|
-
if (event
|
|
210
|
+
if (!admitPanelEvent(child, event, runtimeStatus, () => projection.cancelKeyboard()))
|
|
211
|
+
return;
|
|
216
212
|
if (event.type === "ready") {
|
|
217
213
|
clearTimeout(child._readyTimer);
|
|
218
214
|
panelReady = true;
|
|
219
215
|
runtimeStatus.state = "ready";
|
|
220
216
|
runtimeStatus.readyAt = Date.now();
|
|
221
|
-
runtimeStatus.panelPid =
|
|
217
|
+
runtimeStatus.panelPid = child.pid ?? null; // Supervised child, never a reported PID.
|
|
222
218
|
runtimeStatus.error = null;
|
|
223
219
|
clearTimeout(child._stableTimer);
|
|
224
220
|
child._stableTimer = setTimeout(() => {
|
|
@@ -240,9 +236,20 @@ function consumePanelEvents(child, chunk) {
|
|
|
240
236
|
const visible = event.visible === true;
|
|
241
237
|
const changed = runtimeStatus.windowVisible !== visible;
|
|
242
238
|
runtimeStatus.windowVisible = visible;
|
|
243
|
-
//
|
|
244
|
-
// 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.
|
|
245
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();
|
|
246
253
|
} else if (event.type === "expanded") {
|
|
247
254
|
runtimeStatus.panelExpanded = event.expanded === true;
|
|
248
255
|
} else if (event.type === "activate") {
|
|
@@ -265,7 +272,7 @@ function consumePanelEvents(child, chunk) {
|
|
|
265
272
|
} else if (event.type === "jump") {
|
|
266
273
|
runtimeStatus.panelJumpCount = (runtimeStatus.panelJumpCount ?? 0) + 1;
|
|
267
274
|
const windowId = Number(event.windowId);
|
|
268
|
-
void
|
|
275
|
+
void requestWindowJump(event).then((result) =>
|
|
269
276
|
writePanel({
|
|
270
277
|
type: "jump-result",
|
|
271
278
|
windowId,
|
|
@@ -297,10 +304,12 @@ function startPanel() {
|
|
|
297
304
|
stdio: ["pipe", "pipe", "pipe"],
|
|
298
305
|
});
|
|
299
306
|
panel = child;
|
|
307
|
+
hoverKeyboard = createHoverKeyboard({ projection, runtimeStatus, writePanel });
|
|
300
308
|
panelReady = false;
|
|
301
309
|
panelWriteReady = true;
|
|
302
310
|
panelStderr = "";
|
|
303
311
|
runtimeStatus.panelPid = child.pid ?? null;
|
|
312
|
+
runtimeStatus.nativeKeyboardObservation = null; // New supervised child, new native clock domain.
|
|
304
313
|
runtimeStatus.state = "starting";
|
|
305
314
|
child.stdout.setEncoding("utf8");
|
|
306
315
|
child.stdin.on("error", () => {
|
|
@@ -323,9 +332,15 @@ function startPanel() {
|
|
|
323
332
|
clearTimeout(child._stableTimer);
|
|
324
333
|
if (panel !== child) return;
|
|
325
334
|
panelReady = false;
|
|
335
|
+
projection.cancelKeyboard();
|
|
326
336
|
runtimeStatus.windowVisible = false;
|
|
327
337
|
runtimeStatus.panelPid = null;
|
|
328
338
|
if (shuttingDown) return;
|
|
339
|
+
if (child._protocolError) {
|
|
340
|
+
runtimeStatus.state = "error";
|
|
341
|
+
runtimeStatus.error = child._protocolError;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
329
344
|
if (panelRestartCount < 3) {
|
|
330
345
|
panelRestartCount += 1;
|
|
331
346
|
runtimeStatus.panelRestartCount = panelRestartCount;
|
|
@@ -379,7 +394,7 @@ const theme = createThemeRuntime({
|
|
|
379
394
|
execFileAsync,
|
|
380
395
|
env: process.env,
|
|
381
396
|
runtimeStatus,
|
|
382
|
-
publish: (definitions) => writePanel({ protocol:
|
|
397
|
+
publish: (definitions) => writePanel({ protocol: PANEL_PROTOCOL, type: "theme", definitions }),
|
|
383
398
|
});
|
|
384
399
|
|
|
385
400
|
const heightRepair = createHeightRepair({
|
|
@@ -403,7 +418,7 @@ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
|
|
|
403
418
|
const sessions = projection.getRawSessions();
|
|
404
419
|
runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
|
|
405
420
|
const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
|
|
406
|
-
|
|
421
|
+
placement.learnFromPlacements(view?.sessions ?? [], windows, sessions);
|
|
407
422
|
projection.setDirectory(resolveWindowDirectory(windows, workspaces, sessions, placement.options));
|
|
408
423
|
projection.publishWorkspaceView(
|
|
409
424
|
view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
|
|
@@ -417,7 +432,7 @@ async function shutdown(exitCode = 0) {
|
|
|
417
432
|
watcher?.stop();
|
|
418
433
|
placement.persist();
|
|
419
434
|
writePanel({
|
|
420
|
-
protocol:
|
|
435
|
+
protocol: PANEL_PROTOCOL,
|
|
421
436
|
type: "view",
|
|
422
437
|
revision: Number.MAX_SAFE_INTEGER,
|
|
423
438
|
visible: false,
|
|
@@ -437,14 +452,10 @@ async function main() {
|
|
|
437
452
|
broker = await createActivityStripBroker({
|
|
438
453
|
focusSession,
|
|
439
454
|
focusStrip,
|
|
440
|
-
getRuntimeStatus: (
|
|
441
|
-
...runtimeStatus,
|
|
442
|
-
warnings: [...(runtimeStatus.warnings ?? [])],
|
|
443
|
-
}),
|
|
455
|
+
getRuntimeStatus: createRuntimeStatusReader({ runtimeStatus, projection }),
|
|
444
456
|
});
|
|
445
457
|
broker.on("snapshot", (snapshot) => {
|
|
446
|
-
// Compare
|
|
447
|
-
// 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.
|
|
448
459
|
const membershipChanged = !haveSameRecordMembership(
|
|
449
460
|
projection.getBrokerSessions(),
|
|
450
461
|
snapshot.sessions,
|
|
@@ -460,16 +471,11 @@ async function main() {
|
|
|
460
471
|
watcher = createNiriWorkspaceEventWatcher({
|
|
461
472
|
spawn,
|
|
462
473
|
env: process.env,
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
},
|
|
468
|
-
onWindowClosed: (windowId) => {
|
|
469
|
-
if (placement.forgetWindow(windowId)) reconcileRunner.request();
|
|
470
|
-
},
|
|
471
|
-
onWindowFocusChanged: () => reconcileRunner.request(),
|
|
472
|
-
onWorkspacesChanged: () => reconcileRunner.request(),
|
|
474
|
+
...createWorkspaceProjectionHandlers({
|
|
475
|
+
projection,
|
|
476
|
+
placement,
|
|
477
|
+
reconcile: () => reconcileRunner.request(),
|
|
478
|
+
}),
|
|
473
479
|
fallbackMs: ACTIVITY_STRIP_WORKSPACE_SYNC_MS,
|
|
474
480
|
});
|
|
475
481
|
}
|