@tryinget/pi-activity-strip 0.5.0 → 0.7.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 (46) hide show
  1. package/README.md +59 -7
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +98 -64
  4. package/extensions/activity-strip.js +25 -21
  5. package/native/bin/linux-x64-gnu/artifact.json +2 -2
  6. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  7. package/native/panel/src/app.rs +20 -20
  8. package/native/panel/src/card_view.rs +396 -9
  9. package/native/panel/src/main.rs +64 -1
  10. package/native/panel/src/protocol.rs +142 -0
  11. package/native/panel/src/runtime.rs +50 -9
  12. package/native/panel/src/style.css +147 -60
  13. package/package.json +4 -4
  14. package/src/client/runtime-lock.mjs +140 -0
  15. package/src/common/agent-identity.mjs +163 -0
  16. package/src/common/ak-tasks.mjs +354 -0
  17. package/src/common/claude-events.mjs +190 -0
  18. package/src/common/claude-hook-config.mjs +56 -0
  19. package/src/common/claude-transcript.mjs +163 -0
  20. package/src/common/codex-transcript.mjs +153 -0
  21. package/src/common/compatibility.mjs +13 -0
  22. package/src/common/constants.mjs +2 -0
  23. package/src/common/contracts.d.ts +35 -0
  24. package/src/common/contracts.ts +67 -1
  25. package/src/common/ghostty-present.mjs +100 -0
  26. package/src/common/ghostty-theme.mjs +149 -0
  27. package/src/common/niri-focus.mjs +179 -111
  28. package/src/common/session-cards.mjs +5 -2
  29. package/src/common/status-report.mjs +51 -0
  30. package/src/common/surface-bindings.mjs +234 -0
  31. package/src/common/terminal-identity.mjs +75 -12
  32. package/src/common/window-placement.mjs +59 -0
  33. package/src/common/workspace-view.mjs +161 -0
  34. package/src/native/agent-discovery.mjs +294 -0
  35. package/src/native/ak-runtime.mjs +164 -0
  36. package/src/native/codex-discovery.mjs +128 -0
  37. package/src/native/ghostty-tab-inventory.py +99 -0
  38. package/src/native/height-repair.mjs +154 -0
  39. package/src/native/main.mjs +122 -64
  40. package/src/native/panel-binary.mjs +56 -0
  41. package/src/native/panel-projection.mjs +70 -5
  42. package/src/native/placement.mjs +225 -0
  43. package/src/native/tab-inventory.mjs +188 -0
  44. package/src/native/theme-runtime.mjs +72 -0
  45. package/src/native/theme-source.mjs +197 -0
  46. package/src/native/workspace-events.mjs +22 -1
@@ -0,0 +1,234 @@
1
+ // ---
2
+ // summary: "remembers which Niri window last displayed each Ghostty terminal surface so hidden tabs stay placed"
3
+ // read_when:
4
+ // - "changing hidden-tab placement memory, its persistence, or its invalidation rules"
5
+ // ---
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { appIdForGhosttyFamily, normalizeGhosttySurfaceId } from "./terminal-identity.mjs";
10
+
11
+ export const SURFACE_BINDINGS_SCHEMA = "pi-activity-strip-surface-bindings.v1";
12
+ const TITLE_BINDING = / · gs:(main|legacy):(\d+) · ([0-9a-f]{32})$/i;
13
+ const SESSION_BINDING_PREFIX = "pi-session:";
14
+
15
+ /**
16
+ * Ghostty surface ids are per-process handles that can drift from the value a long-lived Pi
17
+ * process captured at startup, while the 32-hex session token in the same title never does. Both
18
+ * keys are therefore remembered, and lookups prefer the more specific surface key.
19
+ * @param {unknown} sessionToken
20
+ */
21
+ export function sessionBindingKey(sessionToken) {
22
+ const token = String(sessionToken ?? "")
23
+ .trim()
24
+ .toLowerCase();
25
+ return /^[0-9a-f]{32}$/.test(token) ? `${SESSION_BINDING_PREFIX}${token}` : "";
26
+ }
27
+
28
+ /** @typedef {{windowId: number; windowPid: number; appId: string; observedAt: number}} SurfaceBinding */
29
+ /** @typedef {{instanceKey: string; filePath?: string; fs?: Pick<typeof fs, "readFileSync" | "writeFileSync" | "renameSync" | "mkdirSync" | "unlinkSync">; now?: () => number}} SurfaceBindingStoreOptions */
30
+
31
+ /**
32
+ * A Ghostty window title only names its active tab. Parse that name into the bound terminal key so
33
+ * every observation of a visible title teaches which window currently owns the surface.
34
+ * @param {unknown} title
35
+ */
36
+ export function parseTerminalTitleBinding(title) {
37
+ const match = String(title ?? "").match(TITLE_BINDING);
38
+ if (!match) return null;
39
+ const surfaceId = normalizeGhosttySurfaceId(match[2]);
40
+ if (!surfaceId) return null;
41
+ const family = String(match[1]).toLowerCase();
42
+ const sessionToken = String(match[3]).toLowerCase();
43
+ return {
44
+ family,
45
+ surfaceId,
46
+ sessionToken,
47
+ terminalKey: `ghostty:${family}:${surfaceId}`,
48
+ sessionKey: sessionBindingKey(sessionToken),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Every binding key a title or tab label teaches: the exact surface, and the logical session.
54
+ * @param {ReturnType<typeof parseTerminalTitleBinding>} parsed
55
+ */
56
+ export function bindingKeysFor(parsed) {
57
+ return parsed ? [parsed.terminalKey, parsed.sessionKey].filter(Boolean) : [];
58
+ }
59
+
60
+ /**
61
+ * Bindings are memory, not proof: they are only consulted for windows that still exist, still
62
+ * belong to the same Ghostty process, and are still admitted by the terminal's own host process.
63
+ * Persisted memory is bound to one Niri instance because window ids restart with the compositor.
64
+ * @param {SurfaceBindingStoreOptions} options
65
+ */
66
+ export function createSurfaceBindingStore({
67
+ instanceKey,
68
+ filePath = "",
69
+ fs: fsImpl = fs,
70
+ now = Date.now,
71
+ }) {
72
+ /** @type {Map<string, SurfaceBinding>} */
73
+ const bindings = new Map();
74
+ let dirty = false;
75
+
76
+ function load() {
77
+ if (!filePath) return;
78
+ /** @type {any} */
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(fsImpl.readFileSync(filePath, "utf8"));
82
+ } catch {
83
+ return;
84
+ }
85
+ if (parsed?.schema !== SURFACE_BINDINGS_SCHEMA || parsed.instanceKey !== instanceKey) return;
86
+ const entries = /** @type {Array<[string, any]>} */ (Object.entries(parsed.bindings ?? {}));
87
+ for (const [terminalKey, value] of entries) {
88
+ if (
89
+ !Number.isInteger(value?.windowId) ||
90
+ !Number.isInteger(value?.windowPid) ||
91
+ typeof value?.appId !== "string"
92
+ ) {
93
+ continue;
94
+ }
95
+ bindings.set(terminalKey, {
96
+ windowId: value.windowId,
97
+ windowPid: value.windowPid,
98
+ appId: value.appId,
99
+ observedAt: Number(value.observedAt) || 0,
100
+ });
101
+ }
102
+ }
103
+
104
+ function persist() {
105
+ if (!filePath || !dirty) return false;
106
+ const temporaryPath = `${filePath}.tmp.${process.pid}`;
107
+ try {
108
+ fsImpl.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
109
+ fsImpl.writeFileSync(
110
+ temporaryPath,
111
+ JSON.stringify({
112
+ schema: SURFACE_BINDINGS_SCHEMA,
113
+ instanceKey,
114
+ savedAt: now(),
115
+ bindings: Object.fromEntries(bindings),
116
+ }),
117
+ { mode: 0o600 },
118
+ );
119
+ fsImpl.renameSync(temporaryPath, filePath);
120
+ dirty = false;
121
+ return true;
122
+ } catch {
123
+ try {
124
+ fsImpl.unlinkSync(temporaryPath);
125
+ } catch {
126
+ // The temporary file never existed or is already gone.
127
+ }
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Learn from every Ghostty window whose title carries a surface segment. Accepts a complete
134
+ * window list or a single window event. Returns the terminal keys whose binding changed.
135
+ * @param {Array<Record<string, unknown>>} windows
136
+ */
137
+ function observeWindows(windows) {
138
+ const changed = [];
139
+ for (const window of windows ?? []) {
140
+ if (!Number.isInteger(window?.id) || !Number.isInteger(window?.pid)) continue;
141
+ const parsed = parseTerminalTitleBinding(window.title);
142
+ if (!parsed) continue;
143
+ const appId = String(window.app_id ?? "");
144
+ if (appId !== appIdForGhosttyFamily(parsed.family)) continue;
145
+ for (const key of bindingKeysFor(parsed)) {
146
+ const previous = bindings.get(key);
147
+ if (previous?.windowId === window.id && previous?.windowPid === window.pid) continue;
148
+ bindings.set(key, {
149
+ windowId: Number(window.id),
150
+ windowPid: Number(window.pid),
151
+ appId,
152
+ observedAt: now(),
153
+ });
154
+ changed.push(key);
155
+ dirty = true;
156
+ }
157
+ }
158
+ return changed;
159
+ }
160
+
161
+ /**
162
+ * Adopt bindings established by an exact external inventory (for example AT-SPI tab labels
163
+ * already mapped onto windows). Returns the terminal keys whose binding changed.
164
+ * @param {Array<{bindingKey: string; windowId: number; windowPid: number; appId: string}>} observed
165
+ */
166
+ function observeBindings(observed) {
167
+ const changed = [];
168
+ for (const binding of observed ?? []) {
169
+ if (
170
+ typeof binding?.bindingKey !== "string" ||
171
+ !binding.bindingKey ||
172
+ !Number.isInteger(binding.windowId) ||
173
+ !Number.isInteger(binding.windowPid) ||
174
+ typeof binding.appId !== "string"
175
+ ) {
176
+ continue;
177
+ }
178
+ const previous = bindings.get(binding.bindingKey);
179
+ if (previous?.windowId === binding.windowId && previous?.windowPid === binding.windowPid) {
180
+ continue;
181
+ }
182
+ bindings.set(binding.bindingKey, {
183
+ windowId: binding.windowId,
184
+ windowPid: binding.windowPid,
185
+ appId: binding.appId,
186
+ observedAt: now(),
187
+ });
188
+ changed.push(binding.bindingKey);
189
+ dirty = true;
190
+ }
191
+ return changed;
192
+ }
193
+
194
+ /**
195
+ * Forget bindings whose window vanished or changed process identity. Only a complete window list
196
+ * may prune; an empty observation is indistinguishable from a failed read and is ignored.
197
+ * @param {Array<Record<string, unknown>>} windows
198
+ */
199
+ function prune(windows) {
200
+ if (!Array.isArray(windows) || windows.length === 0) return [];
201
+ const byId = new Map(
202
+ windows.filter((window) => Number.isInteger(window?.id)).map((window) => [window.id, window]),
203
+ );
204
+ const removed = [];
205
+ for (const [terminalKey, binding] of bindings) {
206
+ const window = byId.get(binding.windowId);
207
+ if (
208
+ window &&
209
+ window.pid === binding.windowPid &&
210
+ String(window.app_id ?? "") === binding.appId
211
+ ) {
212
+ continue;
213
+ }
214
+ bindings.delete(terminalKey);
215
+ removed.push(terminalKey);
216
+ dirty = true;
217
+ }
218
+ return removed;
219
+ }
220
+
221
+ load();
222
+ return {
223
+ observeWindows,
224
+ observeBindings,
225
+ prune,
226
+ persist,
227
+ /** @param {string} terminalKey */
228
+ lookup: (terminalKey) => bindings.get(terminalKey) ?? null,
229
+ entries: () => [...bindings.entries()],
230
+ get size() {
231
+ return bindings.size;
232
+ },
233
+ };
234
+ }
@@ -10,9 +10,11 @@ import path from "node:path";
10
10
  const UINT64_MAX = 18_446_744_073_709_551_615n;
11
11
  const GHOSTTY_MAIN_APP_ID = "com.mitchellh.ghostty";
12
12
  const GHOSTTY_LEGACY_APP_ID = "com.tryinget.ghosttysidequest";
13
+ const HOST_PID_CACHE_LIMIT = 1024;
13
14
 
14
15
  /** @typedef {{terminalKind: "ghostty-surface" | "unbound"; terminalKey: string; terminalFamily: string; terminalSurfaceId: string}} TerminalIdentity */
15
16
  /** @typedef {{env?: NodeJS.ProcessEnv; hasUI?: boolean; stdinIsTTY?: boolean; ttyPath?: string; processId?: number; ancestorExecutable?: string}} TerminalIdentityOptions */
17
+ /** @typedef {{readFile: (filePath: string) => string; readLink: (filePath: string) => string}} ProcfsReader */
16
18
 
17
19
  /** @param {string} filePath */
18
20
  function safeReadFile(filePath) {
@@ -32,19 +34,37 @@ function safeReadLink(filePath) {
32
34
  }
33
35
  }
34
36
 
35
- /** @param {number} pid */
36
- function readParentPid(pid) {
37
- const value = safeReadFile(path.join("/proc", String(pid), "stat"));
37
+ /** @type {ProcfsReader} */
38
+ const defaultProcfs = { readFile: safeReadFile, readLink: safeReadLink };
39
+
40
+ /**
41
+ * Fields after the parenthesized command name in /proc/<pid>/stat: index 1 is the parent pid and
42
+ * index 19 is the process start time in clock ticks since boot.
43
+ * @param {number} pid
44
+ * @param {ProcfsReader} procfs
45
+ */
46
+ function readStatFields(pid, procfs) {
47
+ const value = procfs.readFile(path.join("/proc", String(pid), "stat"));
38
48
  const lastParen = value.lastIndexOf(")");
39
- if (lastParen < 0) return 0;
40
- const fields = value
49
+ if (lastParen < 0) return [];
50
+ return value
41
51
  .slice(lastParen + 2)
42
52
  .trim()
43
53
  .split(/\s+/);
44
- const parentPid = Number.parseInt(fields[1] ?? "", 10);
54
+ }
55
+
56
+ /** @param {number} pid @param {ProcfsReader} [procfs] */
57
+ function readParentPid(pid, procfs = defaultProcfs) {
58
+ const parentPid = Number.parseInt(readStatFields(pid, procfs)[1] ?? "", 10);
45
59
  return Number.isInteger(parentPid) && parentPid > 0 ? parentPid : 0;
46
60
  }
47
61
 
62
+ /** @param {number} pid @param {ProcfsReader} [procfs] */
63
+ export function readProcessStartTime(pid, procfs = defaultProcfs) {
64
+ const startTime = readStatFields(pid, procfs)[19] ?? "";
65
+ return /^\d+$/.test(startTime) ? startTime : "";
66
+ }
67
+
48
68
  /** @param {unknown} value */
49
69
  export function normalizeGhosttySurfaceId(value) {
50
70
  try {
@@ -72,18 +92,61 @@ export function appIdForGhosttyFamily(family) {
72
92
  return "";
73
93
  }
74
94
 
75
- /** @param {number} [processId] */
76
- export function findGhosttyAncestorExecutable(processId = process.pid) {
95
+ /**
96
+ * Walk the parent chain to the nearest live Ghostty process. That process owns every Wayland window
97
+ * a tab of this terminal can live in, which makes it the placement authority for hidden tabs.
98
+ * @param {number} [processId]
99
+ * @param {ProcfsReader} [procfs]
100
+ * @returns {{pid: number; executable: string} | null}
101
+ */
102
+ export function findGhosttyAncestor(processId = process.pid, procfs = defaultProcfs) {
77
103
  let pid = Number(processId) || 0;
78
104
  for (let depth = 0; depth < 12 && pid > 0; depth += 1) {
79
- pid = readParentPid(pid);
105
+ pid = readParentPid(pid, procfs);
80
106
  if (pid <= 0) break;
81
- const command = safeReadFile(path.join("/proc", String(pid), "comm"))
107
+ const command = procfs
108
+ .readFile(path.join("/proc", String(pid), "comm"))
82
109
  .trim()
83
110
  .toLowerCase();
84
- if (command === "ghostty") return safeReadLink(path.join("/proc", String(pid), "exe"));
111
+ if (command === "ghostty") {
112
+ return { pid, executable: procfs.readLink(path.join("/proc", String(pid), "exe")) };
113
+ }
85
114
  }
86
- return "";
115
+ return null;
116
+ }
117
+
118
+ /** @param {number} [processId] @param {ProcfsReader} [procfs] */
119
+ export function findGhosttyAncestorExecutable(processId = process.pid, procfs = defaultProcfs) {
120
+ return findGhosttyAncestor(processId, procfs)?.executable ?? "";
121
+ }
122
+
123
+ /**
124
+ * Resolve the live Ghostty process hosting a bound terminal surface. The publisher process must
125
+ * still exist and its Ghostty ancestor must belong to the admitted executable family; anything else
126
+ * fails closed to 0. Results are cached per process start time so pid reuse cannot leak a stale host.
127
+ * @param {Record<string, unknown>} session
128
+ * @param {{procfs?: ProcfsReader; cache?: Map<string, number>}} [options]
129
+ */
130
+ export function resolveTerminalHostPid(session, options = {}) {
131
+ if (!canonicalGhosttyTerminalKey(session)) return 0;
132
+ const processId = Number(session.processId ?? 0);
133
+ if (!Number.isInteger(processId) || processId <= 0) return 0;
134
+ const procfs = options.procfs ?? defaultProcfs;
135
+ const startTime = readProcessStartTime(processId, procfs);
136
+ if (!startTime) return 0;
137
+ const cacheKey = `${processId}:${startTime}`;
138
+ const cached = options.cache?.get(cacheKey);
139
+ if (cached !== undefined) return cached;
140
+ const ancestor = findGhosttyAncestor(processId, procfs);
141
+ const hostPid =
142
+ ancestor && classifyGhosttyFamily(ancestor.executable) === session.terminalFamily
143
+ ? ancestor.pid
144
+ : 0;
145
+ if (options.cache) {
146
+ if (options.cache.size >= HOST_PID_CACHE_LIMIT) options.cache.clear();
147
+ options.cache.set(cacheKey, hostPid);
148
+ }
149
+ return hostPid;
87
150
  }
88
151
 
89
152
  /** @returns {TerminalIdentity} */
@@ -0,0 +1,59 @@
1
+ // ---
2
+ // summary: "places bound Pi terminal surfaces onto Niri windows, including tabs hidden behind another tab"
3
+ // read_when:
4
+ // - "changing how hidden Ghostty tabs are attributed to windows, workspaces, or activation targets"
5
+ // ---
6
+
7
+ import { sessionBindingKey } from "./surface-bindings.mjs";
8
+ import { appIdForGhosttyFamily, canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
9
+
10
+ /** @typedef {"title" | "host" | "binding"} SurfacePlacement */
11
+ /** @typedef {{window: Record<string, unknown>; placement: SurfacePlacement}} WindowPlacement */
12
+ /**
13
+ * @typedef {{
14
+ * resolveHostPid?: (session: Record<string, unknown>) => number;
15
+ * lookupBinding?: (bindingKey: string) => {windowId: number; windowPid: number} | null;
16
+ * }} PlacementOptions
17
+ */
18
+
19
+ /**
20
+ * Place a bound surface whose title is not visible in any window. A surface can only live inside
21
+ * a window of its own Ghostty process, so a single host window is an exact placement. Several host
22
+ * windows require the window that last displayed this surface; without that memory the surface
23
+ * stays unplaced rather than guessed.
24
+ * @param {Array<Record<string, unknown>>} windows
25
+ * @param {Record<string, unknown>} session
26
+ * @param {PlacementOptions & {sessionToken?: string}} [options]
27
+ * @returns {WindowPlacement | null}
28
+ */
29
+ export function resolveHiddenSurfaceWindow(windows, session, options = {}) {
30
+ const terminalKey = canonicalGhosttyTerminalKey(session);
31
+ if (!terminalKey) return null;
32
+ const hostPid = Number(options.resolveHostPid?.(session) ?? 0);
33
+ if (!Number.isInteger(hostPid) || hostPid <= 0) return null;
34
+ const appId = appIdForGhosttyFamily(String(session.terminalFamily ?? ""));
35
+ const hostWindows = windows.filter(
36
+ (window) =>
37
+ Number.isInteger(window?.id) &&
38
+ window.pid === hostPid &&
39
+ String(window.app_id ?? "") === appId,
40
+ );
41
+ if (hostWindows.length === 1) return { window: hostWindows[0], placement: "host" };
42
+ if (hostWindows.length === 0) return null;
43
+ for (const key of [terminalKey, sessionBindingKey(options.sessionToken)].filter(Boolean)) {
44
+ const binding = options.lookupBinding?.(key);
45
+ if (!binding) continue;
46
+ const remembered = hostWindows.find(
47
+ (window) => window.id === binding.windowId && window.pid === binding.windowPid,
48
+ );
49
+ if (remembered) return { window: remembered, placement: "binding" };
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /** @param {SurfacePlacement | undefined} placement */
55
+ export function describePlacement(placement) {
56
+ if (placement === "host") return "hidden tab inside its Ghostty process's only window";
57
+ if (placement === "binding") return "hidden tab in the window that last displayed it";
58
+ return "visible tab";
59
+ }
@@ -0,0 +1,161 @@
1
+ // ---
2
+ // summary: "projects tracked sessions onto one Niri workspace as cards with known visibility"
3
+ // read_when:
4
+ // - "changing workspace card membership, card visibility, or focused-card selection"
5
+ // ---
6
+
7
+ import { canVerifyTabVisibility, placeSessionWindow } from "./niri-focus.mjs";
8
+ import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
9
+ import { parseTerminalTitleBinding } from "./surface-bindings.mjs";
10
+ import { canonicalGhosttyTerminalKey } from "./terminal-identity.mjs";
11
+
12
+ /** @typedef {import("./window-placement.mjs").PlacementOptions} PlacementOptions */
13
+ /**
14
+ * @typedef {PlacementOptions & {
15
+ * env?: NodeJS.ProcessEnv;
16
+ * readFileSync?: typeof import("node:fs").readFileSync;
17
+ * existsSync?: typeof import("node:fs").existsSync;
18
+ * }} ResolveOptions
19
+ */
20
+
21
+ /**
22
+ * Decide whether a placed tab is the one its window is currently showing. A window title naming a
23
+ * different terminal surface, or missing a title this session is known to set, is proof the tab is
24
+ * hidden. Without either signal visibility is unknown, and an unknown tab is never marked hidden:
25
+ * an agent that sets no recognizable title would otherwise look permanently buried.
26
+ * @param {Record<string, unknown>} window
27
+ * @param {Record<string, unknown>} session
28
+ * @param {string} placement
29
+ * @param {ResolveOptions} [options]
30
+ * @returns {"visible" | "hidden" | "unknown"}
31
+ */
32
+ export function resolveSurfaceVisibility(window, session, placement, options = {}) {
33
+ if (placement === "title") return "visible";
34
+ // A session that writes its own identity into the terminal title was already matched against
35
+ // every window title. Reaching here means no title matched, so this tab is not the one shown.
36
+ if (canVerifyTabVisibility(session, options)) return "hidden";
37
+ const terminalKey = canonicalGhosttyTerminalKey(session);
38
+ const titled = parseTerminalTitleBinding(window?.title);
39
+ if (titled && terminalKey && titled.terminalKey !== terminalKey) return "hidden";
40
+ return "unknown";
41
+ }
42
+
43
+ /**
44
+ * Project the global broker snapshot onto one exact Niri workspace. Membership requires an exact
45
+ * window placement: a visible title, or a hidden tab attributed through its Ghostty host process.
46
+ * Multiple publisher streams bound to the same terminal are aggregated into one stable card; a
47
+ * window may carry several terminal cards because each tab is its own surface, but a card claimed
48
+ * by distinct windows still fails closed. Only a visible tab in the focused window is "current".
49
+ * @param {Array<Record<string, unknown>>} windows
50
+ * @param {Record<string, unknown>} workspace
51
+ * @param {Array<Record<string, unknown>>} sessions
52
+ * @param {ResolveOptions} [options]
53
+ */
54
+ export function resolveWorkspaceView(windows, workspace, sessions, options = {}) {
55
+ if (!Number.isInteger(workspace?.id)) return null;
56
+ /** @type {Array<{cardId: string; session: Record<string, unknown>; window: Record<string, unknown>; placement: string}>} */
57
+ const candidates = [];
58
+ for (const session of sessions) {
59
+ const placed = placeSessionWindow(windows, session, options);
60
+ if (!placed || placed.window.workspace_id !== workspace.id) continue;
61
+ const cardId = sessionCardId(session);
62
+ if (!cardId) continue;
63
+ candidates.push({
64
+ cardId,
65
+ session: { ...session, cardId },
66
+ window: placed.window,
67
+ placement: placed.placement,
68
+ });
69
+ }
70
+
71
+ /** @type {Map<unknown, Set<string>>} */
72
+ const cardIdsByWindow = new Map();
73
+ for (const candidate of candidates) {
74
+ const ids = cardIdsByWindow.get(candidate.window.id) ?? new Set();
75
+ ids.add(candidate.cardId);
76
+ cardIdsByWindow.set(candidate.window.id, ids);
77
+ }
78
+ /** @type {Map<unknown, Set<string>>} */
79
+ const allowedCardsByWindow = new Map();
80
+ for (const [windowId, ids] of cardIdsByWindow) {
81
+ const terminalIds = [...ids].filter((cardId) => cardId.startsWith("terminal:"));
82
+ if (terminalIds.length > 0) allowedCardsByWindow.set(windowId, new Set(terminalIds));
83
+ else if (ids.size === 1) allowedCardsByWindow.set(windowId, ids);
84
+ }
85
+
86
+ /** @type {Map<string, Array<{session: Record<string, unknown>; window: Record<string, unknown>; placement: string}>>} */
87
+ const groups = new Map();
88
+ for (const candidate of candidates) {
89
+ if (!allowedCardsByWindow.get(candidate.window.id)?.has(candidate.cardId)) continue;
90
+ const group = groups.get(candidate.cardId) ?? [];
91
+ group.push(candidate);
92
+ groups.set(candidate.cardId, group);
93
+ }
94
+
95
+ // The number the operator sees in the workspace list, not Niri's internal workspace id.
96
+ const workspaceIdx = Number.isInteger(workspace.idx) ? Number(workspace.idx) : null;
97
+ const projectedSessions = [];
98
+ for (const [cardId, group] of groups) {
99
+ const windowIds = new Set(group.map((candidate) => candidate.window.id));
100
+ if (windowIds.size !== 1) continue;
101
+ const [card] = projectSessionCards(group.map((candidate) => candidate.session));
102
+ if (!card) continue;
103
+ const window = group[0]?.window;
104
+ const placement = group.some((candidate) => candidate.placement === "title")
105
+ ? "title"
106
+ : (group[0]?.placement ?? "title");
107
+ const visibility = resolveSurfaceVisibility(
108
+ window,
109
+ group[0]?.session ?? card,
110
+ placement,
111
+ options,
112
+ );
113
+ projectedSessions.push({
114
+ ...card,
115
+ cardId,
116
+ // A hidden tab has no window of its own, so this is the window hosting it.
117
+ windowId: window?.id,
118
+ workspaceIdx,
119
+ placement,
120
+ // Only a tab proven to sit behind another one is marked hidden.
121
+ surfaceVisible: visibility !== "hidden",
122
+ });
123
+ }
124
+ // The focused window shows exactly one tab. Several candidates mean the compositor cannot tell
125
+ // us which, so nothing is marked current rather than marking the wrong one.
126
+ const focusedCandidates = projectedSessions.filter(
127
+ (session) =>
128
+ session.surfaceVisible &&
129
+ windows.some((window) => window?.id === session.windowId && window?.is_focused === true),
130
+ );
131
+ const focusedCard = focusedCandidates.length === 1 ? focusedCandidates[0] : null;
132
+ return {
133
+ workspace,
134
+ sessions: projectedSessions,
135
+ focusedSessionId: focusedCard ? String(focusedCard.sessionId ?? "") || null : null,
136
+ focusedCardId: focusedCard ? String(focusedCard.cardId ?? "") || null : null,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Project the global broker snapshot onto the one focused Niri workspace.
142
+ * @param {Array<Record<string, unknown>>} windows
143
+ * @param {Array<Record<string, unknown>>} workspaces
144
+ * @param {Array<Record<string, unknown>>} sessions
145
+ * @param {ResolveOptions} [options]
146
+ */
147
+ export function resolveFocusedWorkspaceView(windows, workspaces, sessions, options = {}) {
148
+ const workspace = resolveFocusedNiriWorkspace(workspaces);
149
+ return workspace ? resolveWorkspaceView(windows, workspace, sessions, options) : null;
150
+ }
151
+
152
+ /** @param {Array<Record<string, unknown>>} workspaces */
153
+ export function resolveFocusedNiriWorkspace(workspaces) {
154
+ const matches = workspaces.filter(
155
+ (workspace) =>
156
+ workspace?.is_focused === true &&
157
+ Number.isInteger(workspace?.id) &&
158
+ (typeof workspace?.name === "string" || Number.isInteger(workspace?.idx)),
159
+ );
160
+ return matches.length === 1 ? matches[0] : null;
161
+ }