1#![allow(
5 missing_docs,
6 reason = "fields mirror pi's TypeScript types; contracts are noted where they differ from the name"
7)]
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::message::{ImageContent, ThinkingLevel};
13use crate::session::FileEntry;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub enum StreamingBehavior {
19 Steer,
21 FollowUp,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum PromptDisposition {
29 Started,
31 Queued,
33 Handled,
35}
36
37#[derive(Clone, Debug, PartialEq, Deserialize)]
39#[serde(
40 tag = "type",
41 rename_all = "snake_case",
42 rename_all_fields = "camelCase"
43)]
44pub enum RpcCommand {
45 Prompt {
46 message: String,
47 #[serde(default)]
48 images: Vec<ImageContent>,
49 #[serde(default)]
50 streaming_behavior: Option<StreamingBehavior>,
51 },
52 Steer {
53 message: String,
54 #[serde(default)]
55 images: Vec<ImageContent>,
56 },
57 FollowUp {
58 message: String,
59 #[serde(default)]
60 images: Vec<ImageContent>,
61 },
62 Abort,
63 ClearQueue,
64 NewSession {
65 #[serde(default)]
66 parent_session: Option<String>,
67 },
68 GetState,
69 SetModel {
70 provider: String,
71 model_id: String,
72 },
73 CycleModel,
74 GetAvailableModels,
75 SetThinkingLevel {
78 #[serde(default)]
79 level: Value,
80 },
81 CycleThinkingLevel,
82 GetAvailableThinkingLevels,
83 SetSteeringMode {
86 #[serde(default, deserialize_with = "crate::present")]
87 mode: Option<Value>,
88 },
89 SetFollowUpMode {
91 #[serde(default, deserialize_with = "crate::present")]
92 mode: Option<Value>,
93 },
94 Compact {
95 #[serde(default)]
96 custom_instructions: Option<String>,
97 },
98 SetAutoCompaction {
101 #[serde(default, deserialize_with = "crate::present")]
102 enabled: Option<Value>,
103 },
104 SetAutoRetry {
106 #[serde(default, deserialize_with = "crate::present")]
107 enabled: Option<Value>,
108 },
109 AbortRetry,
110 Bash {
111 command: String,
112 #[serde(default)]
113 exclude_from_context: Option<bool>,
114 },
115 AbortBash,
116 GetSessionStats,
117 ExportHtml {
118 #[serde(default)]
119 output_path: Option<String>,
120 },
121 SwitchSession {
122 session_path: String,
123 },
124 Fork {
125 entry_id: String,
126 },
127 Clone,
128 GetForkMessages,
129 GetEntries {
130 #[serde(default)]
131 since: Option<String>,
132 },
133 GetTree,
134 GetLastAssistantText,
135 SetSessionName {
136 name: String,
137 },
138 GetMessages,
139 GetCommands,
140 #[serde(other)]
142 Unknown,
143}
144
145pub fn response_line(
149 id: Option<&Value>,
150 command: Option<&str>,
151 outcome: Result<Option<&str>, &str>,
152) -> String {
153 let mut line = String::from("{");
154 if let Some(id) = id
155 && let Ok(id) = crate::json::to_string(id)
156 {
157 line.push_str("\"id\":");
158 line.push_str(&id);
159 line.push(',');
160 }
161 line.push_str("\"type\":\"response\"");
162 if let Some(command) = command {
163 line.push_str(",\"command\":");
164 line.push_str(&crate::json::to_string(command).unwrap_or_else(|_| "\"\"".into()));
165 }
166 match outcome {
167 Ok(data) => {
168 line.push_str(",\"success\":true");
169 if let Some(data) = data {
170 line.push_str(",\"data\":");
171 line.push_str(data);
172 }
173 }
174 Err(error) => {
175 line.push_str(",\"success\":false,\"error\":");
176 line.push_str(&crate::json::to_string(error).unwrap_or_else(|_| "\"\"".into()));
177 }
178 }
179 line.push('}');
180 line
181}
182
183#[derive(Clone, Debug, PartialEq, Serialize)]
185#[serde(rename_all = "camelCase")]
186pub struct SessionState {
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub model: Option<Value>,
189 pub thinking_level: ThinkingLevel,
190 pub is_streaming: bool,
191 pub is_compacting: bool,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub steering_mode: Option<Value>,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 pub follow_up_mode: Option<Value>,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub session_file: Option<String>,
199 pub session_id: String,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub session_name: Option<String>,
202 pub auto_compaction_enabled: Value,
204 pub message_count: usize,
205 pub pending_message_count: usize,
206}
207
208#[derive(Clone, Debug, Default, PartialEq, Serialize)]
210#[serde(rename_all = "camelCase")]
211pub struct TokenTotals {
212 pub input: u64,
213 pub output: u64,
214 pub cache_read: u64,
215 pub cache_write: u64,
216 pub total: u64,
217}
218
219#[derive(Clone, Debug, PartialEq, Serialize)]
222#[serde(rename_all = "camelCase")]
223pub struct ContextUsage {
224 pub tokens: Option<u64>,
225 pub context_window: u64,
226 pub percent: Option<f64>,
227}
228
229#[derive(Clone, Debug, PartialEq, Serialize)]
231#[serde(rename_all = "camelCase")]
232pub struct SessionStats {
233 #[serde(skip_serializing_if = "Option::is_none")]
234 pub session_file: Option<String>,
235 pub session_id: String,
236 pub user_messages: usize,
237 pub assistant_messages: usize,
238 pub tool_calls: usize,
239 pub tool_results: usize,
240 pub total_messages: usize,
241 pub tokens: TokenTotals,
242 pub cost: f64,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub context_usage: Option<ContextUsage>,
245}
246
247#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "camelCase")]
250pub struct BashResult {
251 pub output: String,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub exit_code: Option<i32>,
256 pub cancelled: bool,
258 pub truncated: bool,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub full_output_path: Option<String>,
263}
264
265#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
267#[serde(rename_all = "camelCase")]
268pub struct ForkMessage {
269 pub entry_id: String,
270 pub text: String,
271}
272
273#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct SourceInfo {
279 pub path: String,
280 pub source: String,
281 pub scope: String,
282 pub origin: String,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub base_dir: Option<String>,
285}
286
287#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
289#[serde(rename_all = "lowercase")]
290pub enum CommandSource {
291 Extension,
292 Prompt,
293 Skill,
294}
295
296#[derive(Clone, Debug, PartialEq, Serialize)]
298#[serde(rename_all = "camelCase")]
299pub struct SlashCommand {
300 pub name: String,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub description: Option<String>,
303 pub source: CommandSource,
304 pub source_info: SourceInfo,
305}
306
307pub struct TreeNode<'a> {
309 pub entry: &'a FileEntry,
310 pub children: Vec<usize>,
312 pub label: Option<&'a str>,
313 pub label_timestamp: Option<&'a str>,
314}
315
316pub fn tree_json(nodes: &[TreeNode<'_>], roots: &[usize]) -> serde_json::Result<String> {
319 enum Step {
320 Open(usize),
321 Close(usize),
322 Comma,
323 }
324 let mut out = String::from("[");
325 let mut stack: Vec<Step> = Vec::new();
326 let push_list = |stack: &mut Vec<Step>, list: &[usize]| {
327 for (index, &node) in list.iter().enumerate().rev() {
328 stack.push(Step::Open(node));
329 if index > 0 {
330 stack.push(Step::Comma);
331 }
332 }
333 };
334 push_list(&mut stack, roots);
335 while let Some(step) = stack.pop() {
336 match step {
337 Step::Comma => out.push(','),
338 Step::Open(index) => {
339 let node = &nodes[index];
340 out.push_str("{\"entry\":");
341 out.push_str(&crate::json::to_string(node.entry)?);
342 out.push_str(",\"children\":[");
343 stack.push(Step::Close(index));
344 push_list(&mut stack, &node.children);
345 }
346 Step::Close(index) => {
347 out.push(']');
348 let node = &nodes[index];
349 if let Some(label) = node.label {
350 out.push_str(",\"label\":");
351 out.push_str(&crate::json::to_string(label)?);
352 }
353 if let Some(timestamp) = node.label_timestamp {
354 out.push_str(",\"labelTimestamp\":");
355 out.push_str(&crate::json::to_string(timestamp)?);
356 }
357 out.push('}');
358 }
359 }
360 }
361 out.push(']');
362 Ok(out)
363}
364
365#[derive(Clone, Debug, PartialEq, Deserialize)]
367pub struct ExtensionUiResponse {
368 pub id: String,
369 #[serde(default)]
370 pub value: Option<String>,
371 #[serde(default)]
372 pub confirmed: Option<bool>,
373 #[serde(default)]
374 pub cancelled: Option<bool>,
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn commands_parse_like_pi() {
383 let prompt: RpcCommand = serde_json::from_str(
384 r#"{"type":"prompt","message":"hi","streamingBehavior":"followUp"}"#,
385 )
386 .unwrap();
387 assert_eq!(
388 prompt,
389 RpcCommand::Prompt {
390 message: "hi".into(),
391 images: Vec::new(),
392 streaming_behavior: Some(StreamingBehavior::FollowUp),
393 }
394 );
395 let mode: RpcCommand =
396 serde_json::from_str(r#"{"type":"set_steering_mode","mode":"one-at-a-time"}"#).unwrap();
397 assert_eq!(
398 mode,
399 RpcCommand::SetSteeringMode {
400 mode: Some("one-at-a-time".into())
401 }
402 );
403 let unset: RpcCommand = serde_json::from_str(r#"{"type":"set_auto_retry"}"#).unwrap();
404 assert_eq!(unset, RpcCommand::SetAutoRetry { enabled: None });
405 let unknown: RpcCommand = serde_json::from_str(r#"{"type":"dance"}"#).unwrap();
406 assert_eq!(unknown, RpcCommand::Unknown);
407 }
408
409 #[test]
410 fn responses_match_pi() {
411 let id = Value::from("7");
412 assert_eq!(
413 response_line(Some(&id), Some("abort"), Ok(None)),
414 r#"{"id":"7","type":"response","command":"abort","success":true}"#
415 );
416 assert_eq!(
417 response_line(None, Some("cycle_model"), Ok(Some("null"))),
418 r#"{"type":"response","command":"cycle_model","success":true,"data":null}"#
419 );
420 assert_eq!(
421 response_line(Some(&Value::from(3)), Some("x"), Err("Unknown command: x")),
422 r#"{"id":3,"type":"response","command":"x","success":false,"error":"Unknown command: x"}"#
423 );
424 }
425
426 #[test]
427 fn deep_trees_no_recursion() {
428 let entry: FileEntry = serde_json::from_str(
429 r#"{"type":"label","id":"a","parentId":null,"timestamp":"t","targetId":"b"}"#,
430 )
431 .unwrap();
432 let depth = 200_000;
433 let nodes: Vec<TreeNode<'_>> = (0..depth)
434 .map(|index| TreeNode {
435 entry: &entry,
436 children: if index + 1 < depth {
437 vec![index + 1]
438 } else {
439 Vec::new()
440 },
441 label: (index == 0).then_some("start"),
442 label_timestamp: None,
443 })
444 .collect();
445 let json = tree_json(&nodes, &[0]).unwrap();
446 assert!(json.ends_with(&format!(
447 "{}],\"label\":\"start\"}}]",
448 "]}".repeat(depth - 1)
449 )));
450 let small = tree_json(&nodes[depth - 2..depth - 1], &[]).unwrap();
451 assert_eq!(small, "[]");
452 }
453}