Skip to main content

orch/cli/
chat.rs

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