@tryinget/pi-activity-strip 0.4.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.
- package/README.md +96 -152
- package/bin/pi-activity-strip.mjs +31 -62
- package/native/bin/linux-x64-gnu/artifact.json +21 -0
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/Cargo.lock +1045 -0
- package/native/panel/Cargo.toml +23 -0
- package/native/panel/src/app.rs +606 -0
- package/native/panel/src/card_view.rs +303 -0
- package/native/panel/src/main.rs +31 -0
- package/native/panel/src/protocol.rs +234 -0
- package/native/panel/src/runtime.rs +110 -0
- package/native/panel/src/style.css +176 -0
- package/package.json +13 -1
- package/src/broker/server.mjs +43 -7
- package/src/broker/session-store.mjs +41 -13
- package/src/client/broker-client.mjs +10 -3
- package/src/client/session-telemetry.mjs +54 -14
- package/src/common/activity-order.mjs +15 -9
- package/src/common/alignment-controller.mjs +24 -42
- package/src/common/card-display.mjs +5 -0
- package/src/common/compatibility.mjs +15 -13
- package/src/common/constants.mjs +10 -28
- package/src/common/contracts.d.ts +42 -4
- package/src/common/contracts.ts +42 -4
- package/src/common/niri-focus.mjs +79 -79
- package/src/common/protocol.mjs +48 -16
- package/src/common/session-cards.mjs +102 -0
- package/src/common/status-report.mjs +2 -1
- package/src/common/telemetry.mjs +8 -1
- package/src/common/terminal-identity.mjs +149 -0
- package/src/native/main.mjs +393 -0
- package/src/native/panel-projection.mjs +99 -0
- package/src/{electron/niri-workspace-events.mjs → native/workspace-events.mjs} +4 -1
- package/src/common/electron.mjs +0 -57
- package/src/electron/main.mjs +0 -499
- package/src/electron/niri-native-window-runtime.mjs +0 -178
- package/src/electron/preload.cjs +0 -50
- package/src/electron/renderer-visibility-runtime.mjs +0 -107
- package/src/electron/workspace-view-runtime.mjs +0 -360
- package/src/ui/strip-html.mjs +0 -500
|
@@ -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: >k::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
|
+
}
|