@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,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
- * Resolve the one snapshot session whose exact Ghostty window currently owns
171
- * compositor focus. Missing, ambiguous, non-Ghostty, and stale identities all
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 {Array<Record<string, unknown>>} sessions
175
- * @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
188
+ * @param {string} sessionId
189
+ * @param {Record<string, unknown>} [session]
190
+ * @param {PlacementOptions} [options]
191
+ * @returns {WindowPlacement | null}
176
192
  */
177
- export function resolveFocusedSnapshotSessionId(windows, sessions, options = {}) {
178
- const focusedWindows = windows.filter((window) => window?.is_focused === true);
179
- if (focusedWindows.length !== 1) return null;
180
- const focusedWindow = focusedWindows[0];
181
- const matches = sessions.filter((session) => {
182
- const sessionId = resolvePiSessionIdentity(session, options);
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
- * Project the global broker snapshot onto one exact Niri workspace. Membership
191
- * requires an exact Ghostty window. Multiple publisher streams bound to the same terminal are
192
- * aggregated into one stable card; ambiguity between distinct windows still fails closed.
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>} workspace
195
- * @param {Array<Record<string, unknown>>} sessions
196
- * @param {{env?: NodeJS.ProcessEnv; readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
207
+ * @param {Record<string, unknown>} session
208
+ * @param {PlacementOptions} [options]
209
+ * @returns {WindowPlacement | null}
197
210
  */
198
- export function resolveWorkspaceView(windows, workspace, sessions, options = {}) {
199
- if (!Number.isInteger(workspace?.id)) return null;
200
- /** @type {Array<{cardId: string; session: Record<string, unknown>; window: Record<string, unknown>}>} */
201
- const candidates = [];
202
- for (const session of sessions) {
203
- const sessionIdentity = resolvePiSessionIdentity(session, options);
204
- if (!sessionIdentity) continue;
205
- const window = resolveExactGhosttyWindow(windows, sessionIdentity, session);
206
- if (!window || window.workspace_id !== workspace.id) continue;
207
- const cardId = sessionCardId(session);
208
- if (!cardId) continue;
209
- candidates.push({ cardId, session: { ...session, cardId }, window });
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
- /** @type {Map<string, Array<{session: Record<string, unknown>; window: Record<string, unknown>}>>} */
226
- const groups = new Map();
227
- for (const candidate of candidates) {
228
- if (allowedCardByWindow.get(candidate.window.id) !== candidate.cardId) continue;
229
- const group = groups.get(candidate.cardId) ?? [];
230
- group.push(candidate);
231
- groups.set(candidate.cardId, group);
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
- const projectedSessions = [];
235
- for (const [cardId, group] of groups) {
236
- const windowIds = new Set(group.map((candidate) => candidate.window.id));
237
- if (windowIds.size !== 1) continue;
238
- const [card] = projectSessionCards(group.map((candidate) => candidate.session));
239
- if (!card) continue;
240
- const window = group[0]?.window;
241
- projectedSessions.push({ ...card, cardId, windowId: window?.id });
242
- }
243
- const focusedCard = projectedSessions.find((session) =>
244
- windows.some((window) => window?.id === session.windowId && window?.is_focused === true),
245
- );
246
- return {
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
- * Project the global broker snapshot onto the one focused Niri workspace.
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 resolveFocusedWorkspaceView(windows, workspaces, sessions, options = {}) {
262
- const workspace = resolveFocusedNiriWorkspace(workspaces);
263
- return workspace ? resolveWorkspaceView(windows, workspace, sessions, options) : null;
264
- }
265
-
266
- /** @param {Array<Record<string, unknown>>} workspaces */
267
- export function resolveFocusedNiriWorkspace(workspaces) {
268
- const matches = workspaces.filter(
269
- (workspace) =>
270
- workspace?.is_focused === true &&
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 {{readFileSync?: typeof fs.readFileSync; existsSync?: typeof fs.existsSync}} [options]
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 sessionId = resolvePiSessionIdentity(session, {
286
- env,
287
- readFileSync: options.readFileSync,
288
- existsSync: options.existsSync,
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
- const result = await execFileAsync("niri", ["msg", "-j", "windows"], { env });
299
- windows = JSON.parse(String(result.stdout ?? "[]"));
300
- } catch {
301
- return { ok: false, error: "Could not inspect Niri windows; focus did nothing." };
302
- }
303
- if (!Array.isArray(windows)) {
304
- return { ok: false, error: "Unexpected Niri window data; focus did nothing." };
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 record = session && typeof session === "object" ? session : {};
307
- const target = resolveExactGhosttyWindow(windows, sessionId, record);
308
- if (!target) {
317
+ const placed = placeSessionWindow(windows, record, placementOptions);
318
+ if (!placed) {
309
319
  return {
310
320
  ok: false,
311
- error: "Pi identity did not resolve to exactly one Ghostty window; focus did nothing.",
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 execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(target.id)], {
316
- env,
317
- });
318
- return { ok: true, windowId: target.id };
345
+ await focusWindow();
319
346
  } catch {
320
- return { ok: false, error: "Niri rejected the exact focus request; focus did nothing." };
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
  }