@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.
@@ -8,14 +8,18 @@ use relm4::gtk::prelude::*;
8
8
  use std::cell::Cell;
9
9
  use std::rc::Rc;
10
10
 
11
- /// Rendered AK chips per card: at most two live-claim buttons plus two state badges.
12
- const AK_CHIP_SLOTS: usize = 4;
11
+ /// Rendered AK chips per card. The controller sends live-claim buttons first, then state badges;
12
+ /// the card shows the first and folds the rest into the `+N` count. Nothing else in the footer
13
+ /// shrinks, so this bound and the bounded timers are what hold the card at its width. The
14
+ /// inspector still lists every reference.
15
+ const AK_CHIP_SLOTS: usize = 1;
13
16
 
14
17
  pub struct CardView {
15
18
  pub root: gtk::Button,
16
19
  phase: gtk::Label,
17
20
  repo: gtk::Label,
18
21
  state: gtk::Label,
22
+ window: gtk::Label,
19
23
  tool: gtk::Label,
20
24
  elapsed: gtk::Label,
21
25
  detail: gtk::Label,
@@ -23,6 +27,7 @@ pub struct CardView {
23
27
  reply: gtk::Label,
24
28
  path: gtk::Label,
25
29
  pid: gtk::Label,
30
+ window_detail: gtk::Label,
26
31
  agent: gtk::Label,
27
32
  task: gtk::Label,
28
33
  activation: gtk::Label,
@@ -50,19 +55,29 @@ impl CardView {
50
55
  let phase = label("card-phase", gtk::Align::Start);
51
56
  label_box.append(&repo);
52
57
  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
58
  let state = label("card-state", gtk::Align::End);
57
59
  header.append(&label_box);
58
- header.append(&ak_row);
59
60
  header.append(&state);
60
61
 
61
62
  let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
62
63
  footer.set_margin_top(5);
64
+ // The Niri window id, so a window an agent or tool names by id can be found on the ribbon.
65
+ // It leads the footer so it sits at the same place on every card and costs the title nothing.
66
+ let window = label("card-window", gtk::Align::Start);
67
+ window.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
68
+ window.set_visible(false);
69
+ // AK references ride in the footer as bare task numbers: the header belongs to the folder
70
+ // name, which is what tells cards apart. The titles live in each chip's tooltip.
71
+ let ak_row = gtk::Box::new(gtk::Orientation::Horizontal, 3);
72
+ ak_row.set_valign(gtk::Align::Center);
73
+ ak_row.set_visible(false);
63
74
  let tool = label("card-tool", gtk::Align::Start);
64
75
  tool.set_hexpand(true);
65
76
  let elapsed = label("card-elapsed", gtk::Align::End);
77
+ // The window id and timers are never cut: the tool name gives way to the AK chips first.
78
+ elapsed.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
79
+ footer.append(&window);
80
+ footer.append(&ak_row);
66
81
  footer.append(&tool);
67
82
  footer.append(&elapsed);
68
83
 
@@ -77,11 +92,15 @@ impl CardView {
77
92
  let reply = inspector_row(&inspector, 2, "reply");
78
93
  let path = inspector_row(&inspector, 3, "path");
79
94
  let pid = inspector_row(&inspector, 4, "pid");
80
- let agent = inspector_row(&inspector, 5, "agent");
81
- let task = inspector_row(&inspector, 6, "task");
95
+ let window_detail = inspector_row(&inspector, 5, "window");
96
+ // Always one short line; a wrapping label would reserve a second line of height for it.
97
+ window_detail.set_wrap(false);
98
+ window_detail.set_lines(-1);
99
+ let agent = inspector_row(&inspector, 6, "agent");
100
+ let task = inspector_row(&inspector, 7, "task");
82
101
  let activation = label("activation", gtk::Align::Start);
83
102
  activation.set_visible(false);
84
- inspector.attach(&activation, 0, 7, 2, 1);
103
+ inspector.attach(&activation, 0, 8, 2, 1);
85
104
 
86
105
  content.append(&header);
87
106
  content.append(&footer);
@@ -100,9 +119,8 @@ impl CardView {
100
119
  let mut ak_chips = Vec::with_capacity(AK_CHIP_SLOTS);
101
120
  let mut ak_clickable = Vec::with_capacity(AK_CHIP_SLOTS);
102
121
  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);
122
+ let chip = label("ak-task", gtk::Align::Start);
123
+ chip.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
106
124
  chip.set_visible(false);
107
125
  let clickable = Rc::new(Cell::new(false));
108
126
  let click = gtk::GestureClick::new();
@@ -120,7 +138,8 @@ impl CardView {
120
138
  ak_chips.push(chip);
121
139
  ak_clickable.push(clickable);
122
140
  }
123
- let ak_overflow = label("ak-overflow", gtk::Align::End);
141
+ let ak_overflow = label("ak-overflow", gtk::Align::Start);
142
+ ak_overflow.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
124
143
  ak_overflow.set_visible(false);
125
144
  ak_row.append(&ak_overflow);
126
145
 
@@ -177,6 +196,7 @@ impl CardView {
177
196
  phase,
178
197
  repo,
179
198
  state,
199
+ window,
180
200
  tool,
181
201
  elapsed,
182
202
  detail,
@@ -184,6 +204,7 @@ impl CardView {
184
204
  reply,
185
205
  path,
186
206
  pid,
207
+ window_detail,
187
208
  agent,
188
209
  task,
189
210
  activation,
@@ -229,6 +250,14 @@ impl CardView {
229
250
  self.reply.set_text(text_or(&card.assistant_preview, "—"));
230
251
  self.path.set_text(text_or(&card.cwd, "—"));
231
252
  self.pid.set_text(&pid_row(card, hidden_tab));
253
+ match window_chip_text(card) {
254
+ Some(text) => {
255
+ self.window.set_text(&text);
256
+ self.window.set_visible(true);
257
+ }
258
+ None => self.window.set_visible(false),
259
+ }
260
+ self.window_detail.set_text(&window_row(card));
232
261
  self.agent
233
262
  .set_text(text_or(&card.agent_label, "Pi session"));
234
263
  self.task
@@ -271,17 +300,20 @@ impl CardView {
271
300
  "no pid".to_owned()
272
301
  };
273
302
  let ak_note = ak_accessible_note(&card.ak_tasks);
303
+ let window_note = window_note(card);
274
304
  self.root.set_tooltip_text(Some(&format!(
275
- "{} {} ({}{}){}",
305
+ "{} {} ({}{}{}){}",
276
306
  if hidden_tab { "Present" } else { "Focus" },
277
307
  text_or(&card.repo_label, "Pi session"),
278
308
  pid_note,
309
+ window_note,
279
310
  if hidden_tab { ", hidden tab" } else { "" },
280
311
  ak_note
281
312
  )));
282
313
  let accessible_label = format!(
283
- "{}, {}, {}{}",
314
+ "{}{}, {}, {}{}",
284
315
  text_or(&card.repo_label, "Pi session"),
316
+ window_note,
285
317
  if stalled {
286
318
  "stalled"
287
319
  } else {
@@ -325,13 +357,9 @@ impl CardView {
325
357
  }
326
358
  }
327
359
  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
- }
360
+ let overflow = ak_folded_count(chips.len(), card.ak_task_overflow);
361
+ self.ak_overflow.set_visible(overflow > 0);
362
+ self.ak_overflow.set_text(&format!("+{overflow}"));
335
363
  }
336
364
 
337
365
  pub fn set_expanded(&self, expanded: bool) {
@@ -410,6 +438,37 @@ fn pid_row(card: &Card, hidden_tab: bool) -> String {
410
438
  }
411
439
  }
412
440
 
441
+ /// Compact chip text: the bare Niri window id, as agents and tools print it.
442
+ fn window_chip_text(card: &Card) -> Option<String> {
443
+ card.window_id.map(|id| format!("#{id}"))
444
+ }
445
+
446
+ /// Inspector row: the window id and the workspace number the operator sees. A hidden tab has no
447
+ /// window of its own, so its id is marked as the window hosting it.
448
+ fn window_row(card: &Card) -> String {
449
+ let Some(window_id) = card.window_id else {
450
+ return "—".to_owned();
451
+ };
452
+ let window = if card.hidden_tab() {
453
+ format!("host #{window_id}")
454
+ } else {
455
+ format!("#{window_id}")
456
+ };
457
+ match card.workspace_idx {
458
+ Some(workspace) => format!("{window} · workspace {workspace}"),
459
+ None => window,
460
+ }
461
+ }
462
+
463
+ /// Appended to the card's tooltip and accessible label when the window is known.
464
+ fn window_note(card: &Card) -> String {
465
+ match card.window_id {
466
+ Some(id) if card.hidden_tab() => format!(", host window {id}"),
467
+ Some(id) => format!(", window {id}"),
468
+ None => String::new(),
469
+ }
470
+ }
471
+
413
472
  fn state_label(state: &str) -> &str {
414
473
  match state {
415
474
  "success" => "done",
@@ -435,9 +494,16 @@ fn ak_chip_class(state: &str) -> &'static str {
435
494
  }
436
495
  }
437
496
 
438
- /// Compact chip text: task id plus as much title as the pill can hold.
497
+ /// References beyond the rendered chips: those the card has no slot for plus the controller's own
498
+ /// overflow.
499
+ fn ak_folded_count(joined: usize, overflow: u32) -> u32 {
500
+ overflow.saturating_add(joined.saturating_sub(AK_CHIP_SLOTS) as u32)
501
+ }
502
+
503
+ /// Compact chip text: the task number as it is written in commits, `AK5701`, so it never reads as
504
+ /// the `#43` window chip beside it. The title is in the tooltip and the inspector.
439
505
  fn ak_chip_text(task: &AkTask) -> String {
440
- format!("AK #{} · {}", task.id, task.title)
506
+ format!("AK{}", task.id)
441
507
  }
442
508
 
443
509
  fn ak_chip_tooltip(task: &AkTask) -> String {
@@ -504,9 +570,19 @@ fn text_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
504
570
  if value.is_empty() { fallback } else { value }
505
571
  }
506
572
 
573
+ /// Elapsed time in at most five characters (`59:59`, `23h59`, `99d`), so a long-running session
574
+ /// never widens the card's footer.
507
575
  fn duration(now_ms: i64, anchor_ms: i64) -> String {
508
576
  let seconds = now_ms.saturating_sub(anchor_ms.max(1)) / 1000;
509
- format!("{}:{:02}", seconds / 60, seconds % 60)
577
+ let minutes = seconds / 60;
578
+ let hours = minutes / 60;
579
+ if hours == 0 {
580
+ format!("{}:{:02}", minutes, seconds % 60)
581
+ } else if hours < 24 {
582
+ format!("{}h{:02}", hours, minutes % 60)
583
+ } else {
584
+ format!("{}d", (hours / 24).min(99))
585
+ }
510
586
  }
511
587
 
512
588
  #[cfg(test)]
@@ -547,6 +623,39 @@ mod tests {
547
623
  );
548
624
  }
549
625
 
626
+ #[test]
627
+ fn window_chip_names_the_niri_window_and_the_detail_adds_its_workspace() {
628
+ let visible = card(serde_json::json!({
629
+ "repoLabel": "dspx", "windowId": 43, "workspaceIdx": 2, "surfaceVisible": true
630
+ }));
631
+ assert_eq!(window_chip_text(&visible).as_deref(), Some("#43"));
632
+ assert_eq!(window_row(&visible), "#43 · workspace 2");
633
+ assert_eq!(window_note(&visible), ", window 43");
634
+
635
+ let hidden = card(serde_json::json!({
636
+ "repoLabel": "dspx", "windowId": 43, "workspaceIdx": 2, "surfaceVisible": false
637
+ }));
638
+ assert_eq!(
639
+ window_chip_text(&hidden).as_deref(),
640
+ Some("#43"),
641
+ "a hidden tab shows the window hosting it"
642
+ );
643
+ assert_eq!(window_row(&hidden), "host #43 · workspace 2");
644
+ assert_eq!(window_note(&hidden), ", host window 43");
645
+
646
+ let unindexed = card(serde_json::json!({ "windowId": 43 }));
647
+ assert_eq!(window_row(&unindexed), "#43");
648
+
649
+ let unplaced = card(serde_json::json!({ "repoLabel": "dspx" }));
650
+ assert_eq!(
651
+ window_chip_text(&unplaced),
652
+ None,
653
+ "no chip without a window"
654
+ );
655
+ assert_eq!(window_row(&unplaced), "—");
656
+ assert_eq!(window_note(&unplaced), "");
657
+ }
658
+
550
659
  #[test]
551
660
  fn ak_chip_states_map_to_classes_and_only_active_is_clickable() {
552
661
  let active = AkTask {
@@ -569,14 +678,25 @@ mod tests {
569
678
  assert!(active.is_active());
570
679
  assert!(!orphaned.is_active());
571
680
  assert!(!deferred.is_active());
572
- assert_eq!(
573
- ak_chip_text(&active),
574
- "AK #5701 · Show clickable AK-task references"
575
- );
681
+ assert_eq!(ak_chip_text(&active), "AK5701");
682
+ assert_eq!(ak_folded_count(1, 0), 0);
683
+ assert_eq!(ak_folded_count(4, 0), 3);
684
+ assert_eq!(ak_folded_count(4, 21), 24);
576
685
  assert!(ak_chip_tooltip(&orphaned).contains("not clickable"));
577
686
  assert!(ak_chip_tooltip(&active).contains("focus the claiming session"));
578
687
  }
579
688
 
689
+ #[test]
690
+ fn durations_stay_within_five_characters() {
691
+ let at = |seconds: i64| duration(1_000 + seconds * 1000, 1_000);
692
+ assert_eq!(at(65), "1:05");
693
+ assert_eq!(at(59 * 60 + 59), "59:59");
694
+ assert_eq!(at(3600 + 5 * 60), "1h05");
695
+ assert_eq!(at(23 * 3600 + 59 * 60), "23h59");
696
+ assert_eq!(at(3 * 86_400), "3d");
697
+ assert_eq!(at(400 * 86_400), "99d");
698
+ }
699
+
580
700
  #[test]
581
701
  fn ak_inspector_lists_every_reference_with_overflow() {
582
702
  let tasks = vec![
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  use serde::Deserialize;
2
2
  use serde_json::{Value, json};
3
3
  use std::io::{self, Write};
4
+ use std::sync::atomic::{AtomicBool, Ordering};
4
5
  use std::sync::{Mutex, OnceLock};
5
6
 
6
7
  #[derive(Clone, Debug, Deserialize)]
@@ -73,6 +74,22 @@ pub struct Card {
73
74
  /// placed it through its host process, so activation presents the tab before focusing.
74
75
  #[serde(default)]
75
76
  pub surface_visible: Option<bool>,
77
+ /// Niri window id, so a window an agent or tool names by id can be found on the ribbon. A
78
+ /// hidden tab has no window of its own and carries the id of the window hosting it.
79
+ #[serde(default, deserialize_with = "lenient_integer")]
80
+ pub window_id: Option<i64>,
81
+ /// Niri workspace `idx`: the number the operator sees, not the internal workspace id.
82
+ #[serde(default, deserialize_with = "lenient_integer")]
83
+ pub workspace_idx: Option<i64>,
84
+ }
85
+
86
+ /// An optional label is not worth a view: a value that is not an integer drops the field rather
87
+ /// than failing the whole message and every card in it.
88
+ fn lenient_integer<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
89
+ where
90
+ D: serde::Deserializer<'de>,
91
+ {
92
+ Ok(Value::deserialize(deserializer)?.as_i64())
76
93
  }
77
94
 
78
95
  impl Card {
@@ -130,6 +147,7 @@ fn protocol_version() -> u8 {
130
147
  }
131
148
 
132
149
  static OUTPUT: OnceLock<Mutex<io::Stdout>> = OnceLock::new();
150
+ static READY: AtomicBool = AtomicBool::new(false);
133
151
 
134
152
  pub fn emit(event: Value) {
135
153
  let stdout = OUTPUT.get_or_init(|| Mutex::new(io::stdout()));
@@ -140,7 +158,14 @@ pub fn emit(event: Value) {
140
158
  }
141
159
  }
142
160
 
161
+ /// Whether this panel ever came up. GTK returns from `run` without building anything when it hands
162
+ /// the activation to another panel on the same display.
163
+ pub fn was_ready() -> bool {
164
+ READY.load(Ordering::SeqCst)
165
+ }
166
+
143
167
  pub fn emit_ready() {
168
+ READY.store(true, Ordering::SeqCst);
144
169
  emit(json!({
145
170
  "protocol": 1,
146
171
  "type": "ready",
@@ -177,6 +202,8 @@ pub fn demo_view() -> ViewMessage {
177
202
  started_at: now - 135_000,
178
203
  last_event_at: now - 4_000,
179
204
  updated_at: now,
205
+ window_id: Some(36),
206
+ workspace_idx: Some(2),
180
207
  ..empty_card()
181
208
  },
182
209
  Card {
@@ -202,6 +229,26 @@ pub fn demo_view() -> ViewMessage {
202
229
  state: "active".into(),
203
230
  }],
204
231
  ak_task_overflow: 1,
232
+ window_id: Some(43),
233
+ workspace_idx: Some(2),
234
+ ..empty_card()
235
+ },
236
+ Card {
237
+ card_id: "demo-hidden".into(),
238
+ session_id: "demo-hidden".into(),
239
+ repo_label: "pi-extensions".into(),
240
+ phase: "Waiting behind another tab".into(),
241
+ state: "idle".into(),
242
+ detail: "Hidden tab placed through its Ghostty host window".into(),
243
+ cwd: "/home/tryinget/ai-society/softwareco/owned/pi-extensions".into(),
244
+ started_at: now - 610_000,
245
+ last_event_at: now - 95_000,
246
+ updated_at: now,
247
+ pid: 48213,
248
+ agent_label: "Claude Code".into(),
249
+ surface_visible: Some(false),
250
+ window_id: Some(43),
251
+ workspace_idx: Some(2),
205
252
  ..empty_card()
206
253
  },
207
254
  ],
@@ -231,6 +278,8 @@ fn empty_card() -> Card {
231
278
  ak_tasks: Vec::new(),
232
279
  ak_task_overflow: 0,
233
280
  surface_visible: None,
281
+ window_id: None,
282
+ workspace_idx: None,
234
283
  }
235
284
  }
236
285
 
@@ -253,6 +302,36 @@ mod tests {
253
302
  assert_eq!(view.sessions[0].ak_task_overflow, 0);
254
303
  }
255
304
 
305
+ #[test]
306
+ fn window_and_workspace_numbers_are_optional_card_fields() {
307
+ let view: ViewMessage = serde_json::from_str(
308
+ r#"{"protocol":1,"type":"view","revision":9,"sessions":[{"cardId":"card-a","windowId":43,"workspaceIdx":2},{"cardId":"card-b","windowId":null,"workspaceIdx":null},{"cardId":"card-c"}]}"#,
309
+ )
310
+ .expect("view should parse");
311
+ assert_eq!(view.sessions[0].window_id, Some(43));
312
+ assert_eq!(view.sessions[0].workspace_idx, Some(2));
313
+ assert_eq!(view.sessions[1].window_id, None);
314
+ assert_eq!(view.sessions[1].workspace_idx, None);
315
+ assert_eq!(
316
+ view.sessions[2].window_id, None,
317
+ "controllers that send no window id still parse"
318
+ );
319
+ assert_eq!(view.sessions[2].workspace_idx, None);
320
+ }
321
+
322
+ #[test]
323
+ fn a_malformed_window_or_workspace_number_drops_only_that_field() {
324
+ let view: ViewMessage = serde_json::from_str(
325
+ r#"{"protocol":1,"type":"view","revision":10,"sessions":[{"cardId":"card-a","windowId":"43","workspaceIdx":2.5},{"cardId":"card-b","windowId":44,"workspaceIdx":3}]}"#,
326
+ )
327
+ .expect("a bad optional field must not reject the whole view");
328
+ assert_eq!(view.sessions.len(), 2);
329
+ assert_eq!(view.sessions[0].window_id, None);
330
+ assert_eq!(view.sessions[0].workspace_idx, None);
331
+ assert_eq!(view.sessions[1].window_id, Some(44));
332
+ assert_eq!(view.sessions[1].workspace_idx, Some(3));
333
+ }
334
+
256
335
  #[test]
257
336
  fn ak_task_chips_parse_with_states() {
258
337
  let view: ViewMessage = serde_json::from_str(
@@ -30,6 +30,7 @@ window {
30
30
  .brand-eyebrow,
31
31
  .meta,
32
32
  .card-state,
33
+ .card-window,
33
34
  .card-tool,
34
35
  .card-elapsed,
35
36
  .inspector-key {
@@ -88,11 +89,14 @@ window {
88
89
  border-color: alpha(@pi_accent, 0.5);
89
90
  }
90
91
 
91
- /* 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
+ */
92
96
  .card.current {
93
- background: mix(@pi_background, @pi_foreground, 0.16);
94
- border-color: alpha(@pi_accent, 0.42);
95
- 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;
96
100
  }
97
101
 
98
102
  .card.hidden-tab {
@@ -184,6 +188,18 @@ window {
184
188
  font-size: 8px;
185
189
  }
186
190
 
191
+ /*
192
+ * The Niri window id, for finding a window an agent or tool names by id. A tint rather than a
193
+ * border and no vertical padding, so it reads as a chip without growing the footer line.
194
+ */
195
+ .card-window {
196
+ background: alpha(@pi_foreground, 0.09);
197
+ border-radius: 999px;
198
+ color: alpha(@pi_foreground, 0.7);
199
+ font-size: 8px;
200
+ padding: 0 5px;
201
+ }
202
+
187
203
  .inspector {
188
204
  border-top: 1px solid alpha(@pi_foreground, 0.12);
189
205
  padding-top: 9px;
@@ -211,6 +227,7 @@ window {
211
227
  * itself: it reuses the card's activation, so it carries the accent colour and a pointer cursor.
212
228
  * Badge states (a claim that outlives its session, a deferred task) are deliberately inert pills.
213
229
  */
230
+ /* Footer-sized: a bare task number with no vertical padding, like the window chip beside it. */
214
231
  .ak-task {
215
232
  background: alpha(@pi_foreground, 0.07);
216
233
  border: 1px solid alpha(@pi_foreground, 0.16);
@@ -218,7 +235,7 @@ window {
218
235
  color: alpha(@pi_foreground, 0.68);
219
236
  font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
220
237
  font-size: 8px;
221
- padding: 3px 8px;
238
+ padding: 0 5px;
222
239
  }
223
240
 
224
241
  .ak-task.ak-active {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tryinget/pi-activity-strip",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",