@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.
Files changed (57) hide show
  1. package/README.md +59 -22
  2. package/bin/pi-activity-strip.mjs +32 -4
  3. package/native/bin/linux-x64-gnu/artifact.json +3 -2
  4. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  5. package/native/panel/src/app.rs +572 -266
  6. package/native/panel/src/app_gui_tests.rs +730 -0
  7. package/native/panel/src/app_hover_gui_tests.rs +917 -0
  8. package/native/panel/src/card_view.rs +19 -4
  9. package/native/panel/src/directory_view.rs +79 -17
  10. package/native/panel/src/hints.rs +65 -0
  11. package/native/panel/src/hover-lifecycle-fixtures.json +30 -0
  12. package/native/panel/src/hover_keyboard.rs +237 -0
  13. package/native/panel/src/interaction.rs +156 -0
  14. package/native/panel/src/keyboard-observation-fixtures.json +241 -0
  15. package/native/panel/src/keyboard_observation.rs +70 -0
  16. package/native/panel/src/main.rs +4 -0
  17. package/native/panel/src/protocol.rs +22 -8
  18. package/native/panel/src/runtime.rs +43 -2
  19. package/package.json +5 -5
  20. package/src/broker/server.mjs +31 -14
  21. package/src/client/launcher.mjs +3 -7
  22. package/src/client/runtime-lock.mjs +2 -1
  23. package/src/common/agent-identity.mjs +6 -1
  24. package/src/common/contracts.d.ts +1 -0
  25. package/src/common/contracts.ts +38 -0
  26. package/src/common/keyboard-observation.mjs +35 -0
  27. package/src/common/overlay-readiness.mjs +11 -0
  28. package/src/common/presence-apps.mjs +57 -0
  29. package/src/common/recovery-identity.mjs +85 -0
  30. package/src/common/recovery-manifest.mjs +417 -0
  31. package/src/common/recovery-types.ts +75 -0
  32. package/src/common/ribbon-status.mjs +41 -0
  33. package/src/common/status-report.mjs +37 -2
  34. package/src/common/workspace-view.mjs +54 -0
  35. package/src/native/ak-runtime.mjs +50 -9
  36. package/src/native/focus-visibility.mjs +67 -0
  37. package/src/native/ghostty-tab-inventory.py +40 -23
  38. package/src/native/hover-keyboard.mjs +32 -0
  39. package/src/native/interaction-context.mjs +45 -0
  40. package/src/native/keyboard-entry.mjs +32 -0
  41. package/src/native/main.mjs +77 -71
  42. package/src/native/panel-binary.mjs +25 -8
  43. package/src/native/panel-neutral-source.mjs +53 -0
  44. package/src/native/panel-presentation.mjs +137 -0
  45. package/src/native/panel-projection.mjs +338 -24
  46. package/src/native/panel-protocol.mjs +23 -0
  47. package/src/native/panel-window-jump.mjs +90 -0
  48. package/src/native/placement.mjs +83 -20
  49. package/src/native/recovery-inventory.mjs +209 -0
  50. package/src/native/recovery-proc.mjs +178 -0
  51. package/src/native/recovery-sources.mjs +358 -0
  52. package/src/native/runtime-diagnostics.mjs +242 -0
  53. package/src/native/strip-hotkey-kdl.mjs +252 -0
  54. package/src/native/strip-hotkey.mjs +62 -0
  55. package/src/native/tab-inventory.mjs +62 -11
  56. package/src/native/window-jump.mjs +43 -0
  57. package/src/native/workspace-events.mjs +123 -2
@@ -0,0 +1,252 @@
1
+ // ---
2
+ // summary: "reads active Niri binding/action nodes with the KDL v1 grammar niri itself parses"
3
+ // read_when:
4
+ // - "changing the conservative focus-strip hotkey discovery grammar"
5
+ // ---
6
+
7
+ // KDL v1 (what niri 26.04 parses): newlines terminate nodes; these spaces only separate entries.
8
+ const NEWLINE = /^(?:\r\n|[\n\r\f\u{85}\u{2028}\u{2029}])/u;
9
+ const SPACE = /^[\t \u{A0}\u{1680}\u{2000}-\u{200A}\u{202F}\u{205F}\u{3000}\u{FEFF}]+/u;
10
+ // A bare word: no control, space or newline character and none of KDL's `\/(){}<>;[]=,"`.
11
+ const WORD =
12
+ /^[^\0-\x20\x7F-\x9F\u{A0}\u{1680}\u{2000}-\u{200A}\u{202F}\u{205F}\u{3000}\u{FEFF}\u{2028}\u{2029}\\/(){}<>;[\]=,"]+/u;
13
+ const NUMBER =
14
+ /^[+-]?(?:[0-9][0-9_]*(?:\.[0-9][0-9_]*)?(?:[eE][+-]?[0-9][0-9_]*)?|0x[0-9a-fA-F][0-9a-fA-F_]*|0o[0-7][0-7_]*|0b[01][01_]*)$/;
15
+ const KEYWORDS = new Set(["true", "false", "null"]);
16
+ /** @type {Record<string, string>} */
17
+ const ESCAPES = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
18
+
19
+ /** @typedef {{kind: string; value: string; spaced: boolean}} Token */
20
+
21
+ /** @param {string} text @param {number} i @returns {number} index just past the comment */
22
+ function skipBlockComment(text, i) {
23
+ let depth = 0;
24
+ do {
25
+ if (text.startsWith("/*", i)) {
26
+ depth++;
27
+ i += 2;
28
+ } else if (text.startsWith("*/", i)) {
29
+ depth--;
30
+ i += 2;
31
+ } else if (i < text.length) i++;
32
+ else throw new Error("unterminated comment");
33
+ } while (depth);
34
+ return i;
35
+ }
36
+
37
+ /** @param {string} text @param {number} i @returns {number} index of the line's newline or end */
38
+ function skipLineComment(text, i) {
39
+ while (i < text.length && !NEWLINE.test(text.slice(i, i + 1))) i++;
40
+ return i;
41
+ }
42
+
43
+ /** `\` ws* (single-line comment | newline): joins a node's entries across lines.
44
+ * @param {string} text @param {number} i @returns {number} */
45
+ function skipEscline(text, i) {
46
+ i++;
47
+ for (;;) {
48
+ const space = text.slice(i).match(SPACE);
49
+ if (space) i += space[0].length;
50
+ else if (text.startsWith("/*", i)) i = skipBlockComment(text, i);
51
+ else break;
52
+ }
53
+ if (text.startsWith("//", i)) i = skipLineComment(text, i);
54
+ else if (!NEWLINE.test(text.slice(i))) throw new Error("unsupported continuation");
55
+ return i + (text.slice(i).match(NEWLINE)?.[0].length ?? 0);
56
+ }
57
+
58
+ /** An escaped string with exactly KDL v1's escapes; literal newlines and tabs are content.
59
+ * @param {string} text @param {number} i @returns {[string, number]} */
60
+ function readString(text, i) {
61
+ let value = "";
62
+ for (i++; ; ) {
63
+ const c = text[i];
64
+ if (c === undefined) throw new Error("unterminated string");
65
+ if (c === '"') return [value, i + 1];
66
+ if (c !== "\\") {
67
+ value += c;
68
+ i++;
69
+ continue;
70
+ }
71
+ const unicode = text.slice(i).match(/^\\u\{([0-9a-fA-F]{1,6})\}/);
72
+ const code = unicode ? Number.parseInt(unicode[1], 16) : -1;
73
+ if (unicode && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff)) {
74
+ value += String.fromCodePoint(code);
75
+ i += unicode[0].length;
76
+ } else if (Object.hasOwn(ESCAPES, text[i + 1])) {
77
+ value += ESCAPES[text[i + 1]];
78
+ i += 2;
79
+ } else throw new Error("invalid string escape");
80
+ }
81
+ }
82
+
83
+ /** Unsupported or malformed input yields no hint, never a guessed shortcut; nothing is evaluated.
84
+ * @param {string} text @returns {Token[]} */
85
+ function tokenize(text) {
86
+ if (text.length > 512 * 1024) throw new Error("config too large");
87
+ /** @type {Token[]} */
88
+ const tokens = [];
89
+ let spaced = false;
90
+ /** @param {string} kind @param {string} [value] */
91
+ const push = (kind, value = kind) => {
92
+ tokens.push({ kind, value, spaced });
93
+ spaced = kind === "escline";
94
+ };
95
+ let i = 0;
96
+ while (i < text.length) {
97
+ const rest = text.slice(i);
98
+ const space = rest.match(SPACE);
99
+ const newline = rest.match(NEWLINE);
100
+ const raw = rest.match(/^r(#*)"/);
101
+ const word = rest.match(WORD);
102
+ if (space) {
103
+ i += space[0].length;
104
+ spaced = true;
105
+ } else if (newline) {
106
+ i += newline[0].length;
107
+ push("newline");
108
+ } else if (rest.startsWith("/*")) {
109
+ i = skipBlockComment(text, i);
110
+ spaced = true;
111
+ } else if (rest.startsWith("//")) {
112
+ i = skipLineComment(text, i);
113
+ } else if (rest.startsWith("/-")) {
114
+ i += 2;
115
+ push("slashdash");
116
+ } else if (rest[0] === "\\") {
117
+ i = skipEscline(text, i);
118
+ push("escline");
119
+ } else if ("{};=".includes(rest[0])) {
120
+ push(rest[0]);
121
+ i++;
122
+ } else if (raw) {
123
+ const close = `"${raw[1]}`;
124
+ const end = text.indexOf(close, i + raw[0].length);
125
+ if (end < 0) throw new Error("unterminated raw string");
126
+ push("raw", text.slice(i + raw[0].length, end));
127
+ i = end + close.length;
128
+ } else if (rest[0] === '"') {
129
+ const [value, end] = readString(text, i);
130
+ push("string", value);
131
+ i = end;
132
+ } else if (word) {
133
+ // niri takes `!` only as a word's first character (after an optional sign). It joins a
134
+ // byte-order mark to a preceding identifier; after a number or keyword the mark would be
135
+ // whitespace, so refusing it there too only costs a hint.
136
+ if (!/^[+-]?!?[^!]*$/.test(word[0]) || text[i + word[0].length] === "\u{FEFF}")
137
+ throw new Error("unsupported KDL word");
138
+ push("word", word[0]);
139
+ i += word[0].length;
140
+ } else throw new Error("unsupported KDL character");
141
+ }
142
+ push("eof");
143
+ return tokens;
144
+ }
145
+
146
+ /** @typedef {{name: string; args: (string | null)[]; children: KdlNode[]}} KdlNode */
147
+ /** Active nodes of a config niri's KDL v1 parser accepts; any grammar error yields none.
148
+ * Arguments keep string values; numbers and keywords become null and never match a command.
149
+ * @param {string} text @returns {KdlNode[]} */
150
+ export function activeKdlNodes(text) {
151
+ try {
152
+ const tokens = tokenize(text);
153
+ let i = 0;
154
+ const peek = () => tokens[i];
155
+ /** Node-space may hold line continuations; a node's own entries may span lines with them. */
156
+ const skipEsclines = () => {
157
+ while (peek().kind === "escline") i++;
158
+ };
159
+ /** niri takes raw strings as property keys but never as node names. */
160
+ const identifier = (rawAllowed = false) => {
161
+ const token = tokens[i++];
162
+ if (token.kind === "string" || (rawAllowed && token.kind === "raw")) return token.value;
163
+ const bare =
164
+ token.kind === "word" && !KEYWORDS.has(token.value) && !/^[+-]?[0-9]/.test(token.value);
165
+ if (!bare) throw new Error("expected KDL identifier");
166
+ return token.value;
167
+ };
168
+ /** Bare identifiers are not values in KDL v1. */
169
+ const value = () => {
170
+ const token = tokens[i++];
171
+ if (token.kind === "string" || token.kind === "raw") return token.value;
172
+ if (token.kind === "word" && (KEYWORDS.has(token.value) || NUMBER.test(token.value)))
173
+ return null;
174
+ throw new Error("expected KDL value");
175
+ };
176
+ /** After a node only node-space, then `;`, a newline or the end; `}` is not a terminator. */
177
+ const terminate = () => {
178
+ skipEsclines();
179
+ const end = peek().kind;
180
+ if (end === "newline" || end === ";") i++;
181
+ else if (end !== "eof") throw new Error("missing node terminator");
182
+ };
183
+ /** @param {number} depth @returns {KdlNode | null} */
184
+ const node = (depth) => {
185
+ const disabled = peek().kind === "slashdash";
186
+ if (disabled) {
187
+ i++;
188
+ skipEsclines();
189
+ }
190
+ /** @type {KdlNode} */
191
+ const result = { name: identifier(), args: [], children: [] };
192
+ for (;;) {
193
+ skipEsclines();
194
+ const token = peek();
195
+ if (["newline", ";", "eof", "}"].includes(token.kind)) break;
196
+ const omit = token.kind === "slashdash";
197
+ let ahead = i + (omit ? 1 : 0);
198
+ while (tokens[ahead].kind === "escline") ahead++;
199
+ if (tokens[ahead].kind === "{") {
200
+ i = ahead + 1;
201
+ const children = nodes(true, depth + 1);
202
+ if (!omit) result.children = children;
203
+ break;
204
+ }
205
+ if (!token.spaced) throw new Error("missing entry separator");
206
+ if (omit) {
207
+ i++;
208
+ skipEsclines();
209
+ }
210
+ if (tokens[i + 1].kind === "=") {
211
+ identifier(true);
212
+ if (peek().spaced || tokens[i + 1].spaced) throw new Error("spaced property");
213
+ i++;
214
+ value();
215
+ } else {
216
+ const argument = value();
217
+ if (!omit) result.args.push(argument);
218
+ }
219
+ }
220
+ terminate();
221
+ return disabled ? null : result;
222
+ };
223
+ /** @param {boolean} nested @param {number} depth @returns {KdlNode[]} */
224
+ const nodes = (nested, depth) => {
225
+ if (depth > 64) throw new Error("config too deep");
226
+ /** @type {KdlNode[]} */
227
+ const result = [];
228
+ for (;;) {
229
+ const { kind } = peek();
230
+ if (kind === "newline") i++;
231
+ else if (kind === "eof" || kind === "}") {
232
+ if (nested !== (kind === "}")) throw new Error("unbalanced braces");
233
+ i++;
234
+ return result;
235
+ } else {
236
+ const parsed = node(depth);
237
+ if (parsed) result.push(parsed);
238
+ }
239
+ }
240
+ };
241
+ return nodes(false, 0);
242
+ } catch {
243
+ return [];
244
+ }
245
+ }
246
+
247
+ /** @param {KdlNode} action */
248
+ export function isFocusStripAction(action) {
249
+ if (action.name !== "spawn") return false;
250
+ const args = /(?:^|\/)node$/.test(action.args[0] ?? "") ? action.args.slice(1) : action.args;
251
+ return /(?:^|\/)pi-activity-strip(?:\.mjs)?$/.test(args[0] ?? "") && args[1] === "focus-strip";
252
+ }
@@ -0,0 +1,62 @@
1
+ // ---
2
+ // summary: "finds the compositor shortcut that enters the strip's keyboard mode, for the panel's hints"
3
+ // read_when:
4
+ // - "changing how the strip learns or shows its keyboard-entry shortcut"
5
+ // ---
6
+
7
+ import { readFileSync as nodeReadFileSync } from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { activeKdlNodes, isFocusStripAction } from "./strip-hotkey-kdl.mjs";
11
+
12
+ /**
13
+ * The bind whose action runs this package's `focus-strip` command. Niri binds are KDL nodes named
14
+ * by their chord, `Mod+Shift+A repeat=false { spawn "node" ".../pi-activity-strip.mjs" "focus-strip"; }`,
15
+ * so discover it from active direct children of `binds` with an actual matching spawn action.
16
+ * Comments, disabled subtrees and quoted command lookalikes never count.
17
+ * @param {string} text
18
+ * @returns {string | null}
19
+ */
20
+ export function findFocusStripChord(text) {
21
+ for (const group of activeKdlNodes(String(text ?? ""))) {
22
+ if (group.name !== "binds") continue;
23
+ for (const bind of group.children) {
24
+ if (bind.children.some(isFocusStripAction)) return bind.name;
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /** Niri's `Mod` is Super in a normal session; spell the chord the way a keyboard does.
31
+ * @param {string} chord */
32
+ export function humanizeChord(chord) {
33
+ return chord
34
+ .split("+")
35
+ .map((part) => (part === "Mod" ? "Super" : part === "Mod5" ? "AltGr" : part))
36
+ .join("+");
37
+ }
38
+
39
+ /**
40
+ * `PI_ACTIVITY_STRIP_HOTKEY` names the shortcut directly (`none` hides it); otherwise the Niri
41
+ * config is read once at startup. A missing or unreadable config shows no shortcut rather than a
42
+ * guessed one: the hint must be the key the operator's compositor actually binds.
43
+ * @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof nodeReadFileSync; homeDir?: string}} [options]
44
+ * @returns {string | null}
45
+ */
46
+ export function resolveStripHotkey({
47
+ env = process.env,
48
+ readFileSync = nodeReadFileSync,
49
+ homeDir = os.homedir(),
50
+ } = {}) {
51
+ const override = String(env.PI_ACTIVITY_STRIP_HOTKEY ?? "").trim();
52
+ if (override) return override.toLowerCase() === "none" ? null : override;
53
+ const configHome = String(env.XDG_CONFIG_HOME ?? "").trim() || path.join(homeDir, ".config");
54
+ const configPath =
55
+ String(env.NIRI_CONFIG ?? "").trim() || path.join(configHome, "niri", "config.kdl");
56
+ try {
57
+ const chord = findFocusStripChord(readFileSync(configPath, "utf8"));
58
+ return chord ? humanizeChord(chord) : null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
@@ -4,6 +4,7 @@
4
4
  // - "changing hidden-tab discovery, its cadence, or its capability gating"
5
5
  // ---
6
6
 
7
+ import os from "node:os";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { bindingKeysFor, parseTerminalTitleBinding } from "../common/surface-bindings.mjs";
@@ -18,6 +19,22 @@ export const TAB_INVENTORY_TIMEOUT_MS = 4000;
18
19
  export const TAB_INVENTORY_UNAVAILABLE_EXIT_CODE = 3;
19
20
  const ATSPI_IMPORT_PROBE =
20
21
  "import gi; gi.require_version('Atspi', '2.0'); from gi.repository import Atspi";
22
+ // Signal names are a fixed OS vocabulary; anything else is reported as unknown.
23
+ const FAILURE_SIGNALS = new Set(Object.keys(os.constants.signals));
24
+ const FAILURE_STAGES = new Set([
25
+ "import",
26
+ "desktop-lookup",
27
+ "desktop-count",
28
+ "desktop-index",
29
+ "application-count",
30
+ "application-index",
31
+ ]);
32
+ const FAILURE_REASONS = new Set([
33
+ "bindings-unavailable",
34
+ "desktop-unavailable",
35
+ "enumeration-failed",
36
+ "access-denied",
37
+ ]);
21
38
 
22
39
  /** @typedef {{pid: number; name: string; tabs: string[]}} TabInventoryFrame */
23
40
  /** @typedef {{bindingKey: string; windowId: number; windowPid: number; appId: string}} TabBinding */
@@ -113,20 +130,54 @@ export async function probeGhosttyTabInventory({
113
130
  });
114
131
  stdout = String(result.stdout ?? "");
115
132
  } catch (error) {
116
- const failure = /** @type {{code?: unknown; killed?: unknown; stdout?: unknown}} */ (
117
- error && typeof error === "object" ? error : {}
118
- );
133
+ const failure =
134
+ /** @type {{code?: unknown; signal?: unknown; killed?: unknown; stdout?: unknown}} */ (
135
+ error && typeof error === "object" ? error : {}
136
+ );
119
137
  const unavailable =
120
138
  failure.code === TAB_INVENTORY_UNAVAILABLE_EXIT_CODE || failure.code === "ENOENT";
121
- let detail = "the tab inventory helper failed";
139
+ const code = Number.isSafeInteger(failure.code) ? failure.code : "unknown";
140
+ const signal =
141
+ typeof failure.signal === "string" && FAILURE_SIGNALS.has(failure.signal)
142
+ ? failure.signal
143
+ : "unknown";
144
+ // execFile marks only its own deadline kill as `killed`; output limits and aborts reject with
145
+ // a string code instead. The child's exit after that kill (signal or status) is kept as is.
146
+ let reason = "unknown";
147
+ switch (failure.code) {
148
+ case "ENOENT":
149
+ reason = "missing-interpreter";
150
+ break;
151
+ case TAB_INVENTORY_UNAVAILABLE_EXIT_CODE:
152
+ reason = "import-unavailable";
153
+ break;
154
+ case "EACCES":
155
+ case "EPERM":
156
+ reason = "access-denied";
157
+ break;
158
+ case "ERR_CHILD_PROCESS_STDIO_MAXBUFFER":
159
+ reason = "output-limit";
160
+ break;
161
+ default:
162
+ if (failure.killed === true && typeof failure.code !== "string") reason = "timeout";
163
+ else if (signal !== "unknown") reason = "signal";
164
+ else if (code !== "unknown") reason = "exit-status";
165
+ else if (failure.killed === true) reason = "terminated-unknown";
166
+ }
167
+ let stage = "unknown";
168
+ let helperReason = "unknown";
122
169
  try {
123
- const parsed = JSON.parse(String(failure.stdout ?? ""));
124
- if (typeof parsed?.error === "string") detail = parsed.error;
170
+ const parsed = JSON.parse(typeof failure.stdout === "string" ? failure.stdout : "");
171
+ if (FAILURE_STAGES.has(parsed?.stage)) stage = parsed.stage;
172
+ if (FAILURE_REASONS.has(parsed?.reason)) helperReason = parsed.reason;
125
173
  } catch {
126
- if (failure.code === "ENOENT") detail = `${pythonBin} is not installed`;
127
- else if (failure.killed) detail = "the tab inventory helper timed out";
174
+ /* Transport metadata is independent of helper JSON. */
128
175
  }
129
- return { ok: false, unavailable, error: detail };
176
+ return {
177
+ ok: false,
178
+ unavailable,
179
+ error: `the tab inventory helper failed (reason=${reason}, code=${code}, signal=${signal}, stage=${stage}, helperReason=${helperReason})`,
180
+ };
130
181
  }
131
182
  try {
132
183
  const parsed = JSON.parse(stdout);
@@ -181,8 +232,8 @@ export async function detectTabInventorySupport({
181
232
  available: false,
182
233
  detail:
183
234
  code === "ENOENT"
184
- ? `${pythonBin} is not installed`
185
- : `${pythonBin} gi.repository.Atspi (python-gobject + at-spi2-core) is unavailable`,
235
+ ? "the tab inventory interpreter is not installed"
236
+ : "AT-SPI bindings (python-gobject + at-spi2-core) are unavailable",
186
237
  };
187
238
  }
188
239
  }
@@ -0,0 +1,43 @@
1
+ /** Numeric hover jumps remain bounded by the admitted directory, rechecked AFTER the IPC read.
2
+ * Explicit/manual ownership alone may request an existing unlisted window.
3
+ * @param {{
4
+ * isNiriSession: () => boolean;
5
+ * readWindows: () => Promise<Record<string, unknown>[]>;
6
+ * isAdmittedWindow: (id: number, windows: Record<string, unknown>[]) => boolean;
7
+ * focusWindow: (id: number) => Promise<unknown>;
8
+ * reconcile: () => void;
9
+ * }} options
10
+ */
11
+ export function createWindowJump({
12
+ isNiriSession,
13
+ readWindows,
14
+ isAdmittedWindow,
15
+ focusWindow,
16
+ reconcile,
17
+ }) {
18
+ /** @param {number} windowId @param {boolean} [allowUnlisted] @param {(windows: Record<string, unknown>[]) => boolean} [isCurrent] */
19
+ return async function jumpToWindow(windowId, allowUnlisted = false, isCurrent = () => true) {
20
+ if (!isNiriSession()) return { ok: false, error: "Niri is not available; nothing moved." };
21
+ if (!Number.isInteger(windowId) || windowId < 0)
22
+ return { ok: false, error: "Not a window number; nothing moved." };
23
+ let windows;
24
+ try {
25
+ windows = await readWindows();
26
+ } catch {
27
+ return { ok: false, error: "Could not read Niri windows; nothing moved." };
28
+ }
29
+ if (!isCurrent(windows))
30
+ return { ok: false, error: "Keyboard context was revoked; nothing moved." };
31
+ if (!allowUnlisted && !isAdmittedWindow(windowId, windows))
32
+ return { ok: false, error: "Window is not in the admitted agent directory; nothing moved." };
33
+ if (!windows.some((window) => window?.id === windowId))
34
+ return { ok: false, error: `No window #${windowId}; nothing moved.` };
35
+ try {
36
+ await focusWindow(windowId);
37
+ } catch {
38
+ return { ok: false, error: `Niri refused to focus #${windowId}; nothing moved.` };
39
+ }
40
+ reconcile();
41
+ return { ok: true };
42
+ };
43
+ }
@@ -18,8 +18,11 @@
18
18
  * onFallback: () => void;
19
19
  * onWindowChanged?: (window: Record<string, unknown>) => void;
20
20
  * onWindowClosed?: (windowId: number) => void;
21
+ * onWindowsChanged?: (windows: Record<string, unknown>[]) => void;
21
22
  * onWindowFocusChanged?: (windowId: number | null) => void;
23
+ * onWorkspaceActiveWindowChanged?: (workspaceId: number, windowId: number | null) => void;
22
24
  * onWorkspacesChanged?: () => void;
25
+ * onFocusInvalidated?: () => void;
23
26
  * fallbackMs: number;
24
27
  * setIntervalFn?: typeof setInterval;
25
28
  * clearIntervalFn?: typeof clearInterval;
@@ -40,6 +43,22 @@ export function createNiriWorkspaceEventWatcher(options) {
40
43
  }, options.fallbackMs);
41
44
  fallbackTimer.unref?.();
42
45
 
46
+ /** Selection is a contradiction signal, never a substitute for keyboard focus.
47
+ * @param {unknown} workspaceId @param {unknown} windowId */
48
+ function selection(workspaceId, windowId) {
49
+ if (
50
+ !Number.isSafeInteger(workspaceId) ||
51
+ Number(workspaceId) <= 0 ||
52
+ (windowId !== null && (!Number.isSafeInteger(windowId) || Number(windowId) <= 0))
53
+ )
54
+ options.onFocusInvalidated?.();
55
+ else
56
+ options.onWorkspaceActiveWindowChanged?.(
57
+ Number(workspaceId),
58
+ windowId === null ? null : Number(windowId),
59
+ );
60
+ }
61
+
43
62
  /** @param {unknown} chunk */
44
63
  function consume(chunk) {
45
64
  if (stopped) return;
@@ -61,8 +80,45 @@ export function createNiriWorkspaceEventWatcher(options) {
61
80
  options.onFocusedWorkspace(activation.id);
62
81
  }
63
82
  if (event && typeof event === "object" && "WorkspacesChanged" in event) {
83
+ const workspaces = event.WorkspacesChanged?.workspaces;
84
+ const valid =
85
+ Array.isArray(workspaces) &&
86
+ workspaces.every(
87
+ (workspace) =>
88
+ Number.isInteger(workspace?.id) && typeof workspace?.is_focused === "boolean",
89
+ );
90
+ const focused = valid
91
+ ? workspaces.filter((workspace) => workspace.is_focused === true)
92
+ : [];
93
+ if (focused.length !== 1 || !Number.isInteger(focused[0].id))
94
+ options.onFocusInvalidated?.();
95
+ else if (focused[0].id !== lastFocusedWorkspaceId) {
96
+ lastFocusedWorkspaceId = focused[0].id;
97
+ options.onFocusedWorkspace(focused[0].id);
98
+ }
99
+ if (focused.length === 1 && Object.hasOwn(focused[0], "active_window_id"))
100
+ selection(focused[0].id, focused[0].active_window_id);
64
101
  options.onWorkspacesChanged?.();
65
102
  }
103
+ if (event && typeof event === "object" && "WindowsChanged" in event) {
104
+ const windows = event.WindowsChanged?.windows;
105
+ const valid =
106
+ Array.isArray(windows) &&
107
+ windows.every(
108
+ (window) => Number.isInteger(window?.id) && typeof window?.is_focused === "boolean",
109
+ );
110
+ if (valid) options.onWindowsChanged?.(windows);
111
+ const focused = valid ? windows.filter((window) => window.is_focused === true) : null;
112
+ if (focused?.length === 1 && Number.isInteger(focused[0].id))
113
+ options.onWindowFocusChanged?.(focused[0].id);
114
+ else if (focused?.length === 0 && windows.length > 0)
115
+ options.onWindowFocusChanged?.(null);
116
+ else options.onFocusInvalidated?.();
117
+ }
118
+ if (event && typeof event === "object" && "WorkspaceActiveWindowChanged" in event) {
119
+ const changed = event.WorkspaceActiveWindowChanged;
120
+ selection(changed?.workspace_id, changed?.active_window_id);
121
+ }
66
122
  const changedWindow = event?.WindowOpenedOrChanged?.window;
67
123
  if (changedWindow && typeof changedWindow === "object") {
68
124
  options.onWindowChanged?.(changedWindow);
@@ -71,7 +127,9 @@ export function createNiriWorkspaceEventWatcher(options) {
71
127
  if (Number.isInteger(closedId)) options.onWindowClosed?.(closedId);
72
128
  if (event && typeof event === "object" && "WindowFocusChanged" in event) {
73
129
  const focusedId = event.WindowFocusChanged?.id;
74
- options.onWindowFocusChanged?.(Number.isInteger(focusedId) ? focusedId : null);
130
+ if (focusedId === null || Number.isInteger(focusedId))
131
+ options.onWindowFocusChanged?.(focusedId);
132
+ else options.onFocusInvalidated?.();
75
133
  }
76
134
  } catch {
77
135
  // A malformed event is ignored; the fallback poll remains authoritative.
@@ -87,8 +145,14 @@ export function createNiriWorkspaceEventWatcher(options) {
87
145
  stdio: ["ignore", "pipe", "ignore"],
88
146
  });
89
147
  child.stdout?.on?.("data", consume);
90
- child.on?.("error", () => {});
148
+ child.on?.("error", () => {
149
+ if (!stopped) options.onFocusInvalidated?.();
150
+ });
151
+ child.on?.("close", () => {
152
+ if (!stopped) options.onFocusInvalidated?.();
153
+ });
91
154
  } catch {
155
+ options.onFocusInvalidated?.();
92
156
  child = null;
93
157
  }
94
158
 
@@ -103,3 +167,60 @@ export function createNiriWorkspaceEventWatcher(options) {
103
167
 
104
168
  return { stop };
105
169
  }
170
+
171
+ /** Keep focus admission on the compositor event path; slow fallback reads must not queue a
172
+ * keyboard grab or override a newer known non-agent event.
173
+ * @param {{
174
+ * projection: ReturnType<typeof import("./panel-projection.mjs").createNativePanelProjection>;
175
+ * placement: Pick<ReturnType<typeof import("./placement.mjs").createPlacementRuntime>, "observeWindowEvent" | "observeWindowList" | "forgetWindow" | "resolveCachedWorkspaceView">;
176
+ * reconcile: () => unknown;
177
+ * }} options
178
+ */
179
+ export function createWorkspaceProjectionHandlers({ projection, placement, reconcile }) {
180
+ const invalidate = () => {
181
+ projection.invalidateFocus();
182
+ reconcile();
183
+ };
184
+ return {
185
+ onFocusedWorkspace: invalidate,
186
+ onFallback: reconcile,
187
+ onFocusInvalidated: invalidate,
188
+ /** @param {Record<string, unknown>} window */
189
+ onWindowChanged(window) {
190
+ const changed = placement.observeWindowEvent(window);
191
+ if (changed) projection.invalidateWindowFocus(Number(window.id));
192
+ // Correctness does not depend on the work-scheduling signature or on which window changed.
193
+ const valid = projection.revalidateWindowFocus(placement.resolveCachedWorkspaceView);
194
+ if (changed || !valid) reconcile();
195
+ },
196
+ /** @param {number} windowId */
197
+ onWindowClosed(windowId) {
198
+ projection.invalidateWindowFocus(windowId);
199
+ placement.forgetWindow(windowId);
200
+ projection.revalidateWindowFocus(placement.resolveCachedWorkspaceView);
201
+ reconcile();
202
+ },
203
+ /** @param {Record<string, unknown>[]} windows */
204
+ onWindowsChanged(windows) {
205
+ placement.observeWindowList(windows);
206
+ projection.revalidateWindowFocus(placement.resolveCachedWorkspaceView);
207
+ reconcile();
208
+ },
209
+ /** @param {number | null} windowId */
210
+ onWindowFocusChanged(windowId) {
211
+ projection.publishWindowFocus(windowId, placement.resolveCachedWorkspaceView);
212
+ reconcile();
213
+ },
214
+ /** @param {number} workspaceId @param {number | null} windowId */
215
+ onWorkspaceActiveWindowChanged(workspaceId, windowId) {
216
+ projection.observeWorkspaceSelection(
217
+ workspaceId,
218
+ windowId,
219
+ placement.resolveCachedWorkspaceView,
220
+ );
221
+ projection.revalidateWindowFocus(placement.resolveCachedWorkspaceView);
222
+ reconcile();
223
+ },
224
+ onWorkspacesChanged: reconcile,
225
+ };
226
+ }