@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.
- package/README.md +59 -7
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +98 -64
- 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 +20 -20
- package/native/panel/src/card_view.rs +396 -9
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +142 -0
- package/native/panel/src/runtime.rs +50 -9
- package/native/panel/src/style.css +147 -60
- package/package.json +4 -4
- package/src/client/runtime-lock.mjs +140 -0
- package/src/common/agent-identity.mjs +163 -0
- package/src/common/ak-tasks.mjs +354 -0
- package/src/common/claude-events.mjs +190 -0
- package/src/common/claude-hook-config.mjs +56 -0
- package/src/common/claude-transcript.mjs +163 -0
- package/src/common/codex-transcript.mjs +153 -0
- package/src/common/compatibility.mjs +13 -0
- package/src/common/constants.mjs +2 -0
- package/src/common/contracts.d.ts +35 -0
- package/src/common/contracts.ts +67 -1
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +179 -111
- package/src/common/session-cards.mjs +5 -2
- package/src/common/status-report.mjs +51 -0
- package/src/common/surface-bindings.mjs +234 -0
- package/src/common/terminal-identity.mjs +75 -12
- package/src/common/window-placement.mjs +59 -0
- package/src/common/workspace-view.mjs +161 -0
- package/src/native/agent-discovery.mjs +294 -0
- package/src/native/ak-runtime.mjs +164 -0
- package/src/native/codex-discovery.mjs +128 -0
- package/src/native/ghostty-tab-inventory.py +99 -0
- package/src/native/height-repair.mjs +154 -0
- package/src/native/main.mjs +122 -64
- package/src/native/panel-binary.mjs +56 -0
- package/src/native/panel-projection.mjs +70 -5
- package/src/native/placement.mjs +225 -0
- package/src/native/tab-inventory.mjs +188 -0
- package/src/native/theme-runtime.mjs +72 -0
- package/src/native/theme-source.mjs +197 -0
- 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,
|
|
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
|
-
"
|
|
203
|
-
|
|
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
|
-
"{}, {},
|
|
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 {
|
|
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: >k::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")
|
|
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
|
+
}
|
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
|
}
|