use crate::config::{ChatConfig, Config};
use crate::llm::context::build_system_prompt;
use crate::llm::session::{validate_name, ChatSession, SessionContextConfig};
use crate::llm::{LlmClient, Message};
use crate::store::Store;
use anyhow::{bail, Context as _};
use chrono::Local;
use futures_util::StreamExt;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub struct ChatCliArgs {
pub url: Option<String>,
pub model: Option<String>,
pub api_key: Option<String>,
pub context_days: Option<u64>,
pub since: Option<String>,
pub all: bool,
pub stream: Option<bool>,
pub session_name: Option<String>,
pub new: bool,
pub list_sessions: bool,
}
pub async fn run(config: Config, args: ChatCliArgs) -> anyhow::Result<()> {
let mut chat_cfg = config.chat.clone();
if let Some(ref url) = args.url {
chat_cfg.url = Some(url.clone());
}
if let Some(ref model) = args.model {
chat_cfg.model = model.clone();
}
if let Some(ref key) = args.api_key {
chat_cfg.api_key = key.clone();
}
if let Some(days) = args.context_days {
chat_cfg.context_days = days;
}
if let Some(stream) = args.stream {
chat_cfg.stream = stream;
}
let sessions_dir = resolve_sessions_dir(&config, &chat_cfg);
if args.list_sessions {
return list_sessions(&sessions_dir);
}
let base_url = match chat_cfg.url.clone() {
Some(u) => u,
None => match LlmClient::auto_detect().await {
Some(u) => u,
None => bail!(
"Cannot reach LLM — no server found on :11434 or :8080.\n\
Start ollama/llama-server or set 'chat.url' in config."
),
},
};
let client = LlmClient::new(
&base_url,
&chat_cfg.model,
&chat_cfg.api_key,
chat_cfg.connect_timeout_secs,
);
let (mut session, resumed) = match &args.session_name {
None => (None, false),
Some(name) => {
if !args.new {
let path = sessions_dir.join(format!("{}.json", name));
if path.exists() {
match ChatSession::load(&sessions_dir, name) {
Ok(s) => {
let n = s.messages.len();
(Some(s), n > 0)
}
Err(e) => {
eprintln!(
"warning: could not load session '{}': {} — starting fresh.",
name, e
);
(
Some(ChatSession::new(
name,
make_context_config(&chat_cfg, &args),
)),
false,
)
}
}
} else {
(
Some(ChatSession::new(
name,
make_context_config(&chat_cfg, &args),
)),
false,
)
}
} else {
(
Some(ChatSession::new(
name,
make_context_config(&chat_cfg, &args),
)),
false,
)
}
}
};
let store = Store::new(&config.storage_dir);
let today = Local::now().date_naive();
let since = if args.all {
chrono::NaiveDate::from_ymd_opt(2000, 1, 1).unwrap()
} else {
match &args.since {
Some(s) => crate::date_parse::parse_date(s)
.with_context(|| format!("cannot parse --since date '{}'", s))?,
None => {
let days_back = chat_cfg.context_days.max(1) as i64 - 1;
today - chrono::Duration::days(days_back)
}
}
};
let (system_text, element_count) =
build_system_prompt(&store, since, today).context("failed to build journal context")?;
let display_url = base_url
.trim_start_matches("http://")
.trim_start_matches("https://");
println!("\n chat — {} @ {}", chat_cfg.model, display_url);
println!(
" context: {}, last {} days ({} elements)",
today.format("%Y-%m-%d"),
chat_cfg.context_days,
element_count
);
if let Some(ref s) = session {
let status = if resumed {
format!("(resumed, {} messages)", s.messages.len())
} else {
"(new)".to_string()
};
println!(" session: {} {}", s.name, status);
}
println!(" :help :clear :context :session :save :sessions :quit\n");
let system_msg = Message::system(&system_text);
let mut messages: Vec<Message> = std::iter::once(system_msg.clone())
.chain(
session
.as_ref()
.map(|s| s.to_messages())
.unwrap_or_default(),
)
.collect();
let mut rl = DefaultEditor::new().context("readline init")?;
loop {
let readline = rl.readline("> ");
match readline {
Ok(line) => {
let input = line.trim().to_string();
if input.is_empty() {
continue;
}
let _ = rl.add_history_entry(&input);
if input == ":quit" || input == ":exit" {
auto_save(&mut session, &sessions_dir);
break;
}
if input == ":help" {
print_help();
continue;
}
if input == ":context" {
println!("{}\n", system_text);
continue;
}
if input == ":clear" {
messages = vec![system_msg.clone()];
if let Some(ref mut s) = session {
s.messages.clear();
let _ = s.save(&sessions_dir);
}
println!(" context cleared.\n");
continue;
}
if input == ":reload" {
match build_system_prompt(&store, since, today) {
Ok((new_text, new_count)) => {
let new_sys = Message::system(&new_text);
messages[0] = new_sys.clone();
println!(" context reloaded ({} elements).\n", new_count);
}
Err(e) => eprintln!(" reload failed: {}\n", e),
}
continue;
}
if input == ":session" {
match &session {
Some(s) => println!(
" session: {} | {} messages | updated {}\n",
s.name,
s.messages.len(),
s.updated_at.format("%Y-%m-%d %H:%M")
),
None => println!(" no active session (ephemeral).\n"),
}
continue;
}
if input == ":sessions" {
let _ = list_sessions(&sessions_dir);
continue;
}
if input == ":save" {
match &mut session {
Some(s) => match s.save(&sessions_dir) {
Ok(()) => println!(" saved session '{}'.\n", s.name),
Err(e) => eprintln!(" save failed: {}\n", e),
},
None => {
eprintln!(" no session name — use :save NAME to name this session.\n")
}
}
continue;
}
if let Some(name) = input
.strip_prefix(":save ")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
if let Err(e) = validate_name(name) {
eprintln!(" invalid session name: {}\n", e);
continue;
}
match &mut session {
Some(s) => {
s.name = name.to_string();
match s.save(&sessions_dir) {
Ok(()) => println!(" saved as '{}'.\n", name),
Err(e) => eprintln!(" save failed: {}\n", e),
}
}
None => {
let mut new_session =
ChatSession::new(name, make_context_config(&chat_cfg, &args));
new_session.messages = messages[1..]
.iter()
.enumerate()
.map(|(i, m)| crate::llm::session::SessionMessage {
role: m.role.clone(),
content: m.content.clone(),
ts: chrono::Utc::now() + chrono::Duration::seconds(i as i64),
})
.collect();
new_session.llm_hint = format!("{} @ {}", chat_cfg.model, base_url);
match new_session.save(&sessions_dir) {
Ok(()) => println!(" saved as '{}'.\n", name),
Err(e) => eprintln!(" save failed: {}\n", e),
}
session = Some(new_session);
}
}
continue;
}
if let Some(name) = input
.strip_prefix(":save-as ")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
if let Err(e) = validate_name(name) {
eprintln!(" invalid session name: {}\n", e);
continue;
}
let old_messages = messages[1..].to_vec();
let mut new_session =
ChatSession::new(name, make_context_config(&chat_cfg, &args));
new_session.messages = old_messages
.iter()
.enumerate()
.map(|(i, m)| crate::llm::session::SessionMessage {
role: m.role.clone(),
content: m.content.clone(),
ts: chrono::Utc::now() + chrono::Duration::seconds(i as i64),
})
.collect();
new_session.llm_hint = format!("{} @ {}", chat_cfg.model, base_url);
match new_session.save(&sessions_dir) {
Ok(()) => println!(" saved copy as '{}'.\n", name),
Err(e) => eprintln!(" save failed: {}\n", e),
}
session = Some(new_session);
continue;
}
if let Some(name) = input
.strip_prefix(":load ")
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
match ChatSession::load(&sessions_dir, name) {
Ok(loaded) => {
let loaded_msgs = loaded.to_messages();
messages = std::iter::once(messages[0].clone())
.chain(loaded_msgs)
.collect();
println!(
" loaded session '{}' ({} messages).\n",
name,
loaded.messages.len()
);
session = Some(loaded);
}
Err(e) => eprintln!(" load failed: {}\n", e),
}
continue;
}
messages.push(Message::user(&input));
if let Some(ref mut s) = session {
s.push_user(input.clone());
}
let assistant_text = if chat_cfg.stream {
stream_response(&client, &messages).await
} else {
non_stream_response(&client, &messages).await
};
match assistant_text {
Ok(text) => {
messages.push(Message::assistant(&text));
if let Some(ref mut s) = session {
s.push_assistant(text.clone());
s.llm_hint = format!("{} @ {}", chat_cfg.model, base_url);
if let Err(e) = s.save(&sessions_dir) {
eprintln!(" auto-save failed: {}\n", e);
}
}
println!();
}
Err(e) => {
messages.pop();
if let Some(ref mut s) = session {
s.messages.pop();
}
eprintln!("\n error: {}\n", e);
}
}
}
Err(ReadlineError::Interrupted) => {
println!();
}
Err(ReadlineError::Eof) => {
auto_save(&mut session, &sessions_dir);
break;
}
Err(e) => {
eprintln!("readline error: {}", e);
break;
}
}
}
println!("\n goodbye.\n");
Ok(())
}
async fn stream_response(client: &LlmClient, messages: &[Message]) -> anyhow::Result<String> {
print!(" ⠋ thinking…");
std::io::stdout().flush().ok();
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
let spinner = tokio::spawn(run_spinner(stop_rx));
let stream_result = client.chat_stream(messages).await;
let _ = stop_tx.send(());
spinner.await.ok();
let mut stream = stream_result?;
let mut full_text = String::new();
println!();
while let Some(chunk) = stream.next().await {
let token = chunk?;
if !token.is_empty() {
print!("{}", token);
std::io::stdout().flush().ok();
full_text.push_str(&token);
}
}
println!();
Ok(full_text)
}
async fn non_stream_response(client: &LlmClient, messages: &[Message]) -> anyhow::Result<String> {
print!(" ⠋ thinking…");
std::io::stdout().flush().ok();
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
let spinner = tokio::spawn(run_spinner(stop_rx));
let stream_result = client.chat_stream(messages).await;
let _ = stop_tx.send(());
spinner.await.ok();
let mut stream = stream_result?;
let mut full_text = String::new();
while let Some(chunk) = stream.next().await {
full_text.push_str(&chunk?);
}
println!("\n{}\n", full_text);
Ok(full_text)
}
async fn run_spinner(mut stop: tokio::sync::oneshot::Receiver<()>) {
const FRAMES: &[&str] = &["⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠋"];
let mut i = 0usize;
loop {
tokio::select! {
_ = &mut stop => break,
_ = tokio::time::sleep(Duration::from_millis(80)) => {
print!("\r {} thinking…", FRAMES[i % FRAMES.len()]);
std::io::stdout().flush().ok();
i += 1;
}
}
}
print!("\r \r");
std::io::stdout().flush().ok();
}
fn auto_save(session: &mut Option<ChatSession>, sessions_dir: &Path) {
if let Some(ref mut s) = session {
if !s.messages.is_empty() {
let _ = s.save(sessions_dir);
}
}
}
fn list_sessions(sessions_dir: &Path) -> anyhow::Result<()> {
let summaries = ChatSession::list(sessions_dir)?;
if summaries.is_empty() {
println!(" no saved sessions.\n");
return Ok(());
}
println!();
println!(" {:<24} {:<20} messages", "session", "updated");
println!(" {}", "─".repeat(56));
for s in &summaries {
println!(
" {:<24} {:<20} {}",
s.name,
s.updated_at.format("%Y-%m-%d %H:%M"),
s.message_count
);
}
println!();
Ok(())
}
fn print_help() {
println!(
"\n session commands:\n\
\n\
:help print this help\n\
:context print the journal context (system prompt)\n\
:clear clear chat history (keep session name)\n\
:reload rebuild journal context from disk\n\
:session show current session info\n\
:sessions list all saved sessions\n\
:save save to current session name\n\
:save NAME save (and name) session as NAME\n\
:save-as NAME copy session as NAME, switch to it\n\
:load NAME load session NAME into current chat\n\
:quit / :exit save and quit\n\
Ctrl-D save and quit\n\
Ctrl-C cancel current input\n"
);
}
fn resolve_sessions_dir(config: &Config, chat_cfg: &ChatConfig) -> PathBuf {
match &chat_cfg.sessions_dir {
Some(dir) => PathBuf::from(dir),
None => config.mps_dir.join("sessions"),
}
}
fn make_context_config(chat_cfg: &ChatConfig, args: &ChatCliArgs) -> SessionContextConfig {
SessionContextConfig {
context_days: chat_cfg.context_days,
since: args.since.clone(),
}
}