@tryinget/pi-activity-strip 0.7.0 → 0.9.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.
@@ -45,20 +45,36 @@ pub fn apply_theme(definitions: &str) {
45
45
  THEME_PROVIDER.with(|provider| provider.load_from_data(&css));
46
46
  }
47
47
 
48
+ /// Read controller messages from stdin on their own thread. The thread is detached on purpose: it
49
+ /// lives exactly as long as stdin, and on EOF it reports the parent gone, which closes the panel.
48
50
  pub fn start_input_reader(sender: relm4::Sender<AppMsg>) {
49
- std::thread::spawn(move || {
50
- for line in BufReader::new(std::io::stdin()).lines() {
51
- match line {
52
- Ok(line) if !line.trim().is_empty() => consume_line(&sender, &line),
53
- Ok(_) => {}
54
- Err(error) => {
55
- let _ = sender.send(AppMsg::InputError(format!("panel input failed: {error}")));
56
- break;
57
- }
51
+ let reader_sender = sender.clone();
52
+ let spawned = std::thread::Builder::new()
53
+ .name("panel-input".into())
54
+ .spawn(move || read_input(reader_sender));
55
+ match spawned {
56
+ Ok(handle) => drop(handle),
57
+ Err(error) => {
58
+ let _ = sender.send(AppMsg::InputError(format!(
59
+ "could not start the panel input reader: {error}"
60
+ )));
61
+ let _ = sender.send(AppMsg::ParentGone);
62
+ }
63
+ }
64
+ }
65
+
66
+ fn read_input(sender: relm4::Sender<AppMsg>) {
67
+ for line in BufReader::new(std::io::stdin()).lines() {
68
+ match line {
69
+ Ok(line) if !line.trim().is_empty() => consume_line(&sender, &line),
70
+ Ok(_) => {}
71
+ Err(error) => {
72
+ let _ = sender.send(AppMsg::InputError(format!("panel input failed: {error}")));
73
+ break;
58
74
  }
59
75
  }
60
- let _ = sender.send(AppMsg::ParentGone);
61
- });
76
+ }
77
+ let _ = sender.send(AppMsg::ParentGone);
62
78
  }
63
79
 
64
80
  fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
@@ -121,6 +137,22 @@ fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
121
137
  .to_owned();
122
138
  let _ = sender.send(AppMsg::ActivationResult(card_id, ok, message));
123
139
  }
140
+ Some("jump-result") => {
141
+ let ok = value
142
+ .get("ok")
143
+ .and_then(|item| item.as_bool())
144
+ .unwrap_or(false);
145
+ let message = value
146
+ .get("message")
147
+ .and_then(|item| item.as_str())
148
+ .unwrap_or(if ok { "Jumped." } else { "Nothing moved." })
149
+ .to_owned();
150
+ let window_id = value
151
+ .get("windowId")
152
+ .and_then(|item| item.as_i64())
153
+ .unwrap_or(-1);
154
+ let _ = sender.send(AppMsg::JumpResult(window_id, ok, message));
155
+ }
124
156
  Some(kind) => {
125
157
  let _ = sender.send(AppMsg::InputError(format!(
126
158
  "unsupported panel message: {kind}"
@@ -89,11 +89,14 @@ window {
89
89
  border-color: alpha(@pi_accent, 0.5);
90
90
  }
91
91
 
92
- /* The tab the compositor is actually showing, marked by weight rather than another border. */
92
+ /*
93
+ * The tab the compositor is actually showing: the card the brand block names. A solid accent ring
94
+ * rather than a tint, so it never reads as the hover state and survives soft light themes.
95
+ */
93
96
  .card.current {
94
- background: mix(@pi_background, @pi_foreground, 0.16);
95
- border-color: alpha(@pi_accent, 0.42);
96
- box-shadow: inset 0 0 0 1px alpha(@pi_accent, 0.22);
97
+ background: mix(@pi_background, @pi_accent, 0.14);
98
+ border-color: @pi_accent;
99
+ box-shadow: inset 0 0 0 1px @pi_accent;
97
100
  }
98
101
 
99
102
  .card.hidden-tab {
@@ -224,6 +227,7 @@ window {
224
227
  * itself: it reuses the card's activation, so it carries the accent colour and a pointer cursor.
225
228
  * Badge states (a claim that outlives its session, a deferred task) are deliberately inert pills.
226
229
  */
230
+ /* Footer-sized: a bare task number with no vertical padding, like the window chip beside it. */
227
231
  .ak-task {
228
232
  background: alpha(@pi_foreground, 0.07);
229
233
  border: 1px solid alpha(@pi_foreground, 0.16);
@@ -231,7 +235,7 @@ window {
231
235
  color: alpha(@pi_foreground, 0.68);
232
236
  font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
233
237
  font-size: 8px;
234
- padding: 3px 8px;
238
+ padding: 0 5px;
235
239
  }
236
240
 
237
241
  .ak-task.ak-active {
@@ -261,3 +265,88 @@ window {
261
265
  font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
262
266
  font-size: 8px;
263
267
  }
268
+
269
+ /*
270
+ * The brand block's window list: every agent window on every workspace, as its card's first line
271
+ * and its Niri window number. The number is the thing you type, so it carries the accent.
272
+ */
273
+ .directory {
274
+ border-top: 1px solid alpha(@pi_foreground, 0.12);
275
+ padding-top: 7px;
276
+ }
277
+
278
+ .jump-line {
279
+ color: @pi_accent;
280
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
281
+ font-size: 10px;
282
+ font-weight: 600;
283
+ }
284
+
285
+ /* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
286
+ .directory-scroller,
287
+ .directory-scroller > viewport {
288
+ background: transparent;
289
+ }
290
+
291
+ /* Same as the card row: scrolls by wheel or touchpad without a visible bar. */
292
+ /* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
293
+ .directory-scroller scrollbar {
294
+ opacity: 0;
295
+ min-width: 0;
296
+ min-height: 0;
297
+ }
298
+
299
+ .directory-header {
300
+ color: alpha(@pi_foreground, 0.5);
301
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
302
+ font-size: 8px;
303
+ letter-spacing: 1px;
304
+ margin-top: 5px;
305
+ }
306
+
307
+ .directory-row {
308
+ background: transparent;
309
+ border: 1px solid transparent;
310
+ border-radius: 6px;
311
+ color: @pi_foreground;
312
+ min-height: 0;
313
+ padding: 2px 6px;
314
+ }
315
+
316
+ .directory-row:hover,
317
+ .directory-row:focus-visible {
318
+ background: mix(@pi_background, @pi_foreground, 0.1);
319
+ }
320
+
321
+ /* The window you are in: the same accent ring as its card. */
322
+ .directory-row.current {
323
+ border-color: @pi_accent;
324
+ }
325
+
326
+ .directory-label {
327
+ font-size: 11px;
328
+ }
329
+
330
+ .directory-id {
331
+ color: @pi_accent;
332
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
333
+ font-size: 10px;
334
+ font-weight: 600;
335
+ }
336
+
337
+ .directory-empty {
338
+ color: alpha(@pi_foreground, 0.5);
339
+ font-size: 10px;
340
+ }
341
+
342
+ /* Which workspace a row's window is on; quieter than the number you type. */
343
+ .directory-workspace {
344
+ color: alpha(@pi_foreground, 0.5);
345
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
346
+ font-size: 9px;
347
+ }
348
+
349
+ /* The row Enter would open: a typed name always shows where it leads. */
350
+ .directory-row.chosen {
351
+ background: alpha(@pi_accent, 0.16);
352
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryinget/pi-activity-strip",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "pi extension package for activity-strip workflows in monorepo runtime",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -92,9 +92,8 @@
92
92
  "@earendil-works/pi-ai": "0.84.4",
93
93
  "@earendil-works/pi-coding-agent": "0.84.4",
94
94
  "@types/node": "22.14.0",
95
- "@typescript/native-preview": "7.0.0-dev.20260417.1",
96
95
  "typebox": "1.3.7",
97
- "typescript": "5.9.2"
96
+ "typescript": "7.0.2"
98
97
  },
99
98
  "overrides": {
100
99
  "fast-xml-parser": "5.7.0"
@@ -99,6 +99,7 @@ export interface ActivityStripRuntimeStatus {
99
99
  clickThrough?: boolean;
100
100
  panelMoveCount?: number;
101
101
  panelActivationCount?: number;
102
+ panelJumpCount?: number;
102
103
  panelRestartCount?: number;
103
104
  lastMovedCardId?: string | null;
104
105
  warnings?: string[];
@@ -123,6 +123,7 @@ export interface ActivityStripRuntimeStatus {
123
123
  clickThrough?: boolean;
124
124
  panelMoveCount?: number;
125
125
  panelActivationCount?: number;
126
+ panelJumpCount?: number;
126
127
  panelRestartCount?: number;
127
128
  lastMovedCardId?: string | null;
128
129
  warnings?: string[];
@@ -149,6 +149,70 @@ export function resolveFocusedWorkspaceView(windows, workspaces, sessions, optio
149
149
  return workspace ? resolveWorkspaceView(windows, workspace, sessions, options) : null;
150
150
  }
151
151
 
152
+ /**
153
+ * @typedef {{
154
+ * cardId: string;
155
+ * label: string;
156
+ * windowId: number;
157
+ * workspaceId: number;
158
+ * workspaceIdx: number | null;
159
+ * workspaceName: string | null;
160
+ * hiddenTab: boolean;
161
+ * current: boolean;
162
+ * }} WindowDirectoryEntry
163
+ */
164
+
165
+ /**
166
+ * Every agent window on every workspace, for the ribbon's window list. It reuses the per-workspace
167
+ * card projection, so an entry exists exactly when that workspace would show the card. Ordered
168
+ * alphabetically by project name, then by window number; each entry carries its workspace.
169
+ * @param {Array<Record<string, unknown>>} windows
170
+ * @param {Array<Record<string, unknown>>} workspaces
171
+ * @param {Array<Record<string, unknown>>} sessions
172
+ * @param {ResolveOptions} [options]
173
+ * @returns {{entries: WindowDirectoryEntry[]; cards: Map<string, Record<string, unknown>>}}
174
+ */
175
+ export function resolveWindowDirectory(windows, workspaces, sessions, options = {}) {
176
+ const ordered = workspaces
177
+ .filter((workspace) => Number.isInteger(workspace?.id))
178
+ .sort(
179
+ (a, b) =>
180
+ String(a.output ?? "").localeCompare(String(b.output ?? "")) ||
181
+ Number(a.idx ?? 0) - Number(b.idx ?? 0),
182
+ );
183
+ /** @type {WindowDirectoryEntry[]} */
184
+ const entries = [];
185
+ /** @type {Map<string, Record<string, unknown>>} */
186
+ const cards = new Map();
187
+ for (const workspace of ordered) {
188
+ const view = resolveWorkspaceView(windows, workspace, sessions, options);
189
+ if (!view) continue;
190
+ const placed = view.sessions
191
+ .filter((card) => Number.isInteger(card.windowId))
192
+ .sort((a, b) => Number(a.windowId) - Number(b.windowId));
193
+ for (const card of placed) {
194
+ const cardId = String(card.cardId ?? "");
195
+ if (!cardId || cards.has(cardId)) continue;
196
+ cards.set(cardId, card);
197
+ entries.push({
198
+ cardId,
199
+ label: String(card.repoLabel || "session"),
200
+ windowId: Number(card.windowId),
201
+ workspaceId: Number(workspace.id),
202
+ workspaceIdx: Number.isInteger(card.workspaceIdx) ? Number(card.workspaceIdx) : null,
203
+ workspaceName: typeof workspace.name === "string" ? workspace.name : null,
204
+ hiddenTab: card.surfaceVisible === false,
205
+ current: cardId === view.focusedCardId,
206
+ });
207
+ }
208
+ }
209
+ entries.sort(
210
+ (a, b) =>
211
+ a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.windowId - b.windowId,
212
+ );
213
+ return { entries, cards };
214
+ }
215
+
152
216
  /** @param {Array<Record<string, unknown>>} workspaces */
153
217
  export function resolveFocusedNiriWorkspace(workspaces) {
154
218
  const matches = workspaces.filter(
@@ -13,7 +13,7 @@ import { pruneClaudeEventRecords } from "../common/claude-events.mjs";
13
13
  import { ACTIVITY_STRIP_WORKSPACE_SYNC_MS } from "../common/constants.mjs";
14
14
  import { focusNiriSession, readNiriWindows, readNiriWorkspaces } from "../common/niri-focus.mjs";
15
15
  import { haveSameRecordMembership } from "../common/session-cards.mjs";
16
- import { resolveFocusedWorkspaceView } from "../common/workspace-view.mjs";
16
+ import { resolveFocusedWorkspaceView, resolveWindowDirectory } from "../common/workspace-view.mjs";
17
17
  import { discoverAgentTabs } from "./agent-discovery.mjs";
18
18
  import { startAkTaskProjection } from "./ak-runtime.mjs";
19
19
  import { createHeightRepair } from "./height-repair.mjs";
@@ -158,6 +158,37 @@ async function focusSession(targetId) {
158
158
  return result;
159
159
  }
160
160
 
161
+ /**
162
+ * Jump to a window by the number Niri gives it, as typed into the window list. Only a window that
163
+ * exists right now is focused; any other number does nothing.
164
+ * @param {number} windowId
165
+ * @returns {Promise<{ok: boolean; error?: string}>}
166
+ */
167
+ async function jumpToWindow(windowId) {
168
+ if (!isNiriSession()) return { ok: false, error: "Niri is not available; nothing moved." };
169
+ if (!Number.isInteger(windowId) || windowId < 0) {
170
+ return { ok: false, error: "Not a window number; nothing moved." };
171
+ }
172
+ let windows;
173
+ try {
174
+ windows = await getNiriWindows();
175
+ } catch {
176
+ return { ok: false, error: "Could not read Niri windows; nothing moved." };
177
+ }
178
+ if (!windows.some((window) => window?.id === windowId)) {
179
+ return { ok: false, error: `No window #${windowId}; nothing moved.` };
180
+ }
181
+ try {
182
+ await execFileAsync("niri", ["msg", "action", "focus-window", "--id", String(windowId)], {
183
+ env: process.env,
184
+ });
185
+ } catch {
186
+ return { ok: false, error: `Niri refused to focus #${windowId}; nothing moved.` };
187
+ }
188
+ reconcileRunner.request();
189
+ return { ok: true };
190
+ }
191
+
161
192
  async function focusStrip() {
162
193
  if (runtimeStatus.clickThrough) {
163
194
  return { ok: false, error: "Keyboard entry is disabled in click-through mode." };
@@ -231,6 +262,17 @@ function consumePanelEvents(child, chunk) {
231
262
  : result.error || "Focus failed; nothing moved.",
232
263
  }),
233
264
  );
265
+ } else if (event.type === "jump") {
266
+ runtimeStatus.panelJumpCount = (runtimeStatus.panelJumpCount ?? 0) + 1;
267
+ const windowId = Number(event.windowId);
268
+ void jumpToWindow(windowId).then((result) =>
269
+ writePanel({
270
+ type: "jump-result",
271
+ windowId,
272
+ ok: result.ok === true,
273
+ message: result.ok ? `Jumped to #${windowId}.` : result.error || "Nothing moved.",
274
+ }),
275
+ );
234
276
  } else if (event.type === "moved") {
235
277
  runtimeStatus.panelMoveCount = (runtimeStatus.panelMoveCount ?? 0) + 1;
236
278
  runtimeStatus.lastMovedCardId = String(event.cardId ?? "") || null;
@@ -362,6 +404,7 @@ const reconcileRunner = createLatestOnlyRunner(async ({ isCurrent }) => {
362
404
  runtimeStatus.unplacedSurfaceCount = placement.countUnplaced(windows, sessions);
363
405
  const view = resolveFocusedWorkspaceView(windows, workspaces, sessions, placement.options);
364
406
  if (view) placement.learnFromPlacements(view.sessions, windows);
407
+ projection.setDirectory(resolveWindowDirectory(windows, workspaces, sessions, placement.options));
365
408
  projection.publishWorkspaceView(
366
409
  view ?? { workspace: null, sessions: [], focusedSessionId: null, focusedCardId: null },
367
410
  );
@@ -36,6 +36,11 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
36
36
  /** Read-only AK task claims joined onto cards; absent until the first successful AK read. */
37
37
  /** @type {{claims: import("../common/contracts.ts").AkTaskClaim[]; deferred: import("../common/contracts.ts").AkTaskDeferred[]} | null} */
38
38
  let akTasks = null;
39
+ /** Every agent window on every workspace, for the brand block's window list. */
40
+ /** @type {import("../common/workspace-view.mjs").WindowDirectoryEntry[]} */
41
+ let directory = [];
42
+ /** @type {Map<string, SessionRecord>} */
43
+ let directoryCards = new Map();
39
44
  let revision = 0;
40
45
 
41
46
  function allSessions() {
@@ -83,6 +88,7 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
83
88
  focusedCardId,
84
89
  generatedAt: snapshot.generatedAt,
85
90
  sessions,
91
+ windows: directory,
86
92
  };
87
93
  }
88
94
 
@@ -117,8 +123,24 @@ export function createNativePanelProjection({ isNiriSession, publish }) {
117
123
  akTasks = state;
118
124
  send();
119
125
  },
120
- /** @param {string} targetId */
121
- resolveTarget: (targetId) => resolveSnapshotSession(getDisplaySessions(), targetId),
126
+ /**
127
+ * A card on this workspace, or else an entry of the window list, which spans every workspace.
128
+ * @param {string} targetId
129
+ */
130
+ resolveTarget: (targetId) =>
131
+ resolveSnapshotSession(getDisplaySessions(), targetId) ??
132
+ directoryCards.get(targetId) ??
133
+ null,
134
+ /** @param {number} windowId */
135
+ hasDirectoryWindow: (windowId) => directory.some((entry) => entry.windowId === windowId),
136
+ /**
137
+ * Replace the window list; it is published with the next view.
138
+ * @param {{entries: import("../common/workspace-view.mjs").WindowDirectoryEntry[]; cards: Map<string, SessionRecord>}} next
139
+ */
140
+ setDirectory(next) {
141
+ directory = next.entries;
142
+ directoryCards = next.cards;
143
+ },
122
144
  /** @param {WorkspaceView} view */
123
145
  publishWorkspaceView(view) {
124
146
  workspace = view.workspace ?? null;