@tryinget/pi-activity-strip 0.4.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 (61) hide show
  1. package/README.md +136 -157
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +50 -60
  4. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/Cargo.lock +1045 -0
  7. package/native/panel/Cargo.toml +23 -0
  8. package/native/panel/src/app.rs +606 -0
  9. package/native/panel/src/card_view.rs +602 -0
  10. package/native/panel/src/main.rs +31 -0
  11. package/native/panel/src/protocol.rs +297 -0
  12. package/native/panel/src/runtime.rs +151 -0
  13. package/native/panel/src/style.css +250 -0
  14. package/package.json +16 -4
  15. package/src/broker/server.mjs +43 -7
  16. package/src/broker/session-store.mjs +41 -13
  17. package/src/client/broker-client.mjs +10 -3
  18. package/src/client/session-telemetry.mjs +54 -14
  19. package/src/common/activity-order.mjs +15 -9
  20. package/src/common/agent-identity.mjs +163 -0
  21. package/src/common/ak-tasks.mjs +354 -0
  22. package/src/common/alignment-controller.mjs +24 -42
  23. package/src/common/card-display.mjs +5 -0
  24. package/src/common/claude-events.mjs +190 -0
  25. package/src/common/claude-hook-config.mjs +56 -0
  26. package/src/common/claude-transcript.mjs +163 -0
  27. package/src/common/codex-transcript.mjs +153 -0
  28. package/src/common/compatibility.mjs +28 -13
  29. package/src/common/constants.mjs +10 -28
  30. package/src/common/contracts.d.ts +68 -4
  31. package/src/common/contracts.ts +105 -4
  32. package/src/common/ghostty-present.mjs +100 -0
  33. package/src/common/ghostty-theme.mjs +149 -0
  34. package/src/common/niri-focus.mjs +209 -140
  35. package/src/common/protocol.mjs +48 -16
  36. package/src/common/session-cards.mjs +105 -0
  37. package/src/common/status-report.mjs +53 -1
  38. package/src/common/surface-bindings.mjs +234 -0
  39. package/src/common/telemetry.mjs +8 -1
  40. package/src/common/terminal-identity.mjs +212 -0
  41. package/src/common/window-placement.mjs +59 -0
  42. package/src/common/workspace-view.mjs +161 -0
  43. package/src/native/agent-discovery.mjs +294 -0
  44. package/src/native/ak-runtime.mjs +164 -0
  45. package/src/native/codex-discovery.mjs +128 -0
  46. package/src/native/ghostty-tab-inventory.py +99 -0
  47. package/src/native/height-repair.mjs +154 -0
  48. package/src/native/main.mjs +499 -0
  49. package/src/native/panel-projection.mjs +161 -0
  50. package/src/native/placement.mjs +225 -0
  51. package/src/native/tab-inventory.mjs +188 -0
  52. package/src/native/theme-runtime.mjs +72 -0
  53. package/src/native/theme-source.mjs +197 -0
  54. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +20 -2
  55. package/src/common/electron.mjs +0 -57
  56. package/src/electron/main.mjs +0 -499
  57. package/src/electron/niri-native-window-runtime.mjs +0 -178
  58. package/src/electron/preload.cjs +0 -50
  59. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  60. package/src/electron/workspace-view-runtime.mjs +0 -360
  61. package/src/ui/strip-html.mjs +0 -500
@@ -0,0 +1,602 @@
1
+ use crate::app::AppMsg;
2
+ use crate::protocol::{AkTask, Card};
3
+ use relm4::Sender;
4
+ use relm4::gtk;
5
+ use relm4::gtk::gdk;
6
+ use relm4::gtk::glib;
7
+ use relm4::gtk::prelude::*;
8
+ use std::cell::Cell;
9
+ use std::rc::Rc;
10
+
11
+ /// Rendered AK chips per card: at most two live-claim buttons plus two state badges.
12
+ const AK_CHIP_SLOTS: usize = 4;
13
+
14
+ pub struct CardView {
15
+ pub root: gtk::Button,
16
+ phase: gtk::Label,
17
+ repo: gtk::Label,
18
+ state: gtk::Label,
19
+ tool: gtk::Label,
20
+ elapsed: gtk::Label,
21
+ detail: gtk::Label,
22
+ prompt: gtk::Label,
23
+ reply: gtk::Label,
24
+ path: gtk::Label,
25
+ pid: gtk::Label,
26
+ agent: gtk::Label,
27
+ task: gtk::Label,
28
+ activation: gtk::Label,
29
+ inspector: gtk::Grid,
30
+ ak_row: gtk::Box,
31
+ ak_chips: Vec<gtk::Label>,
32
+ ak_clickable: Vec<Rc<Cell<bool>>>,
33
+ ak_overflow: gtk::Label,
34
+ }
35
+
36
+ impl CardView {
37
+ pub fn new(id: &str, sender: &Sender<AppMsg>) -> Self {
38
+ let root = gtk::Button::new();
39
+ root.add_css_class("card");
40
+ root.set_focusable(true);
41
+ root.set_can_focus(true);
42
+ root.set_width_request(224);
43
+ root.set_height_request(60);
44
+
45
+ let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
46
+ let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
47
+ let label_box = gtk::Box::new(gtk::Orientation::Vertical, 3);
48
+ label_box.set_hexpand(true);
49
+ let repo = label("card-repo", gtk::Align::Start);
50
+ let phase = label("card-phase", gtk::Align::Start);
51
+ label_box.append(&repo);
52
+ label_box.append(&phase);
53
+ let ak_row = gtk::Box::new(gtk::Orientation::Horizontal, 4);
54
+ ak_row.set_valign(gtk::Align::Center);
55
+ ak_row.set_visible(false);
56
+ let state = label("card-state", gtk::Align::End);
57
+ header.append(&label_box);
58
+ header.append(&ak_row);
59
+ header.append(&state);
60
+
61
+ let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
62
+ footer.set_margin_top(5);
63
+ let tool = label("card-tool", gtk::Align::Start);
64
+ tool.set_hexpand(true);
65
+ let elapsed = label("card-elapsed", gtk::Align::End);
66
+ footer.append(&tool);
67
+ footer.append(&elapsed);
68
+
69
+ let inspector = gtk::Grid::new();
70
+ inspector.add_css_class("inspector");
71
+ inspector.set_row_spacing(6);
72
+ inspector.set_column_spacing(8);
73
+ inspector.set_margin_top(10);
74
+ inspector.set_visible(false);
75
+ let detail = inspector_row(&inspector, 0, "detail");
76
+ let prompt = inspector_row(&inspector, 1, "prompt");
77
+ let reply = inspector_row(&inspector, 2, "reply");
78
+ let path = inspector_row(&inspector, 3, "path");
79
+ let pid = inspector_row(&inspector, 4, "pid");
80
+ let agent = inspector_row(&inspector, 5, "agent");
81
+ let task = inspector_row(&inspector, 6, "task");
82
+ let activation = label("activation", gtk::Align::Start);
83
+ activation.set_visible(false);
84
+ inspector.attach(&activation, 0, 7, 2, 1);
85
+
86
+ content.append(&header);
87
+ content.append(&footer);
88
+ content.append(&inspector);
89
+ root.set_child(Some(&content));
90
+
91
+ let card_id = id.to_owned();
92
+ let tx = sender.clone();
93
+ root.connect_clicked(move |_| {
94
+ let _ = tx.send(AppMsg::Activate(card_id.clone()));
95
+ });
96
+
97
+ // AK task chips: an `active` chip reuses the card's own activation, so clicking it focuses
98
+ // the exact Ghostty window of the session holding that task's live claim. Badge states stop
99
+ // propagation instead, so a non-actionable reference never fires anything.
100
+ let mut ak_chips = Vec::with_capacity(AK_CHIP_SLOTS);
101
+ let mut ak_clickable = Vec::with_capacity(AK_CHIP_SLOTS);
102
+ for _slot in 0..AK_CHIP_SLOTS {
103
+ let chip = label("ak-task", gtk::Align::End);
104
+ chip.set_ellipsize(relm4::gtk::pango::EllipsizeMode::End);
105
+ chip.set_max_width_chars(20);
106
+ chip.set_visible(false);
107
+ let clickable = Rc::new(Cell::new(false));
108
+ let click = gtk::GestureClick::new();
109
+ let card_id = id.to_owned();
110
+ let tx = sender.clone();
111
+ let chip_clickable = Rc::clone(&clickable);
112
+ click.connect_released(move |gesture, _, _, _| {
113
+ if chip_clickable.get() {
114
+ let _ = tx.send(AppMsg::Activate(card_id.clone()));
115
+ }
116
+ gesture.set_state(gtk::EventSequenceState::Claimed);
117
+ });
118
+ chip.add_controller(click);
119
+ ak_row.append(&chip);
120
+ ak_chips.push(chip);
121
+ ak_clickable.push(clickable);
122
+ }
123
+ let ak_overflow = label("ak-overflow", gtk::Align::End);
124
+ ak_overflow.set_visible(false);
125
+ ak_row.append(&ak_overflow);
126
+
127
+ let motion = gtk::EventControllerMotion::new();
128
+ let card_id = id.to_owned();
129
+ let tx = sender.clone();
130
+ motion.connect_enter(move |_, _, _| {
131
+ let _ = tx.send(AppMsg::Hover(card_id.clone(), true));
132
+ });
133
+ let card_id = id.to_owned();
134
+ let tx = sender.clone();
135
+ motion.connect_leave(move |_| {
136
+ let _ = tx.send(AppMsg::Hover(card_id.clone(), false));
137
+ });
138
+ root.add_controller(motion);
139
+
140
+ let focus = gtk::EventControllerFocus::new();
141
+ let card_id = id.to_owned();
142
+ let tx = sender.clone();
143
+ focus.connect_enter(move |_| {
144
+ let _ = tx.send(AppMsg::Focus(card_id.clone(), true));
145
+ });
146
+ let card_id = id.to_owned();
147
+ let tx = sender.clone();
148
+ focus.connect_leave(move |_| {
149
+ let _ = tx.send(AppMsg::Focus(card_id.clone(), false));
150
+ });
151
+ root.add_controller(focus);
152
+
153
+ let keys = gtk::EventControllerKey::new();
154
+ let card_id = id.to_owned();
155
+ let tx = sender.clone();
156
+ keys.connect_key_pressed(move |_, key, _, modifiers| {
157
+ let direction = if key == gdk::Key::Left {
158
+ -1
159
+ } else if key == gdk::Key::Right {
160
+ 1
161
+ } else if key == gdk::Key::Escape {
162
+ let _ = tx.send(AppMsg::Collapse);
163
+ return glib::Propagation::Stop;
164
+ } else if key == gdk::Key::space || key == gdk::Key::KP_Space {
165
+ return glib::Propagation::Stop;
166
+ } else {
167
+ return glib::Propagation::Proceed;
168
+ };
169
+ let manual = modifiers.contains(gdk::ModifierType::SHIFT_MASK);
170
+ let _ = tx.send(AppMsg::Navigate(card_id.clone(), direction, manual));
171
+ glib::Propagation::Stop
172
+ });
173
+ root.add_controller(keys);
174
+
175
+ Self {
176
+ root,
177
+ phase,
178
+ repo,
179
+ state,
180
+ tool,
181
+ elapsed,
182
+ detail,
183
+ prompt,
184
+ reply,
185
+ path,
186
+ pid,
187
+ agent,
188
+ task,
189
+ activation,
190
+ inspector,
191
+ ak_row,
192
+ ak_chips,
193
+ ak_clickable,
194
+ ak_overflow,
195
+ }
196
+ }
197
+
198
+ pub fn update(&self, card: &Card, focused: bool, duplicate_label: bool, now_ms: i64) {
199
+ let hidden_tab = card.hidden_tab();
200
+ self.repo.set_text(&display_repo(card, duplicate_label));
201
+ self.phase.set_text(text_or(&card.phase, "Idle"));
202
+ let real_event_at = if card.last_event_at > 0 {
203
+ card.last_event_at
204
+ } else {
205
+ card.updated_at
206
+ };
207
+ let stalled = card.stalled(now_ms);
208
+ self.state.set_text(if stalled {
209
+ "stalled"
210
+ } else {
211
+ state_label(&card.state)
212
+ });
213
+ self.tool.set_text(text_or(
214
+ if card.tool_name.is_empty() {
215
+ &card.tool_target
216
+ } else {
217
+ &card.tool_name
218
+ },
219
+ "monitoring",
220
+ ));
221
+ self.elapsed.set_text(&format!(
222
+ "{} · {}",
223
+ duration(now_ms, card.agent_started_at.unwrap_or(card.started_at)),
224
+ duration(now_ms, real_event_at)
225
+ ));
226
+ self.detail.set_text(text_or(&card.detail, "Ready"));
227
+ self.prompt
228
+ .set_text(text_or(&card.last_prompt_preview, "—"));
229
+ self.reply.set_text(text_or(&card.assistant_preview, "—"));
230
+ self.path.set_text(text_or(&card.cwd, "—"));
231
+ self.pid.set_text(&pid_row(card, hidden_tab));
232
+ self.agent
233
+ .set_text(text_or(&card.agent_label, "Pi session"));
234
+ self.task
235
+ .set_text(&ak_inspector_text(&card.ak_tasks, card.ak_task_overflow));
236
+ self.apply_ak_tasks(card);
237
+
238
+ for class in [
239
+ "state-idle",
240
+ "state-thinking",
241
+ "state-tool",
242
+ "state-waiting",
243
+ "state-success",
244
+ "state-error",
245
+ "current",
246
+ "stalled",
247
+ "hidden-tab",
248
+ ] {
249
+ self.root.remove_css_class(class);
250
+ }
251
+ if hidden_tab {
252
+ self.root.add_css_class("hidden-tab");
253
+ }
254
+ self.root.add_css_class(&format!(
255
+ "state-{}",
256
+ if stalled {
257
+ "waiting"
258
+ } else {
259
+ state_class(&card.state)
260
+ }
261
+ ));
262
+ if focused {
263
+ self.root.add_css_class("current");
264
+ }
265
+ if stalled {
266
+ self.root.add_css_class("stalled");
267
+ }
268
+ let pid_note = if card.pid > 0 {
269
+ format!("pid {}", card.pid)
270
+ } else {
271
+ "no pid".to_owned()
272
+ };
273
+ let ak_note = ak_accessible_note(&card.ak_tasks);
274
+ self.root.set_tooltip_text(Some(&format!(
275
+ "{} {} ({}{}){}",
276
+ if hidden_tab { "Present" } else { "Focus" },
277
+ text_or(&card.repo_label, "Pi session"),
278
+ pid_note,
279
+ if hidden_tab { ", hidden tab" } else { "" },
280
+ ak_note
281
+ )));
282
+ let accessible_label = format!(
283
+ "{}, {}, {}{}",
284
+ text_or(&card.repo_label, "Pi session"),
285
+ if stalled {
286
+ "stalled"
287
+ } else {
288
+ text_or(&card.phase, "idle")
289
+ },
290
+ if hidden_tab {
291
+ "hidden tab, press Enter to present it in its Ghostty window"
292
+ } else {
293
+ "press Enter to focus its Ghostty window"
294
+ },
295
+ ak_note
296
+ );
297
+ self.root
298
+ .update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
299
+ self.root
300
+ .update_state(&[gtk::accessible::State::Selected(Some(focused))]);
301
+ }
302
+
303
+ fn apply_ak_tasks(&self, card: &Card) {
304
+ let chips = &card.ak_tasks;
305
+ for (index, chip) in self.ak_chips.iter().enumerate() {
306
+ let clickable = self.ak_clickable.get(index).map(Rc::as_ref);
307
+ match (chips.get(index), clickable) {
308
+ (Some(task), Some(flag)) => {
309
+ chip.set_visible(true);
310
+ chip.set_text(&ak_chip_text(task));
311
+ for class in ["ak-active", "ak-deferred", "ak-orphaned"] {
312
+ chip.remove_css_class(class);
313
+ }
314
+ chip.add_css_class(ak_chip_class(&task.state));
315
+ chip.set_tooltip_text(Some(&ak_chip_tooltip(task)));
316
+ flag.set(task.is_active());
317
+ }
318
+ _ => {
319
+ chip.set_visible(false);
320
+ chip.set_tooltip_text(None);
321
+ if let Some(flag) = clickable {
322
+ flag.set(false);
323
+ }
324
+ }
325
+ }
326
+ }
327
+ self.ak_row.set_visible(!chips.is_empty());
328
+ if card.ak_task_overflow > 0 {
329
+ self.ak_overflow.set_visible(true);
330
+ self.ak_overflow
331
+ .set_text(&format!("+{} more", card.ak_task_overflow));
332
+ } else {
333
+ self.ak_overflow.set_visible(false);
334
+ }
335
+ }
336
+
337
+ pub fn set_expanded(&self, expanded: bool) {
338
+ self.root
339
+ .update_state(&[gtk::accessible::State::Expanded(Some(expanded))]);
340
+ self.root
341
+ .set_height_request(if expanded { 252 } else { 60 });
342
+ self.inspector.set_visible(expanded);
343
+ if expanded {
344
+ self.root.add_css_class("open");
345
+ } else {
346
+ self.root.remove_css_class("open");
347
+ }
348
+ }
349
+
350
+ pub fn set_activation(&self, ok: bool, message: &str) {
351
+ self.activation.set_text(message);
352
+ self.activation.set_visible(true);
353
+ self.activation.remove_css_class("success");
354
+ self.activation.remove_css_class("error");
355
+ self.activation
356
+ .add_css_class(if ok { "success" } else { "error" });
357
+ self.root
358
+ .update_property(&[gtk::accessible::Property::Description(message)]);
359
+ self.root
360
+ .announce(message, gtk::AccessibleAnnouncementPriority::Medium);
361
+ }
362
+ }
363
+
364
+ fn label(class: &str, align: gtk::Align) -> gtk::Label {
365
+ let value = gtk::Label::new(None);
366
+ value.add_css_class(class);
367
+ value.set_halign(align);
368
+ value.set_ellipsize(relm4::gtk::pango::EllipsizeMode::End);
369
+ value.set_xalign(if align == gtk::Align::End { 1.0 } else { 0.0 });
370
+ value
371
+ }
372
+
373
+ fn inspector_row(grid: &gtk::Grid, row: i32, key: &str) -> gtk::Label {
374
+ let key_label = label("inspector-key", gtk::Align::Start);
375
+ key_label.set_text(key);
376
+ key_label.set_width_chars(7);
377
+ let value = label("inspector-value", gtk::Align::Start);
378
+ value.set_wrap(true);
379
+ value.set_lines(2);
380
+ value.set_hexpand(true);
381
+ grid.attach(&key_label, 0, row, 1, 1);
382
+ grid.attach(&value, 1, row, 1, 1);
383
+ value
384
+ }
385
+
386
+ fn display_repo(card: &Card, duplicate: bool) -> String {
387
+ let marker = if card.hidden_tab() { "⧉ " } else { "" };
388
+ if duplicate && card.pid > 0 {
389
+ format!(
390
+ "{}{} · {:04}",
391
+ marker,
392
+ text_or(&card.repo_label, "Pi session"),
393
+ card.pid % 10_000
394
+ )
395
+ } else {
396
+ format!("{}{}", marker, text_or(&card.repo_label, "Pi session"))
397
+ }
398
+ }
399
+
400
+ fn pid_row(card: &Card, hidden_tab: bool) -> String {
401
+ let pid = if card.pid > 0 {
402
+ card.pid.to_string()
403
+ } else {
404
+ "—".to_owned()
405
+ };
406
+ if hidden_tab {
407
+ format!("{pid} · hidden tab")
408
+ } else {
409
+ pid
410
+ }
411
+ }
412
+
413
+ fn state_label(state: &str) -> &str {
414
+ match state {
415
+ "success" => "done",
416
+ "thinking" | "tool" | "waiting" | "error" | "idle" => state,
417
+ _ => "idle",
418
+ }
419
+ }
420
+
421
+ fn state_class(state: &str) -> &str {
422
+ match state {
423
+ "thinking" | "tool" | "waiting" | "success" | "error" => state,
424
+ _ => "idle",
425
+ }
426
+ }
427
+
428
+ /// CSS class for one AK chip state. Unknown states render as a muted, non-clickable chip.
429
+ fn ak_chip_class(state: &str) -> &'static str {
430
+ match state {
431
+ "active" => "ak-active",
432
+ "deferred" => "ak-deferred",
433
+ "orphaned" => "ak-orphaned",
434
+ _ => "ak-unknown",
435
+ }
436
+ }
437
+
438
+ /// Compact chip text: task id plus as much title as the pill can hold.
439
+ fn ak_chip_text(task: &AkTask) -> String {
440
+ format!("AK #{} · {}", task.id, task.title)
441
+ }
442
+
443
+ fn ak_chip_tooltip(task: &AkTask) -> String {
444
+ match task.state.as_str() {
445
+ "active" => format!(
446
+ "AK task #{} — {}. Click to focus the claiming session's Ghostty window.",
447
+ task.id, task.title
448
+ ),
449
+ "orphaned" => format!(
450
+ "AK task #{} — {}. Claim outlives its session (lease not expired); not clickable.",
451
+ task.id, task.title
452
+ ),
453
+ "deferred" => format!(
454
+ "AK task #{} — {}. Deferred by AK; not clickable.",
455
+ task.id, task.title
456
+ ),
457
+ _ => format!("AK task #{} — {}", task.id, task.title),
458
+ }
459
+ }
460
+
461
+ /// Inspector row: every joined reference with its state, plus the overflow count.
462
+ fn ak_inspector_text(tasks: &[AkTask], overflow: u32) -> String {
463
+ if tasks.is_empty() && overflow == 0 {
464
+ return "—".to_owned();
465
+ }
466
+ let mut lines: Vec<String> = tasks
467
+ .iter()
468
+ .map(|task| {
469
+ format!(
470
+ "#{} [{}] {}",
471
+ task.id,
472
+ match task.state.as_str() {
473
+ "active" => "live claim",
474
+ "orphaned" => "claim outlives session",
475
+ "deferred" => "deferred",
476
+ _ => "unknown",
477
+ },
478
+ task.title
479
+ )
480
+ })
481
+ .collect();
482
+ if overflow > 0 {
483
+ lines.push(format!("+{overflow} more"));
484
+ }
485
+ lines.join("\n")
486
+ }
487
+
488
+ /// Appended to the card's tooltip and accessible label when AK references exist.
489
+ fn ak_accessible_note(tasks: &[AkTask]) -> String {
490
+ match tasks.first() {
491
+ Some(task) if task.is_active() => format!(
492
+ ". AK task #{} {} (click the chip to focus the claiming session)",
493
+ task.id, task.title
494
+ ),
495
+ Some(task) => format!(
496
+ ". AK task #{} {} (badge only, not clickable)",
497
+ task.id, task.title
498
+ ),
499
+ None => String::new(),
500
+ }
501
+ }
502
+
503
+ fn text_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
504
+ if value.is_empty() { fallback } else { value }
505
+ }
506
+
507
+ fn duration(now_ms: i64, anchor_ms: i64) -> String {
508
+ let seconds = now_ms.saturating_sub(anchor_ms.max(1)) / 1000;
509
+ format!("{}:{:02}", seconds / 60, seconds % 60)
510
+ }
511
+
512
+ #[cfg(test)]
513
+ mod tests {
514
+ use super::*;
515
+
516
+ fn card(json: serde_json::Value) -> Card {
517
+ serde_json::from_value(json).expect("card")
518
+ }
519
+
520
+ #[test]
521
+ fn hidden_tabs_carry_a_marker_and_pid_note() {
522
+ let agent = card(serde_json::json!({ "agentLabel": "Claude Code" }));
523
+ assert_eq!(agent.agent_label, "Claude Code");
524
+ assert_eq!(text_or(&agent.agent_label, "Pi session"), "Claude Code");
525
+ assert_eq!(
526
+ text_or(&card(serde_json::json!({})).agent_label, "Pi session"),
527
+ "Pi session"
528
+ );
529
+
530
+ let hidden = card(serde_json::json!({
531
+ "repoLabel": "dspx", "processId": 12345, "surfaceVisible": false
532
+ }));
533
+ assert!(hidden.hidden_tab());
534
+ assert_eq!(display_repo(&hidden, false), "⧉ dspx");
535
+ assert_eq!(display_repo(&hidden, true), "⧉ dspx · 2345");
536
+ assert_eq!(pid_row(&hidden, true), "12345 · hidden tab");
537
+
538
+ let visible = card(serde_json::json!({ "repoLabel": "dspx", "surfaceVisible": true }));
539
+ assert!(!visible.hidden_tab());
540
+ assert_eq!(display_repo(&visible, false), "dspx");
541
+ assert_eq!(pid_row(&visible, false), "—");
542
+
543
+ let legacy = card(serde_json::json!({ "repoLabel": "dspx" }));
544
+ assert!(
545
+ !legacy.hidden_tab(),
546
+ "controllers without placement never mark tabs hidden"
547
+ );
548
+ }
549
+
550
+ #[test]
551
+ fn ak_chip_states_map_to_classes_and_only_active_is_clickable() {
552
+ let active = AkTask {
553
+ id: 5701,
554
+ title: "Show clickable AK-task references".into(),
555
+ state: "active".into(),
556
+ };
557
+ let orphaned = AkTask {
558
+ state: "orphaned".into(),
559
+ ..active.clone()
560
+ };
561
+ let deferred = AkTask {
562
+ state: "deferred".into(),
563
+ ..active.clone()
564
+ };
565
+ assert_eq!(ak_chip_class("active"), "ak-active");
566
+ assert_eq!(ak_chip_class("orphaned"), "ak-orphaned");
567
+ assert_eq!(ak_chip_class("deferred"), "ak-deferred");
568
+ assert_eq!(ak_chip_class("surprise"), "ak-unknown");
569
+ assert!(active.is_active());
570
+ assert!(!orphaned.is_active());
571
+ assert!(!deferred.is_active());
572
+ assert_eq!(
573
+ ak_chip_text(&active),
574
+ "AK #5701 · Show clickable AK-task references"
575
+ );
576
+ assert!(ak_chip_tooltip(&orphaned).contains("not clickable"));
577
+ assert!(ak_chip_tooltip(&active).contains("focus the claiming session"));
578
+ }
579
+
580
+ #[test]
581
+ fn ak_inspector_lists_every_reference_with_overflow() {
582
+ let tasks = vec![
583
+ AkTask {
584
+ id: 5701,
585
+ title: "Live claim task".into(),
586
+ state: "active".into(),
587
+ },
588
+ AkTask {
589
+ id: 4220,
590
+ title: "Deferred task".into(),
591
+ state: "deferred".into(),
592
+ },
593
+ ];
594
+ let text = ak_inspector_text(&tasks, 3);
595
+ assert!(text.contains("#5701 [live claim] Live claim task"));
596
+ assert!(text.contains("#4220 [deferred] Deferred task"));
597
+ assert!(text.contains("+3 more"));
598
+ assert_eq!(ak_inspector_text(&[], 0), "—");
599
+ assert!(ak_accessible_note(&tasks).starts_with(". AK task #5701"));
600
+ assert_eq!(ak_accessible_note(&[]), "");
601
+ }
602
+ }
@@ -0,0 +1,31 @@
1
+ mod app;
2
+ mod card_view;
3
+ mod protocol;
4
+ mod runtime;
5
+
6
+ use app::{App, AppInit};
7
+ use relm4::RelmApp;
8
+
9
+ #[cfg(target_os = "linux")]
10
+ fn bind_lifetime_to_parent() {
11
+ unsafe {
12
+ libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
13
+ if libc::getppid() == 1 {
14
+ std::process::exit(1);
15
+ }
16
+ }
17
+ }
18
+
19
+ #[cfg(not(target_os = "linux"))]
20
+ fn bind_lifetime_to_parent() {}
21
+
22
+ fn main() {
23
+ bind_lifetime_to_parent();
24
+ let demo = std::env::var("PI_ACTIVITY_STRIP_PANEL_DEMO").as_deref() == Ok("1");
25
+ let click_through = std::env::var("PI_ACTIVITY_STRIP_CLICK_THROUGH").as_deref() == Ok("1");
26
+ let app = RelmApp::new("com.tryinget.pi-activity-strip.panel").visible_on_activate(false);
27
+ app.run::<App>(AppInit {
28
+ demo,
29
+ click_through,
30
+ });
31
+ }