@tryinget/pi-activity-strip 0.7.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
CHANGED
|
@@ -128,9 +128,10 @@ The shortcut toggles exclusive keyboard mode. On entry, the first card is select
|
|
|
128
128
|
- **Hidden tabs:** a Ghostty window title only names its active tab. A bound surface whose title is not visible is placed through its Ghostty host process: one host window is exact containment; several host windows use the window remembered for that tab. Memory comes from titles seen while the strip runs and from a read-only AT-SPI inventory of tab labels, and is persisted per Niri instance under `~/.pi/agent/state/pi-activity-strip/surface-bindings.json`. A tab whose window has never been observed stays unplaced rather than guessed; `status` reports that count. Activating a hidden-tab card calls the host process's `present-surface` action on the session bus, then focuses the window, and reports success only after the title proves the tab is visible.
|
|
129
129
|
- **Window ids:** agents and tools name windows by their Niri window id (`claude-window` prints `window_id`, continuity receipts carry it). Each card shows that id as a quiet `#43` chip at the start of its footer; a hidden tab has no window of its own, so its chip shows the window hosting it. Hover or keyboard detail adds a `window` row with the workspace number the operator sees, Niri's workspace `idx` and never its internal workspace id, for example `#43 · workspace 2` or `host #43 · workspace 2` for a hidden tab. The tooltip and accessible label name the window too. Off Niri there is no window id, so no chip is drawn.
|
|
130
130
|
- **Agent tabs:** a tab is admitted as an agent when the process owning its terminal is a recognized agent CLI, never on a window title alone, so plain terminal programs are not cards. Claude Code tabs are identified exactly through the per-session scratchpad the process holds open, which yields the session id and the title Claude Code put on the terminal; that title places the tab in its window and is remembered so the tab stays placed once hidden.
|
|
131
|
-
- **AK task references:** cards can show the Agent Kernel task their session is working on. The controller reads the read-only `ak` CLI (`ak task list --status claimed --format json --all --verbose` and `ak task deferred --format json --all`) on a calm 15-second clock and joins claims by exact Pi session id under the AK5700 claim semantics: only `session-<uuid>` claims count, and an expired lease is vacant custody that renders nothing. A card whose session holds a live claim shows a clickable
|
|
131
|
+
- **AK task references:** cards can show the Agent Kernel task their session is working on. The controller reads the read-only `ak` CLI (`ak task list --status claimed --format json --all --verbose` and `ak task deferred --format json --all`) on a calm 15-second clock and joins claims by exact Pi session id under the AK5700 claim semantics: only `session-<uuid>` claims count, and an expired lease is vacant custody that renders nothing. Chips sit in the card's footer as bare task numbers (`AK5701`, title in the tooltip and the expanded inspector), one per card with the rest folded into a `+N` count, so the folder name keeps the header. A card whose session holds a live claim shows a clickable chip that reuses the card's own activation, so clicking it focuses the exact Ghostty window of the claiming session, presenting hidden tabs first. Claims whose lease has not lapsed but whose claiming session no longer exists ("claim outlives session") and tasks carrying an active deferral are joined by task repo onto cards working inside that repo and render as inert badges, never buttons — there is no live window to focus and no AK action is ever offered. Ambiguous session matches (one logical session resumed into two terminals), a missing `ak` binary, or malformed output bind nothing: fail closed, no chip, no invented state, strip behavior unchanged. The strip never writes AK state and never touches the society database directly.
|
|
132
132
|
- **Codex telemetry:** Codex keeps a thread index naming every session's rollout file, working directory and title. A process binds to its thread by an open rollout descriptor, or, before any task has run, by being the only session created in that directory after the process started; anything ambiguous binds nothing. The rollout tail then reports the running tool and its command, turn count, approval and sandbox policy, prompt and reply. Reading the index needs the runtime's built-in SQLite, and a host without it degrades to a process-only card.
|
|
133
133
|
- **Claude Code telemetry:** cards read live state from the tail of the session transcript, giving the topic, current tool and its target, last prompt, latest reply, turn count and activity clock with no configuration. That format is internal to Claude Code and can change between releases, so a transcript that no longer parses degrades to a process-only card rather than inventing activity. Optional hooks add the one state a transcript cannot express, that a session is blocked waiting for you; run `claude-hooks` for the settings fragment. Only low-frequency events are hooked, so nothing runs per tool call. OpenTelemetry is deliberately not used: it reports aggregate usage and cost, not which tool a session is running now.
|
|
134
|
+
- **You are here:** the block at the ribbon's left names the focused window's project in large type with its folder beneath (home written as `~`, cut from the front so the deepest folder stays visible), and the matching card carries a solid accent ring. With no agent window focused it shows the session counts instead.
|
|
134
135
|
- **Appearance:** the ribbon reads the same theme files Ghostty reads. It resolves the `theme` setting for the desktop's current colour scheme, including the `light:…,dark:…` form, and takes colours set directly in the config over the theme file, exactly as Ghostty layers them. State colours reuse the terminal's own meanings, so green is settled, yellow is working, red failed, and the cursor colour marks a session waiting for you. The panel derives every shade from eight named colours, so a theme change is a handful of values and one stylesheet reload rather than a restart. A theme that cannot be read falls back to a neutral palette.
|
|
135
136
|
- **Height repair:** showing or hiding the ribbon changes the output working area, and a window whose height is not automatic keeps the old value. After each change the strip compares window heights before and after, and resets to automatic only those windows still sitting at a height the moving windows just vacated. A height nobody vacated is never touched, each window is reset at most once per height, and a pass is bounded.
|
|
136
137
|
- **Two identity keys:** a Ghostty surface id is a per-process handle that can drift away from the value a long-lived Pi process captured at startup, while the 32-hex session token in the same title never does. Memory therefore stores both, and lookups prefer the exact surface. A session token that two windows claim is ambiguous and binds nothing; two terminals resuming one logical session in the same window are both placed there.
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "pi-activity-strip-native-artifact.v1",
|
|
3
3
|
"target": "x86_64-unknown-linux-gnu",
|
|
4
|
-
"sha256": "
|
|
4
|
+
"sha256": "99b6f36e858b9c241c5aa73f7517c0f5f418673f54d9fa245156272c39bf004b",
|
|
5
5
|
"cargoLockSha256": "f8ebc11bc721c569af982169e448a5ea4fed15bc06001017d97acda22b1c890b",
|
|
6
|
-
"sourceSha256": "
|
|
6
|
+
"sourceSha256": "b272f4f3131cc4335ed6324ad051042fbd3d577b16944189fcdeaed7e776628f",
|
|
7
7
|
"rustc": "rustc 1.98.0 (88d9e12ae 2026-08-18)",
|
|
8
8
|
"glibcFloor": "GLIBC_2.34",
|
|
9
9
|
"neededSonames": [
|
|
Binary file
|
package/native/panel/src/app.rs
CHANGED
|
@@ -16,6 +16,7 @@ const COMPACT_HEIGHT: i32 = 84;
|
|
|
16
16
|
const OUTER_MARGIN: i32 = 8;
|
|
17
17
|
const EXPANDED_HEIGHT: i32 = 276;
|
|
18
18
|
const ORDER_REFRESH_MS: i64 = 15_000;
|
|
19
|
+
const BRAND_WIDTH: i32 = 196;
|
|
19
20
|
|
|
20
21
|
pub struct AppInit {
|
|
21
22
|
pub demo: bool,
|
|
@@ -77,28 +78,42 @@ impl Component for App {
|
|
|
77
78
|
set_orientation: gtk::Orientation::Horizontal,
|
|
78
79
|
set_spacing: 0,
|
|
79
80
|
|
|
81
|
+
// "You are here": the project and folder of the focused window. The labels fill a
|
|
82
|
+
// fixed width instead of sizing it, so moving focus never shifts the cards beside it.
|
|
80
83
|
#[name = "brand"]
|
|
81
84
|
gtk::Box {
|
|
82
85
|
add_css_class: "brand",
|
|
83
86
|
set_orientation: gtk::Orientation::Vertical,
|
|
84
|
-
set_width_request:
|
|
87
|
+
set_width_request: BRAND_WIDTH,
|
|
85
88
|
set_valign: gtk::Align::Center,
|
|
86
89
|
|
|
90
|
+
#[name = "eyebrow"]
|
|
87
91
|
gtk::Label {
|
|
88
92
|
add_css_class: "brand-eyebrow",
|
|
89
93
|
set_label: "π ACTIVITY",
|
|
90
|
-
set_halign: gtk::Align::
|
|
94
|
+
set_halign: gtk::Align::Fill,
|
|
95
|
+
set_xalign: 0.0,
|
|
96
|
+
set_ellipsize: gtk::pango::EllipsizeMode::End,
|
|
97
|
+
set_max_width_chars: 1,
|
|
91
98
|
},
|
|
99
|
+
#[name = "title"]
|
|
92
100
|
gtk::Label {
|
|
93
101
|
add_css_class: "brand-title",
|
|
94
102
|
set_label: "Sessions",
|
|
95
|
-
set_halign: gtk::Align::
|
|
103
|
+
set_halign: gtk::Align::Fill,
|
|
104
|
+
set_xalign: 0.0,
|
|
105
|
+
set_ellipsize: gtk::pango::EllipsizeMode::End,
|
|
106
|
+
set_max_width_chars: 1,
|
|
96
107
|
},
|
|
108
|
+
// Ellipsized at the start: the deepest folder is the part that tells you where you are.
|
|
97
109
|
#[name = "meta"]
|
|
98
110
|
gtk::Label {
|
|
99
111
|
add_css_class: "meta",
|
|
100
112
|
set_label: "Waiting for sessions…",
|
|
101
|
-
set_halign: gtk::Align::
|
|
113
|
+
set_halign: gtk::Align::Fill,
|
|
114
|
+
set_xalign: 0.0,
|
|
115
|
+
set_ellipsize: gtk::pango::EllipsizeMode::Start,
|
|
116
|
+
set_max_width_chars: 1,
|
|
102
117
|
},
|
|
103
118
|
},
|
|
104
119
|
|
|
@@ -208,7 +223,7 @@ impl Component for App {
|
|
|
208
223
|
self.next_order_refresh_at = now_ms() + ORDER_REFRESH_MS;
|
|
209
224
|
}
|
|
210
225
|
self.refresh_cards();
|
|
211
|
-
self.
|
|
226
|
+
self.update_brand(widgets);
|
|
212
227
|
}
|
|
213
228
|
AppMsg::Hover(id, entered) => {
|
|
214
229
|
if entered {
|
|
@@ -305,6 +320,57 @@ fn move_order_item(order: &mut Vec<String>, index: usize, target: usize) {
|
|
|
305
320
|
order.insert(target, moved);
|
|
306
321
|
}
|
|
307
322
|
|
|
323
|
+
struct BrandCounts {
|
|
324
|
+
active: usize,
|
|
325
|
+
settled: usize,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#[derive(Debug, PartialEq)]
|
|
329
|
+
struct BrandText {
|
|
330
|
+
eyebrow: String,
|
|
331
|
+
title: String,
|
|
332
|
+
meta: String,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/// The brand block names the focused window's project and folder. Without a focused agent window
|
|
336
|
+
/// it falls back to the ribbon's own name and the session counts.
|
|
337
|
+
fn brand_text(focused: Option<&Card>, counts: &BrandCounts, home: &str) -> BrandText {
|
|
338
|
+
let summary = format!("{} active · {} settled", counts.active, counts.settled);
|
|
339
|
+
match focused {
|
|
340
|
+
Some(card) if !card.repo_label.is_empty() || !card.cwd.is_empty() => BrandText {
|
|
341
|
+
eyebrow: format!("π {}", summary.to_uppercase()),
|
|
342
|
+
title: if card.repo_label.is_empty() {
|
|
343
|
+
short_path(&card.cwd, home)
|
|
344
|
+
} else {
|
|
345
|
+
card.repo_label.clone()
|
|
346
|
+
},
|
|
347
|
+
meta: if card.cwd.is_empty() {
|
|
348
|
+
"—".to_owned()
|
|
349
|
+
} else {
|
|
350
|
+
short_path(&card.cwd, home)
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
_ => BrandText {
|
|
354
|
+
eyebrow: "π ACTIVITY".to_owned(),
|
|
355
|
+
title: "Sessions".to_owned(),
|
|
356
|
+
meta: summary,
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// A path with the home directory written as `~`.
|
|
362
|
+
fn short_path(path: &str, home: &str) -> String {
|
|
363
|
+
let home = home.trim_end_matches('/');
|
|
364
|
+
if home.is_empty() {
|
|
365
|
+
return path.to_owned();
|
|
366
|
+
}
|
|
367
|
+
match path.strip_prefix(home) {
|
|
368
|
+
Some("") => "~".to_owned(),
|
|
369
|
+
Some(rest) if rest.starts_with('/') => format!("~{rest}"),
|
|
370
|
+
_ => path.to_owned(),
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
308
374
|
fn engaged_card_id(
|
|
309
375
|
hovered: &HashSet<String>,
|
|
310
376
|
keyboard_active: bool,
|
|
@@ -394,7 +460,7 @@ impl App {
|
|
|
394
460
|
}
|
|
395
461
|
self.reorder_widgets(&widgets.cards_box);
|
|
396
462
|
self.refresh_cards();
|
|
397
|
-
self.
|
|
463
|
+
self.update_brand(widgets);
|
|
398
464
|
|
|
399
465
|
let should_show = view.visible && !self.data.is_empty();
|
|
400
466
|
if should_show != self.visible {
|
|
@@ -452,14 +518,26 @@ impl App {
|
|
|
452
518
|
}
|
|
453
519
|
}
|
|
454
520
|
|
|
455
|
-
fn
|
|
521
|
+
fn update_brand(&self, widgets: &AppWidgets) {
|
|
456
522
|
let active = self.data.values().filter(|card| card.active()).count();
|
|
457
|
-
|
|
458
|
-
"{} active · {} settled · order {}s",
|
|
523
|
+
let counts = BrandCounts {
|
|
459
524
|
active,
|
|
460
|
-
self.data.len().saturating_sub(active),
|
|
461
|
-
|
|
462
|
-
|
|
525
|
+
settled: self.data.len().saturating_sub(active),
|
|
526
|
+
};
|
|
527
|
+
let focused = self
|
|
528
|
+
.focused_card_id
|
|
529
|
+
.as_ref()
|
|
530
|
+
.and_then(|id| self.data.get(id));
|
|
531
|
+
let home = std::env::var("HOME").unwrap_or_default();
|
|
532
|
+
let brand = brand_text(focused, &counts, &home);
|
|
533
|
+
widgets.eyebrow.set_text(&brand.eyebrow);
|
|
534
|
+
widgets.title.set_text(&brand.title);
|
|
535
|
+
widgets.meta.set_text(&brand.meta);
|
|
536
|
+
widgets.brand.set_tooltip_text(
|
|
537
|
+
focused
|
|
538
|
+
.map(|card| card.cwd.as_str())
|
|
539
|
+
.filter(|cwd| !cwd.is_empty()),
|
|
540
|
+
);
|
|
463
541
|
}
|
|
464
542
|
|
|
465
543
|
fn regroup(&mut self) {
|
|
@@ -559,9 +637,59 @@ impl App {
|
|
|
559
637
|
|
|
560
638
|
#[cfg(test)]
|
|
561
639
|
mod tests {
|
|
562
|
-
use super::{
|
|
640
|
+
use super::{
|
|
641
|
+
BrandCounts, brand_text, engaged_card_id, move_order_item, navigation_target, short_path,
|
|
642
|
+
};
|
|
643
|
+
use crate::protocol::Card;
|
|
563
644
|
use std::collections::HashSet;
|
|
564
645
|
|
|
646
|
+
fn counts() -> BrandCounts {
|
|
647
|
+
BrandCounts {
|
|
648
|
+
active: 2,
|
|
649
|
+
settled: 3,
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
#[test]
|
|
654
|
+
fn brand_names_the_focused_windows_project_and_folder() {
|
|
655
|
+
let card: Card = serde_json::from_value(serde_json::json!({
|
|
656
|
+
"repoLabel": "pi-activity-strip",
|
|
657
|
+
"cwd": "/home/op/ai-society/softwareco/owned/pi-extensions/packages/pi-activity-strip"
|
|
658
|
+
}))
|
|
659
|
+
.expect("card");
|
|
660
|
+
let brand = brand_text(Some(&card), &counts(), "/home/op");
|
|
661
|
+
assert_eq!(brand.title, "pi-activity-strip");
|
|
662
|
+
assert_eq!(
|
|
663
|
+
brand.meta,
|
|
664
|
+
"~/ai-society/softwareco/owned/pi-extensions/packages/pi-activity-strip"
|
|
665
|
+
);
|
|
666
|
+
assert_eq!(brand.eyebrow, "π 2 ACTIVE · 3 SETTLED");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
#[test]
|
|
670
|
+
fn brand_falls_back_to_counts_without_a_focused_window() {
|
|
671
|
+
let brand = brand_text(None, &counts(), "/home/op");
|
|
672
|
+
assert_eq!(brand.title, "Sessions");
|
|
673
|
+
assert_eq!(brand.meta, "2 active · 3 settled");
|
|
674
|
+
|
|
675
|
+
let unlabelled: Card = serde_json::from_value(serde_json::json!({})).expect("card");
|
|
676
|
+
assert_eq!(
|
|
677
|
+
brand_text(Some(&unlabelled), &counts(), "/home/op").title,
|
|
678
|
+
"Sessions"
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
#[test]
|
|
683
|
+
fn short_path_abbreviates_only_the_whole_home_directory() {
|
|
684
|
+
assert_eq!(short_path("/home/op/repo", "/home/op"), "~/repo");
|
|
685
|
+
assert_eq!(short_path("/home/op", "/home/op/"), "~");
|
|
686
|
+
assert_eq!(
|
|
687
|
+
short_path("/home/operator/repo", "/home/op"),
|
|
688
|
+
"/home/operator/repo"
|
|
689
|
+
);
|
|
690
|
+
assert_eq!(short_path("/srv/repo", ""), "/srv/repo");
|
|
691
|
+
}
|
|
692
|
+
|
|
565
693
|
#[test]
|
|
566
694
|
fn stale_gtk_focus_does_not_retain_expansion_outside_keyboard_mode() {
|
|
567
695
|
let focused = Some("card-a".to_owned());
|
|
@@ -8,8 +8,11 @@ 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,
|
|
@@ -52,12 +55,8 @@ impl CardView {
|
|
|
52
55
|
let phase = label("card-phase", gtk::Align::Start);
|
|
53
56
|
label_box.append(&repo);
|
|
54
57
|
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);
|
|
58
58
|
let state = label("card-state", gtk::Align::End);
|
|
59
59
|
header.append(&label_box);
|
|
60
|
-
header.append(&ak_row);
|
|
61
60
|
header.append(&state);
|
|
62
61
|
|
|
63
62
|
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
@@ -65,11 +64,20 @@ impl CardView {
|
|
|
65
64
|
// The Niri window id, so a window an agent or tool names by id can be found on the ribbon.
|
|
66
65
|
// It leads the footer so it sits at the same place on every card and costs the title nothing.
|
|
67
66
|
let window = label("card-window", gtk::Align::Start);
|
|
67
|
+
window.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
|
|
68
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);
|
|
69
74
|
let tool = label("card-tool", gtk::Align::Start);
|
|
70
75
|
tool.set_hexpand(true);
|
|
71
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);
|
|
72
79
|
footer.append(&window);
|
|
80
|
+
footer.append(&ak_row);
|
|
73
81
|
footer.append(&tool);
|
|
74
82
|
footer.append(&elapsed);
|
|
75
83
|
|
|
@@ -111,9 +119,8 @@ impl CardView {
|
|
|
111
119
|
let mut ak_chips = Vec::with_capacity(AK_CHIP_SLOTS);
|
|
112
120
|
let mut ak_clickable = Vec::with_capacity(AK_CHIP_SLOTS);
|
|
113
121
|
for _slot in 0..AK_CHIP_SLOTS {
|
|
114
|
-
let chip = label("ak-task", gtk::Align::
|
|
115
|
-
chip.set_ellipsize(relm4::gtk::pango::EllipsizeMode::
|
|
116
|
-
chip.set_max_width_chars(20);
|
|
122
|
+
let chip = label("ak-task", gtk::Align::Start);
|
|
123
|
+
chip.set_ellipsize(relm4::gtk::pango::EllipsizeMode::None);
|
|
117
124
|
chip.set_visible(false);
|
|
118
125
|
let clickable = Rc::new(Cell::new(false));
|
|
119
126
|
let click = gtk::GestureClick::new();
|
|
@@ -131,7 +138,8 @@ impl CardView {
|
|
|
131
138
|
ak_chips.push(chip);
|
|
132
139
|
ak_clickable.push(clickable);
|
|
133
140
|
}
|
|
134
|
-
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);
|
|
135
143
|
ak_overflow.set_visible(false);
|
|
136
144
|
ak_row.append(&ak_overflow);
|
|
137
145
|
|
|
@@ -349,13 +357,9 @@ impl CardView {
|
|
|
349
357
|
}
|
|
350
358
|
}
|
|
351
359
|
self.ak_row.set_visible(!chips.is_empty());
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
.set_text(&format!("+{} more", card.ak_task_overflow));
|
|
356
|
-
} else {
|
|
357
|
-
self.ak_overflow.set_visible(false);
|
|
358
|
-
}
|
|
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}"));
|
|
359
363
|
}
|
|
360
364
|
|
|
361
365
|
pub fn set_expanded(&self, expanded: bool) {
|
|
@@ -490,9 +494,16 @@ fn ak_chip_class(state: &str) -> &'static str {
|
|
|
490
494
|
}
|
|
491
495
|
}
|
|
492
496
|
|
|
493
|
-
///
|
|
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.
|
|
494
505
|
fn ak_chip_text(task: &AkTask) -> String {
|
|
495
|
-
format!("AK
|
|
506
|
+
format!("AK{}", task.id)
|
|
496
507
|
}
|
|
497
508
|
|
|
498
509
|
fn ak_chip_tooltip(task: &AkTask) -> String {
|
|
@@ -559,9 +570,19 @@ fn text_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
|
|
|
559
570
|
if value.is_empty() { fallback } else { value }
|
|
560
571
|
}
|
|
561
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.
|
|
562
575
|
fn duration(now_ms: i64, anchor_ms: i64) -> String {
|
|
563
576
|
let seconds = now_ms.saturating_sub(anchor_ms.max(1)) / 1000;
|
|
564
|
-
|
|
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
|
+
}
|
|
565
586
|
}
|
|
566
587
|
|
|
567
588
|
#[cfg(test)]
|
|
@@ -657,14 +678,25 @@ mod tests {
|
|
|
657
678
|
assert!(active.is_active());
|
|
658
679
|
assert!(!orphaned.is_active());
|
|
659
680
|
assert!(!deferred.is_active());
|
|
660
|
-
assert_eq!(
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
);
|
|
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);
|
|
664
685
|
assert!(ak_chip_tooltip(&orphaned).contains("not clickable"));
|
|
665
686
|
assert!(ak_chip_tooltip(&active).contains("focus the claiming session"));
|
|
666
687
|
}
|
|
667
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
|
+
|
|
668
700
|
#[test]
|
|
669
701
|
fn ak_inspector_lists_every_reference_with_overflow() {
|
|
670
702
|
let tasks = vec![
|
|
@@ -89,11 +89,14 @@ window {
|
|
|
89
89
|
border-color: alpha(@pi_accent, 0.5);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/*
|
|
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
|
+
*/
|
|
93
96
|
.card.current {
|
|
94
|
-
background: mix(@pi_background, @
|
|
95
|
-
border-color:
|
|
96
|
-
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;
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
.card.hidden-tab {
|
|
@@ -224,6 +227,7 @@ window {
|
|
|
224
227
|
* itself: it reuses the card's activation, so it carries the accent colour and a pointer cursor.
|
|
225
228
|
* Badge states (a claim that outlives its session, a deferred task) are deliberately inert pills.
|
|
226
229
|
*/
|
|
230
|
+
/* Footer-sized: a bare task number with no vertical padding, like the window chip beside it. */
|
|
227
231
|
.ak-task {
|
|
228
232
|
background: alpha(@pi_foreground, 0.07);
|
|
229
233
|
border: 1px solid alpha(@pi_foreground, 0.16);
|
|
@@ -231,7 +235,7 @@ window {
|
|
|
231
235
|
color: alpha(@pi_foreground, 0.68);
|
|
232
236
|
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
|
233
237
|
font-size: 8px;
|
|
234
|
-
padding:
|
|
238
|
+
padding: 0 5px;
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
.ak-task.ak-active {
|