Files
codecraft/cli/src/app.rs
T
Richard ZinkandClaude Opus 5.5 bfa1f641d7 opencode prices and Go limits, settings out of .env
- Fetch prices for opencode (Zen and Go) incl. limits, image input, reasoning,
  long-context tiers and peak-hour/date factors; costs apply them per request
- Record the cost opencode reports per response; track Go allowance in 5h/7d/month
  windows against a provider plan limit (budget table, status overlay)
- Settings → Config file: move values of ${NAME} references into the config
- Examples, presets and forms keep keys in the config instead of .env

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:19:15 +01:00

1634 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>, open: bool },
Info(String),
Error(String),
}
pub enum Cmd {
Prompt { text: String, images: Vec<ImageData>, effort: Option<String>, cancel: CancellationToken },
SetModel(String),
SetMode(String),
SetPermission(String),
Load(Box<LocalSession>),
New,
Compact(CancellationToken),
SetEnv(Arc<Env>),
}
#[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<String>, input: Value, reply: Option<oneshot::Sender<Reply>>, feedback: Option<String> },
Question { questions: Vec<Question>, idx: usize, sel: usize, custom: String, answers: serde_json::Map<String, Value>, reply: Option<oneshot::Sender<Option<serde_json::Map<String, Value>>>> },
Picker(Picker),
Setup(SetupStep),
Text { title: String, body: String },
}
struct Completion {
start: usize,
items: Vec<(String, String)>,
sel: usize,
}
pub struct App {
env: Arc<Env>,
items: Vec<Item>,
scroll: usize,
editor: Editor,
completion: Option<Completion>,
busy: bool,
busy_since: Instant,
cancel: Option<CancellationToken>,
queue: VecDeque<String>,
dialog: Dialog,
session: LocalSession,
effort: Option<String>,
images: Vec<ImageData>,
todos: Value,
context: u64,
status: String,
quit_armed: Option<Instant>,
quit: bool,
cmd: mpsc::UnboundedSender<Cmd>,
/// History line -> item index (mouse clicks), and the history area.
hit: Vec<Option<usize>>,
history_area: Rect,
tick: usize,
step_chars: usize,
pending_jobs: Option<oneshot::Receiver<Result<Value>>>,
pending_detect: Option<oneshot::Receiver<Vec<&'static str>>>,
/// Running setup action (login / provider test): Ok(message) or error.
pending_setup: Option<oneshot::Receiver<Result<String>>>,
pending_env: Option<oneshot::Receiver<Result<Env>>>,
}
const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
fn wrap(text: &str, width: usize) -> Vec<String> {
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<Span<'static>> {
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<Line<'static>> {
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<Line<'static>> {
let mut out: Vec<Line> = 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<Env>, session: LocalSession, cmd: mpsc::UnboundedSender<Cmd>) -> 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<String> {
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<String> {
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<String> = 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<String, Value>| {
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<Line<'static>> {
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<Line> = Vec::new();
let mut owners: Vec<Option<usize>> = 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<Line> = 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<Line> = 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<Line> = 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<Line> = 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<Line> = 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<UiEvent>, initial: Option<String>, 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;
}
}