@tryinget/pi-activity-strip 0.5.0 → 0.6.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 (41) hide show
  1. package/README.md +41 -6
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +22 -1
  4. package/native/bin/linux-x64-gnu/artifact.json +2 -2
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/src/app.rs +20 -20
  7. package/native/panel/src/card_view.rs +308 -9
  8. package/native/panel/src/protocol.rs +63 -0
  9. package/native/panel/src/runtime.rs +50 -9
  10. package/native/panel/src/style.css +134 -60
  11. package/package.json +4 -4
  12. package/src/common/agent-identity.mjs +163 -0
  13. package/src/common/ak-tasks.mjs +354 -0
  14. package/src/common/claude-events.mjs +190 -0
  15. package/src/common/claude-hook-config.mjs +56 -0
  16. package/src/common/claude-transcript.mjs +163 -0
  17. package/src/common/codex-transcript.mjs +153 -0
  18. package/src/common/compatibility.mjs +13 -0
  19. package/src/common/contracts.d.ts +26 -0
  20. package/src/common/contracts.ts +64 -1
  21. package/src/common/ghostty-present.mjs +100 -0
  22. package/src/common/ghostty-theme.mjs +149 -0
  23. package/src/common/niri-focus.mjs +179 -110
  24. package/src/common/session-cards.mjs +5 -2
  25. package/src/common/status-report.mjs +51 -0
  26. package/src/common/surface-bindings.mjs +234 -0
  27. package/src/common/terminal-identity.mjs +75 -12
  28. package/src/common/window-placement.mjs +59 -0
  29. package/src/common/workspace-view.mjs +161 -0
  30. package/src/native/agent-discovery.mjs +294 -0
  31. package/src/native/ak-runtime.mjs +164 -0
  32. package/src/native/codex-discovery.mjs +128 -0
  33. package/src/native/ghostty-tab-inventory.py +99 -0
  34. package/src/native/height-repair.mjs +154 -0
  35. package/src/native/main.mjs +120 -14
  36. package/src/native/panel-projection.mjs +67 -5
  37. package/src/native/placement.mjs +225 -0
  38. package/src/native/tab-inventory.mjs +188 -0
  39. package/src/native/theme-runtime.mjs +72 -0
  40. package/src/native/theme-source.mjs +197 -0
  41. package/src/native/workspace-events.mjs +16 -1
@@ -0,0 +1,225 @@
1
+ // ---
2
+ // summary: "owns hidden-tab placement state: window memory, host resolution, and the bounded AT-SPI tab inventory"
3
+ // read_when:
4
+ // - "changing hidden-tab placement inputs, inventory cadence, or placement runtime counters"
5
+ // ---
6
+
7
+ import path from "node:path";
8
+ import { ACTIVITY_STRIP_SOCKET_DIR } from "../common/constants.mjs";
9
+ import { resolvePiSessionIdentity, resolveSessionWindow } from "../common/niri-focus.mjs";
10
+ import {
11
+ createSurfaceBindingStore,
12
+ parseTerminalTitleBinding,
13
+ } from "../common/surface-bindings.mjs";
14
+ import {
15
+ canonicalGhosttyTerminalKey,
16
+ resolveTerminalHostPid,
17
+ } from "../common/terminal-identity.mjs";
18
+ import { mapTabInventoryToBindings, probeGhosttyTabInventory } from "./tab-inventory.mjs";
19
+
20
+ // The AT-SPI tab inventory is the only exact source for tabs that have never been shown while the
21
+ // strip ran. It is read-only, bounded, never concurrent, and backs off once the host reports it
22
+ // unavailable. Placement itself never depends on it: it only supplies more window memory.
23
+ export const TAB_INVENTORY_INTERVAL_MS = 10_000;
24
+ export const TAB_INVENTORY_URGENT_INTERVAL_MS = 3000;
25
+ export const TAB_INVENTORY_FAILURE_BACKOFF_MS = 30_000;
26
+ export const TAB_INVENTORY_UNAVAILABLE_RETRY_MS = 10 * 60_000;
27
+
28
+ /** @typedef {import("../common/contracts.ts").ActivityStripRuntimeStatus} ActivityStripRuntimeStatus */
29
+ /** @typedef {Record<string, unknown>} NiriWindow */
30
+
31
+ /**
32
+ * @param {{
33
+ * env?: NodeJS.ProcessEnv;
34
+ * runtimeStatus: ActivityStripRuntimeStatus;
35
+ * execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>;
36
+ * onBindingsLearned: () => void;
37
+ * now?: () => number;
38
+ * probeInventory?: typeof probeGhosttyTabInventory;
39
+ * bindingsFilePath?: string;
40
+ * }} options
41
+ */
42
+ export function createPlacementRuntime({
43
+ env = process.env,
44
+ runtimeStatus,
45
+ execFileAsync,
46
+ onBindingsLearned,
47
+ now = Date.now,
48
+ probeInventory = probeGhosttyTabInventory,
49
+ bindingsFilePath,
50
+ }) {
51
+ const isNiri = Boolean(env.NIRI_SOCKET);
52
+ // Window ids restart with the compositor, so persisted memory is bound to one Niri instance.
53
+ const bindings = createSurfaceBindingStore({
54
+ instanceKey: String(env.NIRI_SOCKET ?? ""),
55
+ filePath:
56
+ bindingsFilePath ??
57
+ (isNiri ? path.join(ACTIVITY_STRIP_SOCKET_DIR, "surface-bindings.json") : ""),
58
+ now,
59
+ });
60
+ /** @type {Map<string, number>} */
61
+ const hostPidCache = new Map();
62
+ /** @type {Map<unknown, string>} */
63
+ const windowSignatures = new Map();
64
+ let inventoryInFlight = false;
65
+ let inventoryNextAt = 0;
66
+
67
+ const options = {
68
+ env,
69
+ /** @param {Record<string, unknown>} session */
70
+ resolveHostPid: (session) => resolveTerminalHostPid(session, { cache: hostPidCache }),
71
+ /** @param {string} bindingKey */
72
+ lookupBinding: (bindingKey) => bindings.lookup(bindingKey),
73
+ };
74
+
75
+ /** Workspace plus visible surface: everything about a window that can move a card. */
76
+ /** @param {NiriWindow} window */
77
+ function signatureOf(window) {
78
+ return `${window.workspace_id}|${parseTerminalTitleBinding(window.title)?.terminalKey ?? ""}`;
79
+ }
80
+
81
+ /**
82
+ * Bound surfaces that resolve to no window at all: hidden tabs inside a multi-window Ghostty
83
+ * process whose window has not been observed or inventoried yet.
84
+ * @param {NiriWindow[]} windows
85
+ * @param {Array<Record<string, unknown>>} sessions
86
+ */
87
+ function countUnplaced(windows, sessions) {
88
+ let unplaced = 0;
89
+ for (const session of sessions) {
90
+ if (!canonicalGhosttyTerminalKey(session)) continue;
91
+ const sessionId = resolvePiSessionIdentity(session, options);
92
+ if (!sessionId) continue;
93
+ if (!resolveSessionWindow(windows, sessionId, session, options)) unplaced += 1;
94
+ }
95
+ return unplaced;
96
+ }
97
+
98
+ /** @param {NiriWindow[]} windows */
99
+ function observeWindowList(windows) {
100
+ if (windows.length === 0) return;
101
+ bindings.observeWindows(windows);
102
+ bindings.prune(windows);
103
+ bindings.persist();
104
+ windowSignatures.clear();
105
+ for (const window of windows) {
106
+ if (Number.isInteger(window?.id)) windowSignatures.set(window.id, signatureOf(window));
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A single window event. Returns true when the change can move or reveal a card.
112
+ * @param {NiriWindow} window
113
+ */
114
+ function observeWindowEvent(window) {
115
+ if (!Number.isInteger(window?.id)) return false;
116
+ const learned = bindings.observeWindows([window]);
117
+ const signature = signatureOf(window);
118
+ const changed = windowSignatures.get(window.id) !== signature;
119
+ windowSignatures.set(window.id, signature);
120
+ return learned.length > 0 || changed;
121
+ }
122
+
123
+ /**
124
+ * A card placed by a visible title proves which window holds that terminal. Remember it so the
125
+ * same tab stays placed once it is hidden behind another tab.
126
+ * @param {Array<Record<string, unknown>>} cards
127
+ * @param {NiriWindow[]} windows
128
+ */
129
+ function learnFromPlacements(cards, windows) {
130
+ const byId = new Map(
131
+ windows.filter((window) => Number.isInteger(window?.id)).map((window) => [window.id, window]),
132
+ );
133
+ const observed = [];
134
+ for (const card of cards ?? []) {
135
+ if (card?.placement !== "title") continue;
136
+ const cardId = String(card.cardId ?? "");
137
+ if (!cardId.startsWith("terminal:")) continue;
138
+ const window = byId.get(card.windowId);
139
+ if (!window || !Number.isInteger(window.pid)) continue;
140
+ observed.push({
141
+ bindingKey: cardId.slice("terminal:".length),
142
+ windowId: Number(window.id),
143
+ windowPid: Number(window.pid),
144
+ appId: String(window.app_id ?? ""),
145
+ });
146
+ }
147
+ if (observed.length > 0 && bindings.observeBindings(observed).length > 0) bindings.persist();
148
+ }
149
+
150
+ /** @param {number} windowId */
151
+ function forgetWindow(windowId) {
152
+ return windowSignatures.delete(windowId);
153
+ }
154
+
155
+ /** @param {NiriWindow[]} windows */
156
+ function ghosttyPidsOf(windows) {
157
+ return [
158
+ ...new Set(
159
+ windows
160
+ .filter(
161
+ (window) =>
162
+ Number.isInteger(window?.pid) && /ghostty/i.test(String(window?.app_id ?? "")),
163
+ )
164
+ .map((window) => Number(window.pid)),
165
+ ),
166
+ ];
167
+ }
168
+
169
+ /** @param {NiriWindow[]} windows */
170
+ function scheduleInventory(windows) {
171
+ if (runtimeStatus.tabInventoryState === "disabled" || inventoryInFlight) return;
172
+ if (now() < inventoryNextAt) return;
173
+ const pids = ghosttyPidsOf(windows);
174
+ if (pids.length === 0) return;
175
+ inventoryInFlight = true;
176
+ return probeInventory({ execFileAsync, env, pids })
177
+ .then((result) => {
178
+ runtimeStatus.tabInventoryProbedAt = now();
179
+ if (!result.ok) {
180
+ runtimeStatus.tabInventoryState = result.unavailable ? "unavailable" : "failed";
181
+ runtimeStatus.tabInventoryDetail = result.error;
182
+ inventoryNextAt =
183
+ now() +
184
+ (result.unavailable
185
+ ? TAB_INVENTORY_UNAVAILABLE_RETRY_MS
186
+ : TAB_INVENTORY_FAILURE_BACKOFF_MS);
187
+ return;
188
+ }
189
+ runtimeStatus.tabInventoryState = "ready";
190
+ runtimeStatus.tabInventoryDetail = null;
191
+ runtimeStatus.tabInventoryFrameCount = result.frames.length;
192
+ runtimeStatus.tabInventoryTabCount = result.frames.reduce(
193
+ (total, frame) => total + frame.tabs.length,
194
+ 0,
195
+ );
196
+ const learned = bindings.observeBindings(mapTabInventoryToBindings(result.frames, windows));
197
+ // The shorter cadence is for catching up, so it only holds while the probe is still
198
+ // learning. A surface the inventory cannot see would otherwise pin it there forever.
199
+ const catchingUp = learned.length > 0 && (runtimeStatus.unplacedSurfaceCount ?? 0) > 0;
200
+ inventoryNextAt =
201
+ now() + (catchingUp ? TAB_INVENTORY_URGENT_INTERVAL_MS : TAB_INVENTORY_INTERVAL_MS);
202
+ if (learned.length > 0) {
203
+ bindings.persist();
204
+ onBindingsLearned();
205
+ }
206
+ })
207
+ .finally(() => {
208
+ inventoryInFlight = false;
209
+ });
210
+ }
211
+
212
+ return {
213
+ options,
214
+ observeWindowList,
215
+ observeWindowEvent,
216
+ learnFromPlacements,
217
+ forgetWindow,
218
+ countUnplaced,
219
+ scheduleInventory,
220
+ persist: () => bindings.persist(),
221
+ get bindingCount() {
222
+ return bindings.size;
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,188 @@
1
+ // ---
2
+ // summary: "maps AT-SPI Ghostty tab labels onto exact Niri windows to place tabs that were never shown"
3
+ // read_when:
4
+ // - "changing hidden-tab discovery, its cadence, or its capability gating"
5
+ // ---
6
+
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { bindingKeysFor, parseTerminalTitleBinding } from "../common/surface-bindings.mjs";
10
+ import { appIdForGhosttyFamily } from "../common/terminal-identity.mjs";
11
+
12
+ const GHOSTTY_APP_IDS = new Set(["com.mitchellh.ghostty", "com.tryinget.ghosttysidequest"]);
13
+ export const TAB_INVENTORY_SCRIPT = path.join(
14
+ path.dirname(fileURLToPath(import.meta.url)),
15
+ "ghostty-tab-inventory.py",
16
+ );
17
+ export const TAB_INVENTORY_TIMEOUT_MS = 4000;
18
+ export const TAB_INVENTORY_UNAVAILABLE_EXIT_CODE = 3;
19
+ const ATSPI_IMPORT_PROBE =
20
+ "import gi; gi.require_version('Atspi', '2.0'); from gi.repository import Atspi";
21
+
22
+ /** @typedef {{pid: number; name: string; tabs: string[]}} TabInventoryFrame */
23
+ /** @typedef {{bindingKey: string; windowId: number; windowPid: number; appId: string}} TabBinding */
24
+ /** @typedef {{ok: true; frames: TabInventoryFrame[]} | {ok: false; unavailable: boolean; error: string}} TabInventoryResult */
25
+ /** @typedef {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} ExecFileAsync */
26
+
27
+ /** @param {NodeJS.ProcessEnv} env */
28
+ function pythonBinaryFor(env) {
29
+ return env.PI_ACTIVITY_STRIP_PYTHON?.trim() || "python3";
30
+ }
31
+
32
+ /**
33
+ * A frame maps to exactly one Niri window of the same process with the same title; every other
34
+ * frame is skipped. Each tab label that carries a surface segment then binds that surface to the
35
+ * window. A surface claimed by two windows is dropped as ambiguous.
36
+ * @param {TabInventoryFrame[]} frames
37
+ * @param {Array<Record<string, unknown>>} windows
38
+ * @returns {TabBinding[]}
39
+ */
40
+ export function mapTabInventoryToBindings(frames, windows) {
41
+ /** @type {Map<string, Array<Record<string, unknown>>>} */
42
+ const byTitle = new Map();
43
+ for (const window of windows) {
44
+ if (!Number.isInteger(window?.id) || !Number.isInteger(window?.pid)) continue;
45
+ if (!GHOSTTY_APP_IDS.has(String(window.app_id ?? ""))) continue;
46
+ const key = `${window.pid} ${String(window.title ?? "")}`;
47
+ const list = byTitle.get(key) ?? [];
48
+ list.push(window);
49
+ byTitle.set(key, list);
50
+ }
51
+
52
+ /** @type {Map<string, TabBinding[]>} */
53
+ const candidates = new Map();
54
+ for (const frame of frames) {
55
+ if (!Array.isArray(frame?.tabs) || frame.tabs.length === 0) continue;
56
+ const matches = byTitle.get(`${frame.pid} ${String(frame.name ?? "")}`) ?? [];
57
+ if (matches.length !== 1) continue;
58
+ const window = matches[0];
59
+ const appId = String(window.app_id ?? "");
60
+ for (const label of frame.tabs) {
61
+ const parsed = parseTerminalTitleBinding(label);
62
+ if (!parsed || appIdForGhosttyFamily(parsed.family) !== appId) continue;
63
+ for (const bindingKey of bindingKeysFor(parsed)) {
64
+ const list = candidates.get(bindingKey) ?? [];
65
+ list.push({
66
+ bindingKey,
67
+ windowId: Number(window.id),
68
+ windowPid: Number(window.pid),
69
+ appId,
70
+ });
71
+ candidates.set(bindingKey, list);
72
+ }
73
+ }
74
+ }
75
+
76
+ const bindings = [];
77
+ for (const list of candidates.values()) {
78
+ const windowIds = new Set(list.map((binding) => binding.windowId));
79
+ if (windowIds.size === 1) bindings.push(list[0]);
80
+ }
81
+ return bindings;
82
+ }
83
+
84
+ /**
85
+ * Run the read-only AT-SPI helper for the given Ghostty process ids. Absent Python bindings are
86
+ * reported as `unavailable` so the caller can stop probing; every other failure is transient.
87
+ * @param {{
88
+ * execFileAsync: ExecFileAsync;
89
+ * env?: NodeJS.ProcessEnv;
90
+ * pids: number[];
91
+ * pythonBin?: string;
92
+ * scriptPath?: string;
93
+ * timeout?: number;
94
+ * }} options
95
+ * @returns {Promise<TabInventoryResult>}
96
+ */
97
+ export async function probeGhosttyTabInventory({
98
+ execFileAsync,
99
+ env = process.env,
100
+ pids,
101
+ pythonBin = pythonBinaryFor(env),
102
+ scriptPath = TAB_INVENTORY_SCRIPT,
103
+ timeout = TAB_INVENTORY_TIMEOUT_MS,
104
+ }) {
105
+ const uniquePids = [...new Set(pids.filter((pid) => Number.isInteger(pid) && pid > 0))];
106
+ if (uniquePids.length === 0) return { ok: true, frames: [] };
107
+ let stdout = "";
108
+ try {
109
+ const result = await execFileAsync(pythonBin, [scriptPath, ...uniquePids.map(String)], {
110
+ env,
111
+ timeout,
112
+ maxBuffer: 4 * 1024 * 1024,
113
+ });
114
+ stdout = String(result.stdout ?? "");
115
+ } catch (error) {
116
+ const failure = /** @type {{code?: unknown; killed?: unknown; stdout?: unknown}} */ (
117
+ error && typeof error === "object" ? error : {}
118
+ );
119
+ const unavailable =
120
+ failure.code === TAB_INVENTORY_UNAVAILABLE_EXIT_CODE || failure.code === "ENOENT";
121
+ let detail = "the tab inventory helper failed";
122
+ try {
123
+ const parsed = JSON.parse(String(failure.stdout ?? ""));
124
+ if (typeof parsed?.error === "string") detail = parsed.error;
125
+ } catch {
126
+ if (failure.code === "ENOENT") detail = `${pythonBin} is not installed`;
127
+ else if (failure.killed) detail = "the tab inventory helper timed out";
128
+ }
129
+ return { ok: false, unavailable, error: detail };
130
+ }
131
+ try {
132
+ const parsed = JSON.parse(stdout);
133
+ if (parsed?.ok !== true || !Array.isArray(parsed.frames)) {
134
+ return {
135
+ ok: false,
136
+ unavailable: false,
137
+ error: "the tab inventory helper returned no frames",
138
+ };
139
+ }
140
+ return {
141
+ ok: true,
142
+ frames: parsed.frames
143
+ .filter(
144
+ /** @param {any} frame */ (frame) =>
145
+ Number.isInteger(frame?.pid) && Array.isArray(frame?.tabs),
146
+ )
147
+ .map(
148
+ /** @param {any} frame */ (frame) => ({
149
+ pid: Number(frame.pid),
150
+ name: String(frame.name ?? ""),
151
+ tabs: frame.tabs.map(String),
152
+ }),
153
+ ),
154
+ };
155
+ } catch {
156
+ return { ok: false, unavailable: false, error: "the tab inventory helper produced no JSON" };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Doctor-time capability probe. Importing the bindings touches no window.
162
+ * @param {{execFileAsync: ExecFileAsync; env?: NodeJS.ProcessEnv; timeout?: number}} options
163
+ * @returns {Promise<{available: boolean; detail: string}>}
164
+ */
165
+ export async function detectTabInventorySupport({
166
+ execFileAsync,
167
+ env = process.env,
168
+ timeout = TAB_INVENTORY_TIMEOUT_MS,
169
+ }) {
170
+ if (env.PI_ACTIVITY_STRIP_TAB_INVENTORY === "0") {
171
+ return { available: false, detail: "disabled by PI_ACTIVITY_STRIP_TAB_INVENTORY=0" };
172
+ }
173
+ const pythonBin = pythonBinaryFor(env);
174
+ try {
175
+ await execFileAsync(pythonBin, ["-c", ATSPI_IMPORT_PROBE], { env, timeout });
176
+ return { available: true, detail: `AT-SPI via ${pythonBin} gi.repository.Atspi` };
177
+ } catch (error) {
178
+ const code = /** @type {{code?: unknown}} */ (error && typeof error === "object" ? error : {})
179
+ .code;
180
+ return {
181
+ available: false,
182
+ detail:
183
+ code === "ENOENT"
184
+ ? `${pythonBin} is not installed`
185
+ : `${pythonBin} gi.repository.Atspi (python-gobject + at-spi2-core) is unavailable`,
186
+ };
187
+ }
188
+ }
@@ -0,0 +1,72 @@
1
+ // ---
2
+ // summary: "keeps the panel's colours in step with Ghostty's theme and the desktop's light/dark switch"
3
+ // read_when:
4
+ // - "changing how often the ribbon re-reads the terminal theme"
5
+ // ---
6
+
7
+ import { paletteDefinitions } from "../common/ghostty-theme.mjs";
8
+ import { detectColorScheme, resolveRibbonTheme, themeSourceFingerprint } from "./theme-source.mjs";
9
+
10
+ /** A theme change is rare and never urgent, so it is polled on a calm clock. */
11
+ export const THEME_POLL_INTERVAL_MS = 20_000;
12
+
13
+ /**
14
+ * @param {{
15
+ * execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>;
16
+ * env?: NodeJS.ProcessEnv;
17
+ * runtimeStatus: import("../common/contracts.ts").ActivityStripRuntimeStatus;
18
+ * publish: (definitions: string) => void;
19
+ * resolveTheme?: typeof resolveRibbonTheme;
20
+ * detectScheme?: typeof detectColorScheme;
21
+ * fingerprint?: typeof themeSourceFingerprint;
22
+ * }} options
23
+ */
24
+ export function createThemeRuntime({
25
+ execFileAsync,
26
+ env = process.env,
27
+ runtimeStatus,
28
+ publish,
29
+ resolveTheme = resolveRibbonTheme,
30
+ detectScheme = detectColorScheme,
31
+ fingerprint = themeSourceFingerprint,
32
+ }) {
33
+ let lastDefinitions = "";
34
+ let inFlight = false;
35
+
36
+ /**
37
+ * Resolve the current theme and hand it to the panel when anything about it changed. `force`
38
+ * republishes unchanged colours, which is what a freshly started panel needs.
39
+ * @param {{force?: boolean}} [options]
40
+ */
41
+ async function refresh({ force = false } = {}) {
42
+ if (inFlight) return false;
43
+ inFlight = true;
44
+ try {
45
+ const scheme = await detectScheme({ execFileAsync, env });
46
+ const resolved = resolveTheme({ scheme, env });
47
+ const definitions = paletteDefinitions(resolved.palette);
48
+ runtimeStatus.themeScheme = scheme;
49
+ runtimeStatus.themeName = resolved.themeName || null;
50
+ runtimeStatus.themeSource = resolved.themePath || resolved.configPath || null;
51
+ runtimeStatus.themeFingerprint = fingerprint(resolved);
52
+ if (!force && definitions === lastDefinitions) return false;
53
+ lastDefinitions = definitions;
54
+ publish(definitions);
55
+ return true;
56
+ } catch (error) {
57
+ runtimeStatus.themeError = error instanceof Error ? error.message : String(error);
58
+ return false;
59
+ } finally {
60
+ inFlight = false;
61
+ }
62
+ }
63
+
64
+ return {
65
+ refresh,
66
+ /** Re-send the colours the panel already had, after it restarts with none. */
67
+ republish: () => (lastDefinitions ? publish(lastDefinitions) : void refresh({ force: true })),
68
+ get definitions() {
69
+ return lastDefinitions;
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,197 @@
1
+ // ---
2
+ // summary: "finds Ghostty's config and theme files and tracks the desktop's light/dark preference"
3
+ // read_when:
4
+ // - "changing Ghostty theme discovery, colour-scheme detection, or the ribbon's theme refresh"
5
+ // ---
6
+
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import {
11
+ buildRibbonPalette,
12
+ parseGhosttyConfig,
13
+ parseGhosttyTheme,
14
+ parseThemeSetting,
15
+ } from "../common/ghostty-theme.mjs";
16
+
17
+ export const COLOR_SCHEME_TIMEOUT_MS = 2000;
18
+ /** The desktop portal's `color-scheme`: 1 asks for dark, 2 for light, 0 expresses no preference. */
19
+ const PORTAL_ARGS = [
20
+ "call",
21
+ "--session",
22
+ "--dest",
23
+ "org.freedesktop.portal.Desktop",
24
+ "--object-path",
25
+ "/org/freedesktop/portal/desktop",
26
+ "--method",
27
+ "org.freedesktop.portal.Settings.ReadOne",
28
+ "org.freedesktop.appearance",
29
+ "color-scheme",
30
+ ];
31
+
32
+ /** @typedef {{readFile: (p: string) => string; exists: (p: string) => boolean; mtime: (p: string) => number}} ThemeFs */
33
+
34
+ /** @type {ThemeFs} */
35
+ export const nodeThemeFs = {
36
+ readFile(filePath) {
37
+ try {
38
+ return fs.readFileSync(filePath, "utf8");
39
+ } catch {
40
+ return "";
41
+ }
42
+ },
43
+ exists(filePath) {
44
+ try {
45
+ return fs.existsSync(filePath);
46
+ } catch {
47
+ return false;
48
+ }
49
+ },
50
+ mtime(filePath) {
51
+ try {
52
+ return fs.statSync(filePath).mtimeMs;
53
+ } catch {
54
+ return 0;
55
+ }
56
+ },
57
+ };
58
+
59
+ /**
60
+ * Where Ghostty looks for its configuration, in the order it reads them.
61
+ * @param {{env?: NodeJS.ProcessEnv; homeDir?: string}} [options]
62
+ */
63
+ export function ghosttyConfigPaths({ env = process.env, homeDir = os.homedir() } = {}) {
64
+ const explicit = String(env.GHOSTTY_CONFIG_PATH ?? "").trim();
65
+ const configHome = String(env.XDG_CONFIG_HOME ?? "").trim() || path.join(homeDir, ".config");
66
+ return [
67
+ ...(explicit ? [explicit] : []),
68
+ path.join(configHome, "ghostty", "config"),
69
+ path.join(homeDir, ".config", "ghostty", "config"),
70
+ ];
71
+ }
72
+
73
+ /**
74
+ * Directories that can hold a named theme. A user theme wins, as it does in Ghostty itself, and
75
+ * installed builds are searched because the current release may ship without the theme set.
76
+ * @param {{env?: NodeJS.ProcessEnv; homeDir?: string; fs?: ThemeFs}} [options]
77
+ */
78
+ export function themeDirectories({
79
+ env = process.env,
80
+ homeDir = os.homedir(),
81
+ fs: fsImpl = nodeThemeFs,
82
+ } = {}) {
83
+ const configHome = String(env.XDG_CONFIG_HOME ?? "").trim() || path.join(homeDir, ".config");
84
+ const resources = String(env.GHOSTTY_RESOURCES_DIR ?? "").trim();
85
+ const candidates = [
86
+ path.join(configHome, "ghostty", "themes"),
87
+ ...(resources ? [path.join(resources, "themes")] : []),
88
+ path.join(homeDir, ".local", "share", "ghostty", "themes"),
89
+ "/usr/share/ghostty/themes",
90
+ "/usr/local/share/ghostty/themes",
91
+ ];
92
+ // Installed builds keep their own resources; the newest that actually carries themes wins.
93
+ const optRoot = path.join(homeDir, ".local", "opt");
94
+ try {
95
+ for (const entry of fs.readdirSync(optRoot)) {
96
+ if (!entry.startsWith("ghostty")) continue;
97
+ for (const release of fs.readdirSync(path.join(optRoot, entry))) {
98
+ candidates.push(path.join(optRoot, entry, release, "share", "ghostty", "themes"));
99
+ }
100
+ }
101
+ } catch {
102
+ // No local installs to search.
103
+ }
104
+ return candidates.filter((directory) => fsImpl.exists(directory));
105
+ }
106
+
107
+ /**
108
+ * Ask the desktop which colour scheme it wants. An unavailable portal is not an error: the ribbon
109
+ * then follows whichever single theme the config names.
110
+ * @param {{execFileAsync: (file: string, args: string[], options: object) => Promise<{stdout?: string}>; env?: NodeJS.ProcessEnv; timeout?: number}} options
111
+ * @returns {Promise<"dark" | "light" | "unknown">}
112
+ */
113
+ export async function detectColorScheme({
114
+ execFileAsync,
115
+ env = process.env,
116
+ timeout = COLOR_SCHEME_TIMEOUT_MS,
117
+ }) {
118
+ // An explicit choice pins the ribbon to one scheme, for a desktop with no portal and for
119
+ // checking how the other scheme looks without changing the whole session.
120
+ const pinned = String(env.PI_ACTIVITY_STRIP_COLOR_SCHEME ?? "")
121
+ .trim()
122
+ .toLowerCase();
123
+ if (pinned === "dark" || pinned === "light") return pinned;
124
+ try {
125
+ const { stdout } = await execFileAsync("gdbus", PORTAL_ARGS, { env, timeout });
126
+ const match = String(stdout ?? "").match(/uint32\s+(\d+)/);
127
+ if (!match) return "unknown";
128
+ if (match[1] === "1") return "dark";
129
+ if (match[1] === "2") return "light";
130
+ return "unknown";
131
+ } catch {
132
+ return "unknown";
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Resolve the palette Ghostty is presenting right now: the configured theme for the active colour
138
+ * scheme, read from the same files the terminal reads. Colours defined directly in the config take
139
+ * precedence over the theme, matching Ghostty's own layering.
140
+ * @param {{scheme?: "dark" | "light" | "unknown"; env?: NodeJS.ProcessEnv; homeDir?: string; fs?: ThemeFs}} [options]
141
+ */
142
+ export function resolveRibbonTheme({
143
+ scheme = "unknown",
144
+ env = process.env,
145
+ homeDir = os.homedir(),
146
+ fs: fsImpl = nodeThemeFs,
147
+ } = {}) {
148
+ const configPath = ghosttyConfigPaths({ env, homeDir }).find((candidate) =>
149
+ fsImpl.exists(candidate),
150
+ );
151
+ const configText = configPath ? fsImpl.readFile(configPath) : "";
152
+ const config = parseGhosttyConfig(configText);
153
+ const setting = parseThemeSetting((config.get("theme") ?? []).at(-1) ?? "");
154
+ const themeName =
155
+ scheme === "light" ? setting.light || setting.dark : setting.dark || setting.light;
156
+
157
+ let themeText = "";
158
+ let themePath = "";
159
+ if (themeName) {
160
+ for (const directory of themeDirectories({ env, homeDir, fs: fsImpl })) {
161
+ const candidate = path.join(directory, themeName);
162
+ if (!fsImpl.exists(candidate)) continue;
163
+ themePath = candidate;
164
+ themeText = fsImpl.readFile(candidate);
165
+ if (themeText) break;
166
+ }
167
+ }
168
+
169
+ const theme = parseGhosttyTheme(themeText);
170
+ const inline = parseGhosttyTheme(configText);
171
+ const merged = {
172
+ background: inline.background || theme.background,
173
+ foreground: inline.foreground || theme.foreground,
174
+ cursor: inline.cursor || theme.cursor,
175
+ selectionBackground: inline.selectionBackground || theme.selectionBackground,
176
+ palette: theme.palette.map((color, index) => inline.palette[index] || color),
177
+ };
178
+ return {
179
+ scheme,
180
+ themeName,
181
+ configPath: configPath ?? "",
182
+ themePath,
183
+ palette: buildRibbonPalette(merged),
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Files whose modification should retrigger a resolve.
189
+ * @param {{configPath?: string; themePath?: string} | null} resolved
190
+ * @param {ThemeFs} [fsImpl]
191
+ */
192
+ export function themeSourceFingerprint(resolved, fsImpl = nodeThemeFs) {
193
+ return [resolved?.configPath, resolved?.themePath]
194
+ .filter(Boolean)
195
+ .map((filePath) => `${filePath}:${fsImpl.mtime(String(filePath))}`)
196
+ .join("|");
197
+ }