//! The full-screen terminal UI. use crate::agent::{Env, Reply, UiEvent}; use crate::editor::Editor; use crate::i18n::t; use crate::session::LocalSession; use crate::theme::{self, Theme}; use anyhow::Result; use base64::Engine; use codecraft_core::types::{ImageData, Part, Question, Role}; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind}; use futures::StreamExt; use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use serde_json::Value; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; use unicode_width::UnicodeWidthStr; // --- conversation items --------------------------------------------------------------- enum ToolState { Running, Ok, Err, } enum Item { User(String, usize), Assistant(String), Thinking(String, bool), Tool { id: String, name: String, title: String, state: ToolState, output: String, diff: Option, open: bool }, Info(String), Error(String), } pub enum Cmd { Prompt { text: String, images: Vec, effort: Option, cancel: CancellationToken }, SetModel(String), SetMode(String), SetPermission(String), Load(Box), New, Compact(CancellationToken), SetEnv(Arc), } #[derive(Clone, Copy, PartialEq)] enum PickKind { Model, Mode, Session, Theme, Permission, Effort, Lang, } struct Picker { kind: PickKind, title: String, items: Vec<(String, String, String)>, filter: String, sel: usize, } impl Picker { fn visible(&self) -> Vec<&(String, String, String)> { let f = self.filter.to_lowercase(); self.items.iter().filter(|(k, l, d)| f.is_empty() || k.to_lowercase().contains(&f) || l.to_lowercase().contains(&f) || d.to_lowercase().contains(&f)).collect() } } enum SetupStep { Choose { sel: usize, detected: Vec<&'static str>, note: String }, Server { fields: [String; 3], focus: usize, error: String }, Provider { preset: usize, fields: [String; 3], focus: usize, error: String }, } enum Dialog { None, Permission { tool: String, title: String, diff: Option, input: Value, reply: Option>, feedback: Option }, Question { questions: Vec, idx: usize, sel: usize, custom: String, answers: serde_json::Map, reply: Option>>> }, Picker(Picker), Setup(SetupStep), Text { title: String, body: String }, } struct Completion { start: usize, items: Vec<(String, String)>, sel: usize, } pub struct App { env: Arc, items: Vec, scroll: usize, editor: Editor, completion: Option, busy: bool, busy_since: Instant, cancel: Option, queue: VecDeque, dialog: Dialog, session: LocalSession, effort: Option, images: Vec, todos: Value, context: u64, status: String, quit_armed: Option, quit: bool, cmd: mpsc::UnboundedSender, /// History line -> item index (mouse clicks), and the history area. hit: Vec>, history_area: Rect, tick: usize, step_chars: usize, pending_jobs: Option>>, pending_detect: Option>>, /// Running setup action (login / provider test): Ok(message) or error. pending_setup: Option>>, pending_env: Option>>, } const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; fn wrap(text: &str, width: usize) -> Vec { let width = width.max(10); let mut out = Vec::new(); for raw in text.split('\n') { if raw.is_empty() { out.push(String::new()); continue; } let mut line = String::new(); let mut w = 0; for word in raw.split_inclusive(' ') { let ww = UnicodeWidthStr::width(word); if w + ww > width && !line.is_empty() { out.push(std::mem::take(&mut line).trim_end().to_string()); w = 0; } if ww > width { // Hard-break very long words. for ch in word.chars() { let cw = UnicodeWidthStr::width(ch.to_string().as_str()); if w + cw > width { out.push(std::mem::take(&mut line)); w = 0; } line.push(ch); w += cw; } } else { line.push_str(word); w += ww; } } out.push(line.trim_end().to_string()); } out } /// Inline markdown: `code` and **bold**. fn inline(text: &str, th: &Theme, base: Style) -> Vec> { let mut spans = Vec::new(); let mut rest = text; while !rest.is_empty() { let next_code = rest.find('`'); let next_bold = rest.find("**"); match (next_code, next_bold) { (Some(c), b) if b.is_none_or(|b| c < b) => { if let Some(end) = rest[c + 1..].find('`') { spans.push(Span::styled(rest[..c].to_string(), base)); spans.push(Span::styled(rest[c + 1..c + 1 + end].to_string(), Style::default().fg(th.accent2))); rest = &rest[c + 2 + end..]; continue; } spans.push(Span::styled(rest.to_string(), base)); break; } (_, Some(b)) => { if let Some(end) = rest[b + 2..].find("**") { spans.push(Span::styled(rest[..b].to_string(), base)); spans.push(Span::styled(rest[b + 2..b + 2 + end].to_string(), base.add_modifier(Modifier::BOLD))); rest = &rest[b + 4 + end..]; continue; } spans.push(Span::styled(rest.to_string(), base)); break; } _ => { spans.push(Span::styled(rest.to_string(), base)); break; } } } spans } fn markdown(text: &str, width: usize, th: &Theme) -> Vec> { let mut out = Vec::new(); let mut in_code = false; let base = Style::default().fg(th.fg1); for raw in text.split('\n') { if raw.trim_start().starts_with("```") { in_code = !in_code; let lang = raw.trim_start().trim_start_matches('`'); if in_code && !lang.is_empty() { out.push(Line::from(Span::styled(format!(" {lang}"), Style::default().fg(th.muted)))); } continue; } if in_code { for l in wrap(raw, width.saturating_sub(2)) { out.push(Line::from(Span::styled(format!(" {l}"), Style::default().fg(th.fg2).bg(th.bg1)))); } continue; } let trimmed = raw.trim_start(); if let Some(h) = trimmed.strip_prefix("# ").or(trimmed.strip_prefix("## ")).or(trimmed.strip_prefix("### ")) { for l in wrap(h, width) { out.push(Line::from(Span::styled(l, Style::default().fg(th.accent).add_modifier(Modifier::BOLD)))); } continue; } let (prefix, body) = if let Some(b) = trimmed.strip_prefix("- ").or(trimmed.strip_prefix("* ")) { let indent = raw.len() - trimmed.len(); (format!("{}• ", " ".repeat(indent)), b) } else { (String::new(), raw) }; let pw = prefix.chars().count(); for (i, l) in wrap(body, width.saturating_sub(pw)).into_iter().enumerate() { let mut spans = vec![Span::styled(if i == 0 { prefix.clone() } else { " ".repeat(pw) }, Style::default().fg(th.accent))]; spans.extend(inline(&l, th, base)); out.push(Line::from(spans)); } } out } fn diff_lines(diff: &str, max: usize, th: &Theme) -> Vec> { let mut out: Vec = diff .lines() .filter(|l| !l.starts_with("---") && !l.starts_with("+++")) .take(max) .map(|l| { let st = if l.starts_with('+') { Style::default().fg(th.green) } else if l.starts_with('-') { Style::default().fg(th.red) } else if l.starts_with("@@") { Style::default().fg(th.blue) } else { Style::default().fg(th.fg3) }; Line::from(Span::styled(format!(" {l}"), st)) }) .collect(); let total = diff.lines().count(); if total > max + 2 { out.push(Line::from(Span::styled(format!(" … {} more lines", total - max - 2), Style::default().fg(th.muted)))); } out } fn tool_title(name: &str, input: &Value) -> String { let s = |k: &str| input[k].as_str().unwrap_or("").to_string(); match name { "bash" => input["description"].as_str().map(String::from).unwrap_or_else(|| s("command")), "read" | "write" | "edit" | "list" => s("path"), "glob" | "grep" => s("pattern"), "task" => s("description"), "skill" => s("name"), "remember" => s("note"), _ => input.to_string().chars().take(80).collect(), } } impl App { pub fn new(env: Arc, session: LocalSession, cmd: mpsc::UnboundedSender) -> Self { let mut app = Self { env, items: vec![], scroll: 0, editor: Editor::new(), completion: None, busy: false, busy_since: Instant::now(), cancel: None, queue: VecDeque::new(), dialog: Dialog::None, todos: session.todos.clone(), context: session.context_tokens, session, effort: None, images: vec![], status: String::new(), quit_armed: None, quit: false, cmd, hit: vec![], history_area: Rect::default(), tick: 0, step_chars: 0, pending_jobs: None, pending_detect: None, pending_setup: None, pending_env: None, }; app.rebuild_items(); app } /// Rebuild the view from a (loaded) session. fn rebuild_items(&mut self) { self.items.clear(); for m in &self.session.messages { for p in &m.parts { match (&m.role, p) { (Role::User, Part::Text { text }) => self.items.push(Item::User(text.clone(), 0)), (Role::User, Part::Summary { .. }) => self.items.push(Item::Info("— conversation compacted —".into())), (_, Part::Text { text }) => self.items.push(Item::Assistant(text.clone())), (_, Part::Reasoning { text, .. }) if !text.trim().is_empty() => self.items.push(Item::Thinking(text.clone(), false)), (_, Part::ToolCall { id, name, input, .. }) => self.items.push(Item::Tool { id: id.clone(), name: name.clone(), title: tool_title(name, input), state: ToolState::Ok, output: String::new(), diff: None, open: false }), (_, Part::ToolResult { call_id, content, is_error, meta, .. }) => { if let Some(Item::Tool { state, output, diff, .. }) = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id, .. } if id == call_id)) { *state = if *is_error { ToolState::Err } else { ToolState::Ok }; *output = content.clone(); *diff = meta.as_ref().and_then(|m| m["diff"].as_str().map(String::from)); } } _ => {} } } } } fn model_label(&self) -> String { self.env.catalog.find(&self.session.model).map(|m| m.label.clone()).unwrap_or_else(|| self.session.model.clone()) } // --- events from the agent -------------------------------------------------------- fn on_agent(&mut self, ev: UiEvent) { match ev { UiEvent::Busy(b) => { self.busy = b; if b { self.busy_since = Instant::now(); } else { self.cancel = None; if let Some(next) = self.queue.pop_front() { self.send(next); } } } UiEvent::Step => self.step_chars = 0, UiEvent::Text(t) => { self.step_chars += t.len(); match self.items.last_mut() { Some(Item::Assistant(s)) => s.push_str(&t), _ => self.items.push(Item::Assistant(t)), } } UiEvent::Reasoning(t) => { self.step_chars += t.len(); match self.items.last_mut() { Some(Item::Thinking(s, _)) => s.push_str(&t), _ => self.items.push(Item::Thinking(t, false)), } } UiEvent::ToolStart { id, name, input } => self.items.push(Item::Tool { id, title: tool_title(&name, &input), name, state: ToolState::Running, output: String::new(), diff: None, open: false }), UiEvent::ToolDone { id, content, is_error, diff } => { if let Some(Item::Tool { state, output, diff: d, .. }) = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id: x, .. } if *x == id)) { *state = if is_error { ToolState::Err } else { ToolState::Ok }; *output = content; *d = diff; } } UiEvent::Todos(v) => self.todos = v, UiEvent::Usage { context, total_cost, .. } => { self.context = context; self.session.cost = total_cost; } UiEvent::Permission { tool, title, input, diff, reply } => self.dialog = Dialog::Permission { tool, title, diff, input, reply: Some(reply), feedback: None }, UiEvent::Question { questions, reply } => self.dialog = Dialog::Question { questions, idx: 0, sel: 0, custom: String::new(), answers: Default::default(), reply: Some(reply) }, UiEvent::Info(s) => self.items.push(Item::Info(s)), UiEvent::Error(e) => self.items.push(Item::Error(e)), } self.scroll = 0; } // --- sending --------------------------------------------------------------------------- fn send(&mut self, text: String) { if self.env.catalog.find(&self.session.model).is_none() { self.items.push(Item::Error(t("no_model").into())); return; } // @image.png mentions are attached as images. let cwd = PathBuf::from(&self.session.cwd); for word in text.split_whitespace() { if let Some(p) = word.strip_prefix('@') { let path = crate::tools::resolve(&cwd, p); let mt = match path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() { Some("png") => Some("image/png"), Some("jpg") | Some("jpeg") => Some("image/jpeg"), Some("webp") => Some("image/webp"), Some("gif") => Some("image/gif"), _ => None, }; if let (Some(mt), Ok(bytes)) = (mt, std::fs::read(&path)) { self.images.push(ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(bytes) }); } } } let images = std::mem::take(&mut self.images); self.items.push(Item::User(text.clone(), images.len())); let cancel = CancellationToken::new(); self.cancel = Some(cancel.clone()); self.busy = true; self.busy_since = Instant::now(); let effort = self.effort.take(); let _ = self.cmd.send(Cmd::Prompt { text, images, effort, cancel }); self.scroll = 0; } fn submit(&mut self) { let text = self.editor.take(); let trimmed = text.trim().to_string(); if trimmed.is_empty() && self.images.is_empty() { return; } if trimmed.starts_with('/') && self.slash(&trimmed) { return; } let text = self.expand_command(&trimmed).unwrap_or(trimmed); if self.busy { self.queue.push_back(text); self.status = format!("{} ({})", t("queued"), self.queue.len()); } else { self.send(text); } } /// Custom commands (server, project, built-in): "/name args" -> prompt. fn expand_command(&mut self, text: &str) -> Option { let rest = text.strip_prefix('/')?; let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); let body = self.find_command(name)?; let (meta, body) = crate::agent::codecraft_core_frontmatter(&body); if let Some(agent) = meta.get("agent") { self.set_mode(agent); } Some(body.replace("$ARGUMENTS", args.trim())) } fn commands(&self) -> Vec<(String, String)> { let mut out: Vec<(String, String)> = Vec::new(); let cwd = PathBuf::from(&self.session.cwd); for base in [".claude", ".codecraft"] { if let Ok(entries) = std::fs::read_dir(cwd.join(base).join("commands")) { for e in entries.flatten() { let p = e.path(); if p.extension().is_some_and(|x| x == "md") { let desc = std::fs::read_to_string(&p).map(|c| crate::agent::codecraft_core_frontmatter(&c).0.get("description").cloned().unwrap_or_default()).unwrap_or_default(); out.push((p.file_stem().unwrap().to_string_lossy().to_string(), desc)); } } } } if let Some(sc) = &self.env.server_cfg { for c in sc["commands"].as_array().into_iter().flatten() { out.push((c["name"].as_str().unwrap_or("").into(), c["description"].as_str().unwrap_or("").into())); } } for (p, c) in crate::tools::BUILTIN { if let Some(name) = p.strip_prefix("commands/").and_then(|n| n.strip_suffix(".md")) { out.push((name.into(), crate::agent::codecraft_core_frontmatter(c).0.get("description").cloned().unwrap_or_default())); } } let mut seen = std::collections::HashSet::new(); out.retain(|(n, _)| !n.is_empty() && seen.insert(n.clone())); out } fn find_command(&self, name: &str) -> Option { let cwd = PathBuf::from(&self.session.cwd); for base in [".codecraft", ".claude"] { if let Ok(c) = std::fs::read_to_string(cwd.join(base).join("commands").join(format!("{name}.md"))) { return Some(c); } } if let Some(sc) = &self.env.server_cfg { if let Some(c) = sc["commands"].as_array().into_iter().flatten().find(|c| c["name"] == name) { let agent = c["agent"].as_str().map(|a| format!("---\nagent: {a}\n---\n")).unwrap_or_default(); return Some(format!("{agent}{}", c["body"].as_str().unwrap_or(""))); } } crate::tools::BUILTIN.iter().find(|(p, _)| *p == format!("commands/{name}.md")).map(|(_, c)| c.to_string()) } fn builtin_slash() -> Vec<(&'static str, &'static str)> { vec![ ("help", "keys and commands"), ("model", "choose the model"), ("mode", "choose the mode (build, plan, craft …)"), ("permissions", "permission mode (ask, auto, edits, everything)"), ("effort", "thinking effort for the next message"), ("new", "start a new session"), ("resume", "continue an earlier session"), ("compact", "summarize the conversation to free context"), ("costs", "costs of the last 30 days"), ("theme", "colour theme"), ("lang", "language (en/de)"), ("setup", "add model providers or sign in to a server"), ("memory", "show the project memory"), ("jobs", "automations on the server"), ("quit", "exit"), ] } /// Built-in slash commands; false = not a built-in. fn slash(&mut self, text: &str) -> bool { let (name, arg) = text[1..].split_once(' ').map(|(a, b)| (a, b.trim())).unwrap_or((&text[1..], "")); match name { "help" => { let mut body = String::from("Keys\n Enter send · Shift/Alt+Enter new line · Esc interrupt · Shift+Tab mode\n ↑/↓ history · Ctrl+V paste image · PgUp/PgDn or mouse wheel scroll · click a tool to expand\n Ctrl+O expand/collapse all tools · Ctrl+C clear / quit\n\nCommands\n"); for (n, d) in Self::builtin_slash() { body.push_str(&format!(" /{n:<12} {d}\n")); } for (n, d) in self.commands() { body.push_str(&format!(" /{n:<12} {d}\n")); } self.dialog = Dialog::Text { title: t("help").into(), body }; } "model" => self.open_picker(PickKind::Model), "mode" => { if arg.is_empty() { self.open_picker(PickKind::Mode) } else { self.set_mode(arg) } } "permissions" => self.open_picker(PickKind::Permission), "effort" => self.open_picker(PickKind::Effort), "theme" => self.open_picker(PickKind::Theme), "lang" => self.open_picker(PickKind::Lang), "resume" | "sessions" => self.open_picker(PickKind::Session), "new" | "clear" => { let _ = self.cmd.send(Cmd::New); self.session = LocalSession::new(&self.session.cwd, &self.session.model, &self.session.mode, &self.session.permission_mode); self.items.clear(); self.todos = Value::Null; self.context = 0; } "compact" => { let c = CancellationToken::new(); self.cancel = Some(c.clone()); self.busy = true; self.busy_since = Instant::now(); let _ = self.cmd.send(Cmd::Compact(c)); } "costs" => { let since = codecraft_core::types::now_ms() - 30 * 86_400_000; let mut body = String::from("Last 30 days (local log)\n\n"); let mut total = 0.0; for (m, n, i, o, c) in crate::stats::summary(since) { body.push_str(&format!(" {m:<44} {n:>5} req {i:>10} in {o:>9} out {c:>9.4}\n")); total += c; } body.push_str(&format!("\n total {total:.4} {}\n this session {:.4}", self.env.catalog.currency, self.session.cost)); self.dialog = Dialog::Text { title: "Costs".into(), body }; } "memory" => { let body = std::fs::read_to_string(PathBuf::from(&self.session.cwd).join(".codecraft").join("memory.md")).unwrap_or_else(|_| "(no project memory yet – the agent saves facts with the remember tool)".into()); self.dialog = Dialog::Text { title: "Project memory".into(), body }; } "jobs" => { let Some(server) = self.env.server.clone() else { self.items.push(Item::Error("not signed in to a server".into())); return true; }; let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let _ = tx.send(server.get("/jobs").await); }); self.pending_jobs = Some(rx); } "setup" | "login" => self.open_setup(), "quit" | "exit" => self.quit = true, _ => return false, } true } fn set_mode(&mut self, name: &str) { if self.env.modes.iter().any(|m| m.name == name && m.primary) { self.session.mode = name.into(); let _ = self.cmd.send(Cmd::SetMode(name.into())); } } fn cycle_mode(&mut self) { let modes: Vec = self.env.modes.iter().filter(|m| m.primary).map(|m| m.name.clone()).collect(); if modes.is_empty() { return; } let i = modes.iter().position(|m| *m == self.session.mode).map(|i| (i + 1) % modes.len()).unwrap_or(0); let m = modes[i].clone(); self.set_mode(&m); } fn open_picker(&mut self, kind: PickKind) { let (title, items): (String, Vec<(String, String, String)>) = match kind { PickKind::Model => (t("choose_model").into(), self.env.catalog.models.iter().map(|m| (m.spec.clone(), m.label.clone(), m.config.context.map(|c| format!("{}k ctx", c / 1000)).unwrap_or_default())).collect()), PickKind::Mode => (t("choose_mode").into(), self.env.modes.iter().filter(|m| m.primary).map(|m| (m.name.clone(), m.name.clone(), m.description.clone())).collect()), PickKind::Permission => ( "Permissions".into(), vec![ ("default".into(), "ask".into(), "ask before changes and commands".into()), ("auto".into(), "automatic".into(), "asks once for larger plans, safe things pass".into()), ("accept_edits".into(), "accept edits".into(), "file changes pass, commands ask".into()), ("bypass".into(), "allow everything".into(), "no questions (careful – runs on your machine)".into()), ], ), PickKind::Effort => ("Effort".into(), ["low", "medium", "high", "max"].iter().map(|e| (e.to_string(), e.to_string(), "next message only".into())).collect()), PickKind::Theme => (t("choose_theme").into(), theme::names().into_iter().flat_map(|n| ["auto", "dark", "light"].map(|m| (format!("{n}:{m}"), format!("{n} ({m})"), String::new()))).collect()), PickKind::Lang => ("Language".into(), vec![("en".into(), "English".into(), String::new()), ("de".into(), "Deutsch".into(), String::new())]), PickKind::Session => ( t("choose_session").into(), crate::session::list(None) .into_iter() .take(100) .map(|s| { let when = chrono::DateTime::from_timestamp_millis(s.updated_at).map(|d| d.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default(); (s.id.clone(), if s.title.is_empty() { "(untitled)".into() } else { s.title.clone() }, format!("{when} · {}", s.cwd)) }) .collect(), ), }; self.dialog = Dialog::Picker(Picker { kind, title, items, filter: String::new(), sel: 0 }); } fn pick(&mut self, kind: PickKind, key: String) { match kind { PickKind::Model => { self.session.model = key.clone(); let _ = self.cmd.send(Cmd::SetModel(key.clone())); let mut cfg = crate::conf::Config::load().unwrap_or_default(); cfg.default_model = Some(key); let _ = cfg.save(); } PickKind::Mode => self.set_mode(&key), PickKind::Permission => { self.session.permission_mode = key.clone(); let _ = self.cmd.send(Cmd::SetPermission(key)); } PickKind::Effort => self.effort = Some(key), PickKind::Theme => { let (base, mode) = key.split_once(':').unwrap_or((&key, "auto")); theme::set(Theme::load(base, mode)); let mut cfg = crate::conf::Config::load().unwrap_or_default(); cfg.theme = Some(base.into()); cfg.theme_mode = Some(mode.into()); let _ = cfg.save(); } PickKind::Lang => { crate::i18n::set_lang(&key); let mut cfg = crate::conf::Config::load().unwrap_or_default(); cfg.lang = Some(key); let _ = cfg.save(); } PickKind::Session => { if let Ok(s) = LocalSession::load(&key) { self.session = s.clone(); self.todos = s.todos.clone(); self.context = s.context_tokens; self.rebuild_items(); let _ = self.cmd.send(Cmd::Load(Box::new(s))); } } } } // --- setup wizard --------------------------------------------------------------- fn open_setup(&mut self) { self.dialog = Dialog::Setup(SetupStep::Choose { sel: 0, detected: vec![], note: String::new() }); let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let _ = tx.send(crate::models::detect_local().await); }); self.pending_detect = Some(rx); } } // --- input --------------------------------------------------------------------------- fn provider_config(preset: &crate::models::Preset, base_url: &str, key: &str) -> codecraft_core::config::ProviderConfig { codecraft_core::config::ProviderConfig { kind: preset.kind, base_url: base_url.trim().to_string(), api_key: key.trim().to_string(), auth_header: None, auth_prefix: None, headers: Default::default(), models: vec![], enabled: true, max_concurrency: None, monthly_budget: None, plan_limit: None, admin_key: String::new(), } } impl App { fn on_key(&mut self, k: KeyEvent) { if k.kind == KeyEventKind::Release { return; } let ctrl = k.modifiers.contains(KeyModifiers::CONTROL); if !matches!(self.dialog, Dialog::None) { self.on_dialog_key(k); return; } // Completion popup. if let Some(c) = &mut self.completion { match k.code { KeyCode::Up => { c.sel = c.sel.saturating_sub(1); return; } KeyCode::Down => { c.sel = (c.sel + 1).min(c.items.len().saturating_sub(1)); return; } KeyCode::Tab | KeyCode::Enter if !c.items.is_empty() => { let (val, _) = c.items[c.sel].clone(); let start = c.start; let enter = k.code == KeyCode::Enter; self.completion = None; // Enter on a slash command runs it right away. if enter && val.starts_with('/') { self.editor.replace_word(start, &val); self.submit(); } else { self.editor.replace_word(start, &format!("{val} ")); } return; } KeyCode::Esc => { self.completion = None; return; } _ => {} } } match (k.code, ctrl) { (KeyCode::Char('c'), true) => { if self.busy { if let Some(c) = &self.cancel { c.cancel(); } self.items.push(Item::Info(t("aborted").into())); } else if !self.editor.is_empty() { self.editor.clear(); } else if self.quit_armed.is_some_and(|t| t.elapsed() < Duration::from_secs(2)) { self.quit = true; } else { self.quit_armed = Some(Instant::now()); self.status = t("quit_again").into(); } } (KeyCode::Char('d'), true) if self.editor.is_empty() => self.quit = true, (KeyCode::Esc, _) => { if self.busy { if let Some(c) = &self.cancel { c.cancel(); } self.queue.clear(); self.items.push(Item::Info(t("aborted").into())); } } (KeyCode::Enter, _) if k.modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) => self.editor.newline(), (KeyCode::Char('j'), true) => self.editor.newline(), (KeyCode::Enter, _) => { // A trailing backslash continues on the next line. if self.editor.lines[self.editor.row].ends_with('\\') { self.editor.backspace(); self.editor.newline(); } else { self.submit(); } } (KeyCode::BackTab, _) => self.cycle_mode(), (KeyCode::Tab, _) => self.update_completion(true), (KeyCode::Backspace, _) => self.editor.backspace(), (KeyCode::Delete, _) => self.editor.delete(), (KeyCode::Char('w'), true) => self.editor.delete_word(), (KeyCode::Char('u'), true) => self.editor.clear(), (KeyCode::Char('a'), true) | (KeyCode::Home, _) => self.editor.home(), (KeyCode::Char('e'), true) | (KeyCode::End, _) => self.editor.end(), (KeyCode::Char('o'), true) => { let open = !self.items.iter().any(|i| matches!(i, Item::Tool { open: true, .. })); for i in &mut self.items { if let Item::Tool { open: o, .. } | Item::Thinking(_, o) = i { *o = open; } } } (KeyCode::Char('v'), true) => self.paste_image(), (KeyCode::Left, _) => self.editor.left(), (KeyCode::Right, _) => self.editor.right(), (KeyCode::Up, _) => self.editor.up(), (KeyCode::Down, _) => self.editor.down(), (KeyCode::PageUp, _) => self.scroll += 10, (KeyCode::PageDown, _) => self.scroll = self.scroll.saturating_sub(10), (KeyCode::Char(c), false) => self.editor.insert(&c.to_string()), _ => {} } self.update_completion(false); } /// Image from the clipboard (Wayland, X11, macOS) via the usual helper programs. fn paste_image(&mut self) { let tries: &[(&str, &[&str])] = &[("wl-paste", &["--type", "image/png"]), ("xclip", &["-selection", "clipboard", "-t", "image/png", "-o"]), ("pngpaste", &["-"])]; for (cmd, args) in tries { if let Ok(out) = std::process::Command::new(cmd).args(*args).stderr(std::process::Stdio::null()).output() { if out.status.success() && out.stdout.starts_with(b"\x89PNG") { self.images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&out.stdout) }); self.status = format!("image {} attached", self.images.len()); return; } } } self.status = "no image in the clipboard (needs wl-paste, xclip or pngpaste) – or write @path/to/image.png".into(); } fn update_completion(&mut self, force: bool) { let (start, word) = self.editor.current_word(); if self.editor.row == 0 && start == 0 && word.starts_with('/') { let q = &word[1..]; let mut items: Vec<(String, String)> = Self::builtin_slash().into_iter().map(|(n, d)| (format!("/{n}"), d.to_string())).collect(); items.extend(self.commands().into_iter().map(|(n, d)| (format!("/{n}"), d))); items.retain(|(n, _)| n[1..].starts_with(q)); self.completion = if items.is_empty() { None } else { Some(Completion { start, items: items.into_iter().take(12).collect(), sel: 0 }) }; } else if let Some(q) = word.strip_prefix('@') { if q.is_empty() && !force { self.completion = None; return; } let ql = q.to_lowercase(); let cwd = PathBuf::from(&self.session.cwd); let mut hits: Vec<(usize, String)> = Vec::new(); for e in ignore::WalkBuilder::new(&cwd).hidden(true).build().flatten().take(20_000) { if e.depth() == 0 { continue; } let rel = crate::tools::rel(&cwd, e.path()); let rl = rel.to_lowercase(); let name = e.file_name().to_string_lossy().to_lowercase(); let rank = if name.starts_with(&ql) { 0 } else if name.contains(&ql) { 1 } else if rl.contains(&ql) { 2 } else { continue }; let dir = e.file_type().is_some_and(|t| t.is_dir()); hits.push((rank * 1000 + rel.len(), format!("{rel}{}", if dir { "/" } else { "" }))); } hits.sort(); self.completion = if hits.is_empty() { None } else { Some(Completion { start, items: hits.into_iter().take(10).map(|(_, r)| (format!("@{r}"), String::new())).collect(), sel: 0 }) }; } else { self.completion = None; } } fn on_dialog_key(&mut self, k: KeyEvent) { let dialog = std::mem::replace(&mut self.dialog, Dialog::None); self.dialog = match dialog { Dialog::Text { title, body } => match k.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => Dialog::None, _ => Dialog::Text { title, body }, }, Dialog::Permission { tool, title, diff, input, mut reply, feedback } => { if let Some(mut fb) = feedback { match k.code { KeyCode::Enter => { if let Some(r) = reply.take() { let _ = r.send(Reply::Reject(Some(fb))); } Dialog::None } KeyCode::Esc => Dialog::Permission { tool, title, diff, input, reply, feedback: None }, KeyCode::Backspace => { fb.pop(); Dialog::Permission { tool, title, diff, input, reply, feedback: Some(fb) } } KeyCode::Char(c) => { fb.push(c); Dialog::Permission { tool, title, diff, input, reply, feedback: Some(fb) } } _ => Dialog::Permission { tool, title, diff, input, reply, feedback: Some(fb) }, } } else { let answer = match k.code { KeyCode::Char('y') | KeyCode::Char('j') | KeyCode::Enter => Some(Reply::Once), KeyCode::Char('a') => Some(Reply::Always), KeyCode::Char('n') | KeyCode::Esc => Some(Reply::Reject(None)), KeyCode::Char('f') => return self.dialog = Dialog::Permission { tool, title, diff, input, reply, feedback: Some(String::new()) }, _ => None, }; match answer { Some(a) => { if let Some(r) = reply.take() { let _ = r.send(a); } Dialog::None } None => Dialog::Permission { tool, title, diff, input, reply, feedback }, } } } Dialog::Question { questions, mut idx, mut sel, mut custom, mut answers, mut reply } => { let q = questions[idx].clone(); let n = q.options.len(); let digit = match k.code { KeyCode::Char(c) if custom.is_empty() && c.is_ascii_digit() && (1..=n).contains(&(c as usize - '0' as usize)) => Some(c as usize - '1' as usize), _ => None, }; let next = |value: String, answers: &mut serde_json::Map| { answers.insert(q.question.clone(), Value::String(value)); }; match k.code { KeyCode::Esc => { if let Some(r) = reply.take() { let _ = r.send(None); } Dialog::None } KeyCode::Up => { sel = sel.saturating_sub(1); Dialog::Question { questions, idx, sel, custom, answers, reply } } KeyCode::Down => { sel = (sel + 1).min(n.saturating_sub(1)); Dialog::Question { questions, idx, sel, custom, answers, reply } } KeyCode::Backspace => { custom.pop(); Dialog::Question { questions, idx, sel, custom, answers, reply } } KeyCode::Enter | KeyCode::Char(_) if matches!(k.code, KeyCode::Enter) || digit.is_some() => { let value = if !custom.trim().is_empty() { custom.trim().to_string() } else { q.options.get(digit.unwrap_or(sel)).map(|o| o.label.clone()).unwrap_or_default() }; next(value, &mut answers); if idx + 1 < questions.len() { idx += 1; Dialog::Question { questions, idx, sel: 0, custom: String::new(), answers, reply } } else { if let Some(r) = reply.take() { let _ = r.send(Some(answers)); } Dialog::None } } KeyCode::Char(c) => { custom.push(c); Dialog::Question { questions, idx, sel, custom, answers, reply } } _ => Dialog::Question { questions, idx, sel, custom, answers, reply }, } } Dialog::Picker(mut p) => match k.code { KeyCode::Esc => Dialog::None, KeyCode::Up => { p.sel = p.sel.saturating_sub(1); Dialog::Picker(p) } KeyCode::Down => { p.sel = (p.sel + 1).min(p.visible().len().saturating_sub(1)); Dialog::Picker(p) } KeyCode::Enter => { if let Some((key, _, _)) = p.visible().get(p.sel).map(|x| (*x).clone()) { self.pick(p.kind, key); } if matches!(self.dialog, Dialog::None) { Dialog::None } else { std::mem::replace(&mut self.dialog, Dialog::None) } } KeyCode::Backspace => { p.filter.pop(); p.sel = 0; Dialog::Picker(p) } KeyCode::Char(c) => { p.filter.push(c); p.sel = 0; Dialog::Picker(p) } _ => Dialog::Picker(p), }, Dialog::Setup(step) => self.on_setup_key(step, k), Dialog::None => Dialog::None, }; } fn on_setup_key(&mut self, step: SetupStep, k: KeyEvent) -> Dialog { let presets = crate::models::presets(); match step { SetupStep::Choose { mut sel, detected, note } => { let n = presets.len() + 2; match k.code { KeyCode::Esc => { self.finish_setup(); Dialog::None } KeyCode::Up => { sel = sel.saturating_sub(1); Dialog::Setup(SetupStep::Choose { sel, detected, note }) } KeyCode::Down => { sel = (sel + 1).min(n - 1); Dialog::Setup(SetupStep::Choose { sel, detected, note }) } KeyCode::Enter if sel == 0 => { let url = crate::conf::Config::load().ok().and_then(|c| c.server.map(|s| s.url)).unwrap_or_else(|| "https://".into()); Dialog::Setup(SetupStep::Server { fields: [url, String::new(), String::new()], focus: 0, error: String::new() }) } KeyCode::Enter if sel == n - 1 => { self.finish_setup(); Dialog::None } KeyCode::Enter => { let p = &presets[sel - 1]; Dialog::Setup(SetupStep::Provider { preset: sel - 1, fields: [p.id.to_string(), p.base_url.to_string(), String::new()], focus: if p.needs_key { 2 } else { 1 }, error: String::new() }) } _ => Dialog::Setup(SetupStep::Choose { sel, detected, note }), } } SetupStep::Server { mut fields, mut focus, error } => match k.code { KeyCode::Esc => Dialog::Setup(SetupStep::Choose { sel: 0, detected: vec![], note: String::new() }), KeyCode::Tab | KeyCode::Down => { focus = (focus + 1) % 3; Dialog::Setup(SetupStep::Server { fields, focus, error }) } KeyCode::BackTab | KeyCode::Up => { focus = (focus + 2) % 3; Dialog::Setup(SetupStep::Server { fields, focus, error }) } KeyCode::Enter if focus < 2 => { focus += 1; Dialog::Setup(SetupStep::Server { fields, focus, error }) } KeyCode::Enter => { let [url, user, pass] = fields.clone(); let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let r = async { let login = crate::server::login(&url, &user, &pass).await?; let mut cfg = crate::conf::Config::load().unwrap_or_default(); let msg = format!("signed in to {} as {}", login.url, login.username); cfg.server = Some(login); cfg.save()?; Ok(msg) } .await; let _ = tx.send(r); }); self.pending_setup = Some(rx); Dialog::Setup(SetupStep::Server { fields, focus, error: "…".into() }) } KeyCode::Backspace => { fields[focus].pop(); Dialog::Setup(SetupStep::Server { fields, focus, error }) } KeyCode::Char(c) => { fields[focus].push(c); Dialog::Setup(SetupStep::Server { fields, focus, error }) } _ => Dialog::Setup(SetupStep::Server { fields, focus, error }), }, SetupStep::Provider { preset, mut fields, mut focus, error } => match k.code { KeyCode::Esc => Dialog::Setup(SetupStep::Choose { sel: preset + 1, detected: vec![], note: String::new() }), KeyCode::Tab | KeyCode::Down => { focus = (focus + 1) % 3; Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }) } KeyCode::BackTab | KeyCode::Up => { focus = (focus + 2) % 3; Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }) } KeyCode::Enter => { let p = &presets[preset]; let pc = provider_config(p, &fields[1], &fields[2]); let name: String = fields[0].trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').collect(); if name.is_empty() { return Dialog::Setup(SetupStep::Provider { preset, fields, focus, error: "name missing".into() }); } let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let r = async { let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), &pc); let models = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await.map_err(|_| anyhow::anyhow!("the endpoint did not answer"))??; let mut cfg = crate::conf::Config::load().unwrap_or_default(); cfg.providers.insert(name.clone(), pc); cfg.save()?; Ok(format!("{name}: {} {}", models.len(), t("models_found"))) } .await; let _ = tx.send(r); }); self.pending_setup = Some(rx); Dialog::Setup(SetupStep::Provider { preset, fields, focus, error: "…".into() }) } KeyCode::Backspace => { fields[focus].pop(); Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }) } KeyCode::Char(c) => { fields[focus].push(c); Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }) } _ => Dialog::Setup(SetupStep::Provider { preset, fields, focus, error }), }, } } /// Reload configuration, models and modes after the setup. fn finish_setup(&mut self) { let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let _ = tx.send(crate::build_env().await); }); self.pending_env = Some(rx); self.status = "loading models…".into(); } fn poll_pending(&mut self) { if let Some(rx) = &mut self.pending_detect { if let Ok(found) = rx.try_recv() { self.pending_detect = None; if let Dialog::Setup(SetupStep::Choose { detected, .. }) = &mut self.dialog { *detected = found; } } } if let Some(rx) = &mut self.pending_setup { if let Ok(res) = rx.try_recv() { self.pending_setup = None; match res { Ok(msg) => self.dialog = Dialog::Setup(SetupStep::Choose { sel: crate::models::presets().len() + 1, detected: vec![], note: msg }), Err(e) => match &mut self.dialog { Dialog::Setup(SetupStep::Server { error, .. }) | Dialog::Setup(SetupStep::Provider { error, .. }) => *error = format!("{e:#}"), _ => self.items.push(Item::Error(format!("{e:#}"))), }, } } } if let Some(rx) = &mut self.pending_env { if let Ok(res) = rx.try_recv() { self.pending_env = None; match res { Ok(env) => { let env = Arc::new(env); if env.catalog.find(&self.session.model).is_none() { if let Some(m) = env.catalog.models.first() { self.session.model = m.spec.clone(); let _ = self.cmd.send(Cmd::SetModel(m.spec.clone())); } } self.env = env.clone(); let _ = self.cmd.send(Cmd::SetEnv(env)); self.status = format!("{} models available", self.env.catalog.models.len()); } Err(e) => self.items.push(Item::Error(format!("{e:#}"))), } } } if let Some(rx) = &mut self.pending_jobs { if let Ok(res) = rx.try_recv() { self.pending_jobs = None; let body = match res { Ok(v) => { let mut b = String::new(); for j in v.as_array().into_iter().flatten() { b.push_str(&format!( "{} {:<32} {:<14} last: {}\n", if j["enabled"] == true { "on " } else { "off" }, j["spec"]["name"].as_str().unwrap_or("?"), j["spec"]["schedule"].as_str().unwrap_or("?"), j["last_status"].as_str().unwrap_or("-") )); } if b.is_empty() { "(no automations)".into() } else { b } } Err(e) => format!("{e:#}"), }; self.dialog = Dialog::Text { title: "Automations (server)".into(), body }; } } } fn on_mouse(&mut self, m: crossterm::event::MouseEvent) { match m.kind { MouseEventKind::ScrollUp => self.scroll += 3, MouseEventKind::ScrollDown => self.scroll = self.scroll.saturating_sub(3), MouseEventKind::Down(MouseButton::Left) => { let a = self.history_area; if m.row >= a.y && m.row < a.y + a.height && m.column >= a.x && m.column < a.x + a.width { if let Some(Some(idx)) = self.hit.get((m.row - a.y) as usize) { match &mut self.items[*idx] { Item::Tool { open, .. } | Item::Thinking(_, open) => *open = !*open, _ => {} } } } } _ => {} } } // --- rendering ---------------------------------------------------------------------- fn item_lines(&self, item: &Item, width: usize, th: &Theme) -> Vec> { let mut out = Vec::new(); match item { Item::User(text, imgs) => { out.push(Line::default()); for (i, l) in wrap(text, width.saturating_sub(2)).into_iter().enumerate() { out.push(Line::from(vec![Span::styled(if i == 0 { "› " } else { " " }, Style::default().fg(th.accent)), Span::styled(l, Style::default().fg(th.fg0).bg(th.bg1))])); } if *imgs > 0 { out.push(Line::from(Span::styled(format!(" [{imgs} image(s)]"), Style::default().fg(th.muted)))); } } Item::Assistant(text) => { out.push(Line::default()); out.extend(markdown(text.trim(), width, th)); } Item::Thinking(text, open) => { let words = text.split_whitespace().count(); out.push(Line::from(Span::styled(format!("{} {} ({words} words)", if *open { "▾" } else { "▸" }, t("thinking")), Style::default().fg(th.muted)))); if *open { for l in wrap(text.trim(), width.saturating_sub(2)) { out.push(Line::from(Span::styled(format!(" {l}"), Style::default().fg(th.fg3).add_modifier(Modifier::ITALIC)))); } } } Item::Tool { name, title, state, output, diff, open, .. } => { let (icon, col) = match state { ToolState::Running => (SPINNER[self.tick % SPINNER.len()], th.accent), ToolState::Ok => ("✓", th.green), ToolState::Err => ("✗", th.red), }; let title: String = title.lines().next().unwrap_or("").chars().take(width.saturating_sub(name.len() + 6)).collect(); out.push(Line::from(vec![Span::styled(format!("{icon} "), Style::default().fg(col)), Span::styled(name.clone(), Style::default().fg(th.accent2).add_modifier(Modifier::BOLD)), Span::styled(format!(" {title}"), Style::default().fg(th.fg2))])); if let Some(d) = diff { out.extend(diff_lines(d, if *open { 400 } else { 12 }, th)); } else if *open && !output.is_empty() { for l in wrap(output, width.saturating_sub(4)).into_iter().take(80) { out.push(Line::from(Span::styled(format!(" {l}"), Style::default().fg(th.fg3)))); } } else if matches!(state, ToolState::Err) { let first: String = output.lines().next().unwrap_or("").chars().take(width.saturating_sub(4)).collect(); out.push(Line::from(Span::styled(format!(" {first}"), Style::default().fg(th.red)))); } } Item::Info(s) => out.push(Line::from(Span::styled(format!(" {s}"), Style::default().fg(th.muted)))), Item::Error(s) => { for l in wrap(s, width.saturating_sub(2)) { out.push(Line::from(Span::styled(format!("! {l}"), Style::default().fg(th.red)))); } } } out } fn draw(&mut self, f: &mut Frame) { let th = theme::get(); let area = f.area(); f.render_widget(Block::default().style(Style::default().bg(th.bg0).fg(th.fg1)), area); let input_h = (self.editor.lines.len() as u16).clamp(1, 8) + 2; let todo_items: Vec<&Value> = self.todos.as_array().map(|a| a.iter().collect()).unwrap_or_default(); let todo_h = if todo_items.iter().any(|t| t["status"] != "completed") { (todo_items.len() as u16).min(6) } else { 0 }; let [head, hist, todos, act, input, status] = Layout::vertical([Constraint::Length(1), Constraint::Min(3), Constraint::Length(todo_h), Constraint::Length(1), Constraint::Length(input_h), Constraint::Length(1)]).areas(area); // Header. let cwd = self.session.cwd.replace(&crate::conf::home().display().to_string(), "~"); f.render_widget( Paragraph::new(Line::from(vec![ Span::styled(" CodeCraft ", Style::default().fg(th.on_accent).bg(th.accent).add_modifier(Modifier::BOLD)), Span::styled(format!(" {cwd}"), Style::default().fg(th.fg2)), Span::styled(if self.session.title.is_empty() { String::new() } else { format!(" · {}", self.session.title) }, Style::default().fg(th.muted)), ])), head, ); // History. let width = hist.width.saturating_sub(2) as usize; let mut lines: Vec = Vec::new(); let mut owners: Vec> = Vec::new(); if self.items.is_empty() { lines.push(Line::default()); lines.push(Line::from(Span::styled(format!(" {}", t("welcome")), Style::default().fg(th.accent).add_modifier(Modifier::BOLD)))); lines.push(Line::from(Span::styled(format!(" {} · {} · /help", self.model_label(), self.session.mode), Style::default().fg(th.muted)))); owners.extend([None, None, None]); } for (i, item) in self.items.iter().enumerate() { let l = self.item_lines(item, width, &th); owners.extend(std::iter::repeat_n(Some(i), l.len())); lines.extend(l); } let h = hist.height as usize; let max_scroll = lines.len().saturating_sub(h); self.scroll = self.scroll.min(max_scroll); let start = lines.len().saturating_sub(h + self.scroll); let shown: Vec = lines[start..(start + h).min(lines.len())].to_vec(); self.hit = owners[start..(start + h).min(owners.len())].to_vec(); self.history_area = Rect { x: hist.x + 1, ..hist }; f.render_widget(Paragraph::new(shown), Rect { x: hist.x + 1, width: hist.width.saturating_sub(1), ..hist }); if self.scroll > 0 { let note = format!(" ↓ {} more ", self.scroll); let w = note.chars().count() as u16; f.render_widget(Paragraph::new(Span::styled(note, Style::default().fg(th.on_accent).bg(th.accent))), Rect { x: hist.x + hist.width.saturating_sub(w + 1), y: hist.y + hist.height - 1, width: w, height: 1 }); } // Todos. if todo_h > 0 { let tl: Vec = todo_items .iter() .take(6) .map(|t| { let (mark, col) = match t["status"].as_str() { Some("completed") => ("[x]", th.green), Some("in_progress") => ("[>]", th.accent), _ => ("[ ]", th.muted), }; Line::from(vec![Span::styled(format!(" {mark} "), Style::default().fg(col)), Span::styled(t["content"].as_str().unwrap_or("").to_string(), Style::default().fg(th.fg2))]) }) .collect(); f.render_widget(Paragraph::new(tl), todos); } // Activity line. let activity = if self.busy { let secs = self.busy_since.elapsed().as_secs(); Line::from(vec![ Span::styled(format!(" {} ", SPINNER[self.tick % SPINNER.len()]), Style::default().fg(th.accent2)), Span::styled(format!("{}…", t("working")), Style::default().fg(th.accent2)), Span::styled(format!(" ({secs}s · ~{} tokens) · {}", self.step_chars / 4, t("interrupt")), Style::default().fg(th.muted)), Span::styled(if self.queue.is_empty() { String::new() } else { format!(" · {} {}", self.queue.len(), t("queued")) }, Style::default().fg(th.muted)), ]) } else { Line::from(Span::styled(format!(" {}", self.status), Style::default().fg(th.muted))) }; f.render_widget(Paragraph::new(activity), act); // Input. let mode_col = match self.session.mode.as_str() { "plan" => th.blue, "craft" => th.purple, _ => th.accent, }; let mut title = vec![Span::styled(format!(" {} ", self.session.mode), Style::default().fg(th.on_accent).bg(mode_col).add_modifier(Modifier::BOLD))]; if !self.images.is_empty() { title.push(Span::styled(format!(" {} image(s) ", self.images.len()), Style::default().fg(th.accent2))); } if let Some(e) = &self.effort { title.push(Span::styled(format!(" effort {e} "), Style::default().fg(th.accent2))); } let block = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(if self.busy { th.bg3 } else { mode_col })).title(Line::from(title)); let inner = block.inner(input); f.render_widget(block, input); let visible = inner.height as usize; let first = self.editor.row.saturating_sub(visible.saturating_sub(1)); let text: Vec = if self.editor.is_empty() { vec![Line::from(Span::styled(if self.busy { t("busy_placeholder") } else { t("placeholder") }, Style::default().fg(th.muted)))] } else { self.editor.lines[first..].iter().take(visible).map(|l| Line::from(Span::styled(l.clone(), Style::default().fg(th.fg0)))).collect() }; let scroll_x = self.editor.cursor_x().saturating_sub(inner.width.saturating_sub(1) as usize) as u16; f.render_widget(Paragraph::new(text).scroll((0, scroll_x)), inner); if matches!(self.dialog, Dialog::None) { f.set_cursor_position((inner.x + self.editor.cursor_x() as u16 - scroll_x, inner.y + (self.editor.row - first) as u16)); } // Status line. let ctx_pct = self.env.catalog.find(&self.session.model).and_then(|m| m.config.context).filter(|c| *c > 0).map(|c| format!("{} {}%", t("context"), self.context * 100 / c)).unwrap_or_default(); let cost = if self.session.cost > 0.0 { format!(" · {:.3} {}", self.session.cost, self.env.catalog.currency) } else { String::new() }; let left = format!(" {} · {} ", self.model_label(), self.session.permission_mode); let right = format!("{ctx_pct}{cost} · Shift+Tab {} · /help ", t("mode")); let pad = (area.width as usize).saturating_sub(left.chars().count() + right.chars().count()); f.render_widget(Paragraph::new(Line::from(vec![Span::styled(left, Style::default().fg(th.fg3)), Span::raw(" ".repeat(pad)), Span::styled(right, Style::default().fg(th.muted))])).style(Style::default().bg(th.bg1)), status); // Completion popup. if let Some(c) = &self.completion { let h = c.items.len() as u16 + 2; let w = (c.items.iter().map(|(a, b)| a.chars().count() + b.chars().count() + 4).max().unwrap_or(20) as u16 + 2).min(area.width - 4); let r = Rect { x: input.x + 1, y: input.y.saturating_sub(h), width: w, height: h }; f.render_widget(Clear, r); let lines: Vec = c .items .iter() .enumerate() .map(|(i, (a, b))| { let st = if i == c.sel { Style::default().fg(th.on_accent).bg(th.accent) } else { Style::default().fg(th.fg1) }; Line::from(vec![Span::styled(format!(" {a} "), st), Span::styled(format!(" {b}"), Style::default().fg(th.muted))]) }) .collect(); f.render_widget(Paragraph::new(lines).block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(th.bg3))).style(Style::default().bg(th.bg1)), r); } self.draw_dialog(f, area, &th); } fn draw_dialog(&self, f: &mut Frame, area: Rect, th: &Theme) { let boxed = |f: &mut Frame, title: &str, w: u16, h: u16, color| -> Rect { let w = w.min(area.width.saturating_sub(4)); let h = h.min(area.height.saturating_sub(2)); let r = Rect { x: area.x + (area.width - w) / 2, y: area.y + (area.height - h) / 2, width: w, height: h }; f.render_widget(Clear, r); let b = Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(color)).title(Span::styled(format!(" {title} "), Style::default().fg(color).add_modifier(Modifier::BOLD))).style(Style::default().bg(th.bg1).fg(th.fg1)); let inner = b.inner(r); f.render_widget(b, r); inner }; match &self.dialog { Dialog::None => {} Dialog::Text { title, body } => { let lines = body.lines().count() as u16 + 3; let inner = boxed(f, title, 100, lines, th.accent); f.render_widget(Paragraph::new(body.clone()).style(Style::default().fg(th.fg1)), inner); } Dialog::Permission { tool, title, diff, input, feedback, .. } => { let mut lines = vec![Line::from(vec![Span::styled(format!("{tool} "), Style::default().fg(th.accent2).add_modifier(Modifier::BOLD)), Span::raw(title.clone())])]; if tool == "bash" { for l in wrap(&format!("$ {}", input["command"].as_str().unwrap_or("")), 94) { lines.push(Line::from(Span::styled(l, Style::default().fg(th.fg0)))); } } if let Some(d) = diff { lines.extend(diff_lines(d, 30, th)); } else if tool != "bash" { for l in wrap(&serde_json::to_string_pretty(input).unwrap_or_default(), 94).into_iter().take(16) { lines.push(Line::from(Span::styled(l, Style::default().fg(th.fg3)))); } } lines.push(Line::default()); match feedback { Some(fb) => { lines.push(Line::from(Span::styled(t("feedback"), Style::default().fg(th.fg2)))); lines.push(Line::from(Span::styled(format!("> {fb}_"), Style::default().fg(th.fg0)))); } None => lines.push(Line::from(Span::styled(t("perm_keys"), Style::default().fg(th.accent)))), } let inner = boxed(f, t("permission"), 100, lines.len() as u16 + 2, th.accent); f.render_widget(Paragraph::new(lines), inner); } Dialog::Question { questions, idx, sel, custom, .. } => { let q = &questions[*idx]; let mut lines = vec![Line::from(Span::styled(q.question.clone(), Style::default().fg(th.fg0).add_modifier(Modifier::BOLD))), Line::default()]; for (i, o) in q.options.iter().enumerate() { let st = if i == *sel && custom.is_empty() { Style::default().fg(th.on_accent).bg(th.accent) } else { Style::default().fg(th.fg1) }; lines.push(Line::from(vec![Span::styled(format!(" {}. {} ", i + 1, o.label), st), Span::styled(format!(" {}", o.description), Style::default().fg(th.muted))])); } lines.push(Line::default()); lines.push(Line::from(Span::styled(format!("> {custom}_"), Style::default().fg(th.fg0)))); lines.push(Line::from(Span::styled(t("question_keys"), Style::default().fg(th.muted)))); let title = format!("{} {}/{}", t("question"), idx + 1, questions.len()); let inner = boxed(f, &title, 90, lines.len() as u16 + 2, th.blue); f.render_widget(Paragraph::new(lines), inner); } Dialog::Picker(p) => { let vis = p.visible(); let h = (vis.len() as u16).min(18) + 4; let inner = boxed(f, &p.title, 90, h, th.accent); let mut lines = vec![Line::from(Span::styled(if p.filter.is_empty() { format!("({})", t("filter")) } else { format!("> {}_", p.filter) }, Style::default().fg(th.muted)))]; let first = p.sel.saturating_sub(15); for (i, (key, label, detail)) in vis.iter().enumerate().skip(first).take(16) { let current = match p.kind { PickKind::Model => *key == self.session.model, PickKind::Mode => *key == self.session.mode, PickKind::Permission => *key == self.session.permission_mode, _ => false, }; let st = if i == p.sel { Style::default().fg(th.on_accent).bg(th.accent) } else { Style::default().fg(if current { th.accent } else { th.fg1 }) }; lines.push(Line::from(vec![Span::styled(format!(" {}{label} ", if current { "● " } else { " " }), st), Span::styled(format!(" {detail}"), Style::default().fg(th.muted))])); } f.render_widget(Paragraph::new(lines), inner); } Dialog::Setup(step) => { let presets = crate::models::presets(); let mut lines: Vec = Vec::new(); match step { SetupStep::Choose { sel, detected, note } => { lines.push(Line::from(Span::styled(t("setup_intro"), Style::default().fg(th.fg2)))); lines.push(Line::default()); let cfg = crate::conf::Config::load().unwrap_or_default(); let mut entries: Vec<(String, String)> = vec![(t("setup_server").into(), cfg.server.as_ref().map(|s| format!("✓ {}", s.url)).unwrap_or_default())]; for p in &presets { let mut d = String::new(); if cfg.providers.contains_key(p.id) { d.push_str("✓ "); } if detected.contains(&p.id) { d.push_str(t("detected")); } entries.push((p.label.to_string(), d)); } entries.push((t("setup_done").into(), String::new())); for (i, (l, d)) in entries.iter().enumerate() { let st = if i == *sel { Style::default().fg(th.on_accent).bg(th.accent) } else { Style::default().fg(th.fg1) }; lines.push(Line::from(vec![Span::styled(format!(" {l} "), st), Span::styled(format!(" {d}"), Style::default().fg(th.green))])); } if !note.is_empty() { lines.push(Line::default()); lines.push(Line::from(Span::styled(format!(" ✓ {note}"), Style::default().fg(th.green)))); } } SetupStep::Server { fields, focus, error } => { let labels = [t("server_url"), t("username"), t("password")]; for i in 0..3 { let val = if i == 2 { "•".repeat(fields[i].chars().count()) } else { fields[i].clone() }; let st = if i == *focus { Style::default().fg(th.fg0).bg(th.bg2) } else { Style::default().fg(th.fg1) }; lines.push(Line::from(vec![Span::styled(format!(" {:<14}", labels[i]), Style::default().fg(th.fg3)), Span::styled(format!(" {val}{} ", if i == *focus { "_" } else { "" }), st)])); } lines.push(Line::default()); lines.push(Line::from(Span::styled(if error.is_empty() { t("enter_next").to_string() } else { error.clone() }, Style::default().fg(if error.is_empty() || error == "…" { th.muted } else { th.red })))); } SetupStep::Provider { preset, fields, focus, error } => { let p = &presets[*preset]; lines.push(Line::from(Span::styled(p.label, Style::default().fg(th.accent).add_modifier(Modifier::BOLD)))); let labels = [t("provider_name"), t("base_url"), t("api_key")]; for i in 0..3 { let val = if i == 2 && !fields[i].starts_with("${") { "•".repeat(fields[i].chars().count()) } else { fields[i].clone() }; let st = if i == *focus { Style::default().fg(th.fg0).bg(th.bg2) } else { Style::default().fg(th.fg1) }; lines.push(Line::from(vec![Span::styled(format!(" {:<22}", labels[i]), Style::default().fg(th.fg3)), Span::styled(format!(" {val}{} ", if i == *focus { "_" } else { "" }), st)])); } if !p.key_hint.is_empty() { lines.push(Line::from(Span::styled(format!(" {}", p.key_hint), Style::default().fg(th.muted)))); } lines.push(Line::default()); lines.push(Line::from(Span::styled(if error.is_empty() { t("enter_next").to_string() } else { error.clone() }, Style::default().fg(if error.is_empty() || error == "…" { th.muted } else { th.red })))); } } let inner = boxed(f, t("setup_title"), 80, lines.len() as u16 + 2, th.accent); f.render_widget(Paragraph::new(lines), inner); } } } } /// Run the TUI until the user quits. pub async fn run(mut app: App, mut ui: mpsc::UnboundedReceiver, initial: Option, setup: bool) -> Result<()> { use crossterm::{event, execute, terminal}; terminal::enable_raw_mode()?; let mut out = std::io::stdout(); execute!(out, terminal::EnterAlternateScreen, event::EnableMouseCapture, event::EnableBracketedPaste)?; let enhanced = terminal::supports_keyboard_enhancement().unwrap_or(false); if enhanced { execute!(out, event::PushKeyboardEnhancementFlags(event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES))?; } // Restore the terminal on panic. let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { let _ = crossterm::terminal::disable_raw_mode(); let _ = crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen, crossterm::event::DisableMouseCapture); hook(info); })); let backend = ratatui::backend::CrosstermBackend::new(std::io::stdout()); let mut term = ratatui::Terminal::new(backend)?; let mut events = EventStream::new(); let mut tick = tokio::time::interval(Duration::from_millis(120)); if setup { app.open_setup(); } else if let Some(text) = initial { app.send(text); } let result: Result<()> = async { loop { term.draw(|f| app.draw(f))?; tokio::select! { ev = events.next() => match ev { Some(Ok(Event::Key(k))) => app.on_key(k), Some(Ok(Event::Mouse(m))) => app.on_mouse(m), Some(Ok(Event::Paste(s))) => { if let Dialog::Permission { feedback: Some(fb), .. } = &mut app.dialog { fb.push_str(&s); } else if matches!(app.dialog, Dialog::None) { app.editor.insert(&s); } } Some(Ok(_)) => {} Some(Err(e)) => return Err(e.into()), None => break, }, Some(ev) = ui.recv() => app.on_agent(ev), _ = tick.tick() => { app.tick += 1; app.poll_pending(); } } if app.quit { break; } } Ok(()) } .await; if enhanced { let _ = execute!(std::io::stdout(), event::PopKeyboardEnhancementFlags); } let _ = execute!(std::io::stdout(), event::DisableBracketedPaste, event::DisableMouseCapture, terminal::LeaveAlternateScreen); terminal::disable_raw_mode()?; if !app.session.messages.is_empty() { println!("Session saved – continue with: codecraft --resume {}", app.session.id); } result } impl App { pub fn set_status(&mut self, s: String) { self.status = s; } }