@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.
- package/README.md +21 -3
- package/bin/pi-activity-strip.mjs +76 -63
- package/extensions/activity-strip.js +25 -21
- package/native/bin/linux-x64-gnu/artifact.json +2 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +141 -13
- package/native/panel/src/card_view.rs +149 -29
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +79 -0
- package/native/panel/src/style.css +22 -5
- package/package.json +1 -1
- package/src/client/runtime-lock.mjs +140 -0
- package/src/common/claude-events.mjs +1 -1
- package/src/common/constants.mjs +2 -0
- package/src/common/contracts.d.ts +9 -0
- package/src/common/contracts.ts +3 -0
- package/src/common/niri-focus.mjs +1 -2
- package/src/common/workspace-view.mjs +5 -5
- package/src/native/main.mjs +3 -51
- package/src/native/panel-binary.mjs +56 -0
- package/src/native/panel-projection.mjs +4 -1
- package/src/native/workspace-events.mjs +6 -0
|
@@ -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
|
|
12
|
-
|
|
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
|
|
81
|
-
|
|
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,
|
|
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::
|
|
104
|
-
chip.set_ellipsize(relm4::gtk::pango::EllipsizeMode::
|
|
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::
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
///
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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![
|
package/native/panel/src/main.rs
CHANGED
|
@@ -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
|
|
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
|
-
/*
|
|
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, @
|
|
94
|
-
border-color:
|
|
95
|
-
box-shadow: inset 0 0 0 1px
|
|
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:
|
|
238
|
+
padding: 0 5px;
|
|
222
239
|
}
|
|
223
240
|
|
|
224
241
|
.ak-task.ak-active {
|