Skip to main content

orch/cli/
chat.rs

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