@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
@@ -19,6 +19,15 @@ export interface SessionSnapshot {
19
19
  publisherCount?: number;
20
20
  publisherIds?: string[];
21
21
  publisherRecordKeys?: string[];
22
+ publisherSessionIds?: string[];
23
+ akTasks?: AkTaskChip[];
24
+ akTaskOverflow?: number;
25
+ /** Niri window id; for a hidden tab, the window hosting it. */
26
+ windowId?: number | null;
27
+ /** Niri workspace `idx`, the number the operator sees, never the internal workspace id. */
28
+ workspaceIdx?: number | null;
29
+ placement?: "title" | "host" | "binding";
30
+ surfaceVisible?: boolean;
22
31
  cwd: string;
23
32
  repoLabel: string;
24
33
  sessionName: string;
@@ -38,6 +47,32 @@ export interface SessionSnapshot {
38
47
  errorMessage: string;
39
48
  }
40
49
 
50
+ export type AkTaskChipState = "active" | "deferred" | "orphaned";
51
+
52
+ /** One read-only AK task reference joined onto a terminal card by the native projection. */
53
+ export interface AkTaskChip {
54
+ id: number;
55
+ title: string;
56
+ state: AkTaskChipState;
57
+ }
58
+
59
+ /** A live-lease task claim parsed from read-only `ak task list` output. */
60
+ export interface AkTaskClaim {
61
+ id: number;
62
+ title: string;
63
+ repo: string;
64
+ sessionId: string;
65
+ leaseExpiresAt: number;
66
+ claimedAt: number;
67
+ }
68
+
69
+ /** A task with an active deferral parsed from read-only `ak task deferred` output. */
70
+ export interface AkTaskDeferred {
71
+ id: number;
72
+ title: string;
73
+ repo: string;
74
+ }
75
+
41
76
  export interface BrokerSnapshot {
42
77
  generatedAt: number;
43
78
  sessions: SessionSnapshot[];
@@ -57,6 +92,30 @@ export interface ActivityStripRuntimeStatus {
57
92
  alignmentMode?: "layer-shell";
58
93
  rendererCardCount?: number;
59
94
  rendererCardIds?: string[];
95
+ rendererHiddenTabCardCount?: number;
96
+ surfaceBindingCount?: number;
97
+ unplacedSurfaceCount?: number;
98
+ agentTabCount?: number;
99
+ themeScheme?: "dark" | "light" | "unknown";
100
+ themeName?: string | null;
101
+ themeSource?: string | null;
102
+ themeFingerprint?: string;
103
+ themeError?: string;
104
+ agentScanError?: string | null;
105
+ heightRepairState?: "enabled" | "disabled";
106
+ heightRepairCount?: number;
107
+ lastHeightRepairAt?: number;
108
+ tabInventoryState?: "pending" | "ready" | "failed" | "unavailable" | "disabled";
109
+ tabInventoryDetail?: string | null;
110
+ tabInventoryFrameCount?: number;
111
+ tabInventoryTabCount?: number;
112
+ tabInventoryProbedAt?: number | null;
113
+ akTaskState?: "pending" | "ok" | "unavailable" | "disabled";
114
+ akTaskError?: string | null;
115
+ akTaskClaimCount?: number;
116
+ akTaskDeferredCount?: number;
117
+ akTaskOrphanCount?: number;
118
+ akTaskRenderedCount?: number;
60
119
  rendererVisibilityTransitionCount?: number;
61
120
  controllerPid?: number;
62
121
  panelPid?: number | null;
@@ -98,7 +157,13 @@ export interface ActivityStripBrokerOptions {
98
157
  remove(sessionId: string, publisherId?: string): boolean;
99
158
  };
100
159
  getRuntimeStatus?: () => ActivityStripRuntimeStatus | undefined;
101
- focusSession?: (targetId: string) => Promise<{ ok: boolean; error?: string; windowId?: number }>;
160
+ focusSession?: (targetId: string) => Promise<{
161
+ ok: boolean;
162
+ error?: string;
163
+ windowId?: number;
164
+ presented?: boolean;
165
+ verified?: boolean;
166
+ }>;
102
167
  focusStrip?: () => Promise<{ ok: boolean; error?: string }>;
103
168
  }
104
169
 
@@ -196,6 +261,7 @@ export interface ActivityStripCompatibilityReport {
196
261
  alignmentMode: "layer-shell";
197
262
  primaryDisplayOnly: boolean;
198
263
  clickThroughDefault: boolean;
264
+ tabInventory?: { available: boolean; detail: string };
199
265
  blockers: string[];
200
266
  warnings: string[];
201
267
  }
@@ -0,0 +1,100 @@
1
+ // ---
2
+ // summary: "presents one Ghostty surface through the running process's session-bus present-surface action"
3
+ // read_when:
4
+ // - "changing hidden-tab activation, Ghostty D-Bus targeting, or its fail-closed conditions"
5
+ // ---
6
+
7
+ import { normalizeGhosttySurfaceId } from "./terminal-identity.mjs";
8
+
9
+ const GHOSTTY_OBJECT_PATHS = new Map([
10
+ ["main", "/com/mitchellh/ghostty"],
11
+ ["legacy", "/com/tryinget/ghosttysidequest"],
12
+ ]);
13
+ export const GHOSTTY_PRESENT_SURFACE_TIMEOUT_MS = 2000;
14
+
15
+ /** @typedef {(file: string, args: string[], options: object) => Promise<{stdout?: string}>} ExecFileAsync */
16
+ /** @typedef {{ok: true; busName: string} | {ok: false; error: string}} PresentSurfaceResult */
17
+
18
+ /** @param {unknown} family */
19
+ export function ghosttyObjectPathForFamily(family) {
20
+ return GHOSTTY_OBJECT_PATHS.get(String(family ?? "")) ?? "";
21
+ }
22
+
23
+ /**
24
+ * Every running Ghostty process, single-instance server or standalone window, exports
25
+ * `org.gtk.Actions` on its unique bus name. `present-surface` selects the tab owning the surface
26
+ * and presents its window; a surface id the process does not own is ignored by Ghostty itself.
27
+ * The target is the exact host process already proven to contain the surface, never a
28
+ * well-known name that another build could claim.
29
+ * @param {{execFileAsync: ExecFileAsync; env?: NodeJS.ProcessEnv; hostPid: number; terminalFamily: unknown; surfaceId: unknown; timeout?: number}} options
30
+ * @returns {Promise<PresentSurfaceResult>}
31
+ */
32
+ export async function presentGhosttySurface({
33
+ execFileAsync,
34
+ env = process.env,
35
+ hostPid,
36
+ terminalFamily,
37
+ surfaceId,
38
+ timeout = GHOSTTY_PRESENT_SURFACE_TIMEOUT_MS,
39
+ }) {
40
+ const objectPath = ghosttyObjectPathForFamily(terminalFamily);
41
+ const normalizedSurfaceId = normalizeGhosttySurfaceId(surfaceId);
42
+ if (
43
+ !objectPath ||
44
+ !normalizedSurfaceId ||
45
+ normalizedSurfaceId === "0" ||
46
+ !Number.isInteger(hostPid) ||
47
+ hostPid <= 0
48
+ ) {
49
+ return { ok: false, error: "the surface target is incomplete" };
50
+ }
51
+
52
+ let rows;
53
+ try {
54
+ const { stdout } = await execFileAsync(
55
+ "busctl",
56
+ ["--user", "list", "--no-pager", "--no-legend"],
57
+ { env, timeout },
58
+ );
59
+ rows = String(stdout ?? "")
60
+ .split("\n")
61
+ .map((line) => line.trim().split(/\s+/));
62
+ } catch {
63
+ return { ok: false, error: "the session bus is unavailable" };
64
+ }
65
+ const uniqueNames = rows
66
+ .filter(
67
+ (fields) => fields[0]?.startsWith(":") && Number.parseInt(fields[1] ?? "", 10) === hostPid,
68
+ )
69
+ .map((fields) => String(fields[0]));
70
+ if (uniqueNames.length === 0) {
71
+ return { ok: false, error: "the Ghostty process is not on the session bus" };
72
+ }
73
+ if (uniqueNames.length > 1) {
74
+ return { ok: false, error: "the Ghostty process owns several bus connections" };
75
+ }
76
+
77
+ try {
78
+ await execFileAsync(
79
+ "busctl",
80
+ [
81
+ "--user",
82
+ "call",
83
+ uniqueNames[0],
84
+ objectPath,
85
+ "org.gtk.Actions",
86
+ "Activate",
87
+ "sava{sv}",
88
+ "present-surface",
89
+ "1",
90
+ "t",
91
+ normalizedSurfaceId,
92
+ "0",
93
+ ],
94
+ { env, timeout },
95
+ );
96
+ return { ok: true, busName: uniqueNames[0] };
97
+ } catch {
98
+ return { ok: false, error: "Ghostty rejected present-surface" };
99
+ }
100
+ }
@@ -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
+ }