@tryinget/pi-activity-strip 0.5.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 (41) hide show
  1. package/README.md +41 -6
  2. package/bin/pi-activity-strip-claude-hook.mjs +71 -0
  3. package/bin/pi-activity-strip.mjs +22 -1
  4. package/native/bin/linux-x64-gnu/artifact.json +2 -2
  5. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  6. package/native/panel/src/app.rs +20 -20
  7. package/native/panel/src/card_view.rs +308 -9
  8. package/native/panel/src/protocol.rs +63 -0
  9. package/native/panel/src/runtime.rs +50 -9
  10. package/native/panel/src/style.css +134 -60
  11. package/package.json +4 -4
  12. package/src/common/agent-identity.mjs +163 -0
  13. package/src/common/ak-tasks.mjs +354 -0
  14. package/src/common/claude-events.mjs +190 -0
  15. package/src/common/claude-hook-config.mjs +56 -0
  16. package/src/common/claude-transcript.mjs +163 -0
  17. package/src/common/codex-transcript.mjs +153 -0
  18. package/src/common/compatibility.mjs +13 -0
  19. package/src/common/contracts.d.ts +26 -0
  20. package/src/common/contracts.ts +64 -1
  21. package/src/common/ghostty-present.mjs +100 -0
  22. package/src/common/ghostty-theme.mjs +149 -0
  23. package/src/common/niri-focus.mjs +179 -110
  24. package/src/common/session-cards.mjs +5 -2
  25. package/src/common/status-report.mjs +51 -0
  26. package/src/common/surface-bindings.mjs +234 -0
  27. package/src/common/terminal-identity.mjs +75 -12
  28. package/src/common/window-placement.mjs +59 -0
  29. package/src/common/workspace-view.mjs +161 -0
  30. package/src/native/agent-discovery.mjs +294 -0
  31. package/src/native/ak-runtime.mjs +164 -0
  32. package/src/native/codex-discovery.mjs +128 -0
  33. package/src/native/ghostty-tab-inventory.py +99 -0
  34. package/src/native/height-repair.mjs +154 -0
  35. package/src/native/main.mjs +120 -14
  36. package/src/native/panel-projection.mjs +67 -5
  37. package/src/native/placement.mjs +225 -0
  38. package/src/native/tab-inventory.mjs +188 -0
  39. package/src/native/theme-runtime.mjs +72 -0
  40. package/src/native/theme-source.mjs +197 -0
  41. package/src/native/workspace-events.mjs +16 -1
@@ -1,10 +1,15 @@
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,
@@ -17,8 +22,15 @@ pub struct CardView {
17
22
  prompt: gtk::Label,
18
23
  reply: gtk::Label,
19
24
  path: gtk::Label,
25
+ pid: gtk::Label,
26
+ agent: gtk::Label,
27
+ task: gtk::Label,
20
28
  activation: gtk::Label,
21
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,
22
34
  }
23
35
 
24
36
  impl CardView {
@@ -38,8 +50,12 @@ impl CardView {
38
50
  let phase = label("card-phase", gtk::Align::Start);
39
51
  label_box.append(&repo);
40
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);
41
56
  let state = label("card-state", gtk::Align::End);
42
57
  header.append(&label_box);
58
+ header.append(&ak_row);
43
59
  header.append(&state);
44
60
 
45
61
  let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
@@ -60,9 +76,12 @@ impl CardView {
60
76
  let prompt = inspector_row(&inspector, 1, "prompt");
61
77
  let reply = inspector_row(&inspector, 2, "reply");
62
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");
63
82
  let activation = label("activation", gtk::Align::Start);
64
83
  activation.set_visible(false);
65
- inspector.attach(&activation, 0, 4, 2, 1);
84
+ inspector.attach(&activation, 0, 7, 2, 1);
66
85
 
67
86
  content.append(&header);
68
87
  content.append(&footer);
@@ -75,6 +94,36 @@ impl CardView {
75
94
  let _ = tx.send(AppMsg::Activate(card_id.clone()));
76
95
  });
77
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
+
78
127
  let motion = gtk::EventControllerMotion::new();
79
128
  let card_id = id.to_owned();
80
129
  let tx = sender.clone();
@@ -134,12 +183,20 @@ impl CardView {
134
183
  prompt,
135
184
  reply,
136
185
  path,
186
+ pid,
187
+ agent,
188
+ task,
137
189
  activation,
138
190
  inspector,
191
+ ak_row,
192
+ ak_chips,
193
+ ak_clickable,
194
+ ak_overflow,
139
195
  }
140
196
  }
141
197
 
142
198
  pub fn update(&self, card: &Card, focused: bool, duplicate_label: bool, now_ms: i64) {
199
+ let hidden_tab = card.hidden_tab();
143
200
  self.repo.set_text(&display_repo(card, duplicate_label));
144
201
  self.phase.set_text(text_or(&card.phase, "Idle"));
145
202
  let real_event_at = if card.last_event_at > 0 {
@@ -171,6 +228,12 @@ impl CardView {
171
228
  .set_text(text_or(&card.last_prompt_preview, "—"));
172
229
  self.reply.set_text(text_or(&card.assistant_preview, "—"));
173
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);
174
237
 
175
238
  for class in [
176
239
  "state-idle",
@@ -181,9 +244,13 @@ impl CardView {
181
244
  "state-error",
182
245
  "current",
183
246
  "stalled",
247
+ "hidden-tab",
184
248
  ] {
185
249
  self.root.remove_css_class(class);
186
250
  }
251
+ if hidden_tab {
252
+ self.root.add_css_class("hidden-tab");
253
+ }
187
254
  self.root.add_css_class(&format!(
188
255
  "state-{}",
189
256
  if stalled {
@@ -198,18 +265,34 @@ impl CardView {
198
265
  if stalled {
199
266
  self.root.add_css_class("stalled");
200
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);
201
274
  self.root.set_tooltip_text(Some(&format!(
202
- "Focus {}",
203
- text_or(&card.repo_label, "Pi session")
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
204
281
  )));
205
282
  let accessible_label = format!(
206
- "{}, {}, press Enter to focus its Ghostty window",
283
+ "{}, {}, {}{}",
207
284
  text_or(&card.repo_label, "Pi session"),
208
285
  if stalled {
209
286
  "stalled"
210
287
  } else {
211
288
  text_or(&card.phase, "idle")
212
- }
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
213
296
  );
214
297
  self.root
215
298
  .update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
@@ -217,11 +300,45 @@ impl CardView {
217
300
  .update_state(&[gtk::accessible::State::Selected(Some(focused))]);
218
301
  }
219
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
+
220
337
  pub fn set_expanded(&self, expanded: bool) {
221
338
  self.root
222
339
  .update_state(&[gtk::accessible::State::Expanded(Some(expanded))]);
223
340
  self.root
224
- .set_height_request(if expanded { 228 } else { 60 });
341
+ .set_height_request(if expanded { 252 } else { 60 });
225
342
  self.inspector.set_visible(expanded);
226
343
  if expanded {
227
344
  self.root.add_css_class("open");
@@ -267,14 +384,29 @@ fn inspector_row(grid: &gtk::Grid, row: i32, key: &str) -> gtk::Label {
267
384
  }
268
385
 
269
386
  fn display_repo(card: &Card, duplicate: bool) -> String {
387
+ let marker = if card.hidden_tab() { "⧉ " } else { "" };
270
388
  if duplicate && card.pid > 0 {
271
389
  format!(
272
- "{} · {:04}",
390
+ "{}{} · {:04}",
391
+ marker,
273
392
  text_or(&card.repo_label, "Pi session"),
274
393
  card.pid % 10_000
275
394
  )
276
395
  } else {
277
- text_or(&card.repo_label, "Pi session").to_owned()
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
278
410
  }
279
411
  }
280
412
 
@@ -293,6 +425,81 @@ fn state_class(state: &str) -> &str {
293
425
  }
294
426
  }
295
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
+
296
503
  fn text_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
297
504
  if value.is_empty() { fallback } else { value }
298
505
  }
@@ -301,3 +508,95 @@ fn duration(now_ms: i64, anchor_ms: i64) -> String {
301
508
  let seconds = now_ms.saturating_sub(anchor_ms.max(1)) / 1000;
302
509
  format!("{}:{:02}", seconds / 60, seconds % 60)
303
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
+ }
@@ -3,6 +3,25 @@ use serde_json::{Value, json};
3
3
  use std::io::{self, Write};
4
4
  use std::sync::{Mutex, OnceLock};
5
5
 
6
+ #[derive(Clone, Debug, Deserialize)]
7
+ #[serde(rename_all = "camelCase")]
8
+ pub struct AkTask {
9
+ #[serde(default)]
10
+ pub id: i64,
11
+ #[serde(default)]
12
+ pub title: String,
13
+ #[serde(default)]
14
+ pub state: String,
15
+ }
16
+
17
+ impl AkTask {
18
+ /// Only an `active` chip is a button: the card's own session holds a live claim, so clicking
19
+ /// it focuses that session's Ghostty window. Every other state is a badge and never fires.
20
+ pub fn is_active(&self) -> bool {
21
+ self.state == "active"
22
+ }
23
+ }
24
+
6
25
  #[derive(Clone, Debug, Deserialize)]
7
26
  #[serde(rename_all = "camelCase")]
8
27
  pub struct Card {
@@ -40,6 +59,20 @@ pub struct Card {
40
59
  pub agent_active: bool,
41
60
  #[serde(default, rename = "processId")]
42
61
  pub pid: i64,
62
+ /// Set for tabs running an agent other than Pi, which publish no telemetry of their own.
63
+ #[serde(default)]
64
+ pub agent_label: String,
65
+ /// Read-only AK task references joined onto this card by the controller. `active` chips are
66
+ /// clickable and focus this card's terminal; the rest are inert badges.
67
+ #[serde(default)]
68
+ pub ak_tasks: Vec<AkTask>,
69
+ /// How many further AK references exist beyond the rendered chips.
70
+ #[serde(default)]
71
+ pub ak_task_overflow: u32,
72
+ /// `Some(false)` marks a tab hidden behind another tab of its Ghostty window; the controller
73
+ /// placed it through its host process, so activation presents the tab before focusing.
74
+ #[serde(default)]
75
+ pub surface_visible: Option<bool>,
43
76
  }
44
77
 
45
78
  impl Card {
@@ -51,6 +84,10 @@ impl Card {
51
84
  }
52
85
  }
53
86
 
87
+ pub fn hidden_tab(&self) -> bool {
88
+ self.surface_visible == Some(false)
89
+ }
90
+
54
91
  pub fn active(&self) -> bool {
55
92
  self.agent_active || matches!(self.state.as_str(), "thinking" | "tool" | "waiting")
56
93
  }
@@ -159,6 +196,12 @@ pub fn demo_view() -> ViewMessage {
159
196
  last_event_at: now - 1_000,
160
197
  updated_at: now,
161
198
  agent_active: true,
199
+ ak_tasks: vec![AkTask {
200
+ id: 5701,
201
+ title: "Show clickable AK-task references on the activity ribbon".into(),
202
+ state: "active".into(),
203
+ }],
204
+ ak_task_overflow: 1,
162
205
  ..empty_card()
163
206
  },
164
207
  ],
@@ -184,6 +227,10 @@ fn empty_card() -> Card {
184
227
  updated_at: 0,
185
228
  agent_active: false,
186
229
  pid: 0,
230
+ agent_label: String::new(),
231
+ ak_tasks: Vec::new(),
232
+ ak_task_overflow: 0,
233
+ surface_visible: None,
187
234
  }
188
235
  }
189
236
 
@@ -202,6 +249,22 @@ mod tests {
202
249
  assert!(view.sessions[0].active());
203
250
  assert_eq!(view.sessions[0].agent_started_at, None);
204
251
  assert_eq!(view.sessions[0].pid, 4242);
252
+ assert!(view.sessions[0].ak_tasks.is_empty());
253
+ assert_eq!(view.sessions[0].ak_task_overflow, 0);
254
+ }
255
+
256
+ #[test]
257
+ fn ak_task_chips_parse_with_states() {
258
+ let view: ViewMessage = serde_json::from_str(
259
+ r#"{"protocol":1,"type":"view","revision":8,"sessions":[{"cardId":"card-a","akTasks":[{"id":5701,"title":"Show clickable AK-task references","state":"active"},{"id":5432,"title":"Preserve safe NEXUS recovery reasons","state":"orphaned"},{"id":4220,"title":"Control Decision 77 epoch admissions","state":"deferred"}],"akTaskOverflow":3}]}"#,
260
+ )
261
+ .expect("view should parse");
262
+ let card = &view.sessions[0];
263
+ assert_eq!(card.ak_tasks.len(), 3);
264
+ assert!(card.ak_tasks[0].is_active());
265
+ assert!(!card.ak_tasks[1].is_active());
266
+ assert!(!card.ak_tasks[2].is_active());
267
+ assert_eq!(card.ak_task_overflow, 3);
205
268
  }
206
269
 
207
270
  #[test]
@@ -5,17 +5,44 @@ use std::collections::{HashMap, HashSet};
5
5
  use std::io::{BufRead, BufReader};
6
6
  use std::time::{SystemTime, UNIX_EPOCH};
7
7
 
8
+ /// Colour definitions used until the controller reports what Ghostty is showing.
9
+ const FALLBACK_DEFINITIONS: &str = concat!(
10
+ "@define-color pi_background #1e2326;\n",
11
+ "@define-color pi_foreground #d3c6aa;\n",
12
+ "@define-color pi_accent #7fbbb3;\n",
13
+ "@define-color pi_thinking #7fbbb3;\n",
14
+ "@define-color pi_tool #dbbc7f;\n",
15
+ "@define-color pi_waiting #e69875;\n",
16
+ "@define-color pi_success #a7c080;\n",
17
+ "@define-color pi_error #e67e80;\n",
18
+ );
19
+
20
+ thread_local! {
21
+ static THEME_PROVIDER: gtk::CssProvider = gtk::CssProvider::new();
22
+ }
23
+
24
+ /// The stylesheet derives every shade from a handful of named colours, so following the terminal's
25
+ /// theme is a matter of redefining those names and reloading one provider.
8
26
  #[allow(deprecated)]
9
27
  pub fn install_css() {
10
- let provider = gtk::CssProvider::new();
11
- provider.load_from_data(include_str!("style.css"));
12
- if let Some(display) = gtk::gdk::Display::default() {
13
- gtk::style_context_add_provider_for_display(
14
- &display,
15
- &provider,
16
- gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
17
- );
18
- }
28
+ apply_theme(FALLBACK_DEFINITIONS);
29
+ THEME_PROVIDER.with(|provider| {
30
+ if let Some(display) = gtk::gdk::Display::default() {
31
+ gtk::style_context_add_provider_for_display(
32
+ &display,
33
+ provider,
34
+ gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
35
+ );
36
+ }
37
+ });
38
+ }
39
+
40
+ /// Replace the active colour definitions. Definitions must sit in the same provider as the rules
41
+ /// that reference them, so both are loaded together.
42
+ #[allow(deprecated)]
43
+ pub fn apply_theme(definitions: &str) {
44
+ let css = format!("{definitions}\n{}", include_str!("style.css"));
45
+ THEME_PROVIDER.with(|provider| provider.load_from_data(&css));
19
46
  }
20
47
 
21
48
  pub fn start_input_reader(sender: relm4::Sender<AppMsg>) {
@@ -53,6 +80,20 @@ fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
53
80
  let _ = sender.send(AppMsg::InputError(format!("invalid view message: {error}")));
54
81
  }
55
82
  },
83
+ Some("theme") => {
84
+ let definitions = value
85
+ .get("definitions")
86
+ .and_then(|item| item.as_str())
87
+ .unwrap_or_default()
88
+ .to_owned();
89
+ if definitions.is_empty() {
90
+ let _ = sender.send(AppMsg::InputError(
91
+ "theme message carried no colours".into(),
92
+ ));
93
+ } else {
94
+ let _ = sender.send(AppMsg::Theme(definitions));
95
+ }
96
+ }
56
97
  Some("focus-strip") => {
57
98
  let _ = sender.send(AppMsg::FocusStrip);
58
99
  }