@tryinget/pi-activity-strip 0.6.0 → 0.8.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 +21 -3
- package/bin/pi-activity-strip.mjs +76 -63
- package/extensions/activity-strip.js +25 -21
- 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 +141 -13
- package/native/panel/src/card_view.rs +149 -29
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +79 -0
- package/native/panel/src/style.css +22 -5
- package/package.json +1 -1
- package/src/client/runtime-lock.mjs +140 -0
- package/src/common/claude-events.mjs +1 -1
- package/src/common/constants.mjs +2 -0
- package/src/common/contracts.d.ts +9 -0
- package/src/common/contracts.ts +3 -0
- package/src/common/niri-focus.mjs +1 -2
- package/src/common/workspace-view.mjs +5 -5
- package/src/native/main.mjs +3 -51
- package/src/native/panel-binary.mjs +56 -0
- package/src/native/panel-projection.mjs +4 -1
- package/src/native/workspace-events.mjs +6 -0
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ The runtime is Electron-free. A Node controller retains the tested telemetry, id
|
|
|
28
28
|
- shows the AK task a session is working on: read-only `ak` output is joined onto cards, clicking the task reference focuses the claiming terminal, and claims that outlive their session or deferred tasks appear as non-clickable badges
|
|
29
29
|
- displays repo, phase, tool, detail, elapsed time, and freshness
|
|
30
30
|
- marks the exact currently focused terminal card and prefixes hidden-tab cards with `⧉`
|
|
31
|
+
- labels each card with its Niri window id (`#43`), so a window an agent or tool names by id can be found on the ribbon
|
|
31
32
|
- keeps monitoring-success cards beside the Activity tile, then active and settled cards
|
|
32
33
|
- expands rich details on hover or keyboard focus
|
|
33
34
|
- supports Left/Right navigation and Shift+Left/Right manual movement
|
|
@@ -46,9 +47,11 @@ Pi publisher streams
|
|
|
46
47
|
-> wlr-layer-shell top surface with an 84px exclusive zone
|
|
47
48
|
```
|
|
48
49
|
|
|
50
|
+
Every child-protocol message carries `protocol: 1`, and the panel drops any view carrying another version. A new card field is therefore added as an optional field: a panel that does not know it ignores it, and a panel talking to a controller that does not send it falls back to a default. The version changes only for a change an existing panel would misread.
|
|
51
|
+
|
|
49
52
|
Layer-shell replaces the old floating Electron window and dynamic Niri-config strut helper. The package no longer edits `~/.config/niri/config.kdl`, resets tiled heights, or requires Electron.
|
|
50
53
|
|
|
51
|
-
The compact surface is 84px tall and sits inset by an 8px margin on the top, left and right, matching the compositor's window gaps, with the same 12px corner radius as tiled windows. It therefore reserves 92px in total. One engaged card expands the surface to 276px while the reservation is unchanged, so detail overlays content without repeatedly resizing tiled windows.
|
|
54
|
+
The compact surface is 84px tall and sits inset by an 8px margin on the top, left and right, matching the compositor's window gaps, with the same 12px corner radius as tiled windows. It therefore reserves 92px in total. One engaged card expands the surface to at least 276px, taller when its detail rows wrap, while the reservation is unchanged, so detail overlays content without repeatedly resizing tiled windows.
|
|
52
55
|
|
|
53
56
|
## Supported host
|
|
54
57
|
|
|
@@ -100,6 +103,8 @@ node ./bin/pi-activity-strip.mjs claude-hooks
|
|
|
100
103
|
node ./bin/pi-activity-strip.mjs stop
|
|
101
104
|
```
|
|
102
105
|
|
|
106
|
+
`stop` returns once the runtime has exited and released its lock, waiting up to 15 seconds and exiting non-zero if it has not, so `npm run strip:stop && npm run strip:open` restarts cleanly. Pi's `/activity-strip stop` and `/activity-strip-stop` wait the same way. An `open` that finds the lock still held by a runtime that never answers says so instead of timing out silently.
|
|
107
|
+
|
|
103
108
|
`fix-top` is now a compatibility no-op: layer-shell placement is compositor-owned.
|
|
104
109
|
|
|
105
110
|
## Keyboard-only entry
|
|
@@ -116,15 +121,17 @@ The shortcut toggles exclusive keyboard mode. On entry, the first card is select
|
|
|
116
121
|
|
|
117
122
|
## Interaction contract
|
|
118
123
|
|
|
119
|
-
- **Workspace locality:** Niri focus events trigger immediate reprojection; bounded polling remains a fallback.
|
|
124
|
+
- **Workspace locality:** Niri focus events and workspace list changes trigger immediate reprojection; bounded polling remains a fallback.
|
|
120
125
|
- **Hide/reclaim:** zero cards unmaps the layer surface. Niri then removes its exclusive zone as part of normal Wayland surface lifecycle.
|
|
121
126
|
- **Crash behavior:** panel lifetime is bound to the Node controller through Linux parent-death signaling and stdin EOF. Unexpected panel exits are restarted with bounded backoff; a dead surface cannot retain an exclusive zone.
|
|
122
127
|
- **Exact focus:** card activation returns to Node, which performs existing fail-closed terminal identity resolution and Niri focus.
|
|
123
128
|
- **Hidden tabs:** a Ghostty window title only names its active tab. A bound surface whose title is not visible is placed through its Ghostty host process: one host window is exact containment; several host windows use the window remembered for that tab. Memory comes from titles seen while the strip runs and from a read-only AT-SPI inventory of tab labels, and is persisted per Niri instance under `~/.pi/agent/state/pi-activity-strip/surface-bindings.json`. A tab whose window has never been observed stays unplaced rather than guessed; `status` reports that count. Activating a hidden-tab card calls the host process's `present-surface` action on the session bus, then focuses the window, and reports success only after the title proves the tab is visible.
|
|
129
|
+
- **Window ids:** agents and tools name windows by their Niri window id (`claude-window` prints `window_id`, continuity receipts carry it). Each card shows that id as a quiet `#43` chip at the start of its footer; a hidden tab has no window of its own, so its chip shows the window hosting it. Hover or keyboard detail adds a `window` row with the workspace number the operator sees, Niri's workspace `idx` and never its internal workspace id, for example `#43 · workspace 2` or `host #43 · workspace 2` for a hidden tab. The tooltip and accessible label name the window too. Off Niri there is no window id, so no chip is drawn.
|
|
124
130
|
- **Agent tabs:** a tab is admitted as an agent when the process owning its terminal is a recognized agent CLI, never on a window title alone, so plain terminal programs are not cards. Claude Code tabs are identified exactly through the per-session scratchpad the process holds open, which yields the session id and the title Claude Code put on the terminal; that title places the tab in its window and is remembered so the tab stays placed once hidden.
|
|
125
|
-
- **AK task references:** cards can show the Agent Kernel task their session is working on. The controller reads the read-only `ak` CLI (`ak task list --status claimed --format json --all --verbose` and `ak task deferred --format json --all`) on a calm 15-second clock and joins claims by exact Pi session id under the AK5700 claim semantics: only `session-<uuid>` claims count, and an expired lease is vacant custody that renders nothing. A card whose session holds a live claim shows a clickable
|
|
131
|
+
- **AK task references:** cards can show the Agent Kernel task their session is working on. The controller reads the read-only `ak` CLI (`ak task list --status claimed --format json --all --verbose` and `ak task deferred --format json --all`) on a calm 15-second clock and joins claims by exact Pi session id under the AK5700 claim semantics: only `session-<uuid>` claims count, and an expired lease is vacant custody that renders nothing. Chips sit in the card's footer as bare task numbers (`AK5701`, title in the tooltip and the expanded inspector), one per card with the rest folded into a `+N` count, so the folder name keeps the header. A card whose session holds a live claim shows a clickable chip that reuses the card's own activation, so clicking it focuses the exact Ghostty window of the claiming session, presenting hidden tabs first. Claims whose lease has not lapsed but whose claiming session no longer exists ("claim outlives session") and tasks carrying an active deferral are joined by task repo onto cards working inside that repo and render as inert badges, never buttons — there is no live window to focus and no AK action is ever offered. Ambiguous session matches (one logical session resumed into two terminals), a missing `ak` binary, or malformed output bind nothing: fail closed, no chip, no invented state, strip behavior unchanged. The strip never writes AK state and never touches the society database directly.
|
|
126
132
|
- **Codex telemetry:** Codex keeps a thread index naming every session's rollout file, working directory and title. A process binds to its thread by an open rollout descriptor, or, before any task has run, by being the only session created in that directory after the process started; anything ambiguous binds nothing. The rollout tail then reports the running tool and its command, turn count, approval and sandbox policy, prompt and reply. Reading the index needs the runtime's built-in SQLite, and a host without it degrades to a process-only card.
|
|
127
133
|
- **Claude Code telemetry:** cards read live state from the tail of the session transcript, giving the topic, current tool and its target, last prompt, latest reply, turn count and activity clock with no configuration. That format is internal to Claude Code and can change between releases, so a transcript that no longer parses degrades to a process-only card rather than inventing activity. Optional hooks add the one state a transcript cannot express, that a session is blocked waiting for you; run `claude-hooks` for the settings fragment. Only low-frequency events are hooked, so nothing runs per tool call. OpenTelemetry is deliberately not used: it reports aggregate usage and cost, not which tool a session is running now.
|
|
134
|
+
- **You are here:** the block at the ribbon's left names the focused window's project in large type with its folder beneath (home written as `~`, cut from the front so the deepest folder stays visible), and the matching card carries a solid accent ring. With no agent window focused it shows the session counts instead.
|
|
128
135
|
- **Appearance:** the ribbon reads the same theme files Ghostty reads. It resolves the `theme` setting for the desktop's current colour scheme, including the `light:…,dark:…` form, and takes colours set directly in the config over the theme file, exactly as Ghostty layers them. State colours reuse the terminal's own meanings, so green is settled, yellow is working, red failed, and the cursor colour marks a session waiting for you. The panel derives every shade from eight named colours, so a theme change is a handful of values and one stylesheet reload rather than a restart. A theme that cannot be read falls back to a neutral palette.
|
|
129
136
|
- **Height repair:** showing or hiding the ribbon changes the output working area, and a window whose height is not automatic keeps the old value. After each change the strip compares window heights before and after, and resets to automatic only those windows still sitting at a height the moving windows just vacated. A height nobody vacated is never touched, each window is reset at most once per height, and a pass is bounded.
|
|
130
137
|
- **Two identity keys:** a Ghostty surface id is a per-process handle that can drift away from the value a long-lived Pi process captured at startup, while the 32-hex session token in the same title never does. Memory therefore stores both, and lookups prefer the exact surface. A session token that two windows claim is ambiguous and binds nothing; two terminals resuming one logical session in the same window are both placed there.
|
|
@@ -167,6 +174,16 @@ The staged artifact receipt binds:
|
|
|
167
174
|
- glibc symbol floor
|
|
168
175
|
- required shared libraries
|
|
169
176
|
|
|
177
|
+
To see the panel without touching the live ribbon, run its built-in demo cards in a nested Niri with a config that spawns nothing:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
printf 'prefer-no-csd\nhotkey-overlay {\n skip-at-startup\n}\n' >/tmp/pi-activity-preview.kdl
|
|
181
|
+
niri -c /tmp/pi-activity-preview.kdl -- env PI_ACTIVITY_STRIP_PANEL_DEMO=1 \
|
|
182
|
+
"$PWD/native/bin/linux-x64-gnu/pi-activity-strip-panel"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The panel is unique per Wayland display: this demo runs beside the live ribbon, while a second panel on the live display is refused with an error rather than reserving a second band. Never run the panel under a private session bus such as `dbus-run-session`: GTK then starts a second accessibility bus that takes over the desktop's AT-SPI socket and leaves it dead when it exits.
|
|
186
|
+
|
|
170
187
|
Live verification must inspect Niri layers rather than regular windows:
|
|
171
188
|
|
|
172
189
|
```bash
|
|
@@ -185,6 +202,7 @@ Implemented:
|
|
|
185
202
|
- exact Ghostty activation, including hidden tabs via `present-surface`
|
|
186
203
|
- hidden Ghostty tab placement through host process containment and learned window memory
|
|
187
204
|
- clickable AK task references joined from read-only `ak` output, with claim-outlives-session and deferred badges
|
|
205
|
+
- Niri window id on every card, and its workspace number in the detail
|
|
188
206
|
- non-Pi agent tab discovery with an exact Claude Code adapter
|
|
189
207
|
- live Claude Code telemetry from its transcript, with optional hooks for blocked-on-you states
|
|
190
208
|
- live Codex telemetry from its thread index and rollout files
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
12
11
|
import { fileURLToPath } from "node:url";
|
|
13
12
|
import {
|
|
14
13
|
getBrokerStatus,
|
|
@@ -16,6 +15,13 @@ import {
|
|
|
16
15
|
requestBrokerShutdown,
|
|
17
16
|
sendBrokerMessage,
|
|
18
17
|
} from "../src/client/broker-client.mjs";
|
|
18
|
+
import {
|
|
19
|
+
RUNTIME_LOCK_CONFLICT_EXIT_CODE,
|
|
20
|
+
RUNTIME_LOCK_PATH as runtimeLockPath,
|
|
21
|
+
stopRuntime,
|
|
22
|
+
waitForRuntimeExit,
|
|
23
|
+
waitForStartedRuntime,
|
|
24
|
+
} from "../src/client/runtime-lock.mjs";
|
|
19
25
|
import {
|
|
20
26
|
assessActivityStripCompatibility,
|
|
21
27
|
formatCompatibilityReport,
|
|
@@ -23,6 +29,7 @@ import {
|
|
|
23
29
|
import {
|
|
24
30
|
ACTIVITY_STRIP_SOCKET_DIR,
|
|
25
31
|
ACTIVITY_STRIP_START_TIMEOUT_MS,
|
|
32
|
+
ACTIVITY_STRIP_STOP_TIMEOUT_MS,
|
|
26
33
|
} from "../src/common/constants.mjs";
|
|
27
34
|
import { makeMessage } from "../src/common/protocol.mjs";
|
|
28
35
|
import { formatBrokerRuntimeStatus } from "../src/common/status-report.mjs";
|
|
@@ -30,7 +37,11 @@ import { formatBrokerRuntimeStatus } from "../src/common/status-report.mjs";
|
|
|
30
37
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
38
|
const __dirname = path.dirname(__filename);
|
|
32
39
|
const nativeEntry = path.resolve(__dirname, "..", "src", "native", "main.mjs");
|
|
33
|
-
const
|
|
40
|
+
const STOP_MESSAGES = {
|
|
41
|
+
stopped: "stopped",
|
|
42
|
+
"not-running": "not-running",
|
|
43
|
+
"still-stopping": "stopping (still shutting down)",
|
|
44
|
+
};
|
|
34
45
|
|
|
35
46
|
function usage() {
|
|
36
47
|
console.log(
|
|
@@ -38,37 +49,6 @@ function usage() {
|
|
|
38
49
|
);
|
|
39
50
|
}
|
|
40
51
|
|
|
41
|
-
async function waitForBrokerReady(timeoutMs = ACTIVITY_STRIP_START_TIMEOUT_MS) {
|
|
42
|
-
const timeoutAt = Date.now() + timeoutMs;
|
|
43
|
-
/** @type {import("../src/common/contracts.ts").BrokerResponse | null} */
|
|
44
|
-
let latestStatus = null;
|
|
45
|
-
|
|
46
|
-
while (Date.now() < timeoutAt) {
|
|
47
|
-
try {
|
|
48
|
-
latestStatus = await getBrokerStatus();
|
|
49
|
-
} catch {
|
|
50
|
-
latestStatus = null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (
|
|
54
|
-
latestStatus?.ok &&
|
|
55
|
-
(!latestStatus.runtimeStatus || latestStatus.runtimeStatus.state === "ready")
|
|
56
|
-
) {
|
|
57
|
-
return { ok: true };
|
|
58
|
-
}
|
|
59
|
-
if (latestStatus?.runtimeStatus?.state === "error") {
|
|
60
|
-
return { ok: false, error: formatBrokerRuntimeStatus(latestStatus) };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
await delay(125);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
ok: false,
|
|
68
|
-
error: `${formatBrokerRuntimeStatus(latestStatus || { ok: false })}\nTimeout: ${timeoutMs}ms`,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
52
|
/** @param {{ detached?: boolean }} [options] @returns {Promise<number>} */
|
|
73
53
|
async function openStrip({ detached = true } = {}) {
|
|
74
54
|
if (await isBrokerAlive()) {
|
|
@@ -78,11 +58,12 @@ async function openStrip({ detached = true } = {}) {
|
|
|
78
58
|
return 0;
|
|
79
59
|
}
|
|
80
60
|
await requestBrokerShutdown().catch(() => null);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
61
|
+
// Its broker closes before it exits; starting before the lock is released would lose it.
|
|
62
|
+
const exit = await waitForRuntimeExit(runtimeLockPath, {
|
|
63
|
+
timeoutMs: ACTIVITY_STRIP_STOP_TIMEOUT_MS,
|
|
64
|
+
});
|
|
65
|
+
if (!exit.released) {
|
|
66
|
+
console.error(exit.error ?? formatBrokerRuntimeStatus(status));
|
|
86
67
|
return 1;
|
|
87
68
|
}
|
|
88
69
|
}
|
|
@@ -94,26 +75,62 @@ async function openStrip({ detached = true } = {}) {
|
|
|
94
75
|
}
|
|
95
76
|
|
|
96
77
|
fs.mkdirSync(ACTIVITY_STRIP_SOCKET_DIR, { recursive: true, mode: 0o700 });
|
|
97
|
-
const child = spawn(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
78
|
+
const child = spawn(
|
|
79
|
+
"flock",
|
|
80
|
+
[
|
|
81
|
+
"--nonblock",
|
|
82
|
+
"--conflict-exit-code",
|
|
83
|
+
String(RUNTIME_LOCK_CONFLICT_EXIT_CODE),
|
|
84
|
+
runtimeLockPath,
|
|
85
|
+
process.execPath,
|
|
86
|
+
nativeEntry,
|
|
87
|
+
],
|
|
88
|
+
{
|
|
89
|
+
detached,
|
|
90
|
+
stdio: detached ? "ignore" : "inherit",
|
|
91
|
+
env: { ...process.env, PI_ACTIVITY_STRIP_RUNTIME_LOCK_HELD: "1" },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
if (!detached) {
|
|
95
|
+
return await new Promise((resolve) => {
|
|
96
|
+
child.on("exit", (code) => resolve(typeof code === "number" ? code : 0));
|
|
97
|
+
});
|
|
112
98
|
}
|
|
113
99
|
|
|
114
|
-
|
|
115
|
-
|
|
100
|
+
/** @type {{exitCode: number | null | undefined; error?: string}} */
|
|
101
|
+
const controller = { exitCode: undefined };
|
|
102
|
+
child.once("exit", (code) => {
|
|
103
|
+
controller.exitCode = code;
|
|
104
|
+
});
|
|
105
|
+
child.once("error", (error) => {
|
|
106
|
+
controller.exitCode = null;
|
|
107
|
+
controller.error = error.message;
|
|
116
108
|
});
|
|
109
|
+
child.unref();
|
|
110
|
+
const result = await waitForStartedRuntime({
|
|
111
|
+
controller,
|
|
112
|
+
getStatus: () => getBrokerStatus().catch(() => null),
|
|
113
|
+
});
|
|
114
|
+
if (result.ok) {
|
|
115
|
+
console.log(result.started ? "Started activity strip." : "Activity strip is already running.");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (result.reason === "lock-held") {
|
|
119
|
+
console.error(
|
|
120
|
+
"Another activity-strip runtime still holds the runtime lock, probably one that is shutting down; nothing was started. `stop` waits for it to exit.",
|
|
121
|
+
);
|
|
122
|
+
} else if (result.reason === "exited") {
|
|
123
|
+
console.error(
|
|
124
|
+
controller.error
|
|
125
|
+
? `The activity-strip runtime could not be started: ${controller.error}`
|
|
126
|
+
: `The activity-strip runtime exited during startup (code ${result.exitCode}).`,
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
const timeout =
|
|
130
|
+
result.reason === "timeout" ? `\nTimeout: ${ACTIVITY_STRIP_START_TIMEOUT_MS}ms` : "";
|
|
131
|
+
console.error(`${formatBrokerRuntimeStatus(result.status ?? { ok: false })}${timeout}`);
|
|
132
|
+
}
|
|
133
|
+
return 1;
|
|
117
134
|
}
|
|
118
135
|
|
|
119
136
|
async function main() {
|
|
@@ -234,14 +251,10 @@ async function main() {
|
|
|
234
251
|
process.exitCode = 0;
|
|
235
252
|
return;
|
|
236
253
|
case "stop": {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
} catch {
|
|
242
|
-
console.log("not-running");
|
|
243
|
-
process.exitCode = 1;
|
|
244
|
-
}
|
|
254
|
+
const result = await stopRuntime();
|
|
255
|
+
if (result.outcome === "error") console.error(result.error);
|
|
256
|
+
else console.log(STOP_MESSAGES[result.outcome]);
|
|
257
|
+
process.exitCode = result.outcome === "stopped" ? 0 : 1;
|
|
245
258
|
return;
|
|
246
259
|
}
|
|
247
260
|
case "-h":
|
|
@@ -14,8 +14,9 @@ import { promisify } from "node:util";
|
|
|
14
14
|
/** @typedef {import("../src/common/contracts.ts").SessionStartContextLike} SessionStartContextLike */
|
|
15
15
|
/** @typedef {import("../src/common/contracts.ts").ToolExecutionEventLike} ToolExecutionEventLike */
|
|
16
16
|
/** @typedef {import("../src/common/contracts.ts").TurnStartEventLike} TurnStartEventLike */
|
|
17
|
-
import { getBrokerStatus
|
|
17
|
+
import { getBrokerStatus } from "../src/client/broker-client.mjs";
|
|
18
18
|
import { ensureActivityStripRunning } from "../src/client/launcher.mjs";
|
|
19
|
+
import { stopRuntime } from "../src/client/runtime-lock.mjs";
|
|
19
20
|
import { createSessionTelemetry } from "../src/client/session-telemetry.mjs";
|
|
20
21
|
import {
|
|
21
22
|
formatBrokerRuntimeStatus,
|
|
@@ -34,6 +35,13 @@ function wantsAutostart() {
|
|
|
34
35
|
return process.env.PI_ACTIVITY_STRIP_AUTO_START !== "0";
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** @type {Record<string, [string, "info" | "warning" | "error"]>} */
|
|
39
|
+
const STOP_NOTICES = {
|
|
40
|
+
stopped: ["Activity strip stopped", "info"],
|
|
41
|
+
"not-running": ["Activity strip is not running", "warning"],
|
|
42
|
+
"still-stopping": ["Activity strip is still shutting down", "warning"],
|
|
43
|
+
};
|
|
44
|
+
|
|
37
45
|
/** @param {ExtensionAPI} pi */
|
|
38
46
|
export default function activityStripExtension(pi) {
|
|
39
47
|
const telemetry = createSessionTelemetry({
|
|
@@ -57,6 +65,20 @@ export default function activityStripExtension(pi) {
|
|
|
57
65
|
return result;
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Returns once the runtime has exited, so an open right after it cannot race a runtime that is
|
|
70
|
+
* still shutting down and leave no ribbon running.
|
|
71
|
+
* @param {UiContextLike} ctx
|
|
72
|
+
*/
|
|
73
|
+
async function stopStrip(ctx) {
|
|
74
|
+
const result = await stopRuntime();
|
|
75
|
+
if (!ctx.hasUI) return;
|
|
76
|
+
/** @type {[string, "info" | "warning" | "error"]} */
|
|
77
|
+
const [message, level] =
|
|
78
|
+
result.outcome === "error" ? [result.error, "error"] : STOP_NOTICES[result.outcome];
|
|
79
|
+
ctx.ui?.notify?.(message, level);
|
|
80
|
+
}
|
|
81
|
+
|
|
60
82
|
pi.registerCommand("activity-strip", {
|
|
61
83
|
description: "Open or check the top-row activity strip",
|
|
62
84
|
/** @param {string} args @param {UiContextLike} ctx */
|
|
@@ -122,16 +144,7 @@ export default function activityStripExtension(pi) {
|
|
|
122
144
|
}
|
|
123
145
|
|
|
124
146
|
if (action === "stop") {
|
|
125
|
-
|
|
126
|
-
const result = await requestBrokerShutdown();
|
|
127
|
-
if (ctx.hasUI)
|
|
128
|
-
ctx.ui?.notify?.(
|
|
129
|
-
result?.ok ? "Stopping activity strip" : "Activity strip is not running",
|
|
130
|
-
result?.ok ? "info" : "warning",
|
|
131
|
-
);
|
|
132
|
-
} catch {
|
|
133
|
-
if (ctx.hasUI) ctx.ui?.notify?.("Activity strip is not running", "warning");
|
|
134
|
-
}
|
|
147
|
+
await stopStrip(ctx);
|
|
135
148
|
return;
|
|
136
149
|
}
|
|
137
150
|
|
|
@@ -143,16 +156,7 @@ export default function activityStripExtension(pi) {
|
|
|
143
156
|
description: "Stop the running activity strip broker/window",
|
|
144
157
|
/** @param {string} _args @param {UiContextLike} ctx */
|
|
145
158
|
handler: async (_args, ctx) => {
|
|
146
|
-
|
|
147
|
-
const result = await requestBrokerShutdown();
|
|
148
|
-
if (ctx.hasUI)
|
|
149
|
-
ctx.ui?.notify?.(
|
|
150
|
-
result?.ok ? "Stopping activity strip" : "Activity strip is not running",
|
|
151
|
-
result?.ok ? "info" : "warning",
|
|
152
|
-
);
|
|
153
|
-
} catch {
|
|
154
|
-
if (ctx.hasUI) ctx.ui?.notify?.("Activity strip is not running", "warning");
|
|
155
|
-
}
|
|
159
|
+
await stopStrip(ctx);
|
|
156
160
|
},
|
|
157
161
|
});
|
|
158
162
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "pi-activity-strip-native-artifact.v1",
|
|
3
3
|
"target": "x86_64-unknown-linux-gnu",
|
|
4
|
-
"sha256": "
|
|
4
|
+
"sha256": "99b6f36e858b9c241c5aa73f7517c0f5f418673f54d9fa245156272c39bf004b",
|
|
5
5
|
"cargoLockSha256": "f8ebc11bc721c569af982169e448a5ea4fed15bc06001017d97acda22b1c890b",
|
|
6
|
-
"sourceSha256": "
|
|
6
|
+
"sourceSha256": "b272f4f3131cc4335ed6324ad051042fbd3d577b16944189fcdeaed7e776628f",
|
|
7
7
|
"rustc": "rustc 1.98.0 (88d9e12ae 2026-08-18)",
|
|
8
8
|
"glibcFloor": "GLIBC_2.34",
|
|
9
9
|
"neededSonames": [
|
|
Binary file
|
package/native/panel/src/app.rs
CHANGED
|
@@ -16,6 +16,7 @@ const COMPACT_HEIGHT: i32 = 84;
|
|
|
16
16
|
const OUTER_MARGIN: i32 = 8;
|
|
17
17
|
const EXPANDED_HEIGHT: i32 = 276;
|
|
18
18
|
const ORDER_REFRESH_MS: i64 = 15_000;
|
|
19
|
+
const BRAND_WIDTH: i32 = 196;
|
|
19
20
|
|
|
20
21
|
pub struct AppInit {
|
|
21
22
|
pub demo: bool,
|
|
@@ -77,28 +78,42 @@ impl Component for App {
|
|
|
77
78
|
set_orientation: gtk::Orientation::Horizontal,
|
|
78
79
|
set_spacing: 0,
|
|
79
80
|
|
|
81
|
+
// "You are here": the project and folder of the focused window. The labels fill a
|
|
82
|
+
// fixed width instead of sizing it, so moving focus never shifts the cards beside it.
|
|
80
83
|
#[name = "brand"]
|
|
81
84
|
gtk::Box {
|
|
82
85
|
add_css_class: "brand",
|
|
83
86
|
set_orientation: gtk::Orientation::Vertical,
|
|
84
|
-
set_width_request:
|
|
87
|
+
set_width_request: BRAND_WIDTH,
|
|
85
88
|
set_valign: gtk::Align::Center,
|
|
86
89
|
|
|
90
|
+
#[name = "eyebrow"]
|
|
87
91
|
gtk::Label {
|
|
88
92
|
add_css_class: "brand-eyebrow",
|
|
89
93
|
set_label: "π ACTIVITY",
|
|
90
|
-
set_halign: gtk::Align::
|
|
94
|
+
set_halign: gtk::Align::Fill,
|
|
95
|
+
set_xalign: 0.0,
|
|
96
|
+
set_ellipsize: gtk::pango::EllipsizeMode::End,
|
|
97
|
+
set_max_width_chars: 1,
|
|
91
98
|
},
|
|
99
|
+
#[name = "title"]
|
|
92
100
|
gtk::Label {
|
|
93
101
|
add_css_class: "brand-title",
|
|
94
102
|
set_label: "Sessions",
|
|
95
|
-
set_halign: gtk::Align::
|
|
103
|
+
set_halign: gtk::Align::Fill,
|
|
104
|
+
set_xalign: 0.0,
|
|
105
|
+
set_ellipsize: gtk::pango::EllipsizeMode::End,
|
|
106
|
+
set_max_width_chars: 1,
|
|
96
107
|
},
|
|
108
|
+
// Ellipsized at the start: the deepest folder is the part that tells you where you are.
|
|
97
109
|
#[name = "meta"]
|
|
98
110
|
gtk::Label {
|
|
99
111
|
add_css_class: "meta",
|
|
100
112
|
set_label: "Waiting for sessions…",
|
|
101
|
-
set_halign: gtk::Align::
|
|
113
|
+
set_halign: gtk::Align::Fill,
|
|
114
|
+
set_xalign: 0.0,
|
|
115
|
+
set_ellipsize: gtk::pango::EllipsizeMode::Start,
|
|
116
|
+
set_max_width_chars: 1,
|
|
102
117
|
},
|
|
103
118
|
},
|
|
104
119
|
|
|
@@ -208,7 +223,7 @@ impl Component for App {
|
|
|
208
223
|
self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
|
|
209
224
|
}
|
|
210
225
|
self.refresh_cards();
|
|
211
|
-
self.
|
|
226
|
+
self.update_brand(widgets);
|
|
212
227
|
}
|
|
213
228
|
AppMsg::Hover(id, entered) => {
|
|
214
229
|
if entered {
|
|
@@ -305,6 +320,57 @@ fn move_order_item(order: &mut Vec<String>, index: usize, target: usize) {
|
|
|
305
320
|
order.insert(target, moved);
|
|
306
321
|
}
|
|
307
322
|
|
|
323
|
+
struct BrandCounts {
|
|
324
|
+
active: usize,
|
|
325
|
+
settled: usize,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#[derive(Debug, PartialEq)]
|
|
329
|
+
struct BrandText {
|
|
330
|
+
eyebrow: String,
|
|
331
|
+
title: String,
|
|
332
|
+
meta: String,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/// The brand block names the focused window's project and folder. Without a focused agent window
|
|
336
|
+
/// it falls back to the ribbon's own name and the session counts.
|
|
337
|
+
fn brand_text(focused: Option<&Card>, counts: &BrandCounts, home: &str) -> BrandText {
|
|
338
|
+
let summary = format!("{} active · {} settled", counts.active, counts.settled);
|
|
339
|
+
match focused {
|
|
340
|
+
Some(card) if !card.repo_label.is_empty() || !card.cwd.is_empty() => BrandText {
|
|
341
|
+
eyebrow: format!("π {}", summary.to_uppercase()),
|
|
342
|
+
title: if card.repo_label.is_empty() {
|
|
343
|
+
short_path(&card.cwd, home)
|
|
344
|
+
} else {
|
|
345
|
+
card.repo_label.clone()
|
|
346
|
+
},
|
|
347
|
+
meta: if card.cwd.is_empty() {
|
|
348
|
+
"—".to_owned()
|
|
349
|
+
} else {
|
|
350
|
+
short_path(&card.cwd, home)
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
_ => BrandText {
|
|
354
|
+
eyebrow: "π ACTIVITY".to_owned(),
|
|
355
|
+
title: "Sessions".to_owned(),
|
|
356
|
+
meta: summary,
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// A path with the home directory written as `~`.
|
|
362
|
+
fn short_path(path: &str, home: &str) -> String {
|
|
363
|
+
let home = home.trim_end_matches('/');
|
|
364
|
+
if home.is_empty() {
|
|
365
|
+
return path.to_owned();
|
|
366
|
+
}
|
|
367
|
+
match path.strip_prefix(home) {
|
|
368
|
+
Some("") => "~".to_owned(),
|
|
369
|
+
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
|
|
370
|
+
_ => path.to_owned(),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
308
374
|
fn engaged_card_id(
|
|
309
375
|
hovered: &HashSet<String>,
|
|
310
376
|
keyboard_active: bool,
|
|
@@ -394,7 +460,7 @@ impl App {
|
|
|
394
460
|
}
|
|
395
461
|
self.reorder_widgets(&widgets.cards_box);
|
|
396
462
|
self.refresh_cards();
|
|
397
|
-
self.
|
|
463
|
+
self.update_brand(widgets);
|
|
398
464
|
|
|
399
465
|
let should_show = view.visible && !self.data.is_empty();
|
|
400
466
|
if should_show != self.visible {
|
|
@@ -452,14 +518,26 @@ impl App {
|
|
|
452
518
|
}
|
|
453
519
|
}
|
|
454
520
|
|
|
455
|
-
fn
|
|
521
|
+
fn update_brand(&self, widgets: &AppWidgets) {
|
|
456
522
|
let active = self.data.values().filter(|card| card.active()).count();
|
|
457
|
-
|
|
458
|
-
"{} active · {} settled · order {}s",
|
|
523
|
+
let counts = BrandCounts {
|
|
459
524
|
active,
|
|
460
|
-
self.data.len().saturating_sub(active),
|
|
461
|
-
|
|
462
|
-
|
|
525
|
+
settled: self.data.len().saturating_sub(active),
|
|
526
|
+
};
|
|
527
|
+
let focused = self
|
|
528
|
+
.focused_card_id
|
|
529
|
+
.as_ref()
|
|
530
|
+
.and_then(|id| self.data.get(id));
|
|
531
|
+
let home = std::env::var("HOME").unwrap_or_default();
|
|
532
|
+
let brand = brand_text(focused, &counts, &home);
|
|
533
|
+
widgets.eyebrow.set_text(&brand.eyebrow);
|
|
534
|
+
widgets.title.set_text(&brand.title);
|
|
535
|
+
widgets.meta.set_text(&brand.meta);
|
|
536
|
+
widgets.brand.set_tooltip_text(
|
|
537
|
+
focused
|
|
538
|
+
.map(|card| card.cwd.as_str())
|
|
539
|
+
.filter(|cwd| !cwd.is_empty()),
|
|
540
|
+
);
|
|
463
541
|
}
|
|
464
542
|
|
|
465
543
|
fn regroup(&mut self) {
|
|
@@ -559,9 +637,59 @@ impl App {
|
|
|
559
637
|
|
|
560
638
|
#[cfg(test)]
|
|
561
639
|
mod tests {
|
|
562
|
-
use super::{
|
|
640
|
+
use super::{
|
|
641
|
+
BrandCounts, brand_text, engaged_card_id, move_order_item, navigation_target, short_path,
|
|
642
|
+
};
|
|
643
|
+
use crate::protocol::Card;
|
|
563
644
|
use std::collections::HashSet;
|
|
564
645
|
|
|
646
|
+
fn counts() -> BrandCounts {
|
|
647
|
+
BrandCounts {
|
|
648
|
+
active: 2,
|
|
649
|
+
settled: 3,
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
#[test]
|
|
654
|
+
fn brand_names_the_focused_windows_project_and_folder() {
|
|
655
|
+
let card: Card = serde_json::from_value(serde_json::json!({
|
|
656
|
+
"repoLabel": "pi-activity-strip",
|
|
657
|
+
"cwd": "/home/op/ai-society/softwareco/owned/pi-extensions/packages/pi-activity-strip"
|
|
658
|
+
}))
|
|
659
|
+
.expect("card");
|
|
660
|
+
let brand = brand_text(Some(&card), &counts(), "/home/op");
|
|
661
|
+
assert_eq!(brand.title, "pi-activity-strip");
|
|
662
|
+
assert_eq!(
|
|
663
|
+
brand.meta,
|
|
664
|
+
"~/ai-society/softwareco/owned/pi-extensions/packages/pi-activity-strip"
|
|
665
|
+
);
|
|
666
|
+
assert_eq!(brand.eyebrow, "π 2 ACTIVE · 3 SETTLED");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
#[test]
|
|
670
|
+
fn brand_falls_back_to_counts_without_a_focused_window() {
|
|
671
|
+
let brand = brand_text(None, &counts(), "/home/op");
|
|
672
|
+
assert_eq!(brand.title, "Sessions");
|
|
673
|
+
assert_eq!(brand.meta, "2 active · 3 settled");
|
|
674
|
+
|
|
675
|
+
let unlabelled: Card = serde_json::from_value(serde_json::json!({})).expect("card");
|
|
676
|
+
assert_eq!(
|
|
677
|
+
brand_text(Some(&unlabelled), &counts(), "/home/op").title,
|
|
678
|
+
"Sessions"
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
#[test]
|
|
683
|
+
fn short_path_abbreviates_only_the_whole_home_directory() {
|
|
684
|
+
assert_eq!(short_path("/home/op/repo", "/home/op"), "~/repo");
|
|
685
|
+
assert_eq!(short_path("/home/op", "/home/op/"), "~");
|
|
686
|
+
assert_eq!(
|
|
687
|
+
short_path("/home/operator/repo", "/home/op"),
|
|
688
|
+
"/home/operator/repo"
|
|
689
|
+
);
|
|
690
|
+
assert_eq!(short_path("/srv/repo", ""), "/srv/repo");
|
|
691
|
+
}
|
|
692
|
+
|
|
565
693
|
#[test]
|
|
566
694
|
fn stale_gtk_focus_does_not_retain_expansion_outside_keyboard_mode() {
|
|
567
695
|
let focused = Some("card-a".to_owned());
|