Skip to main content

orch/cli/
chat.rs

1//! CLI handlers for `orch chat` — control session interaction.
2
3use 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
9/// Interactive REPL mode — reads from stdin, sends to control session.
10pub 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
66/// Single message mode — send one message, print response, exit.
67pub 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
81/// Show conversation history.
82pub 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    // Parse --since into an ISO8601 cutoff timestamp if provided.
92    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
161/// Show session cost statistics.
162pub 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}