tl_cli/chat/
session.rs

1use anyhow::Result;
2use futures_util::StreamExt;
3use inquire::Text;
4use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet, Styled};
5use std::io::{self, Write};
6
7use super::command::{Input, SlashCommand, SlashCommandCompleter, parse_input};
8use super::ui;
9use crate::translation::{TranslationClient, TranslationRequest};
10use crate::ui::Spinner;
11
12/// Configuration for a chat session.
13#[derive(Debug, Clone)]
14pub struct SessionConfig {
15    /// The provider name.
16    pub provider_name: String,
17    /// The API endpoint URL.
18    pub endpoint: String,
19    /// The model to use.
20    pub model: String,
21    /// The API key (if required).
22    pub api_key: Option<String>,
23    /// The target language code.
24    pub to: String,
25}
26
27impl SessionConfig {
28    /// Creates a new session configuration.
29    pub const fn new(
30        provider_name: String,
31        endpoint: String,
32        model: String,
33        api_key: Option<String>,
34        to: String,
35    ) -> Self {
36        Self {
37            provider_name,
38            endpoint,
39            model,
40            api_key,
41            to,
42        }
43    }
44}
45
46/// An interactive chat session for translation.
47///
48/// Provides a REPL-style interface for translating text interactively.
49pub struct ChatSession {
50    config: SessionConfig,
51    client: TranslationClient,
52}
53
54impl ChatSession {
55    /// Creates a new chat session with the given configuration.
56    pub fn new(config: SessionConfig) -> Self {
57        let client = TranslationClient::new(config.endpoint.clone(), config.api_key.clone());
58        Self { config, client }
59    }
60
61    pub async fn run(&mut self) -> Result<()> {
62        ui::print_header();
63
64        let prompt_style = Styled::new("❯")
65            .with_fg(Color::LightBlue)
66            .with_attr(Attributes::BOLD);
67        let mut render_config = RenderConfig::default()
68            .with_prompt_prefix(prompt_style)
69            .with_answered_prompt_prefix(prompt_style);
70
71        // Non-highlighted suggestions: gray
72        render_config.option = StyleSheet::new().with_fg(Color::Grey);
73        // Highlighted suggestion: purple
74        render_config.selected_option = Some(StyleSheet::new().with_fg(Color::DarkMagenta));
75
76        loop {
77            let input = Text::new("")
78                .with_render_config(render_config)
79                .with_autocomplete(SlashCommandCompleter)
80                .with_help_message("Type text to translate, /help for commands, Ctrl+C to quit")
81                .prompt();
82
83            match input {
84                Ok(line) => match parse_input(&line) {
85                    Input::Empty => {}
86                    Input::Command(cmd) => {
87                        if !self.handle_command(cmd) {
88                            break;
89                        }
90                    }
91                    Input::Text(text) => {
92                        self.translate_and_print(&text).await?;
93                    }
94                },
95                Err(
96                    inquire::InquireError::OperationCanceled
97                    | inquire::InquireError::OperationInterrupted,
98                ) => {
99                    println!(); // Clear line before goodbye message
100                    break;
101                }
102                Err(e) => return Err(e.into()),
103            }
104        }
105
106        ui::print_goodbye();
107        Ok(())
108    }
109
110    fn handle_command(&self, cmd: SlashCommand) -> bool {
111        match cmd {
112            SlashCommand::Config => {
113                ui::print_config(&self.config);
114                true
115            }
116            SlashCommand::Help => {
117                ui::print_help();
118                true
119            }
120            SlashCommand::Quit => false,
121            SlashCommand::Unknown(cmd) => {
122                ui::print_error(&format!("Unknown command: /{cmd}"));
123                true
124            }
125        }
126    }
127
128    async fn translate_and_print(&self, text: &str) -> Result<()> {
129        let request = TranslationRequest {
130            source_text: text.to_string(),
131            target_language: self.config.to.clone(),
132            model: self.config.model.clone(),
133            endpoint: self.config.endpoint.clone(),
134        };
135
136        let spinner = Spinner::new("Translating...");
137
138        let mut stream = self.client.translate_stream(&request).await?;
139        let mut first_chunk = true;
140
141        while let Some(chunk_result) = stream.next().await {
142            let chunk = chunk_result?;
143
144            if first_chunk {
145                spinner.stop();
146                first_chunk = false;
147            }
148
149            print!("{chunk}");
150            io::stdout().flush()?;
151        }
152
153        if first_chunk {
154            spinner.stop();
155        }
156
157        println!();
158        println!();
159        Ok(())
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_session_config_new() {
169        let config = SessionConfig::new(
170            "ollama".to_string(),
171            "http://localhost:11434".to_string(),
172            "gemma3:12b".to_string(),
173            None,
174            "ja".to_string(),
175        );
176
177        assert_eq!(config.provider_name, "ollama");
178        assert_eq!(config.endpoint, "http://localhost:11434");
179        assert_eq!(config.model, "gemma3:12b");
180        assert!(config.api_key.is_none());
181        assert_eq!(config.to, "ja");
182    }
183}