@tryinget/pi-activity-strip 0.3.0 → 0.5.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 (42) hide show
  1. package/README.md +96 -152
  2. package/bin/pi-activity-strip.mjs +31 -62
  3. package/examples/simulate-sessions.mjs +1 -1
  4. package/extensions/activity-strip.js +2 -2
  5. package/native/bin/linux-x64-gnu/artifact.json +21 -0
  6. package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
  7. package/native/panel/Cargo.lock +1045 -0
  8. package/native/panel/Cargo.toml +23 -0
  9. package/native/panel/src/app.rs +606 -0
  10. package/native/panel/src/card_view.rs +303 -0
  11. package/native/panel/src/main.rs +31 -0
  12. package/native/panel/src/protocol.rs +234 -0
  13. package/native/panel/src/runtime.rs +110 -0
  14. package/native/panel/src/style.css +176 -0
  15. package/package.json +20 -7
  16. package/src/broker/server.mjs +43 -7
  17. package/src/broker/session-store.mjs +53 -11
  18. package/src/client/broker-client.mjs +25 -4
  19. package/src/client/session-telemetry.mjs +123 -13
  20. package/src/common/activity-order.mjs +15 -9
  21. package/src/common/alignment-controller.mjs +24 -42
  22. package/src/common/card-display.mjs +53 -0
  23. package/src/common/compatibility.mjs +15 -13
  24. package/src/common/constants.mjs +12 -28
  25. package/src/common/contracts.d.ts +54 -5
  26. package/src/common/contracts.ts +54 -5
  27. package/src/common/niri-focus.mjs +79 -79
  28. package/src/common/protocol.mjs +49 -14
  29. package/src/common/session-cards.mjs +102 -0
  30. package/src/common/status-report.mjs +2 -1
  31. package/src/common/telemetry.mjs +11 -1
  32. package/src/common/terminal-identity.mjs +149 -0
  33. package/src/native/main.mjs +393 -0
  34. package/src/native/panel-projection.mjs +99 -0
  35. package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
  36. package/src/common/electron.mjs +0 -57
  37. package/src/electron/main.mjs +0 -499
  38. package/src/electron/niri-native-window-runtime.mjs +0 -178
  39. package/src/electron/preload.cjs +0 -50
  40. package/src/electron/renderer-visibility-runtime.mjs +0 -107
  41. package/src/electron/workspace-view-runtime.mjs +0 -360
  42. package/src/ui/strip-html.mjs +0 -484
@@ -0,0 +1,303 @@
1
+ use crate::app::AppMsg;
2
+ use crate::protocol::Card;
3
+ use relm4::Sender;
4
+ use relm4::gtk;
5
+ use relm4::gtk::gdk;
6
+ use relm4::gtk::glib;
7
+ use relm4::gtk::prelude::*;
8
+
9
+ pub struct CardView {
10
+ pub root: gtk::Button,
11
+ phase: gtk::Label,
12
+ repo: gtk::Label,
13
+ state: gtk::Label,
14
+ tool: gtk::Label,
15
+ elapsed: gtk::Label,
16
+ detail: gtk::Label,
17
+ prompt: gtk::Label,
18
+ reply: gtk::Label,
19
+ path: gtk::Label,
20
+ activation: gtk::Label,
21
+ inspector: gtk::Grid,
22
+ }
23
+
24
+ impl CardView {
25
+ pub fn new(id: &str, sender: &Sender<AppMsg>) -> Self {
26
+ let root = gtk::Button::new();
27
+ root.add_css_class("card");
28
+ root.set_focusable(true);
29
+ root.set_can_focus(true);
30
+ root.set_width_request(224);
31
+ root.set_height_request(60);
32
+
33
+ let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
34
+ let header = gtk::Box::new(gtk::Orientation::Horizontal, 8);
35
+ let label_box = gtk::Box::new(gtk::Orientation::Vertical, 3);
36
+ label_box.set_hexpand(true);
37
+ let repo = label("card-repo", gtk::Align::Start);
38
+ let phase = label("card-phase", gtk::Align::Start);
39
+ label_box.append(&repo);
40
+ label_box.append(&phase);
41
+ let state = label("card-state", gtk::Align::End);
42
+ header.append(&label_box);
43
+ header.append(&state);
44
+
45
+ let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
46
+ footer.set_margin_top(5);
47
+ let tool = label("card-tool", gtk::Align::Start);
48
+ tool.set_hexpand(true);
49
+ let elapsed = label("card-elapsed", gtk::Align::End);
50
+ footer.append(&tool);
51
+ footer.append(&elapsed);
52
+
53
+ let inspector = gtk::Grid::new();
54
+ inspector.add_css_class("inspector");
55
+ inspector.set_row_spacing(6);
56
+ inspector.set_column_spacing(8);
57
+ inspector.set_margin_top(10);
58
+ inspector.set_visible(false);
59
+ let detail = inspector_row(&inspector, 0, "detail");
60
+ let prompt = inspector_row(&inspector, 1, "prompt");
61
+ let reply = inspector_row(&inspector, 2, "reply");
62
+ let path = inspector_row(&inspector, 3, "path");
63
+ let activation = label("activation", gtk::Align::Start);
64
+ activation.set_visible(false);
65
+ inspector.attach(&activation, 0, 4, 2, 1);
66
+
67
+ content.append(&header);
68
+ content.append(&footer);
69
+ content.append(&inspector);
70
+ root.set_child(Some(&content));
71
+
72
+ let card_id = id.to_owned();
73
+ let tx = sender.clone();
74
+ root.connect_clicked(move |_| {
75
+ let _ = tx.send(AppMsg::Activate(card_id.clone()));
76
+ });
77
+
78
+ let motion = gtk::EventControllerMotion::new();
79
+ let card_id = id.to_owned();
80
+ let tx = sender.clone();
81
+ motion.connect_enter(move |_, _, _| {
82
+ let _ = tx.send(AppMsg::Hover(card_id.clone(), true));
83
+ });
84
+ let card_id = id.to_owned();
85
+ let tx = sender.clone();
86
+ motion.connect_leave(move |_| {
87
+ let _ = tx.send(AppMsg::Hover(card_id.clone(), false));
88
+ });
89
+ root.add_controller(motion);
90
+
91
+ let focus = gtk::EventControllerFocus::new();
92
+ let card_id = id.to_owned();
93
+ let tx = sender.clone();
94
+ focus.connect_enter(move |_| {
95
+ let _ = tx.send(AppMsg::Focus(card_id.clone(), true));
96
+ });
97
+ let card_id = id.to_owned();
98
+ let tx = sender.clone();
99
+ focus.connect_leave(move |_| {
100
+ let _ = tx.send(AppMsg::Focus(card_id.clone(), false));
101
+ });
102
+ root.add_controller(focus);
103
+
104
+ let keys = gtk::EventControllerKey::new();
105
+ let card_id = id.to_owned();
106
+ let tx = sender.clone();
107
+ keys.connect_key_pressed(move |_, key, _, modifiers| {
108
+ let direction = if key == gdk::Key::Left {
109
+ -1
110
+ } else if key == gdk::Key::Right {
111
+ 1
112
+ } else if key == gdk::Key::Escape {
113
+ let _ = tx.send(AppMsg::Collapse);
114
+ return glib::Propagation::Stop;
115
+ } else if key == gdk::Key::space || key == gdk::Key::KP_Space {
116
+ return glib::Propagation::Stop;
117
+ } else {
118
+ return glib::Propagation::Proceed;
119
+ };
120
+ let manual = modifiers.contains(gdk::ModifierType::SHIFT_MASK);
121
+ let _ = tx.send(AppMsg::Navigate(card_id.clone(), direction, manual));
122
+ glib::Propagation::Stop
123
+ });
124
+ root.add_controller(keys);
125
+
126
+ Self {
127
+ root,
128
+ phase,
129
+ repo,
130
+ state,
131
+ tool,
132
+ elapsed,
133
+ detail,
134
+ prompt,
135
+ reply,
136
+ path,
137
+ activation,
138
+ inspector,
139
+ }
140
+ }
141
+
142
+ pub fn update(&self, card: &Card, focused: bool, duplicate_label: bool, now_ms: i64) {
143
+ self.repo.set_text(&display_repo(card, duplicate_label));
144
+ self.phase.set_text(text_or(&card.phase, "Idle"));
145
+ let real_event_at = if card.last_event_at > 0 {
146
+ card.last_event_at
147
+ } else {
148
+ card.updated_at
149
+ };
150
+ let stalled = card.stalled(now_ms);
151
+ self.state.set_text(if stalled {
152
+ "stalled"
153
+ } else {
154
+ state_label(&card.state)
155
+ });
156
+ self.tool.set_text(text_or(
157
+ if card.tool_name.is_empty() {
158
+ &card.tool_target
159
+ } else {
160
+ &card.tool_name
161
+ },
162
+ "monitoring",
163
+ ));
164
+ self.elapsed.set_text(&format!(
165
+ "{} · {}",
166
+ duration(now_ms, card.agent_started_at.unwrap_or(card.started_at)),
167
+ duration(now_ms, real_event_at)
168
+ ));
169
+ self.detail.set_text(text_or(&card.detail, "Ready"));
170
+ self.prompt
171
+ .set_text(text_or(&card.last_prompt_preview, "—"));
172
+ self.reply.set_text(text_or(&card.assistant_preview, "—"));
173
+ self.path.set_text(text_or(&card.cwd, "—"));
174
+
175
+ for class in [
176
+ "state-idle",
177
+ "state-thinking",
178
+ "state-tool",
179
+ "state-waiting",
180
+ "state-success",
181
+ "state-error",
182
+ "current",
183
+ "stalled",
184
+ ] {
185
+ self.root.remove_css_class(class);
186
+ }
187
+ self.root.add_css_class(&format!(
188
+ "state-{}",
189
+ if stalled {
190
+ "waiting"
191
+ } else {
192
+ state_class(&card.state)
193
+ }
194
+ ));
195
+ if focused {
196
+ self.root.add_css_class("current");
197
+ }
198
+ if stalled {
199
+ self.root.add_css_class("stalled");
200
+ }
201
+ self.root.set_tooltip_text(Some(&format!(
202
+ "Focus {}",
203
+ text_or(&card.repo_label, "Pi session")
204
+ )));
205
+ let accessible_label = format!(
206
+ "{}, {}, press Enter to focus its Ghostty window",
207
+ text_or(&card.repo_label, "Pi session"),
208
+ if stalled {
209
+ "stalled"
210
+ } else {
211
+ text_or(&card.phase, "idle")
212
+ }
213
+ );
214
+ self.root
215
+ .update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
216
+ self.root
217
+ .update_state(&[gtk::accessible::State::Selected(Some(focused))]);
218
+ }
219
+
220
+ pub fn set_expanded(&self, expanded: bool) {
221
+ self.root
222
+ .update_state(&[gtk::accessible::State::Expanded(Some(expanded))]);
223
+ self.root
224
+ .set_height_request(if expanded { 228 } else { 60 });
225
+ self.inspector.set_visible(expanded);
226
+ if expanded {
227
+ self.root.add_css_class("open");
228
+ } else {
229
+ self.root.remove_css_class("open");
230
+ }
231
+ }
232
+
233
+ pub fn set_activation(&self, ok: bool, message: &str) {
234
+ self.activation.set_text(message);
235
+ self.activation.set_visible(true);
236
+ self.activation.remove_css_class("success");
237
+ self.activation.remove_css_class("error");
238
+ self.activation
239
+ .add_css_class(if ok { "success" } else { "error" });
240
+ self.root
241
+ .update_property(&[gtk::accessible::Property::Description(message)]);
242
+ self.root
243
+ .announce(message, gtk::AccessibleAnnouncementPriority::Medium);
244
+ }
245
+ }
246
+
247
+ fn label(class: &str, align: gtk::Align) -> gtk::Label {
248
+ let value = gtk::Label::new(None);
249
+ value.add_css_class(class);
250
+ value.set_halign(align);
251
+ value.set_ellipsize(relm4::gtk::pango::EllipsizeMode::End);
252
+ value.set_xalign(if align == gtk::Align::End { 1.0 } else { 0.0 });
253
+ value
254
+ }
255
+
256
+ fn inspector_row(grid: &gtk::Grid, row: i32, key: &str) -> gtk::Label {
257
+ let key_label = label("inspector-key", gtk::Align::Start);
258
+ key_label.set_text(key);
259
+ key_label.set_width_chars(7);
260
+ let value = label("inspector-value", gtk::Align::Start);
261
+ value.set_wrap(true);
262
+ value.set_lines(2);
263
+ value.set_hexpand(true);
264
+ grid.attach(&key_label, 0, row, 1, 1);
265
+ grid.attach(&value, 1, row, 1, 1);
266
+ value
267
+ }
268
+
269
+ fn display_repo(card: &Card, duplicate: bool) -> String {
270
+ if duplicate && card.pid > 0 {
271
+ format!(
272
+ "{} · {:04}",
273
+ text_or(&card.repo_label, "Pi session"),
274
+ card.pid % 10_000
275
+ )
276
+ } else {
277
+ text_or(&card.repo_label, "Pi session").to_owned()
278
+ }
279
+ }
280
+
281
+ fn state_label(state: &str) -> &str {
282
+ match state {
283
+ "success" => "done",
284
+ "thinking" | "tool" | "waiting" | "error" | "idle" => state,
285
+ _ => "idle",
286
+ }
287
+ }
288
+
289
+ fn state_class(state: &str) -> &str {
290
+ match state {
291
+ "thinking" | "tool" | "waiting" | "success" | "error" => state,
292
+ _ => "idle",
293
+ }
294
+ }
295
+
296
+ fn text_or<'a>(value: &'a str, fallback: &'a str) -> &'a str {
297
+ if value.is_empty() { fallback } else { value }
298
+ }
299
+
300
+ fn duration(now_ms: i64, anchor_ms: i64) -> String {
301
+ let seconds = now_ms.saturating_sub(anchor_ms.max(1)) / 1000;
302
+ format!("{}:{:02}", seconds / 60, seconds % 60)
303
+ }
@@ -0,0 +1,31 @@
1
+ mod app;
2
+ mod card_view;
3
+ mod protocol;
4
+ mod runtime;
5
+
6
+ use app::{App, AppInit};
7
+ use relm4::RelmApp;
8
+
9
+ #[cfg(target_os = "linux")]
10
+ fn bind_lifetime_to_parent() {
11
+ unsafe {
12
+ libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
13
+ if libc::getppid() == 1 {
14
+ std::process::exit(1);
15
+ }
16
+ }
17
+ }
18
+
19
+ #[cfg(not(target_os = "linux"))]
20
+ fn bind_lifetime_to_parent() {}
21
+
22
+ fn main() {
23
+ bind_lifetime_to_parent();
24
+ let demo = std::env::var("PI_ACTIVITY_STRIP_PANEL_DEMO").as_deref() == Ok("1");
25
+ let click_through = std::env::var("PI_ACTIVITY_STRIP_CLICK_THROUGH").as_deref() == Ok("1");
26
+ let app = RelmApp::new("com.tryinget.pi-activity-strip.panel").visible_on_activate(false);
27
+ app.run::<App>(AppInit {
28
+ demo,
29
+ click_through,
30
+ });
31
+ }
@@ -0,0 +1,234 @@
1
+ use serde::Deserialize;
2
+ use serde_json::{Value, json};
3
+ use std::io::{self, Write};
4
+ use std::sync::{Mutex, OnceLock};
5
+
6
+ #[derive(Clone, Debug, Deserialize)]
7
+ #[serde(rename_all = "camelCase")]
8
+ pub struct Card {
9
+ #[serde(default)]
10
+ pub card_id: String,
11
+ #[serde(default)]
12
+ pub session_id: String,
13
+ #[serde(default)]
14
+ pub repo_label: String,
15
+ #[serde(default)]
16
+ pub phase: String,
17
+ #[serde(default)]
18
+ pub state: String,
19
+ #[serde(default)]
20
+ pub tool_name: String,
21
+ #[serde(default)]
22
+ pub tool_target: String,
23
+ #[serde(default)]
24
+ pub detail: String,
25
+ #[serde(default)]
26
+ pub last_prompt_preview: String,
27
+ #[serde(default)]
28
+ pub assistant_preview: String,
29
+ #[serde(default)]
30
+ pub cwd: String,
31
+ #[serde(default)]
32
+ pub started_at: i64,
33
+ #[serde(default)]
34
+ pub agent_started_at: Option<i64>,
35
+ #[serde(default)]
36
+ pub last_event_at: i64,
37
+ #[serde(default)]
38
+ pub updated_at: i64,
39
+ #[serde(default)]
40
+ pub agent_active: bool,
41
+ #[serde(default, rename = "processId")]
42
+ pub pid: i64,
43
+ }
44
+
45
+ impl Card {
46
+ pub fn id(&self) -> &str {
47
+ if self.card_id.is_empty() {
48
+ &self.session_id
49
+ } else {
50
+ &self.card_id
51
+ }
52
+ }
53
+
54
+ pub fn active(&self) -> bool {
55
+ self.agent_active || matches!(self.state.as_str(), "thinking" | "tool" | "waiting")
56
+ }
57
+
58
+ pub fn monitoring(&self) -> bool {
59
+ self.state == "success" && self.tool_name.is_empty() && self.tool_target.is_empty()
60
+ }
61
+
62
+ pub fn stalled(&self, now_ms: i64) -> bool {
63
+ let real_event_at = if self.last_event_at > 0 {
64
+ self.last_event_at
65
+ } else {
66
+ self.updated_at
67
+ };
68
+ self.agent_active
69
+ && matches!(self.state.as_str(), "thinking" | "tool" | "waiting")
70
+ && real_event_at > 0
71
+ && now_ms.saturating_sub(real_event_at) > 900_000
72
+ }
73
+ }
74
+
75
+ #[derive(Clone, Debug, Deserialize)]
76
+ #[serde(rename_all = "camelCase")]
77
+ pub struct ViewMessage {
78
+ #[serde(default = "protocol_version")]
79
+ pub protocol: u8,
80
+ #[serde(rename = "type")]
81
+ pub message_type: String,
82
+ pub revision: u64,
83
+ #[serde(default)]
84
+ pub visible: bool,
85
+ #[serde(default)]
86
+ pub focused_card_id: Option<String>,
87
+ #[serde(default)]
88
+ pub sessions: Vec<Card>,
89
+ }
90
+
91
+ fn protocol_version() -> u8 {
92
+ 1
93
+ }
94
+
95
+ static OUTPUT: OnceLock<Mutex<io::Stdout>> = OnceLock::new();
96
+
97
+ pub fn emit(event: Value) {
98
+ let stdout = OUTPUT.get_or_init(|| Mutex::new(io::stdout()));
99
+ if let Ok(mut output) = stdout.lock() {
100
+ let _ = serde_json::to_writer(&mut *output, &event);
101
+ let _ = output.write_all(b"\n");
102
+ let _ = output.flush();
103
+ }
104
+ }
105
+
106
+ pub fn emit_ready() {
107
+ emit(json!({
108
+ "protocol": 1,
109
+ "type": "ready",
110
+ "pid": std::process::id(),
111
+ "namespace": "pi-activity-strip"
112
+ }));
113
+ }
114
+
115
+ pub fn emit_error(message: impl Into<String>) {
116
+ emit(json!({ "protocol": 1, "type": "error", "message": message.into() }));
117
+ }
118
+
119
+ pub fn demo_view() -> ViewMessage {
120
+ let now = std::time::SystemTime::now()
121
+ .duration_since(std::time::UNIX_EPOCH)
122
+ .unwrap_or_default()
123
+ .as_millis() as i64;
124
+ ViewMessage {
125
+ protocol: 1,
126
+ message_type: "view".into(),
127
+ revision: 1,
128
+ visible: true,
129
+ focused_card_id: Some("demo-tool".into()),
130
+ sessions: vec![
131
+ Card {
132
+ card_id: "demo-monitor".into(),
133
+ session_id: "demo-monitor".into(),
134
+ repo_label: "activity-strip".into(),
135
+ phase: "Monitoring".into(),
136
+ state: "success".into(),
137
+ tool_name: String::new(),
138
+ detail: "Native layer-shell panel online".into(),
139
+ cwd: "/home/tryinget/ai-society/softwareco/owned/pi-extensions".into(),
140
+ started_at: now - 135_000,
141
+ last_event_at: now - 4_000,
142
+ updated_at: now,
143
+ ..empty_card()
144
+ },
145
+ Card {
146
+ card_id: "demo-tool".into(),
147
+ session_id: "demo-tool".into(),
148
+ repo_label: "native-dogfood".into(),
149
+ phase: "Rendering GTK cards".into(),
150
+ state: "tool".into(),
151
+ tool_name: "gtk4-layer-shell".into(),
152
+ tool_target: "top surface".into(),
153
+ detail: "Exclusive zone remains 84px while details expand".into(),
154
+ last_prompt_preview: "Replace Electron without losing the ribbon UX".into(),
155
+ assistant_preview: "Native panel fixture is active.".into(),
156
+ cwd: "/home/tryinget/ai-society/softwareco/owned/pi-extensions/packages/pi-activity-strip".into(),
157
+ started_at: now - 42_000,
158
+ agent_started_at: Some(now - 42_000),
159
+ last_event_at: now - 1_000,
160
+ updated_at: now,
161
+ agent_active: true,
162
+ ..empty_card()
163
+ },
164
+ ],
165
+ }
166
+ }
167
+
168
+ fn empty_card() -> Card {
169
+ Card {
170
+ card_id: String::new(),
171
+ session_id: String::new(),
172
+ repo_label: String::new(),
173
+ phase: String::new(),
174
+ state: String::new(),
175
+ tool_name: String::new(),
176
+ tool_target: String::new(),
177
+ detail: String::new(),
178
+ last_prompt_preview: String::new(),
179
+ assistant_preview: String::new(),
180
+ cwd: String::new(),
181
+ started_at: 0,
182
+ agent_started_at: None,
183
+ last_event_at: 0,
184
+ updated_at: 0,
185
+ agent_active: false,
186
+ pid: 0,
187
+ }
188
+ }
189
+
190
+ #[cfg(test)]
191
+ mod tests {
192
+ use super::*;
193
+
194
+ #[test]
195
+ fn view_messages_accept_current_node_projection_shape() {
196
+ let view: ViewMessage = serde_json::from_str(
197
+ r#"{"protocol":1,"type":"view","revision":7,"visible":true,"focusedCardId":"card-a","sessions":[{"cardId":"card-a","sessionId":"session-a","state":"tool","agentActive":true,"agentStartedAt":null,"processId":4242}]}"#,
198
+ )
199
+ .expect("view should parse");
200
+ assert_eq!(view.revision, 7);
201
+ assert_eq!(view.sessions[0].id(), "card-a");
202
+ assert!(view.sessions[0].active());
203
+ assert_eq!(view.sessions[0].agent_started_at, None);
204
+ assert_eq!(view.sessions[0].pid, 4242);
205
+ }
206
+
207
+ #[test]
208
+ fn monitoring_cards_are_distinct_from_active_work() {
209
+ let mut card = empty_card();
210
+ card.state = "success".into();
211
+ assert!(card.monitoring());
212
+ card.tool_target = "old target".into();
213
+ assert!(!card.monitoring());
214
+ card.tool_target.clear();
215
+ assert!(!card.active());
216
+ }
217
+
218
+ #[test]
219
+ fn stalled_requires_agent_active_and_uses_real_event_time() {
220
+ let mut card = empty_card();
221
+ card.state = "tool".into();
222
+ card.last_event_at = 1_000;
223
+ card.updated_at = 1_000_000;
224
+ assert!(!card.stalled(1_000_001));
225
+ card.agent_active = true;
226
+ assert!(card.stalled(1_000_001));
227
+ card.state = "success".into();
228
+ assert!(!card.stalled(1_000_001));
229
+ card.state = "tool".into();
230
+ card.last_event_at = 0;
231
+ card.updated_at = 0;
232
+ assert!(!card.stalled(1_000_001));
233
+ }
234
+ }
@@ -0,0 +1,110 @@
1
+ use crate::app::AppMsg;
2
+ use crate::protocol::{Card, ViewMessage};
3
+ use relm4::gtk;
4
+ use std::collections::{HashMap, HashSet};
5
+ use std::io::{BufRead, BufReader};
6
+ use std::time::{SystemTime, UNIX_EPOCH};
7
+
8
+ #[allow(deprecated)]
9
+ 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
+ }
19
+ }
20
+
21
+ pub fn start_input_reader(sender: relm4::Sender<AppMsg>) {
22
+ std::thread::spawn(move || {
23
+ for line in BufReader::new(std::io::stdin()).lines() {
24
+ match line {
25
+ Ok(line) if !line.trim().is_empty() => consume_line(&sender, &line),
26
+ Ok(_) => {}
27
+ Err(error) => {
28
+ let _ = sender.send(AppMsg::InputError(format!("panel input failed: {error}")));
29
+ break;
30
+ }
31
+ }
32
+ }
33
+ let _ = sender.send(AppMsg::ParentGone);
34
+ });
35
+ }
36
+
37
+ fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
38
+ let value = match serde_json::from_str::<serde_json::Value>(line) {
39
+ Ok(value) => value,
40
+ Err(error) => {
41
+ let _ = sender.send(AppMsg::InputError(format!(
42
+ "invalid panel message: {error}"
43
+ )));
44
+ return;
45
+ }
46
+ };
47
+ match value.get("type").and_then(|kind| kind.as_str()) {
48
+ Some("view") => match serde_json::from_value::<ViewMessage>(value) {
49
+ Ok(view) => {
50
+ let _ = sender.send(AppMsg::View(view));
51
+ }
52
+ Err(error) => {
53
+ let _ = sender.send(AppMsg::InputError(format!("invalid view message: {error}")));
54
+ }
55
+ },
56
+ Some("focus-strip") => {
57
+ let _ = sender.send(AppMsg::FocusStrip);
58
+ }
59
+ Some("collapse") => {
60
+ let _ = sender.send(AppMsg::Collapse);
61
+ }
62
+ Some("activation-result") => {
63
+ let card_id = value
64
+ .get("cardId")
65
+ .and_then(|item| item.as_str())
66
+ .unwrap_or_default()
67
+ .to_owned();
68
+ let ok = value
69
+ .get("ok")
70
+ .and_then(|item| item.as_bool())
71
+ .unwrap_or(false);
72
+ let message = value
73
+ .get("message")
74
+ .and_then(|item| item.as_str())
75
+ .unwrap_or(if ok {
76
+ "Focused Ghostty window."
77
+ } else {
78
+ "Focus failed; nothing moved."
79
+ })
80
+ .to_owned();
81
+ let _ = sender.send(AppMsg::ActivationResult(card_id, ok, message));
82
+ }
83
+ Some(kind) => {
84
+ let _ = sender.send(AppMsg::InputError(format!(
85
+ "unsupported panel message: {kind}"
86
+ )));
87
+ }
88
+ None => {
89
+ let _ = sender.send(AppMsg::InputError("panel message type is required".into()));
90
+ }
91
+ }
92
+ }
93
+
94
+ pub fn duplicate_labels<'a>(cards: impl Iterator<Item = &'a Card>) -> HashSet<String> {
95
+ let mut counts = HashMap::new();
96
+ for card in cards {
97
+ *counts.entry(card.repo_label.clone()).or_insert(0usize) += 1;
98
+ }
99
+ counts
100
+ .into_iter()
101
+ .filter_map(|(label, count)| (count > 1).then_some(label))
102
+ .collect()
103
+ }
104
+
105
+ pub fn now_ms() -> i64 {
106
+ SystemTime::now()
107
+ .duration_since(UNIX_EPOCH)
108
+ .unwrap_or_default()
109
+ .as_millis() as i64
110
+ }