1#[cfg(test)]
10extern crate hrdr_test_support;
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum WirePaneId {
21 Main,
22 Sub(u64),
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct WireEntry {
30 #[serde(flatten)]
31 pub kind: WireEntryKind,
32 pub time: i64,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
41pub enum WireEntryKind {
42 Header,
43 User(String),
44 Assistant(String),
45 Reasoning {
46 text: String,
47 #[serde(default)]
48 took_ms: Option<u64>,
49 },
50 Tool {
51 id: String,
52 name: String,
53 args: String,
54 result: String,
55 ok: bool,
56 done: bool,
57 },
58 System(String),
59 Notice(String),
60 Stats(String),
61 Diff(String),
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct WireEntryView {
69 pub entry: WireEntry,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub tool: Option<WireToolDisplay>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub diff_lines: Option<Vec<WireDiffLine>>,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct WireToolDisplay {
81 pub headline: String,
82 pub body: WireToolBody,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum WireToolBody {
88 Shell { command: String },
89 Code { lang: String, content: String },
90 Diff {},
91 Read {},
92 Details { rows: Vec<(String, String)> },
93 Text {},
94}
95
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct WireDiffLine {
98 pub kind: WireDiffLineKind,
99 pub text: String,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum WireDiffLineKind {
105 Hunk,
106 Add,
107 Remove,
108 Meta,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct WirePane {
116 pub id: WirePaneId,
117 pub title: String,
118 pub status: WirePaneStatus,
120 pub model: String,
121 pub provider: String,
122 pub effort: Option<String>,
123 pub pending: Vec<String>,
125 pub compacting: bool,
126 pub turn: WireTurn,
127 pub todos: Vec<WireTodo>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum WirePaneStatus {
133 Running,
134 Idle,
135 Done,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct WireTodo {
140 pub content: String,
141 pub status: String,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct WireTurn {
149 pub running: bool,
150 pub inferring: bool,
151 pub elapsed_ms: u64,
153 pub ttft_secs: Option<f64>,
155 pub tok_per_sec: f64,
157 pub out_tokens: usize,
158 pub started_unix: Option<i64>,
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166pub struct WireStatus {
167 pub left: Vec<WireStatusSeg>,
168 pub right: Vec<WireStatusSeg>,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172pub struct WireStatusSeg {
173 pub priority: u8,
174 pub runs: Vec<WireStatusRun>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub gauge: Option<WireGauge>,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct WireStatusRun {
181 pub text: String,
182 pub role: WireStatusRole,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(tag = "role", rename_all = "snake_case")]
187pub enum WireStatusRole {
188 Dir {},
189 Branch {},
190 TokensIn {},
191 TokensOut {},
192 CtxFill { level: WireCtxLevel },
193 CtxRest {},
194 CtxPlain {},
195 Provider {},
196 Model {},
197 Effort {},
198 Ttft {},
199 Session {},
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum WireCtxLevel {
205 Ok,
206 Warn,
207 Critical,
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct WireGauge {
212 pub frac: f64,
213 pub level: WireCtxLevel,
214 pub label: String,
215}
216
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221pub struct ServerFrame {
222 pub seq: u64,
223 #[serde(flatten)]
224 pub msg: ServerMsg,
225}
226
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228#[serde(tag = "type", rename_all = "snake_case")]
229pub enum ServerMsg {
230 Snapshot {
232 session_id: Option<String>,
233 session_name: String,
234 cwd: String,
235 panes: Vec<WirePane>,
236 active: WirePaneId,
237 status: WireStatus,
238 transcripts: Vec<PaneTranscript>,
239 show_thinking: bool,
240 },
241 Entries {
244 pane: WirePaneId,
245 from: usize,
246 entries: Vec<WireEntryView>,
247 },
248 Panes {
250 panes: Vec<WirePane>,
251 active: WirePaneId,
252 },
253 Status {
254 status: WireStatus,
255 },
256 Notice {
258 text: String,
259 },
260 SetInput {
263 mode: InputSetMode,
264 text: String,
265 },
266 Resumed {},
268 Error {
270 message: String,
271 },
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum InputSetMode {
277 Replace,
278 Prepend,
279 InsertAtCursor,
280}
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct PaneTranscript {
284 pub pane: WirePaneId,
285 pub entries: Vec<WireEntryView>,
286}
287
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291#[serde(tag = "type", rename_all = "snake_case")]
292pub enum ClientMsg {
293 Submit {
296 pane: WirePaneId,
297 text: String,
298 },
299 Command {
301 pane: WirePaneId,
302 line: String,
303 },
304 Cancel {
306 pane: WirePaneId,
307 },
308 SwitchPane {
309 pane: WirePaneId,
310 },
311 Resume {
314 seq: u64,
315 },
316}
317
318#[cfg(test)]
321mod tests {
322 use super::*;
323
324 enum Wire {
328 Frame,
330 Client,
332 EntryView,
334 }
335
336 fn assert_round_trips<T>(name: &str, json: &str)
346 where
347 T: Serialize + serde::de::DeserializeOwned,
348 {
349 let before: serde_json::Value =
350 serde_json::from_str(json).unwrap_or_else(|e| panic!("{name}: not valid JSON: {e}"));
351 let typed: T = serde_json::from_str(json).unwrap_or_else(|e| {
352 panic!(
353 "{name}: does not parse as {}: {e}",
354 std::any::type_name::<T>()
355 )
356 });
357 let after = serde_json::to_value(&typed).unwrap();
358 assert_eq!(before, after, "{name}: round-trip through the wire type");
359 }
360
361 #[test]
366 fn wire_examples_round_trip() {
367 let examples: Vec<(Wire, &str, &str)> = vec![
368 (
370 Wire::Frame,
371 "snapshot",
372 r#"{"seq":1,"type":"snapshot","session_id":"fix-parser","session_name":"fix parser","cwd":"/home/me/proj","panes":[{"id":"main","title":"main","status":"idle","model":"gpt-5.5","provider":"openai","effort":null,"pending":[],"compacting":false,"turn":{"running":false,"inferring":false,"elapsed_ms":0,"ttft_secs":null,"tok_per_sec":0.0,"out_tokens":0,"started_unix":null},"todos":[]}],"active":"main","status":{"left":[],"right":[]},"transcripts":[{"pane":"main","entries":[{"entry":{"kind":"user","data":"hi","time":1753500000}}]}],"show_thinking":true}"#,
373 ),
374 (
376 Wire::Frame,
377 "entries",
378 r#"{"seq":42,"type":"entries","pane":"main","from":3,"entries":[{"entry":{"kind":"assistant","data":"Done — it was an off-by-one.","time":1753500100}}]}"#,
379 ),
380 (
382 Wire::Frame,
383 "notice",
384 r#"{"seq":43,"type":"notice","text":"background task finished"}"#,
385 ),
386 (
387 Wire::Frame,
388 "set_input",
389 r#"{"seq":44,"type":"set_input","mode":"insert_at_cursor","text":"@src/lib.rs"}"#,
390 ),
391 (Wire::Frame, "resumed", r#"{"seq":45,"type":"resumed"}"#),
393 (
394 Wire::Frame,
395 "error",
396 r#"{"seq":0,"type":"error","message":"bad token"}"#,
397 ),
398 (
400 Wire::EntryView,
401 "tool_entry",
402 r#"{"entry":{"kind":"tool","data":{"id":"c1","name":"shell","args":"{\"command\":\"ls\"}","result":"src\n","ok":true,"done":true},"time":1753500050},"tool":{"headline":"","body":{"type":"shell","command":"ls"}}}"#,
403 ),
404 (
406 Wire::Client,
407 "sub_pane",
408 r#"{"type":"submit","pane":{"sub":7},"text":"fix the bug"}"#,
409 ),
410 (
412 Wire::Client,
413 "command",
414 r#"{"type":"command","pane":{"sub":7},"line":"/status"}"#,
415 ),
416 (
418 Wire::Client,
419 "cancel",
420 r#"{"type":"cancel","pane":{"sub":7}}"#,
421 ),
422 (
423 Wire::Client,
424 "switch_pane",
425 r#"{"type":"switch_pane","pane":"main"}"#,
426 ),
427 (Wire::Client, "resume", r#"{"type":"resume","seq":41}"#),
429 ];
430
431 for (wire, name, json) in examples {
432 match wire {
433 Wire::Frame => assert_round_trips::<ServerFrame>(name, json),
434 Wire::Client => assert_round_trips::<ClientMsg>(name, json),
435 Wire::EntryView => assert_round_trips::<WireEntryView>(name, json),
436 }
437 }
438 }
439
440 #[test]
442 fn pane_id_wire_shape() {
443 let main: WirePaneId = serde_json::from_str(r#""main""#).unwrap();
445 assert_eq!(main, WirePaneId::Main);
446 let back = serde_json::to_string(&WirePaneId::Main).unwrap();
447 assert_eq!(back, r#""main""#);
448
449 let sub7: WirePaneId = serde_json::from_str(r#"{"sub":7}"#).unwrap();
451 assert_eq!(sub7, WirePaneId::Sub(7));
452 let back = serde_json::to_string(&WirePaneId::Sub(7)).unwrap();
453 assert_eq!(back, r#"{"sub":7}"#);
454 }
455
456 #[test]
459 fn server_frame_flattens_seq() {
460 let frame = ServerFrame {
461 seq: 1,
462 msg: ServerMsg::Notice {
463 text: "hello".into(),
464 },
465 };
466 let json = serde_json::to_string(&frame).unwrap();
467 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
468 assert_eq!(v["seq"], serde_json::json!(1));
469 assert_eq!(v["type"], serde_json::json!("notice"));
470 assert_eq!(v["text"], serde_json::json!("hello"));
471
472 let parsed: ServerFrame = serde_json::from_str(&json).unwrap();
474 assert_eq!(parsed, frame);
475 }
476
477 #[test]
479 fn client_msg_round_trip() {
480 let cases = vec![
481 ClientMsg::Submit {
482 pane: WirePaneId::Main,
483 text: "hi".into(),
484 },
485 ClientMsg::Command {
486 pane: WirePaneId::Sub(9),
487 line: "/status".into(),
488 },
489 ClientMsg::Cancel {
490 pane: WirePaneId::Main,
491 },
492 ClientMsg::SwitchPane {
493 pane: WirePaneId::Sub(3),
494 },
495 ClientMsg::Resume { seq: 42 },
496 ];
497 for msg in cases {
498 let json = serde_json::to_string(&msg).unwrap();
499 let back: ClientMsg = serde_json::from_str(&json).unwrap();
500 assert_eq!(msg, back, "round-trip: {json}");
501 }
502 }
503}