1use crate::cli;
4use crate::cli::cost::format_cost;
5use crate::control;
6use crate::store::{TaskStore, parse_since_duration};
7use std::io::{self, BufRead, Write};
8
9pub async fn interactive(session_id: &str) -> anyhow::Result<()> {
11 let store = cli::init_store().await?;
12 let model = control::get_model(&store).await?;
13 let agent = control::get_agent(&store).await?;
14
15 let session_label = if session_id == TaskStore::DEFAULT_SESSION {
16 String::new()
17 } else {
18 format!(" [{session_id}]")
19 };
20
21 println!("orch control session ({agent}:{model}){session_label}");
22 println!("Type /model, /agent to manage. Ctrl+C to exit.");
23 println!("---");
24
25 let stdin = io::stdin();
26 let mut stdout = io::stdout();
27
28 loop {
29 print!("orch> ");
30 stdout.flush()?;
31
32 let mut line = String::new();
33 let bytes = stdin.lock().read_line(&mut line)?;
34 if bytes == 0 {
35 break;
36 }
37
38 let message = line.trim();
39 if message.is_empty() {
40 continue;
41 }
42 if message == "exit" || message == "quit" {
43 break;
44 }
45
46 match control::maybe_handle_control_command(&store, session_id, None, message).await? {
47 Some(response) => {
48 println!("{response}");
49 println!();
50 }
51 None => match control::send_message(&store, session_id, "cli", None, message).await {
52 Ok(response) => {
53 println!("{response}");
54 println!();
55 }
56 Err(e) => {
57 eprintln!("error: {e}");
58 }
59 },
60 }
61 }
62
63 Ok(())
64}
65
66pub async fn single_message(session_id: &str, message: &str) -> anyhow::Result<()> {
68 let store = cli::init_store().await?;
69 if let Some(response) =
70 control::maybe_handle_control_command(&store, session_id, None, message).await?
71 {
72 println!("{response}");
73 return Ok(());
74 }
75
76 let response = control::send_message(&store, session_id, "cli", None, message).await?;
77 println!("{response}");
78 Ok(())
79}
80
81pub async fn history(
83 session_id: &str,
84 search: Option<String>,
85 since: Option<String>,
86 limit: i64,
87 with_cost: bool,
88) -> anyhow::Result<()> {
89 let store = cli::init_store().await?;
90
91 let since_ts: Option<String> = since
93 .as_deref()
94 .map(parse_since_duration)
95 .transpose()
96 .map_err(|e| anyhow::anyhow!("{e}"))?;
97
98 let messages = if let Some(query) = search {
99 store
100 .search_control_messages(session_id, &query, since_ts.as_deref(), limit)
101 .await?
102 } else {
103 store
104 .list_control_messages(session_id, since_ts.as_deref(), limit)
105 .await?
106 };
107
108 if messages.is_empty() {
109 println!("No messages found.");
110 return Ok(());
111 }
112
113 for msg in &messages {
114 let role_label = match msg.role.as_str() {
115 "user" => "you",
116 "assistant" => msg.agent.as_deref().unwrap_or("assistant"),
117 _ => &msg.role,
118 };
119 let model_info = msg
120 .model
121 .as_deref()
122 .map(|m| format!(" ({m})"))
123 .unwrap_or_default();
124
125 let cost_info = if with_cost {
126 let mut parts = Vec::new();
127 if let Some(cost) = msg.cost_usd {
128 parts.push(format!("${cost:.4}"));
129 }
130 if let (Some(input), Some(output)) = (msg.input_tokens, msg.output_tokens) {
131 parts.push(format!(
132 "in:{} out:{}",
133 format_number(input),
134 format_number(output)
135 ));
136 } else if let Some(tokens) = msg.tokens_used {
137 parts.push(format!("{} tok", format_number(tokens)));
138 }
139 if parts.is_empty() {
140 String::new()
141 } else {
142 format!(" [{}]", parts.join(", "))
143 }
144 } else {
145 String::new()
146 };
147
148 println!(
149 "[{}] {}{}{}",
150 msg.created_at, role_label, model_info, cost_info
151 );
152 for line in msg.content.lines() {
153 println!(" {line}");
154 }
155 println!();
156 }
157
158 Ok(())
159}
160
161pub async fn stats(session_id: &str) -> anyhow::Result<()> {
163 let store = cli::init_store().await?;
164
165 let summary = store.get_session_cost_summary(session_id).await?;
166 let fallback_model = control::get_model(&store).await?;
167 let fallback_agent = control::get_agent(&store).await?;
168 let current_model = summary.primary_model.clone().unwrap_or(fallback_model);
169 let current_agent = summary.primary_agent.clone().unwrap_or(fallback_agent);
170
171 if summary.total_messages == 0 {
172 println!("No messages found in session '{session_id}'.");
173 return Ok(());
174 }
175
176 let session_label = if session_id == TaskStore::DEFAULT_SESSION {
177 "default".to_string()
178 } else {
179 session_id.to_string()
180 };
181
182 println!("Session: {session_label} ({current_agent}:{current_model})");
183 println!(
184 "Messages: {} ({} user, {} assistant)",
185 summary.total_messages,
186 summary.total_messages - summary.assistant_messages,
187 summary.assistant_messages
188 );
189
190 if summary.total_tokens > 0 {
191 println!("Total tokens: {}", format_number(summary.total_tokens));
192 if summary.total_input_tokens > 0 || summary.total_output_tokens > 0 {
193 let input = summary.total_input_tokens;
194 let output = summary.total_output_tokens;
195 println!(" Input: {}", format_number(input));
196 println!(" Output: {}", format_number(output));
197 }
198 }
199
200 println!("Cost: {}", format_cost(summary.total_cost_usd));
201
202 if !summary.by_model.is_empty() {
203 println!("\nBreakdown by model:");
204 for (model, count, tokens, cost) in &summary.by_model {
205 let tokens_str = if *tokens > 0 {
206 format!(", {} tok", format_number(*tokens))
207 } else {
208 String::new()
209 };
210 println!(" {model}: {count} msg{tokens_str} (${cost:.4})");
211 }
212 }
213
214 Ok(())
215}
216
217fn format_number(n: i64) -> String {
218 let s = n.to_string();
219 let mut result = String::new();
220 for (count, c) in s.chars().rev().enumerate() {
221 if count > 0 && count % 3 == 0 {
222 result.push(',');
223 }
224 result.push(c);
225 }
226 result.chars().rev().collect()
227}