- 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>
1634 lines
78 KiB
Rust
1634 lines
78 KiB
Rust
//! 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;
|
||
}
|
||
}
|