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 #[test]
327 fn wire_examples_round_trip() {
328 let examples: Vec<(&str, &str)> = vec![
329 (
331 "snapshot",
332 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}"#,
333 ),
334 (
336 "entries",
337 r#"{"seq":42,"type":"entries","pane":"main","from":3,"entries":[{"entry":{"kind":"assistant","data":"Done — it was an off-by-one.","time":1753500100}}]}"#,
338 ),
339 (
341 "tool_entry",
342 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"}}}"#,
343 ),
344 (
346 "sub_pane",
347 r#"{"type":"submit","pane":{"sub":7},"text":"fix the bug"}"#,
348 ),
349 (
351 "command",
352 r#"{"type":"command","pane":{"sub":7},"line":"/status"}"#,
353 ),
354 ("resume", r#"{"type":"resume","seq":41}"#),
356 ];
357
358 for (name, json) in examples {
359 let v: serde_json::Value =
361 serde_json::from_str(json).unwrap_or_else(|_| panic!("{name}: parse failed"));
362 let round = serde_json::to_string(&v).unwrap();
363 let v2: serde_json::Value = serde_json::from_str(&round).unwrap();
364 assert_eq!(v, v2, "{name}: round-trip mismatch");
365 }
366 }
367
368 #[test]
370 fn pane_id_wire_shape() {
371 let main: WirePaneId = serde_json::from_str(r#""main""#).unwrap();
373 assert_eq!(main, WirePaneId::Main);
374 let back = serde_json::to_string(&WirePaneId::Main).unwrap();
375 assert_eq!(back, r#""main""#);
376
377 let sub7: WirePaneId = serde_json::from_str(r#"{"sub":7}"#).unwrap();
379 assert_eq!(sub7, WirePaneId::Sub(7));
380 let back = serde_json::to_string(&WirePaneId::Sub(7)).unwrap();
381 assert_eq!(back, r#"{"sub":7}"#);
382 }
383
384 #[test]
387 fn server_frame_flattens_seq() {
388 let frame = ServerFrame {
389 seq: 1,
390 msg: ServerMsg::Notice {
391 text: "hello".into(),
392 },
393 };
394 let json = serde_json::to_string(&frame).unwrap();
395 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
396 assert_eq!(v["seq"], serde_json::json!(1));
397 assert_eq!(v["type"], serde_json::json!("notice"));
398 assert_eq!(v["text"], serde_json::json!("hello"));
399
400 let parsed: ServerFrame = serde_json::from_str(&json).unwrap();
402 assert_eq!(parsed, frame);
403 }
404
405 #[test]
407 fn client_msg_round_trip() {
408 let cases = vec![
409 ClientMsg::Submit {
410 pane: WirePaneId::Main,
411 text: "hi".into(),
412 },
413 ClientMsg::Command {
414 pane: WirePaneId::Sub(9),
415 line: "/status".into(),
416 },
417 ClientMsg::Cancel {
418 pane: WirePaneId::Main,
419 },
420 ClientMsg::SwitchPane {
421 pane: WirePaneId::Sub(3),
422 },
423 ClientMsg::Resume { seq: 42 },
424 ];
425 for msg in cases {
426 let json = serde_json::to_string(&msg).unwrap();
427 let back: ClientMsg = serde_json::from_str(&json).unwrap();
428 assert_eq!(msg, back, "round-trip: {json}");
429 }
430 }
431}