@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
@@ -1,24 +1,38 @@
1
1
  use crate::app::AppMsg;
2
- use crate::protocol::Card;
2
+ use crate::protocol::{AkTask, Card};
3
3
  use relm4::Sender;
4
4
  use relm4::gtk;
5
5
  use relm4::gtk::gdk;
6
6
  use relm4::gtk::glib;
7
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;
8
13
 
9
14
  pub struct CardView {
10
15
  pub root: gtk::Button,
11
16
  phase: gtk::Label,
12
17
  repo: gtk::Label,
13
18
  state: gtk::Label,
19
+ window: gtk::Label,
14
20
  tool: gtk::Label,
15
21
  elapsed: gtk::Label,
16
22
  detail: gtk::Label,
17
23
  prompt: gtk::Label,
18
24
  reply: gtk::Label,
19
25
  path: gtk::Label,
26
+ pid: gtk::Label,
27
+ window_detail: gtk::Label,
28
+ agent: gtk::Label,
29
+ task: gtk::Label,
20
30
  activation: gtk::Label,
21
31
  inspector: gtk::Grid,
32
+ ak_row: gtk::Box,
33
+ ak_chips: Vec<gtk::Label>,
34
+ ak_clickable: Vec<Rc<Cell<bool>>>,
35
+ ak_overflow: gtk::Label,
22
36
  }
23
37
 
24
38
  impl CardView {
@@ -38,15 +52,24 @@ impl CardView {
38
52
  let phase = label("card-phase", gtk::Align::Start);
39
53
  label_box.append(&repo);
40
54
  label_box.append(&phase);
55
+ let ak_row = gtk::Box::new(gtk::Orientation::Horizontal, 4);
56
+ ak_row.set_valign(gtk::Align::Center);
57
+ ak_row.set_visible(false);
41
58
  let state = label("card-state", gtk::Align::End);
42
59
  header.append(&label_box);
60
+ header.append(&ak_row);
43
61
  header.append(&state);
44
62
 
45
63
  let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
46
64
  footer.set_margin_top(5);
65
+ // The Niri window id, so a window an agent or tool names by id can be found on the ribbon.
66
+ // It leads the footer so it sits at the same place on every card and costs the title nothing.
67
+ let window = label("card-window", gtk::Align::Start);
68
+ window.set_visible(false);
47
69
  let tool = label("card-tool", gtk::Align::Start);
48
70
  tool.set_hexpand(true);
49
71
  let elapsed = label("card-elapsed", gtk::Align::End);
72
+ footer.append(&window);
50
73
  footer.append(&tool);
51
74
  footer.append(&elapsed);
52
75
 
@@ -60,9 +83,16 @@ impl CardView {
60
83
  let prompt = inspector_row(&inspector, 1, "prompt");
61
84
  let reply = inspector_row(&inspector, 2, "reply");
62
85
  let path = inspector_row(&inspector, 3, "path");
86
+ let pid = inspector_row(&inspector, 4, "pid");
87
+ let window_detail = inspector_row(&inspector, 5, "window");
88
+ // Always one short line; a wrapping label would reserve a second line of height for it.
89
+ window_detail.set_wrap(false);
90
+ window_detail.set_lines(-1);
91
+ let agent = inspector_row(&inspector, 6, "agent");
92
+ let task = inspector_row(&inspector, 7, "task");
63
93
  let activation = label("activation", gtk::Align::Start);
64
94
  activation.set_visible(false);
65
- inspector.attach(&activation, 0, 4, 2, 1);
95
+ inspector.attach(&activation, 0, 8, 2, 1);
66
96
 
67
97
  content.append(&header);
68
98
  content.append(&footer);
@@ -75,6 +105,36 @@ impl CardView {
75
105
  let _ = tx.send(AppMsg::Activate(card_id.clone()));
76
106
  });
77
107
 
108
+ // AK task chips: an `active` chip reuses the card's own activation, so clicking it focuses
109
+ // the exact Ghostty window of the session holding that task's live claim. Badge states stop
110
+ // propagation instead, so a non-actionable reference never fires anything.
111
+ let mut ak_chips = Vec::with_capacity(AK_CHIP_SLOTS);
112
+ let mut ak_clickable = Vec::with_capacity(AK_CHIP_SLOTS);
113
+ for _slot in 0..AK_CHIP_SLOTS {
114
+ let chip = label("ak-task", gtk::Align::End);
115
+ chip.set_ellipsize(relm4::gtk::pango::EllipsizeMode::End);
116
+ chip.set_max_width_chars(20);
117
+ chip.set_visible(false);
118
+ let clickable = Rc::new(Cell::new(false));
119
+ let click = gtk::GestureClick::new();
120
+ let card_id = id.to_owned();
121
+ let tx = sender.clone();
122
+ let chip_clickable = Rc::clone(&clickable);
123
+ click.connect_released(move |gesture, _, _, _| {
124
+ if chip_clickable.get() {
125
+ let _ = tx.send(AppMsg::Activate(card_id.clone()));
126
+ }
127
+ gesture.set_state(gtk::EventSequenceState::Claimed);
128
+ });
129
+ chip.add_controller(click);
130
+ ak_row.append(&chip);
131
+ ak_chips.push(chip);
132
+ ak_clickable.push(clickable);
133
+ }
134
+ let ak_overflow = label("ak-overflow", gtk::Align::End);
135
+ ak_overflow.set_visible(false);
136
+ ak_row.append(&ak_overflow);
137
+
78
138
  let motion = gtk::EventControllerMotion::new();
79
139
  let card_id = id.to_owned();
80
140
  let tx = sender.clone();
@@ -128,18 +188,28 @@ impl CardView {
128
188
  phase,
129
189
  repo,
130
190
  state,
191
+ window,
131
192
  tool,
132
193
  elapsed,
133
194
  detail,
134
195
  prompt,
135
196
  reply,
136
197
  path,
198
+ pid,
199
+ window_detail,
200
+ agent,
201
+ task,
137
202
  activation,
138
203
  inspector,
204
+ ak_row,
205
+ ak_chips,
206
+ ak_clickable,
207
+ ak_overflow,
139
208
  }
140
209
  }
141
210
 
142
211
  pub fn update(&self, card: &Card, focused: bool, duplicate_label: bool, now_ms: i64) {
212
+ let hidden_tab = card.hidden_tab();
143
213
  self.repo.set_text(&display_repo(card, duplicate_label));
144
214
  self.phase.set_text(text_or(&card.phase, "Idle"));
145
215
  let real_event_at = if card.last_event_at > 0 {
@@ -171,6 +241,20 @@ impl CardView {
171
241
  .set_text(text_or(&card.last_prompt_preview, "—"));
172
242
  self.reply.set_text(text_or(&card.assistant_preview, "—"));
173
243
  self.path.set_text(text_or(&card.cwd, "—"));
244
+ self.pid.set_text(&pid_row(card, hidden_tab));
245
+ match window_chip_text(card) {
246
+ Some(text) => {
247
+ self.window.set_text(&text);
248
+ self.window.set_visible(true);
249
+ }
250
+ None => self.window.set_visible(false),
251
+ }
252
+ self.window_detail.set_text(&window_row(card));
253
+ self.agent
254
+ .set_text(text_or(&card.agent_label, "Pi session"));
255
+ self.task
256
+ .set_text(&ak_inspector_text(&card.ak_tasks, card.ak_task_overflow));
257
+ self.apply_ak_tasks(card);
174
258
 
175
259
  for class in [
176
260
  "state-idle",
@@ -181,9 +265,13 @@ impl CardView {
181
265
  "state-error",
182
266
  "current",
183
267
  "stalled",
268
+ "hidden-tab",
184
269
  ] {
185
270
  self.root.remove_css_class(class);
186
271
  }
272
+ if hidden_tab {
273
+ self.root.add_css_class("hidden-tab");
274
+ }
187
275
  self.root.add_css_class(&format!(
188
276
  "state-{}",
189
277
  if stalled {
@@ -198,18 +286,37 @@ impl CardView {
198
286
  if stalled {
199
287
  self.root.add_css_class("stalled");
200
288
  }
289
+ let pid_note = if card.pid > 0 {
290
+ format!("pid {}", card.pid)
291
+ } else {
292
+ "no pid".to_owned()
293
+ };
294
+ let ak_note = ak_accessible_note(&card.ak_tasks);
295
+ let window_note = window_note(card);
201
296
  self.root.set_tooltip_text(Some(&format!(
202
- "Focus {}",
203
- text_or(&card.repo_label, "Pi session")
297
+ "{} {} ({}{}{}){}",
298
+ if hidden_tab { "Present" } else { "Focus" },
299
+ text_or(&card.repo_label, "Pi session"),
300
+ pid_note,
301
+ window_note,
302
+ if hidden_tab { ", hidden tab" } else { "" },
303
+ ak_note
204
304
  )));
205
305
  let accessible_label = format!(
206
- "{}, {}, press Enter to focus its Ghostty window",
306
+ "{}{}, {}, {}{}",
207
307
  text_or(&card.repo_label, "Pi session"),
308
+ window_note,
208
309
  if stalled {
209
310
  "stalled"
210
311
  } else {
211
312
  text_or(&card.phase, "idle")
212
- }
313
+ },
314
+ if hidden_tab {
315
+ "hidden tab, press Enter to present it in its Ghostty window"
316
+ } else {
317
+ "press Enter to focus its Ghostty window"
318
+ },
319
+ ak_note
213
320
  );
214
321
  self.root
215
322
  .update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
@@ -217,11 +324,45 @@ impl CardView {
217
324
  .update_state(&[gtk::accessible::State::Selected(Some(focused))]);
218
325
  }
219
326
 
327
+ fn apply_ak_tasks(&self, card: &Card) {
328
+ let chips = &card.ak_tasks;
329
+ for (index, chip) in self.ak_chips.iter().enumerate() {
330
+ let clickable = self.ak_clickable.get(index).map(Rc::as_ref);
331
+ match (chips.get(index), clickable) {
332
+ (Some(task), Some(flag)) => {
333
+ chip.set_visible(true);
334
+ chip.set_text(&ak_chip_text(task));
335
+ for class in ["ak-active", "ak-deferred", "ak-orphaned"] {
336
+ chip.remove_css_class(class);
337
+ }
338
+ chip.add_css_class(ak_chip_class(&task.state));
339
+ chip.set_tooltip_text(Some(&ak_chip_tooltip(task)));
340
+ flag.set(task.is_active());
341
+ }
342
+ _ => {
343
+ chip.set_visible(false);
344
+ chip.set_tooltip_text(None);
345
+ if let Some(flag) = clickable {
346
+ flag.set(false);
347
+ }
348
+ }
349
+ }
350
+ }
351
+ self.ak_row.set_visible(!chips.is_empty());
352
+ if card.ak_task_overflow > 0 {
353
+ self.ak_overflow.set_visible(true);
354
+ self.ak_overflow
355
+ .set_text(&format!("+{} more", card.ak_task_overflow));
356
+ } else {
357
+ self.ak_overflow.set_visible(false);
358
+ }
359
+ }
360
+
220
361
  pub fn set_expanded(&self, expanded: bool) {
221
362
  self.root
222
363
  .update_state(&[gtk::accessible::State::Expanded(Some(expanded))]);
223
364
  self.root
224
- .set_height_request(if expanded { 228 } else { 60 });
365
+ .set_height_request(if expanded { 252 } else { 60 });
225
366
  self.inspector.set_visible(expanded);
226
367
  if expanded {
227
368
  self.root.add_css_class("open");
@@ -267,14 +408,60 @@ fn inspector_row(grid: &gtk::Grid, row: i32, key: &str) -> gtk::Label {
267
408
  }
268
409
 
269
410
  fn display_repo(card: &Card, duplicate: bool) -> String {
411
+ let marker = if card.hidden_tab() { "⧉ " } else { "" };
270
412
  if duplicate && card.pid > 0 {
271
413
  format!(
272
- "{} · {:04}",
414
+ "{}{} · {:04}",
415
+ marker,
273
416
  text_or(&card.repo_label, "Pi session"),
274
417
  card.pid % 10_000
275
418
  )
276
419
  } else {
277
- text_or(&card.repo_label, "Pi session").to_owned()
420
+ format!("{}{}", marker, text_or(&card.repo_label, "Pi session"))
421
+ }
422
+ }
423
+
424
+ fn pid_row(card: &Card, hidden_tab: bool) -> String {
425
+ let pid = if card.pid > 0 {
426
+ card.pid.to_string()
427
+ } else {
428
+ "—".to_owned()
429
+ };
430
+ if hidden_tab {
431
+ format!("{pid} · hidden tab")
432
+ } else {
433
+ pid
434
+ }
435
+ }
436
+
437
+ /// Compact chip text: the bare Niri window id, as agents and tools print it.
438
+ fn window_chip_text(card: &Card) -> Option<String> {
439
+ card.window_id.map(|id| format!("#{id}"))
440
+ }
441
+
442
+ /// Inspector row: the window id and the workspace number the operator sees. A hidden tab has no
443
+ /// window of its own, so its id is marked as the window hosting it.
444
+ fn window_row(card: &Card) -> String {
445
+ let Some(window_id) = card.window_id else {
446
+ return "—".to_owned();
447
+ };
448
+ let window = if card.hidden_tab() {
449
+ format!("host #{window_id}")
450
+ } else {
451
+ format!("#{window_id}")
452
+ };
453
+ match card.workspace_idx {
454
+ Some(workspace) => format!("{window} · workspace {workspace}"),
455
+ None => window,
456
+ }
457
+ }
458
+
459
+ /// Appended to the card's tooltip and accessible label when the window is known.
460
+ fn window_note(card: &Card) -> String {
461
+ match card.window_id {
462
+ Some(id) if card.hidden_tab() => format!(", host window {id}"),
463
+ Some(id) => format!(", window {id}"),
464
+ None => String::new(),
278
465
  }
279
466
  }
280
467
 
@@ -293,6 +480,81 @@ fn state_class(state: &str) -> &str {
293
480
  }
294
481
  }
295
482
 
483
+ /// CSS class for one AK chip state. Unknown states render as a muted, non-clickable chip.
484
+ fn ak_chip_class(state: &str) -> &'static str {
485
+ match state {
486
+ "active" => "ak-active",
487
+ "deferred" => "ak-deferred",
488
+ "orphaned" => "ak-orphaned",
489
+ _ => "ak-unknown",
490
+ }
491
+ }
492
+
493
+ /// Compact chip text: task id plus as much title as the pill can hold.
494
+ fn ak_chip_text(task: &AkTask) -> String {
495
+ format!("AK #{} · {}", task.id, task.title)
496
+ }
497
+
498
+ fn ak_chip_tooltip(task: &AkTask) -> String {
499
+ match task.state.as_str() {
500
+ "active" => format!(
501
+ "AK task #{} — {}. Click to focus the claiming session's Ghostty window.",
502
+ task.id, task.title
503
+ ),
504
+ "orphaned" => format!(
505
+ "AK task #{} — {}. Claim outlives its session (lease not expired); not clickable.",
506
+ task.id, task.title
507
+ ),
508
+ "deferred" => format!(
509
+ "AK task #{} — {}. Deferred by AK; not clickable.",
510
+ task.id, task.title
511
+ ),
512
+ _ => format!("AK task #{} — {}", task.id, task.title),
513
+ }
514
+ }
515
+
516
+ /// Inspector row: every joined reference with its state, plus the overflow count.
517
+ fn ak_inspector_text(tasks: &[AkTask], overflow: u32) -> String {
518
+ if tasks.is_empty() && overflow == 0 {
519
+ return "—".to_owned();
520
+ }
521
+ let mut lines: Vec<String> = tasks
522
+ .iter()
523
+ .map(|task| {
524
+ format!(
525
+ "#{} [{}] {}",
526
+ task.id,
527
+ match task.state.as_str() {
528
+ "active" => "live claim",
529
+ "orphaned" => "claim outlives session",
530
+ "deferred" => "deferred",
531
+ _ => "unknown",
532
+ },
533
+ task.title
534
+ )
535
+ })
536
+ .collect();
537
+ if overflow > 0 {
538
+ lines.push(format!("+{overflow} more"));
539
+ }
540
+ lines.join("\n")
541
+ }
542
+
543
+ /// Appended to the card's tooltip and accessible label when AK references exist.
544
+ fn ak_accessible_note(tasks: &[AkTask]) -> String {
545
+ match tasks.first() {
546
+ Some(task) if task.is_active() => format!(
547
+ ". AK task #{} {} (click the chip to focus the claiming session)",
548
+ task.id, task.title
549
+ ),
550
+ Some(task) => format!(
551
+ ". AK task #{} {} (badge only, not clickable)",
552
+ task.id, task.title
553
+ ),
554
+ None => String::new(),
555
+ }
556
+ }
557
+
296
558
  fn text_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
297
559
  if value.is_empty() { fallback } else { value }
298
560
  }
@@ -301,3 +563,128 @@ fn duration(now_ms: i64, anchor_ms: i64) -> String {
301
563
  let seconds = now_ms.saturating_sub(anchor_ms.max(1)) / 1000;
302
564
  format!("{}:{:02}", seconds / 60, seconds % 60)
303
565
  }
566
+
567
+ #[cfg(test)]
568
+ mod tests {
569
+ use super::*;
570
+
571
+ fn card(json: serde_json::Value) -> Card {
572
+ serde_json::from_value(json).expect("card")
573
+ }
574
+
575
+ #[test]
576
+ fn hidden_tabs_carry_a_marker_and_pid_note() {
577
+ let agent = card(serde_json::json!({ "agentLabel": "Claude Code" }));
578
+ assert_eq!(agent.agent_label, "Claude Code");
579
+ assert_eq!(text_or(&agent.agent_label, "Pi session"), "Claude Code");
580
+ assert_eq!(
581
+ text_or(&card(serde_json::json!({})).agent_label, "Pi session"),
582
+ "Pi session"
583
+ );
584
+
585
+ let hidden = card(serde_json::json!({
586
+ "repoLabel": "dspx", "processId": 12345, "surfaceVisible": false
587
+ }));
588
+ assert!(hidden.hidden_tab());
589
+ assert_eq!(display_repo(&hidden, false), "⧉ dspx");
590
+ assert_eq!(display_repo(&hidden, true), "⧉ dspx · 2345");
591
+ assert_eq!(pid_row(&hidden, true), "12345 · hidden tab");
592
+
593
+ let visible = card(serde_json::json!({ "repoLabel": "dspx", "surfaceVisible": true }));
594
+ assert!(!visible.hidden_tab());
595
+ assert_eq!(display_repo(&visible, false), "dspx");
596
+ assert_eq!(pid_row(&visible, false), "—");
597
+
598
+ let legacy = card(serde_json::json!({ "repoLabel": "dspx" }));
599
+ assert!(
600
+ !legacy.hidden_tab(),
601
+ "controllers without placement never mark tabs hidden"
602
+ );
603
+ }
604
+
605
+ #[test]
606
+ fn window_chip_names_the_niri_window_and_the_detail_adds_its_workspace() {
607
+ let visible = card(serde_json::json!({
608
+ "repoLabel": "dspx", "windowId": 43, "workspaceIdx": 2, "surfaceVisible": true
609
+ }));
610
+ assert_eq!(window_chip_text(&visible).as_deref(), Some("#43"));
611
+ assert_eq!(window_row(&visible), "#43 · workspace 2");
612
+ assert_eq!(window_note(&visible), ", window 43");
613
+
614
+ let hidden = card(serde_json::json!({
615
+ "repoLabel": "dspx", "windowId": 43, "workspaceIdx": 2, "surfaceVisible": false
616
+ }));
617
+ assert_eq!(
618
+ window_chip_text(&hidden).as_deref(),
619
+ Some("#43"),
620
+ "a hidden tab shows the window hosting it"
621
+ );
622
+ assert_eq!(window_row(&hidden), "host #43 · workspace 2");
623
+ assert_eq!(window_note(&hidden), ", host window 43");
624
+
625
+ let unindexed = card(serde_json::json!({ "windowId": 43 }));
626
+ assert_eq!(window_row(&unindexed), "#43");
627
+
628
+ let unplaced = card(serde_json::json!({ "repoLabel": "dspx" }));
629
+ assert_eq!(
630
+ window_chip_text(&unplaced),
631
+ None,
632
+ "no chip without a window"
633
+ );
634
+ assert_eq!(window_row(&unplaced), "—");
635
+ assert_eq!(window_note(&unplaced), "");
636
+ }
637
+
638
+ #[test]
639
+ fn ak_chip_states_map_to_classes_and_only_active_is_clickable() {
640
+ let active = AkTask {
641
+ id: 5701,
642
+ title: "Show clickable AK-task references".into(),
643
+ state: "active".into(),
644
+ };
645
+ let orphaned = AkTask {
646
+ state: "orphaned".into(),
647
+ ..active.clone()
648
+ };
649
+ let deferred = AkTask {
650
+ state: "deferred".into(),
651
+ ..active.clone()
652
+ };
653
+ assert_eq!(ak_chip_class("active"), "ak-active");
654
+ assert_eq!(ak_chip_class("orphaned"), "ak-orphaned");
655
+ assert_eq!(ak_chip_class("deferred"), "ak-deferred");
656
+ assert_eq!(ak_chip_class("surprise"), "ak-unknown");
657
+ assert!(active.is_active());
658
+ assert!(!orphaned.is_active());
659
+ assert!(!deferred.is_active());
660
+ assert_eq!(
661
+ ak_chip_text(&active),
662
+ "AK #5701 · Show clickable AK-task references"
663
+ );
664
+ assert!(ak_chip_tooltip(&orphaned).contains("not clickable"));
665
+ assert!(ak_chip_tooltip(&active).contains("focus the claiming session"));
666
+ }
667
+
668
+ #[test]
669
+ fn ak_inspector_lists_every_reference_with_overflow() {
670
+ let tasks = vec![
671
+ AkTask {
672
+ id: 5701,
673
+ title: "Live claim task".into(),
674
+ state: "active".into(),
675
+ },
676
+ AkTask {
677
+ id: 4220,
678
+ title: "Deferred task".into(),
679
+ state: "deferred".into(),
680
+ },
681
+ ];
682
+ let text = ak_inspector_text(&tasks, 3);
683
+ assert!(text.contains("#5701 [live claim] Live claim task"));
684
+ assert!(text.contains("#4220 [deferred] Deferred task"));
685
+ assert!(text.contains("+3 more"));
686
+ assert_eq!(ak_inspector_text(&[], 0), "—");
687
+ assert!(ak_accessible_note(&tasks).starts_with(". AK task #5701"));
688
+ assert_eq!(ak_accessible_note(&[]), "");
689
+ }
690
+ }
@@ -19,13 +19,76 @@ fn bind_lifetime_to_parent() {
19
19
  #[cfg(not(target_os = "linux"))]
20
20
  fn bind_lifetime_to_parent() {}
21
21
 
22
+ const APPLICATION_ID: &str = "com.tryinget.pi-activity-strip.panel";
23
+ /// Keeps the display element well inside the 255-byte limit of a D-Bus name.
24
+ const DISPLAY_ELEMENT_MAX: usize = 64;
25
+
26
+ /// GTK uniqueness is scoped to one Wayland display. A second panel on the same compositor is
27
+ /// refused, so no second ribbon can reserve another band, while a panel inside a nested
28
+ /// compositor (the demo, a preview, a test) runs on its own instead of handing itself off.
29
+ fn application_id(wayland_display: Option<&str>) -> String {
30
+ // GTK connects to `wayland-0` when the variable is unset, and a socket path names the same
31
+ // compositor as its file name, so every spelling of one display shares one id.
32
+ let raw = wayland_display
33
+ .filter(|display| !display.is_empty())
34
+ .unwrap_or("wayland-0");
35
+ let name = raw.rsplit('/').next().unwrap_or(raw);
36
+ let display: String = name
37
+ .chars()
38
+ .take(DISPLAY_ELEMENT_MAX)
39
+ .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
40
+ .collect();
41
+ format!("{APPLICATION_ID}.d_{display}")
42
+ }
43
+
22
44
  fn main() {
23
45
  bind_lifetime_to_parent();
24
46
  let demo = std::env::var("PI_ACTIVITY_STRIP_PANEL_DEMO").as_deref() == Ok("1");
25
47
  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);
48
+ let display = std::env::var("WAYLAND_DISPLAY").ok();
49
+ let app = RelmApp::new(&application_id(display.as_deref())).visible_on_activate(false);
27
50
  app.run::<App>(AppInit {
28
51
  demo,
29
52
  click_through,
30
53
  });
54
+ // A second panel on this display hands its activation to the first and returns without ever
55
+ // starting. It used to exit 0 having drawn nothing; say so and fail instead. Registration can
56
+ // also fail this way, so the message names the likely cause rather than asserting it.
57
+ if !protocol::was_ready() {
58
+ protocol::emit_error(
59
+ "The panel returned without starting; another activity-strip panel probably already runs on this Wayland display.",
60
+ );
61
+ std::process::exit(1);
62
+ }
63
+ }
64
+
65
+ #[cfg(test)]
66
+ mod tests {
67
+ use super::application_id;
68
+
69
+ #[test]
70
+ fn uniqueness_is_scoped_to_the_wayland_display() {
71
+ assert_eq!(
72
+ application_id(Some("wayland-1")),
73
+ "com.tryinget.pi-activity-strip.panel.d_wayland_1"
74
+ );
75
+ assert_ne!(
76
+ application_id(Some("wayland-1")),
77
+ application_id(Some("wayland-2")),
78
+ "a panel in a nested compositor never hands off to the live one"
79
+ );
80
+ assert_eq!(
81
+ application_id(Some("/run/user/1000/wayland-1")),
82
+ application_id(Some("wayland-1")),
83
+ "a socket path and its name are the same compositor"
84
+ );
85
+ assert_eq!(
86
+ application_id(None),
87
+ application_id(Some("wayland-0")),
88
+ "GTK connects to wayland-0 when the variable is unset"
89
+ );
90
+ assert_eq!(application_id(Some("")), application_id(Some("wayland-0")));
91
+ let long = application_id(Some(&"x".repeat(400)));
92
+ assert!(long.len() <= 255, "a D-Bus name is at most 255 bytes");
93
+ }
31
94
  }