@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.
- package/README.md +41 -6
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +22 -1
- package/native/bin/linux-x64-gnu/artifact.json +2 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +20 -20
- package/native/panel/src/card_view.rs +308 -9
- package/native/panel/src/protocol.rs +63 -0
- package/native/panel/src/runtime.rs +50 -9
- package/native/panel/src/style.css +134 -60
- package/package.json +4 -4
- package/src/common/agent-identity.mjs +163 -0
- package/src/common/ak-tasks.mjs +354 -0
- package/src/common/claude-events.mjs +190 -0
- package/src/common/claude-hook-config.mjs +56 -0
- package/src/common/claude-transcript.mjs +163 -0
- package/src/common/codex-transcript.mjs +153 -0
- package/src/common/compatibility.mjs +13 -0
- package/src/common/contracts.d.ts +26 -0
- package/src/common/contracts.ts +64 -1
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +179 -110
- package/src/common/session-cards.mjs +5 -2
- package/src/common/status-report.mjs +51 -0
- package/src/common/surface-bindings.mjs +234 -0
- package/src/common/terminal-identity.mjs +75 -12
- package/src/common/window-placement.mjs +59 -0
- package/src/common/workspace-view.mjs +161 -0
- package/src/native/agent-discovery.mjs +294 -0
- package/src/native/ak-runtime.mjs +164 -0
- package/src/native/codex-discovery.mjs +128 -0
- package/src/native/ghostty-tab-inventory.py +99 -0
- package/src/native/height-repair.mjs +154 -0
- package/src/native/main.mjs +120 -14
- package/src/native/panel-projection.mjs +67 -5
- package/src/native/placement.mjs +225 -0
- package/src/native/tab-inventory.mjs +188 -0
- package/src/native/theme-runtime.mjs +72 -0
- package/src/native/theme-source.mjs +197 -0
- package/src/native/workspace-events.mjs +16 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "resolves the colours Ghostty is currently using into the ribbon's palette"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing how the ribbon follows the Ghostty theme or the day/night switch"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ghostty writes its palette as `key = value` lines, in its config and in each theme file. A theme
|
|
9
|
+
* setting may name one theme, or one per colour scheme as `light:Name,dark:Name`. The ribbon reads
|
|
10
|
+
* the same files the terminal does, so the two always agree without any configuration of its own.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const HEX_COLOR = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
14
|
+
export const PALETTE_SIZE = 16;
|
|
15
|
+
|
|
16
|
+
/** @typedef {{background: string; foreground: string; accent: string; thinking: string; tool: string; waiting: string; success: string; error: string}} RibbonPalette */
|
|
17
|
+
|
|
18
|
+
/** Everforest-neutral defaults, used only when no theme can be read. */
|
|
19
|
+
/** @type {RibbonPalette} */
|
|
20
|
+
export const FALLBACK_PALETTE = Object.freeze({
|
|
21
|
+
background: "#1e2326",
|
|
22
|
+
foreground: "#d3c6aa",
|
|
23
|
+
accent: "#7fbbb3",
|
|
24
|
+
thinking: "#7fbbb3",
|
|
25
|
+
tool: "#dbbc7f",
|
|
26
|
+
waiting: "#e69875",
|
|
27
|
+
success: "#a7c080",
|
|
28
|
+
error: "#e67e80",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** @param {unknown} value */
|
|
32
|
+
export function normalizeHexColor(value) {
|
|
33
|
+
const raw = String(value ?? "").trim();
|
|
34
|
+
if (!HEX_COLOR.test(raw)) return "";
|
|
35
|
+
const body = raw.replace(/^#/, "").toLowerCase();
|
|
36
|
+
const full =
|
|
37
|
+
body.length === 3
|
|
38
|
+
? body
|
|
39
|
+
.split("")
|
|
40
|
+
.map((character) => character + character)
|
|
41
|
+
.join("")
|
|
42
|
+
: body;
|
|
43
|
+
return `#${full}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read every `key = value` pair, ignoring comments and blank lines. Repeated keys are kept in
|
|
48
|
+
* order, because `palette` appears once per colour index.
|
|
49
|
+
* @param {string} text
|
|
50
|
+
*/
|
|
51
|
+
export function parseGhosttyConfig(text) {
|
|
52
|
+
/** @type {Map<string, string[]>} */
|
|
53
|
+
const entries = new Map();
|
|
54
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
57
|
+
const separator = trimmed.indexOf("=");
|
|
58
|
+
if (separator <= 0) continue;
|
|
59
|
+
const key = trimmed.slice(0, separator).trim().toLowerCase();
|
|
60
|
+
const value = trimmed.slice(separator + 1).trim();
|
|
61
|
+
entries.set(key, [...(entries.get(key) ?? []), value]);
|
|
62
|
+
}
|
|
63
|
+
return entries;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Names of the themes a config selects. `light:A,dark:B` follows the desktop's colour scheme; a
|
|
68
|
+
* bare name is used for both.
|
|
69
|
+
* @param {string} value
|
|
70
|
+
* @returns {{light: string; dark: string}}
|
|
71
|
+
*/
|
|
72
|
+
export function parseThemeSetting(value) {
|
|
73
|
+
const raw = String(value ?? "").trim();
|
|
74
|
+
if (!raw) return { light: "", dark: "" };
|
|
75
|
+
if (!/(^|,)\s*(light|dark)\s*:/i.test(raw)) return { light: raw, dark: raw };
|
|
76
|
+
const themes = { light: "", dark: "" };
|
|
77
|
+
for (const part of raw.split(",")) {
|
|
78
|
+
const match = part.match(/^\s*(light|dark)\s*:\s*(.+?)\s*$/i);
|
|
79
|
+
if (match) themes[match[1].toLowerCase() === "dark" ? "dark" : "light"] = match[2];
|
|
80
|
+
}
|
|
81
|
+
return themes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Colours from a theme file or a config that carries them inline.
|
|
86
|
+
* @param {string} text
|
|
87
|
+
*/
|
|
88
|
+
export function parseGhosttyTheme(text) {
|
|
89
|
+
const entries = parseGhosttyConfig(text);
|
|
90
|
+
/** @type {string[]} */
|
|
91
|
+
const palette = new Array(PALETTE_SIZE).fill("");
|
|
92
|
+
for (const value of entries.get("palette") ?? []) {
|
|
93
|
+
const match = String(value).match(/^\s*(\d+)\s*=\s*(.+?)\s*$/);
|
|
94
|
+
if (!match) continue;
|
|
95
|
+
const index = Number.parseInt(match[1], 10);
|
|
96
|
+
const color = normalizeHexColor(match[2]);
|
|
97
|
+
if (Number.isInteger(index) && index >= 0 && index < PALETTE_SIZE && color) {
|
|
98
|
+
palette[index] = color;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** @param {string} key */
|
|
102
|
+
const single = (key) => normalizeHexColor((entries.get(key) ?? []).at(-1));
|
|
103
|
+
return {
|
|
104
|
+
background: single("background"),
|
|
105
|
+
foreground: single("foreground"),
|
|
106
|
+
cursor: single("cursor-color"),
|
|
107
|
+
selectionBackground: single("selection-background"),
|
|
108
|
+
palette,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {ReturnType<typeof parseGhosttyTheme>} theme @param {number} index @param {string} fallback */
|
|
113
|
+
function paletteColor(theme, index, fallback) {
|
|
114
|
+
return theme?.palette?.[index] || fallback;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Map terminal colours onto the ribbon's roles. State colours reuse the terminal's own semantics,
|
|
119
|
+
* so a card reads the same way as output in the tab it points at: green settled, yellow working,
|
|
120
|
+
* red failed, and the cursor colour for a session that wants the operator.
|
|
121
|
+
* @param {ReturnType<typeof parseGhosttyTheme> | null} theme
|
|
122
|
+
* @returns {RibbonPalette}
|
|
123
|
+
*/
|
|
124
|
+
export function buildRibbonPalette(theme) {
|
|
125
|
+
if (!theme?.background || !theme?.foreground) return { ...FALLBACK_PALETTE };
|
|
126
|
+
const blue = paletteColor(theme, 4, FALLBACK_PALETTE.thinking);
|
|
127
|
+
return {
|
|
128
|
+
background: theme.background,
|
|
129
|
+
foreground: theme.foreground,
|
|
130
|
+
accent: blue,
|
|
131
|
+
thinking: blue,
|
|
132
|
+
tool: paletteColor(theme, 3, FALLBACK_PALETTE.tool),
|
|
133
|
+
waiting: theme.cursor || paletteColor(theme, 5, FALLBACK_PALETTE.waiting),
|
|
134
|
+
success: paletteColor(theme, 2, FALLBACK_PALETTE.success),
|
|
135
|
+
error: paletteColor(theme, 1, FALLBACK_PALETTE.error),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The GTK colour definitions the panel prepends to its stylesheet. Everything else the ribbon
|
|
141
|
+
* draws is derived from these in CSS, so a theme change is a handful of values.
|
|
142
|
+
* @param {RibbonPalette} palette
|
|
143
|
+
*/
|
|
144
|
+
export function paletteDefinitions(palette) {
|
|
145
|
+
const colors = { ...FALLBACK_PALETTE, ...(palette ?? {}) };
|
|
146
|
+
return Object.entries(colors)
|
|
147
|
+
.map(([name, value]) => `@define-color pi_${name} ${normalizeHexColor(value) || "#000000"};`)
|
|
148
|
+
.join("\n");
|
|
149
|
+
}
|
|
@@ -6,19 +6,34 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
import { presentGhosttySurface } from "./ghostty-present.mjs";
|
|
9
10
|
import { projectSessionCards, sessionCardId } from "./session-cards.mjs";
|
|
11
|
+
import { parseTerminalTitleBinding } from "./surface-bindings.mjs";
|
|
10
12
|
import {
|
|
11
13
|
appIdForGhosttyFamily,
|
|
12
14
|
canonicalGhosttyTerminalKey,
|
|
13
15
|
normalizeGhosttySurfaceId,
|
|
14
16
|
terminalTitleSegment,
|
|
15
17
|
} from "./terminal-identity.mjs";
|
|
18
|
+
import { resolveHiddenSurfaceWindow } from "./window-placement.mjs";
|
|
16
19
|
|
|
17
20
|
const GHOSTTY_APP_IDS = new Set(["com.mitchellh.ghostty", "com.tryinget.ghosttysidequest"]);
|
|
18
21
|
const PI_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
22
|
const SESSION_PRESENCE_SOURCE = "@tryinget/pi-little-helpers/session-presence";
|
|
20
23
|
const SESSION_TITLE_TOKEN_HEX_LENGTH = 32;
|
|
21
24
|
const LEGACY_SESSION_TITLE_TOKEN_HEX_LENGTH = 8;
|
|
25
|
+
const PRESENT_VERIFY_ATTEMPTS = 4;
|
|
26
|
+
const PRESENT_VERIFY_DELAY_MS = 120;
|
|
27
|
+
|
|
28
|
+
/** @typedef {import("./window-placement.mjs").PlacementOptions} PlacementOptions */
|
|
29
|
+
/** @typedef {import("./window-placement.mjs").WindowPlacement} WindowPlacement */
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {PlacementOptions & {
|
|
32
|
+
* env?: NodeJS.ProcessEnv;
|
|
33
|
+
* readFileSync?: typeof fs.readFileSync;
|
|
34
|
+
* existsSync?: typeof fs.existsSync;
|
|
35
|
+
* }} ResolveOptions
|
|
36
|
+
*/
|
|
22
37
|
|
|
23
38
|
/**
|
|
24
39
|
* Read one bounded Niri JSON list. Polling callers treat command, timeout,
|
|
@@ -167,156 +182,210 @@ export function resolveExactGhosttyWindow(windows, sessionId, session = {}) {
|
|
|
167
182
|
}
|
|
168
183
|
|
|
169
184
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* fail closed so the renderer never highlights a guessed session.
|
|
185
|
+
* Place a session on its window. A visible title is exact proof; a bound surface whose tab is
|
|
186
|
+
* hidden behind another tab is placed through its Ghostty host process and remembered window.
|
|
173
187
|
* @param {Array<Record<string, unknown>>} windows
|
|
174
|
-
* @param {
|
|
175
|
-
* @param {
|
|
188
|
+
* @param {string} sessionId
|
|
189
|
+
* @param {Record<string, unknown>} [session]
|
|
190
|
+
* @param {PlacementOptions} [options]
|
|
191
|
+
* @returns {WindowPlacement | null}
|
|
176
192
|
*/
|
|
177
|
-
export function
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (!sessionId) return false;
|
|
184
|
-
return resolveExactGhosttyWindow(windows, sessionId, session)?.id === focusedWindow.id;
|
|
193
|
+
export function resolveSessionWindow(windows, sessionId, session = {}, options = {}) {
|
|
194
|
+
const exact = resolveExactGhosttyWindow(windows, sessionId, session);
|
|
195
|
+
if (exact) return { window: exact, placement: "title" };
|
|
196
|
+
return resolveHiddenSurfaceWindow(windows, session, {
|
|
197
|
+
...options,
|
|
198
|
+
sessionToken: shortSessionId(sessionId),
|
|
185
199
|
});
|
|
186
|
-
return matches.length === 1 ? String(matches[0]?.sessionId ?? "") || null : null;
|
|
187
200
|
}
|
|
188
201
|
|
|
189
202
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
203
|
+
* A tab occupied by a coding agent other than Pi carries no Pi identity, so it is placed by the
|
|
204
|
+
* exact title the agent put on its own terminal, and otherwise by host containment and window
|
|
205
|
+
* memory. A title claimed by more than one window is ambiguous and places nothing.
|
|
193
206
|
* @param {Array<Record<string, unknown>>} windows
|
|
194
|
-
* @param {Record<string, unknown>}
|
|
195
|
-
* @param {
|
|
196
|
-
* @
|
|
207
|
+
* @param {Record<string, unknown>} session
|
|
208
|
+
* @param {PlacementOptions} [options]
|
|
209
|
+
* @returns {WindowPlacement | null}
|
|
197
210
|
*/
|
|
198
|
-
export function
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const cardIdsByWindow = new Map();
|
|
213
|
-
for (const candidate of candidates) {
|
|
214
|
-
const ids = cardIdsByWindow.get(candidate.window.id) ?? new Set();
|
|
215
|
-
ids.add(candidate.cardId);
|
|
216
|
-
cardIdsByWindow.set(candidate.window.id, ids);
|
|
217
|
-
}
|
|
218
|
-
const allowedCardByWindow = new Map();
|
|
219
|
-
for (const [windowId, ids] of cardIdsByWindow) {
|
|
220
|
-
const terminalIds = [...ids].filter((cardId) => cardId.startsWith("terminal:"));
|
|
221
|
-
if (terminalIds.length === 1) allowedCardByWindow.set(windowId, terminalIds[0]);
|
|
222
|
-
else if (ids.size === 1) allowedCardByWindow.set(windowId, [...ids][0]);
|
|
211
|
+
export function resolveAgentSessionWindow(windows, session, options = {}) {
|
|
212
|
+
const titleSuffix = String(session.titleSuffix ?? "").trim();
|
|
213
|
+
if (titleSuffix) {
|
|
214
|
+
const expectedAppId = appIdForGhosttyFamily(String(session.terminalFamily ?? ""));
|
|
215
|
+
const matches = windows.filter(
|
|
216
|
+
(window) =>
|
|
217
|
+
Number.isInteger(window?.id) &&
|
|
218
|
+
String(window?.app_id ?? "") === expectedAppId &&
|
|
219
|
+
String(window?.title ?? "").endsWith(titleSuffix),
|
|
220
|
+
);
|
|
221
|
+
if (matches.length === 1) return { window: matches[0], placement: "title" };
|
|
222
|
+
if (matches.length > 1) return null;
|
|
223
223
|
}
|
|
224
|
+
return resolveHiddenSurfaceWindow(windows, session, options);
|
|
225
|
+
}
|
|
224
226
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Whether a session could ever be placed by a title. Activation can only confirm that a presented
|
|
229
|
+
* tab became visible for these; for anything else the compositor offers no readable proof.
|
|
230
|
+
* @param {Record<string, unknown>} session
|
|
231
|
+
* @param {ResolveOptions} [options]
|
|
232
|
+
*/
|
|
233
|
+
export function canVerifyTabVisibility(session, options = {}) {
|
|
234
|
+
if (resolvePiSessionIdentity(session, options)) return true;
|
|
235
|
+
return String(session?.titleSuffix ?? "").trim().length > 0;
|
|
236
|
+
}
|
|
233
237
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
workspace,
|
|
248
|
-
sessions: projectedSessions,
|
|
249
|
-
focusedSessionId: focusedCard ? String(focusedCard.sessionId ?? "") || null : null,
|
|
250
|
-
focusedCardId: focusedCard ? String(focusedCard.cardId ?? "") || null : null,
|
|
251
|
-
};
|
|
238
|
+
/**
|
|
239
|
+
* Place any tracked session: a Pi session by its exact Pi identity, an agent tab by its own title.
|
|
240
|
+
* @param {Array<Record<string, unknown>>} windows
|
|
241
|
+
* @param {Record<string, unknown>} session
|
|
242
|
+
* @param {ResolveOptions} [options]
|
|
243
|
+
* @returns {WindowPlacement | null}
|
|
244
|
+
*/
|
|
245
|
+
export function placeSessionWindow(windows, session, options = {}) {
|
|
246
|
+
const piSessionId = resolvePiSessionIdentity(session, options);
|
|
247
|
+
if (piSessionId) return resolveSessionWindow(windows, piSessionId, session, options);
|
|
248
|
+
return canonicalGhosttyTerminalKey(session)
|
|
249
|
+
? resolveAgentSessionWindow(windows, session, options)
|
|
250
|
+
: null;
|
|
252
251
|
}
|
|
253
252
|
|
|
254
253
|
/**
|
|
255
|
-
*
|
|
254
|
+
* Resolve the one snapshot session whose exact Ghostty window currently owns
|
|
255
|
+
* compositor focus. Missing, ambiguous, non-Ghostty, and stale identities all
|
|
256
|
+
* fail closed so the renderer never highlights a guessed session.
|
|
256
257
|
* @param {Array<Record<string, unknown>>} windows
|
|
257
|
-
* @param {Array<Record<string, unknown>>} workspaces
|
|
258
258
|
* @param {Array<Record<string, unknown>>} sessions
|
|
259
259
|
* @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
|
|
260
260
|
*/
|
|
261
|
-
export function
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
Number.isInteger(workspace?.id) &&
|
|
272
|
-
(typeof workspace?.name === "string" || Number.isInteger(workspace?.idx)),
|
|
273
|
-
);
|
|
274
|
-
return matches.length === 1 ? matches[0] : null;
|
|
261
|
+
export function resolveFocusedSnapshotSessionId(windows, sessions, options = {}) {
|
|
262
|
+
const focusedWindows = windows.filter((window) => window?.is_focused === true);
|
|
263
|
+
if (focusedWindows.length !== 1) return null;
|
|
264
|
+
const focusedWindow = focusedWindows[0];
|
|
265
|
+
const matches = sessions.filter((session) => {
|
|
266
|
+
const sessionId = resolvePiSessionIdentity(session, options);
|
|
267
|
+
if (!sessionId) return false;
|
|
268
|
+
return resolveExactGhosttyWindow(windows, sessionId, session)?.id === focusedWindow.id;
|
|
269
|
+
});
|
|
270
|
+
return matches.length === 1 ? String(matches[0]?.sessionId ?? "") || null : null;
|
|
275
271
|
}
|
|
276
272
|
|
|
277
273
|
/**
|
|
274
|
+
* Focus the exact terminal behind a card. A visible tab needs only compositor focus. A hidden tab
|
|
275
|
+
* is first presented inside its proven Ghostty host process, then its window is focused, and the
|
|
276
|
+
* result counts as success only once the window title proves the tab became visible.
|
|
278
277
|
* @param {string | Record<string, unknown>} session
|
|
279
278
|
* @param {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} execFileAsync
|
|
280
279
|
* @param {NodeJS.ProcessEnv} [env]
|
|
281
|
-
* @param {
|
|
280
|
+
* @param {PlacementOptions & {
|
|
281
|
+
* readFileSync?: typeof fs.readFileSync;
|
|
282
|
+
* existsSync?: typeof fs.existsSync;
|
|
283
|
+
* presentSurface?: typeof presentGhosttySurface;
|
|
284
|
+
* sleep?: (milliseconds: number) => Promise<void>;
|
|
285
|
+
* }} [options]
|
|
286
|
+
* @returns {Promise<{ok: boolean; error?: string; windowId?: number; presented?: boolean; verified?: boolean}>}
|
|
282
287
|
*/
|
|
283
288
|
export async function focusNiriSession(session, execFileAsync, env = process.env, options = {}) {
|
|
284
289
|
if (!env.NIRI_SOCKET) return { ok: false, error: "Niri is not available; focus did nothing." };
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
});
|
|
290
|
-
if (!sessionId) {
|
|
290
|
+
const record =
|
|
291
|
+
session && typeof session === "object" ? session : { sessionId: String(session ?? "") };
|
|
292
|
+
const placementOptions = { ...options, env };
|
|
293
|
+
if (!resolvePiSessionIdentity(record, placementOptions) && !canonicalGhosttyTerminalKey(record)) {
|
|
291
294
|
return {
|
|
292
295
|
ok: false,
|
|
293
296
|
error: "Exact Pi identity is unavailable; reload that Pi tab and try again.",
|
|
294
297
|
};
|
|
295
298
|
}
|
|
299
|
+
const readWindows = async () => {
|
|
300
|
+
const result = await execFileAsync("niri", ["msg", "-j", "windows"], { env });
|
|
301
|
+
const windows = JSON.parse(String(result.stdout ?? "[]"));
|
|
302
|
+
if (!Array.isArray(windows)) throw new Error("Unexpected Niri window data");
|
|
303
|
+
return windows;
|
|
304
|
+
};
|
|
296
305
|
let windows;
|
|
297
306
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
307
|
+
windows = await readWindows();
|
|
308
|
+
} catch (error) {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
error:
|
|
312
|
+
error instanceof Error && error.message === "Unexpected Niri window data"
|
|
313
|
+
? "Unexpected Niri window data; focus did nothing."
|
|
314
|
+
: "Could not inspect Niri windows; focus did nothing.",
|
|
315
|
+
};
|
|
305
316
|
}
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
if (!target) {
|
|
317
|
+
const placed = placeSessionWindow(windows, record, placementOptions);
|
|
318
|
+
if (!placed) {
|
|
309
319
|
return {
|
|
310
320
|
ok: false,
|
|
311
|
-
error: "
|
|
321
|
+
error: "That session did not resolve to exactly one Ghostty window; focus did nothing.",
|
|
312
322
|
};
|
|
313
323
|
}
|
|
324
|
+
const target = placed.window;
|
|
325
|
+
const targetId = Number(target.id);
|
|
326
|
+
const focusWindow = () =>
|
|
327
|
+
execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(targetId)], { env });
|
|
328
|
+
if (placed.placement === "title") {
|
|
329
|
+
try {
|
|
330
|
+
await focusWindow();
|
|
331
|
+
return { ok: true, windowId: targetId };
|
|
332
|
+
} catch {
|
|
333
|
+
return { ok: false, error: "Niri rejected the exact focus request; focus did nothing." };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const presented = await (options.presentSurface ?? presentGhosttySurface)({
|
|
338
|
+
execFileAsync,
|
|
339
|
+
env,
|
|
340
|
+
hostPid: Number(target.pid),
|
|
341
|
+
terminalFamily: record.terminalFamily,
|
|
342
|
+
surfaceId: record.terminalSurfaceId,
|
|
343
|
+
});
|
|
314
344
|
try {
|
|
315
|
-
await
|
|
316
|
-
env,
|
|
317
|
-
});
|
|
318
|
-
return { ok: true, windowId: target.id };
|
|
345
|
+
await focusWindow();
|
|
319
346
|
} catch {
|
|
320
|
-
return {
|
|
347
|
+
return {
|
|
348
|
+
ok: false,
|
|
349
|
+
windowId: targetId,
|
|
350
|
+
presented: false,
|
|
351
|
+
error: presented.ok
|
|
352
|
+
? "Presented the hidden tab, but Niri rejected the window focus request."
|
|
353
|
+
: "Niri rejected the exact focus request; focus did nothing.",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (!presented.ok) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
windowId: targetId,
|
|
360
|
+
presented: false,
|
|
361
|
+
error: `Focused the Ghostty window, but could not present the hidden tab (${presented.error}); select it manually.`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (!canVerifyTabVisibility(record, placementOptions)) {
|
|
365
|
+
// Nothing this session writes is readable from the compositor, so the presented tab cannot be
|
|
366
|
+
// confirmed. The action succeeded; the outcome is reported without claiming proof.
|
|
367
|
+
return { ok: true, windowId: targetId, presented: true, verified: false };
|
|
368
|
+
}
|
|
369
|
+
const sleep =
|
|
370
|
+
options.sleep ??
|
|
371
|
+
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
372
|
+
for (let attempt = 0; attempt < PRESENT_VERIFY_ATTEMPTS; attempt += 1) {
|
|
373
|
+
if (attempt > 0) await sleep(PRESENT_VERIFY_DELAY_MS);
|
|
374
|
+
try {
|
|
375
|
+
const current = await readWindows();
|
|
376
|
+
const confirmed = placeSessionWindow(current, record, placementOptions);
|
|
377
|
+
if (confirmed?.placement === "title" && Number(confirmed.window.id) === targetId) {
|
|
378
|
+
return { ok: true, windowId: targetId, presented: true, verified: true };
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
321
383
|
}
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
windowId: targetId,
|
|
387
|
+
presented: false,
|
|
388
|
+
error:
|
|
389
|
+
"Focused the Ghostty window, but the hidden tab did not become visible; select it manually.",
|
|
390
|
+
};
|
|
322
391
|
}
|
|
@@ -54,7 +54,7 @@ export function comparePublisherRepresentatives(left, right) {
|
|
|
54
54
|
/**
|
|
55
55
|
* @param {SessionLike[]} [sessions]
|
|
56
56
|
* @param {Set<string> | null} [allowedCardIds]
|
|
57
|
-
* @returns {Array<SessionLike & {cardId: string; publisherCount: number; publisherIds: string[]; publisherRecordKeys: string[]}>}
|
|
57
|
+
* @returns {Array<SessionLike & {cardId: string; publisherCount: number; publisherIds: string[]; publisherRecordKeys: string[]; publisherSessionIds: string[]}>}
|
|
58
58
|
*/
|
|
59
59
|
export function projectSessionCards(sessions = [], allowedCardIds = null) {
|
|
60
60
|
/** @type {Map<string, SessionLike[]>} */
|
|
@@ -67,7 +67,7 @@ export function projectSessionCards(sessions = [], allowedCardIds = null) {
|
|
|
67
67
|
groups.set(cardId, group);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** @type {Array<SessionLike & {cardId: string; publisherCount: number; publisherIds: string[]; publisherRecordKeys: string[]}>} */
|
|
70
|
+
/** @type {Array<SessionLike & {cardId: string; publisherCount: number; publisherIds: string[]; publisherRecordKeys: string[]; publisherSessionIds: string[]}>} */
|
|
71
71
|
const cards = [];
|
|
72
72
|
for (const [cardId, group] of groups) {
|
|
73
73
|
const ordered = [...group].sort(comparePublisherRepresentatives);
|
|
@@ -79,6 +79,9 @@ export function projectSessionCards(sessions = [], allowedCardIds = null) {
|
|
|
79
79
|
publisherCount: group.length,
|
|
80
80
|
publisherIds: group.map((session) => String(session.publisherId ?? "")).filter(Boolean),
|
|
81
81
|
publisherRecordKeys: group.map(sessionRecordKey),
|
|
82
|
+
publisherSessionIds: [
|
|
83
|
+
...new Set(group.map((session) => String(session.sessionId ?? "")).filter(Boolean)),
|
|
84
|
+
],
|
|
82
85
|
});
|
|
83
86
|
}
|
|
84
87
|
return cards;
|
|
@@ -26,6 +26,57 @@ export function formatBrokerRuntimeStatus(result) {
|
|
|
26
26
|
if (typeof runtime.windowVisible === "boolean") {
|
|
27
27
|
lines.push(`surface visible: ${runtime.windowVisible ? "yes" : "no"}`);
|
|
28
28
|
}
|
|
29
|
+
if (typeof runtime.rendererCardCount === "number") {
|
|
30
|
+
const hidden =
|
|
31
|
+
typeof runtime.rendererHiddenTabCardCount === "number"
|
|
32
|
+
? ` (${runtime.rendererHiddenTabCardCount} hidden tabs)`
|
|
33
|
+
: "";
|
|
34
|
+
lines.push(`cards on focused workspace: ${runtime.rendererCardCount}${hidden}`);
|
|
35
|
+
}
|
|
36
|
+
if (typeof runtime.surfaceBindingCount === "number") {
|
|
37
|
+
lines.push(`remembered tab windows: ${runtime.surfaceBindingCount}`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof runtime.agentTabCount === "number") {
|
|
40
|
+
lines.push(`agent tabs discovered: ${runtime.agentTabCount}`);
|
|
41
|
+
}
|
|
42
|
+
if (runtime.themeName) {
|
|
43
|
+
lines.push(`theme: ${runtime.themeName} (${runtime.themeScheme ?? "unknown"})`);
|
|
44
|
+
}
|
|
45
|
+
if (runtime.agentScanError) {
|
|
46
|
+
lines.push(`agent discovery error: ${runtime.agentScanError}`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof runtime.heightRepairState === "string") {
|
|
49
|
+
const repaired = Number(runtime.heightRepairCount ?? 0);
|
|
50
|
+
lines.push(
|
|
51
|
+
`window height repair: ${runtime.heightRepairState}${repaired > 0 ? ` (${repaired} restored to automatic)` : ""}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (typeof runtime.tabInventoryState === "string") {
|
|
55
|
+
const detail =
|
|
56
|
+
runtime.tabInventoryState === "ready"
|
|
57
|
+
? ` (${runtime.tabInventoryFrameCount ?? 0} Ghostty windows, ${runtime.tabInventoryTabCount ?? 0} tabs)`
|
|
58
|
+
: runtime.tabInventoryDetail
|
|
59
|
+
? ` (${runtime.tabInventoryDetail})`
|
|
60
|
+
: "";
|
|
61
|
+
lines.push(`tab inventory: ${runtime.tabInventoryState}${detail}`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof runtime.akTaskState === "string") {
|
|
64
|
+
if (runtime.akTaskState === "ok") {
|
|
65
|
+
const orphaned = Number(runtime.akTaskOrphanCount ?? 0);
|
|
66
|
+
lines.push(
|
|
67
|
+
`AK task refs: ok (${runtime.akTaskClaimCount ?? 0} live claims, ${runtime.akTaskDeferredCount ?? 0} deferred${orphaned > 0 ? `, ${orphaned} orphaned claims` : ""})`,
|
|
68
|
+
);
|
|
69
|
+
} else if (runtime.akTaskState !== "disabled") {
|
|
70
|
+
lines.push(
|
|
71
|
+
`AK task refs: ${runtime.akTaskState}${runtime.akTaskError ? ` (${runtime.akTaskError})` : ""}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (typeof runtime.unplacedSurfaceCount === "number" && runtime.unplacedSurfaceCount > 0) {
|
|
76
|
+
lines.push(
|
|
77
|
+
`unplaced hidden tabs: ${runtime.unplacedSurfaceCount} (placed once the tab inventory or a visible title reveals their window)`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
29
80
|
if (runtime.error) {
|
|
30
81
|
lines.push(`error: ${runtime.error}`);
|
|
31
82
|
}
|