use std::io;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use ratatui::Frame;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
Wrap,
};
use tokio::sync::{mpsc, oneshot};
use std::path::PathBuf;
use super::chrome;
use super::goal_display::{GOAL_USAGE, goal_status_label, goal_status_line, goal_usage_summary};
use super::picker::{self, PickAction, PickRow, Picker, PickerEvent, PickerKind};
use super::term;
use super::theme::{Theme, ThemeRegistry};
use super::{Connect, ConnectStep, Effect, Entry, Model, Msg, update};
use crate::agents::{AgentRegistry, load_agents};
use crate::config::Config;
use crate::core::agent::{AgentEvent, AgentLoop, Session, assemble};
use crate::core::auth::ApiKeyStore;
use crate::core::capability::CapabilitySource;
use crate::core::context::{CompactMode, ContextManager};
use crate::core::goal::runtime::{GoalRuntime, Pricing};
use crate::core::goal::{GoalLimits, GoalStore};
use crate::core::models_dev::{self, Catalog};
use crate::core::permission::{PermissionEngine, Rule};
use crate::core::prompt::{
PromptContext, build_system_prompt, load_project_context, load_system_append,
load_system_override,
};
use crate::core::session_store::{SessionMeta, SessionStore, SessionSummary, now_unix};
use crate::core::types::{ChatRequest, ContentBlock, Message, Role, Usage};
use crate::provider::Provider;
use crate::provider::anthropic_messages::Anthropic;
use crate::provider::models_list::list_models;
use crate::provider::openai_chat::OpenAiChat;
use crate::provider::openai_compatible::openai_compatible;
use crate::provider::openai_responses::OpenAiResponses;
use crate::provider::retry::RetryProvider;
use crate::skills::{SkillSet, load_skills};
use crate::tools::builtins::BuiltinTools;
use crate::tools::optimize::Optimizer;
use crate::tools::subagent::SubAgentTool;
use crate::tools::{Permission, PermissionRequest, Registry, Tool, ToolCtx};
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
type CancelSlot = Arc<Mutex<Option<CancellationToken>>>;
struct PermissionAsk {
summary: String,
tool: String,
key: String,
reply: oneshot::Sender<bool>,
}
enum Control {
SwitchModel(String),
SwitchProvider(String, String),
Compact,
NewSession,
LoadSession(String),
UndoMessage,
RedoMessage,
RewindTo(usize),
Inspect(oneshot::Sender<ContextReport>),
SetMode(String),
}
#[derive(Debug, Clone, Default)]
pub struct ContextReport {
pub sections: Vec<(String, u64)>,
pub total: u64,
pub messages: usize,
pub native_mgmt: bool,
}
fn context_report(session: &Session, registry: &Registry, native_mgmt: bool) -> ContextReport {
use crate::core::types::Role;
use crate::tools::optimize::estimate_tokens;
let system = estimate_tokens(&session.system);
let tools: u64 = registry
.specs()
.iter()
.map(|s| {
serde_json::to_string(s)
.map(|j| estimate_tokens(&j))
.unwrap_or(0)
})
.sum();
let tool_count = registry.specs().len();
let mut user = 0u64;
let mut assistant = 0u64;
let mut results = 0u64;
for m in &session.messages {
let n = serde_json::to_string(m)
.map(|s| estimate_tokens(&s))
.unwrap_or(0);
match m.role {
Role::User => {
if m.content
.iter()
.any(|c| matches!(c, crate::core::types::ContentBlock::ToolResult { .. }))
{
results += n;
} else {
user += n;
}
}
Role::Assistant => assistant += n,
_ => results += n,
}
}
let sections = vec![
("system prompt".to_string(), system),
(format!("tool schemas ({tool_count})"), tools),
("your messages".to_string(), user),
("assistant replies".to_string(), assistant),
("tool results".to_string(), results),
];
ContextReport {
total: sections.iter().map(|(_, n)| *n).sum(),
sections,
messages: session.messages.len(),
native_mgmt,
}
}
enum KeyAction {
Prompt(String),
SwitchModel(String),
SwitchSavedProvider(String),
ConnectProvider {
name: String,
kind: String,
base_url: String,
key: String,
},
Goal(super::input::GoalCmd),
GoalEdit,
Compact,
Interrupt,
NewSession,
OpenSessions,
LoadSession(String),
SetTheme(usize),
OpenEditor,
ExportSession,
CopyLast,
MessagesUndo,
MessagesRedo,
CycleRecent(i8),
ToggleFavorite,
RenameSession(String),
DeleteSession(String),
ForkSession(String),
AllowAlways,
ShowPermissions,
ToggleMouse,
OpenProviders,
DeleteProvider(String),
PasteClipboard,
MsgMenuExec,
RunGit(String),
Redraw,
NewTab,
CloseTab,
FocusTab(usize),
InspectContext,
OpenTerm,
OpenEditorPanel,
Steer(String),
}
struct TuiPermission {
tx: mpsc::UnboundedSender<PermissionAsk>,
}
#[async_trait]
impl Permission for TuiPermission {
async fn request(&self, req: PermissionRequest<'_>) -> bool {
let (reply, rx) = oneshot::channel();
if self
.tx
.send(PermissionAsk {
summary: req.summary.to_string(),
tool: req.tool.to_string(),
key: req.key.to_string(),
reply,
})
.is_err()
{
return false;
}
rx.await.unwrap_or(false)
}
}
struct Preset {
label: &'static str,
name: &'static str,
kind: &'static str,
base_url: Option<&'static str>,
}
const PRESETS: [Preset; 7] = [
Preset {
label: "OpenAI",
name: "openai",
kind: "openai-chat",
base_url: Some("https://api.openai.com/v1"),
},
Preset {
label: "Anthropic (Claude)",
name: "anthropic",
kind: "anthropic",
base_url: Some("https://api.anthropic.com"),
},
Preset {
label: "OpenRouter",
name: "openrouter",
kind: "openai-compatible",
base_url: Some("https://openrouter.ai/api/v1"),
},
Preset {
label: "Groq",
name: "groq",
kind: "openai-compatible",
base_url: Some("https://api.groq.com/openai/v1"),
},
Preset {
label: "NVIDIA",
name: "nvidia",
kind: "openai-compatible",
base_url: Some("https://integrate.api.nvidia.com/v1"),
},
Preset {
label: "Ollama (local)",
name: "ollama",
kind: "openai-compatible",
base_url: Some("http://localhost:11434/v1"),
},
Preset {
label: "Custom…",
name: "custom",
kind: "openai-compatible",
base_url: None,
},
];
fn key_env_for(kind: &str) -> &'static str {
match kind {
"anthropic" => "ANTHROPIC_API_KEY",
_ => "OPENAI_API_KEY",
}
}
fn activate_endpoint(kind: &str, base_url: &str, key: &str) {
unsafe {
if !base_url.is_empty() {
std::env::set_var("CORDY_BASE_URL", base_url);
}
if !key.is_empty() {
std::env::set_var(key_env_for(kind), key);
}
}
}
fn key_store(cwd: &std::path::Path) -> ApiKeyStore {
let path = user_cordy_dir()
.unwrap_or_else(|| cwd.join(".cordy"))
.join("keys.json");
ApiKeyStore::new(path)
}
fn parse_rule_spec(spec: &str, allow: bool) -> Rule {
match spec.split_once(':') {
Some((tool, glob)) => Rule::new(tool.trim(), glob.trim(), allow),
None => Rule::new("*", spec.trim(), allow),
}
}
fn permission_rules(config: &Config, cwd: &std::path::Path) -> Vec<Rule> {
let p = &config.permissions;
let mut rules: Vec<Rule> = Vec::new();
for d in &p.deny {
rules.push(parse_rule_spec(d, false));
}
for a in &p.allow {
rules.push(parse_rule_spec(a, true));
}
for a in load_perm_allows(cwd) {
rules.push(parse_rule_spec(&a, true));
}
if p.mode.as_deref() == Some("auto") {
rules.push(Rule::new("*", "*", true)); }
rules
}
fn perm_store_path(cwd: &std::path::Path) -> PathBuf {
user_cordy_dir()
.unwrap_or_else(|| cwd.join(".cordy"))
.join("permissions.json")
}
fn load_perm_allows(cwd: &std::path::Path) -> Vec<String> {
std::fs::read_to_string(perm_store_path(cwd))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn add_perm_allow(cwd: &std::path::Path, spec: &str) {
let mut list = load_perm_allows(cwd);
if list.iter().any(|s| s == spec) {
return;
}
list.push(spec.to_string());
let path = perm_store_path(cwd);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(&list) {
let _ = std::fs::write(path, json);
}
}
struct ActiveProvider {
name: String,
kind: String,
base_url: String,
model: String,
}
fn active_path(cwd: &std::path::Path) -> PathBuf {
user_cordy_dir()
.unwrap_or_else(|| cwd.join(".cordy"))
.join("active.json")
}
fn load_active(cwd: &std::path::Path) -> Option<ActiveProvider> {
let text = std::fs::read_to_string(active_path(cwd)).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
Some(ActiveProvider {
name: v["name"].as_str()?.to_string(),
kind: v["kind"]
.as_str()
.unwrap_or("openai-compatible")
.to_string(),
base_url: v["base_url"].as_str().unwrap_or("").to_string(),
model: v["model"].as_str().unwrap_or("").to_string(),
})
}
fn save_active(cwd: &std::path::Path, name: &str, kind: &str, base_url: &str, model: &str) {
let path = active_path(cwd);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let v = serde_json::json!({ "name": name, "kind": kind, "base_url": base_url, "model": model });
if let Ok(json) = serde_json::to_string_pretty(&v) {
let _ = std::fs::write(path, json);
}
}
fn update_active_model(cwd: &std::path::Path, model: &str) {
if let Some(act) = load_active(cwd) {
save_active(cwd, &act.name, &act.kind, &act.base_url, model);
}
}
fn favorites_path(cwd: &std::path::Path) -> PathBuf {
user_cordy_dir()
.unwrap_or_else(|| cwd.join(".cordy"))
.join("favorites.json")
}
fn load_favorites(cwd: &std::path::Path) -> Vec<String> {
std::fs::read_to_string(favorites_path(cwd))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_favorites(cwd: &std::path::Path, favs: &[String]) {
let path = favorites_path(cwd);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(favs) {
let _ = std::fs::write(path, json);
}
}
fn recents_path(cwd: &std::path::Path) -> PathBuf {
user_cordy_dir()
.unwrap_or_else(|| cwd.join(".cordy"))
.join("recents.json")
}
fn load_recents(cwd: &std::path::Path) -> Vec<String> {
std::fs::read_to_string(recents_path(cwd))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn push_recent(model: &mut Model, name: &str, cwd: &std::path::Path) {
model.recents.retain(|m| m != name);
model.recents.insert(0, name.to_string());
model.recents.truncate(12);
let path = recents_path(cwd);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(&model.recents) {
let _ = std::fs::write(path, json);
}
}
fn session_store_dir(cwd: &std::path::Path) -> PathBuf {
user_cordy_dir()
.unwrap_or_else(|| cwd.join(".cordy"))
.join("sessions")
.join(project_slug(cwd))
}
fn goal_path(cwd: &std::path::Path, session_id: &str) -> PathBuf {
session_store_dir(cwd).join(format!("{session_id}.goal.json"))
}
fn model_pricing(config: &Config, model: &str) -> Pricing {
let (price_in, price_out) = config
.model(model)
.map(|m| (m.price_in, m.price_out))
.unwrap_or((None, None));
Pricing {
input_per_mtok: price_in,
output_per_mtok: price_out,
}
}
fn config_goal_limits(config: &Config) -> GoalLimits {
GoalLimits {
token_budget: config.goal.token_budget,
cost_cap_usd: config.goal.cost_cap_usd,
max_iterations: config.goal.max_turns,
}
}
fn project_slug(cwd: &std::path::Path) -> String {
let full = cwd.to_string_lossy();
let mut hash: u64 = 0xcbf29ce484222325;
for b in full.bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
let tail: String = cwd
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "root".into())
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(24)
.collect();
let tail = if tail.is_empty() { "root".into() } else { tail };
format!("{tail}-{hash:016x}")
}
fn build_provider(model_name: &str) -> Arc<dyn Provider> {
let kind = std::env::var("CORDY_PROVIDER").unwrap_or_else(|_| "openai-chat".into());
build_provider_for(&kind, model_name)
}
fn build_provider_for(kind: &str, model_name: &str) -> Arc<dyn Provider> {
let base = std::env::var("CORDY_BASE_URL").ok();
let inner: Arc<dyn Provider> = match kind {
"anthropic" => {
let key = std::env::var("ANTHROPIC_API_KEY").unwrap_or_default();
let mut p = Anthropic::new(key, model_name);
if let Some(b) = base {
p = p.with_base_url(b);
}
Arc::new(p)
}
"openai-responses" => {
let key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
let mut p = OpenAiResponses::new(key, model_name);
if let Some(b) = base {
p = p.with_base_url(b);
}
Arc::new(p)
}
"openai-compatible" => {
let key = std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| "sk-local".into());
Arc::new(openai_compatible(
base.unwrap_or_else(|| "http://localhost:11434/v1".into()),
key,
model_name,
))
}
_ => {
let key = std::env::var("OPENAI_API_KEY").unwrap_or_default();
let mut p = OpenAiChat::new(key, model_name);
if let Some(b) = base {
p = p.with_base_url(b);
}
Arc::new(p)
}
};
Arc::new(RetryProvider::new(inner, 3))
}
fn user_config_path() -> Option<PathBuf> {
user_cordy_dir().map(|d| d.join("config.toml"))
}
fn user_cordy_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".cordy"))
}
fn endpoint_for(kind: &str) -> Option<(String, String)> {
let base = std::env::var("CORDY_BASE_URL").ok();
match kind {
"anthropic" => None,
"openai-compatible" => Some((
base.unwrap_or_else(|| "http://localhost:11434/v1".into()),
std::env::var("OPENAI_API_KEY").unwrap_or_default(),
)),
_ => Some((
base.unwrap_or_else(|| "https://api.openai.com/v1".into()),
std::env::var("OPENAI_API_KEY").unwrap_or_default(),
)),
}
}
async fn fetch_all_models(
config: &Config,
active_kind: &str,
cwd: &std::path::Path,
) -> Vec<String> {
let mut endpoints: Vec<(String, String)> = Vec::new();
if let Some((base, key)) = endpoint_for(active_kind) {
endpoints.push((base, key));
}
for p in &config.providers {
if p.kind == "anthropic" {
continue; }
let base = p.base_url.clone().unwrap_or_default();
if base.is_empty() {
continue;
}
let key = p
.api_key_env
.as_ref()
.and_then(|e| std::env::var(e).ok())
.or_else(|| key_store(cwd).get(&p.name))
.unwrap_or_default();
endpoints.push((base, key));
}
endpoints.sort_by(|a, b| a.0.cmp(&b.0));
endpoints.dedup_by(|a, b| a.0 == b.0);
let results = futures::future::join_all(
endpoints
.into_iter()
.map(|(base, key)| async move { list_models(&base, &key).await.unwrap_or_default() }),
)
.await;
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for list in results {
for m in list {
if seen.insert(m.clone()) {
out.push(m);
}
}
}
out
}
fn resolve_price(catalog: &Catalog, config: &Config, name: &str) -> (Option<f64>, Option<f64>) {
let cfg = config.model(name);
let cat = catalog.get(name);
(
cfg.and_then(|m| m.price_in)
.or_else(|| cat.and_then(|m| m.price_in)),
cfg.and_then(|m| m.price_out)
.or_else(|| cat.and_then(|m| m.price_out)),
)
}
fn config_mtime(paths: &[PathBuf]) -> u64 {
paths
.iter()
.filter_map(|p| std::fs::metadata(p).ok()?.modified().ok())
.filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.max()
.unwrap_or(0)
}
fn build_theme(reg: &ThemeRegistry, name: &str, config: &Config) -> Theme {
let c = &config.colors;
reg.get(name)
.unwrap_or_default()
.with_overrides(&super::theme::ColorOverrides {
user: c.user.clone(),
assistant: c.assistant.clone(),
tool: c.tool.clone(),
system: c.system.clone(),
dim: c.dim.clone(),
accent: c.accent.clone(),
border: c.border.clone(),
surface: c.surface.clone(),
base: c.base.clone(),
..Default::default()
})
}
fn theme_dirs(cwd: &std::path::Path) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(ud) = user_cordy_dir() {
dirs.push(ud.join("themes"));
}
dirs.push(cwd.join(".cordy/themes"));
dirs
}
fn resolve_context(catalog: &Catalog, config: &Config, name: &str) -> Option<u64> {
config
.model(name)
.and_then(|m| m.context_window)
.or_else(|| catalog.get(name).and_then(|m| m.context))
}
fn model_hint(catalog: &Catalog, config: &Config, name: &str) -> String {
let ctx = config
.model(name)
.and_then(|m| m.context_window)
.or_else(|| catalog.get(name).and_then(|m| m.context));
let (pin, pout) = resolve_price(catalog, config, name);
let mut parts: Vec<String> = Vec::new();
if let Some(c) = ctx {
parts.push(format!("{} ctx", models_dev::fmt_context(c)));
}
if let (Some(i), Some(o)) = (pin, pout) {
parts.push(format!("${i}/${o}"));
}
if catalog.get(name).is_some_and(|m| m.tool_call) {
parts.push("tools".into());
}
if parts.is_empty() {
"hot-swap model".into()
} else {
parts.join(" · ")
}
}
#[cfg(feature = "mcp")]
async fn connect_mcp(
config: &Config,
sources: &mut Vec<Arc<dyn CapabilitySource>>,
statuses: &mut Vec<(String, String)>,
) -> Vec<crate::mcp::McpConnection> {
use crate::mcp::{McpCapability, connect_http, connect_stdio};
let mut conns = Vec::new();
for srv in &config.mcp_servers {
if !srv.enabled {
continue;
}
let connected = match srv.transport.as_str() {
"stdio" => match &srv.command {
Some(cmdline) => {
let mut parts = cmdline.split_whitespace();
match parts.next() {
Some(cmd) => {
let args: Vec<String> = parts.map(str::to_string).collect();
connect_stdio(&srv.name, cmd, &args, &[]).await
}
None => continue,
}
}
None => continue,
},
"http" | "streamable-http" | "sse" => match &srv.url {
Some(url) => connect_http(&srv.name, url).await,
None => continue,
},
_ => continue,
};
match connected {
Ok(conn) => {
statuses.push((
srv.name.clone(),
format!("Connected · {} tools", conn.tools.len()),
));
sources.push(Arc::new(McpCapability::new(
srv.name.clone(),
conn.tools.clone(),
)));
conns.push(conn);
}
Err(e) => {
let msg: String = e.to_string().chars().take(60).collect();
statuses.push((srv.name.clone(), format!("error: {msg}")));
}
}
}
conns
}
fn open_session(
store: &SessionStore,
resume: &Option<String>,
provider_kind: &str,
model: &str,
cwd: &std::path::Path,
) -> (String, Vec<Message>, Option<SessionMeta>) {
if let Some(sel) = resume {
let id = if sel.is_empty() {
store.latest()
} else {
Some(sel.clone())
};
if let Some(id) = id
&& let Ok((meta, msgs)) = store.load(&id)
{
return (meta.id, msgs, None); }
}
let id = SessionStore::new_id();
let meta = SessionMeta {
id: id.clone(),
provider: provider_kind.to_string(),
model: model.to_string(),
cwd: cwd.display().to_string(),
created_at: now_unix(),
title: String::new(),
};
(id, Vec::new(), Some(meta)) }
fn persona_fragment(mode: &str) -> Option<&'static str> {
Some(match mode {
"plan" => {
"<mode>You are in PLAN mode. Investigate and produce a concrete plan; do not \
modify files. Read, search and run read-only commands freely. End with a numbered plan the user \
can approve. File-editing tools are blocked in this mode — do not attempt them.</mode>"
}
"architect" => {
"<mode>You are in ARCHITECT mode. Work at the level of structure and \
trade-offs: boundaries, data flow, failure modes, migration order. Do not write implementation \
code and do not modify files. Name the options you rejected and why.</mode>"
}
"socratic" => {
"<mode>You are in SOCRATIC mode. Before proposing anything, ask the \
questions whose answers would change the design — no more than three, most decisive first. Only \
once they are answered (or the user declines) do you propose a direction. Do not modify \
files.</mode>"
}
"challenge" => {
"<mode>You are in CHALLENGE mode. Treat the user's request as a claim to \
be tested. State the assumptions it rests on, which of them are load-bearing, and what evidence \
would falsify them. Argue the strongest case against the plan before conceding anything. Do not \
modify files.</mode>"
}
"review" => {
"<mode>You are in REVIEW mode. Read the diff and the surrounding code and \
report defects: correctness first, then contracts, then clarity. Every finding needs a concrete \
failure scenario. Do not modify files.</mode>"
}
_ => return None,
})
}
fn read_only_mode(mode: &str) -> bool {
matches!(
mode,
"plan" | "architect" | "socratic" | "challenge" | "review"
)
}
fn mode_rules(mode: &str) -> Vec<Rule> {
if !read_only_mode(mode) {
return Vec::new();
}
["edit", "write", "multiedit", "apply_patch"]
.into_iter()
.map(|t| Rule::new(t, "*", false))
.collect()
}
fn compose_system(base: &str, mode: &str) -> String {
match persona_fragment(mode) {
Some(f) => format!("{base}\n\n{f}"),
None => base.to_string(),
}
}
fn tab_channel(
tab: usize,
ui: mpsc::UnboundedSender<(usize, AgentEvent)>,
) -> mpsc::UnboundedSender<AgentEvent> {
let (tx, mut rx) = mpsc::unbounded_channel::<AgentEvent>();
tokio::spawn(async move {
while let Some(e) = rx.recv().await {
if ui.send((tab, e)).is_err() {
break;
}
}
});
tx
}
struct TabRt {
tag: usize,
driver: Driver,
goal: Arc<GoalRuntime>,
session: String,
turn_start: Option<std::time::Instant>,
state: super::TabState,
}
fn with_tab<R>(
model: &mut Model,
tabs: &mut [TabRt],
active: usize,
idx: usize,
f: impl FnOnce(&mut Model) -> R,
) -> R {
if idx == active || idx >= tabs.len() {
return f(model);
}
model.swap_tab(&mut tabs[active].state); model.swap_tab(&mut tabs[idx].state); let r = f(model);
model.swap_tab(&mut tabs[idx].state); model.swap_tab(&mut tabs[active].state); r
}
struct DriverCfg {
provider: Arc<dyn Provider>,
registry: Arc<Registry>,
ctx: ToolCtx,
system: String,
model_name: String,
provider_kind: String,
initial_messages: Vec<Message>,
session_id: String,
pending_meta: Option<SessionMeta>,
store: SessionStore,
agent_tx: mpsc::UnboundedSender<AgentEvent>,
goal: Arc<GoalRuntime>,
steer: Arc<Mutex<Vec<String>>>,
mode: String,
compact_threshold: u64,
compact_auto: bool,
}
struct Driver {
prompt_tx: mpsc::UnboundedSender<Vec<ContentBlock>>,
control_tx: mpsc::UnboundedSender<Control>,
cancel: CancelSlot,
steer: Arc<Mutex<Vec<String>>>,
}
fn spawn_driver(cfg: DriverCfg) -> Driver {
let (prompt_tx, mut prompt_rx) = mpsc::unbounded_channel::<Vec<ContentBlock>>();
let (control_tx, mut control_rx) = mpsc::unbounded_channel::<Control>();
let cancel_slot: CancelSlot = Arc::new(Mutex::new(None));
let cancel = cancel_slot.clone();
let steer_handle = cfg.steer.clone();
tokio::spawn(async move {
let DriverCfg {
provider,
registry,
ctx,
system,
model_name,
provider_kind,
initial_messages,
mut session_id,
mut pending_meta,
store,
agent_tx,
goal: goal_rt,
steer,
mode,
compact_threshold,
compact_auto,
} = cfg;
let mut agent = AgentLoop::new(provider, registry.clone(), ctx.clone())
.with_goal(goal_rt.clone())
.with_steering(steer);
let base_system = system;
let mut session = Session::new(compose_system(&base_system, &mode), model_name);
session.messages = initial_messages;
let mut persisted = session.messages.len();
let mut redo_groups: Vec<Vec<Message>> = Vec::new();
loop {
tokio::select! {
maybe = prompt_rx.recv() => {
let Some(content) = maybe else { break };
let mut next: Option<Vec<ContentBlock>> = Some(content);
while let Some(content) = next.take() {
session.push_user_content(content);
let turn_cancel = CancellationToken::new();
if let Ok(mut slot) = cancel_slot.lock() {
*slot = Some(turn_cancel.clone());
}
if let Err(e) = agent.run_turn(&mut session, &agent_tx, &turn_cancel).await {
let _ = agent_tx.send(AgentEvent::Error(e.to_string()));
}
if let Ok(mut slot) = cancel_slot.lock() {
*slot = None;
}
if persisted < session.messages.len()
&& let Some(meta) = pending_meta.take()
{
let _ = store.create(&meta);
}
for m in &session.messages[persisted..] {
let _ = store.append(&session_id, m);
}
persisted = session.messages.len();
if compact_auto {
let native = agent.provider.caps().native_context_mgmt;
let cm = ContextManager::new(compact_threshold, CompactMode::Auto, native);
if cm.needs_compaction(&session.messages) {
compact_session(&agent, &mut session, compact_threshold, &agent_tx).await;
persisted = persisted.min(session.messages.len());
}
}
if turn_cancel.is_cancelled() || !prompt_rx.is_empty() {
break;
}
if let Some(prompt) = goal_rt.continue_if_idle().await
&& let Some(goal) = goal_rt.goal()
{
let _ = agent_tx.send(AgentEvent::GoalContinued {
objective: goal.objective.clone(),
turn: goal.iterations_used.saturating_add(1),
});
next = Some(vec![ContentBlock::text(prompt)]);
}
}
}
maybe = control_rx.recv() => {
match maybe {
Some(Control::SwitchModel(name)) => {
let p = build_provider_for(&provider_kind, &name);
agent = AgentLoop::new(p, registry.clone(), ctx.clone())
.inherit_from(&agent);
session.model = name;
}
Some(Control::SwitchProvider(kind, name)) => {
let p = build_provider_for(&kind, &name);
agent = AgentLoop::new(p, registry.clone(), ctx.clone())
.inherit_from(&agent);
session.model = name;
}
Some(Control::Compact) => {
compact_session(&agent, &mut session, compact_threshold, &agent_tx).await;
persisted = persisted.min(session.messages.len());
}
Some(Control::NewSession) => {
session.messages.clear();
persisted = 0;
redo_groups.clear();
}
Some(Control::UndoMessage) => {
if let Some(pos) =
session.messages.iter().rposition(|m| m.role == Role::User)
{
let removed = session.messages.split_off(pos);
redo_groups.push(removed);
persisted = session.messages.len();
}
}
Some(Control::RedoMessage) => {
if let Some(group) = redo_groups.pop() {
session.messages.extend(group);
persisted = session.messages.len();
}
}
Some(Control::RewindTo(n)) => {
if let Some(pos) = session
.messages
.iter()
.enumerate()
.filter(|(_, m)| m.role == Role::User)
.nth(n)
.map(|(i, _)| i)
{
session.messages.truncate(pos);
persisted = session.messages.len();
redo_groups.clear();
}
}
Some(Control::LoadSession(id)) => {
if let Ok((_m, msgs)) = store.load(&id) {
session.messages = msgs;
persisted = session.messages.len();
session_id = id;
}
}
Some(Control::SetMode(mode)) => {
session.system = compose_system(&base_system, &mode);
}
Some(Control::Inspect(reply)) => {
let _ = reply.send(context_report(&session, ®istry, agent.provider.caps().native_context_mgmt));
}
None => {}
}
}
}
}
});
Driver {
prompt_tx,
control_tx,
cancel,
steer: steer_handle,
}
}
pub fn dump_frame(scenario: &str, size: &str, theme_name: &str, plain: bool) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (w, h) = size
.split_once(['x', 'X', '*'])
.and_then(|(a, b)| Some((a.trim().parse().ok()?, b.trim().parse().ok()?)))
.unwrap_or((110u16, 34u16));
let themes = ThemeRegistry::builtin();
let theme = themes.get(theme_name).unwrap_or_default();
let mut terms = term::Terminals::default();
let mut model = scenario_model(scenario, &themes);
let mut term = match Terminal::new(TestBackend::new(w, h)) {
Ok(t) => t,
Err(e) => return format!("render failed: {e}\n"),
};
if term
.draw(|f| view(f, &mut model, &theme, &themes, &mut terms))
.is_err()
{
return "render failed\n".into();
}
ansi_of(term.backend().buffer(), w, h, plain)
}
fn ansi_of(buf: &ratatui::buffer::Buffer, w: u16, h: u16, plain: bool) -> String {
fn code(c: Color, fg: bool) -> String {
let base = if fg { 38 } else { 48 };
match c {
Color::Rgb(r, g, b) => format!("\x1b[{base};2;{r};{g};{b}m"),
Color::Indexed(i) => format!("\x1b[{base};5;{i}m"),
_ => format!("\x1b[{}m", if fg { 39 } else { 49 }),
}
}
let mut out = String::new();
for y in 0..h {
let mut cur: Option<(Color, Color, Modifier)> = None;
for x in 0..w {
let cell = &buf[(x, y)];
if !plain {
let key = (cell.fg, cell.bg, cell.modifier);
if cur != Some(key) {
out.push_str("\x1b[0m");
out.push_str(&code(cell.fg, true));
out.push_str(&code(cell.bg, false));
if cell.modifier.contains(Modifier::BOLD) {
out.push_str("\x1b[1m");
}
if cell.modifier.contains(Modifier::ITALIC) {
out.push_str("\x1b[3m");
}
if cell.modifier.contains(Modifier::REVERSED) {
out.push_str("\x1b[7m");
}
cur = Some(key);
}
}
out.push_str(cell.symbol());
}
if !plain {
out.push_str("\x1b[0m");
}
out.push('\n');
}
out
}
fn scenario_model(scenario: &str, themes: &ThemeRegistry) -> Model {
let mut m = Model {
status: "ready".into(),
footer: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default(),
modes: vec![
"build".into(),
"plan".into(),
"architect".into(),
"review".into(),
],
model_name: "gpt-5.4-mini".into(),
provider_kind: "openai-chat".into(),
subtitle: "openai-chat · gpt-5.4-mini".into(),
theme_names: themes.names(),
theme_name: "cordy".into(),
show_tool_output: true,
show_thinking: true,
skills: vec![("pdf".into(), "read and fill PDFs".into())],
mcp_names: vec![("browsermcp".into(), "Connected · 12 tools".into())],
tabs: vec![chrome::TabMeta {
title: "main".into(),
model: "gpt-5.4-mini".into(),
mode: "build".into(),
..Default::default()
}],
git: Some(chrome::GitStatus {
branch: "main".into(),
unstaged: 1,
untracked: 3,
..Default::default()
}),
..Default::default()
};
if scenario == "empty" {
return m;
}
m.context_window = Some(128_000);
m.last_in = 18_400;
m.total_in = 18_400;
m.total_out = 1_260;
m.total_saved = 640;
m.price_in = Some(0.15);
m.price_out = Some(0.6);
m.transcript = vec![
Entry::User("add a --json flag to the export command".into()),
Entry::Assistant(
"Looking at `src/export.rs` first — the flag has to reach the writer, not just the CLI \
parser.\n\n- `Args` gets a `json: bool`\n- `write_report` branches on it"
.into(),
),
Entry::Tool {
id: "1".into(),
name: "read".into(),
text: "src/export.rs".into(),
saved: 320,
running: false,
error: false,
},
Entry::Tool {
id: "2".into(),
name: "edit".into(),
text: "src/export.rs".into(),
saved: 0,
running: false,
error: false,
},
Entry::Turn {
mode: "build".into(),
model: "gpt-5.4-mini".into(),
secs: 4.2,
},
];
m.events.push(super::ModelEvent {
model: "gpt-5.4-mini".into(),
mode: "build".into(),
secs: 4.2,
input_tokens: 18_400,
output_tokens: 1_260,
cost: 0.0035,
error: None,
});
match scenario {
"busy" => {
m.busy = true;
m.status = "running bash…".into();
m.streaming = "Now wiring the flag through to the writer".into();
m.bg_count = 1;
m.subagent_count = 1;
}
"typing" => {
m.input = "now do the same for @src/import.rs".into();
m.cursor = m.input.len();
}
"menu" => {
m.msg_menu = Some(super::MsgMenu {
entry: 0,
sel: 0,
col: 14,
row: 5,
});
}
"models" => open_model_picker(&mut m),
"git" => open_git_picker(&mut m),
"mode" => open_mode_picker(&mut m),
"tabs" => {
m.tabs.push(chrome::TabMeta {
title: "tests".into(),
model: "gpt-5.4-mini".into(),
mode: "review".into(),
busy: true,
unread: 4,
});
m.tabs.push(chrome::TabMeta {
title: "docs".into(),
model: "gpt-5.4-mini".into(),
mode: "plan".into(),
..Default::default()
});
open_tabs_picker(&mut m);
}
"context" => open_context_picker(
&mut m,
&ContextReport {
sections: vec![
("system prompt".into(), 2_100),
("tool schemas (16)".into(), 6_800),
("your messages".into(), 900),
("assistant replies".into(), 3_400),
("tool results".into(), 5_200),
],
total: 18_400,
messages: 9,
native_mgmt: false,
},
),
"permission" => {
m.pending = Some(
"edit src/export.rs\n@@ -12,3 +12,6 @@\n- write_report(&rows)\n+ if args.json \
{\n+ write_json(&rows)\n+ }"
.into(),
);
}
_ => {}
}
m
}
pub async fn run(resume: Option<String>) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let mut config = Config::load(
user_config_path().as_deref(),
&cwd.join(".cordy/config.toml"),
);
let mut model_name = std::env::var("CORDY_MODEL").unwrap_or_else(|_| "gpt-4o-mini".into());
let mut provider_kind =
std::env::var("CORDY_PROVIDER").unwrap_or_else(|_| "openai-chat".into());
if std::env::var("CORDY_BASE_URL").is_err()
&& let Some(act) = load_active(&cwd)
{
let key = config
.providers
.iter()
.find(|p| p.name == act.name)
.and_then(|p| p.api_key_env.as_ref())
.and_then(|e| std::env::var(e).ok())
.or_else(|| key_store(&cwd).get(&act.name))
.unwrap_or_default();
activate_endpoint(&act.kind, &act.base_url, &key);
unsafe { std::env::set_var("CORDY_PROVIDER", &act.kind) };
provider_kind = act.kind.clone();
if !act.model.is_empty() {
model_name = act.model.clone();
}
}
let provider = build_provider(&model_name);
let (perm_tx, mut perm_rx) = mpsc::unbounded_channel::<PermissionAsk>();
let permission = Arc::new(
PermissionEngine::new(Arc::new(TuiPermission { tx: perm_tx }))
.with_sandbox(cwd.clone())
.with_rules(permission_rules(&config, &cwd)),
);
let perm_handle = permission.clone(); let ctx = ToolCtx::with_permission(cwd.clone(), permission);
let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<(usize, AgentEvent)>();
let agent_tx = tab_channel(0, ui_tx.clone());
let optimizer = Arc::new(Optimizer::new(config.optimize_enabled()));
let bg = crate::tools::builtins::BgRegistry::default();
let goal_runtime = Arc::new(GoalRuntime::new(
Arc::new(GoalStore::ephemeral()),
config.goal_enabled(),
));
goal_runtime.set_pricing(model_pricing(&config, &model_name));
let mut agent_vec = load_agents(&cwd.join(".cordy/agents"));
if let Some(ud) = user_cordy_dir() {
let have: std::collections::HashSet<String> =
agent_vec.iter().map(|d| d.name.clone()).collect();
for a in load_agents(&ud.join("agents")) {
if !have.contains(&a.name) {
agent_vec.push(a);
}
}
}
let agent_defs = Arc::new(agent_vec);
let agent_names: Vec<String> = agent_defs.iter().map(|d| d.name.clone()).collect();
const SUBAGENT_SLOTS: usize = 4;
let subagent_sema = Arc::new(Semaphore::new(SUBAGENT_SLOTS));
let mut skills = load_skills(&cwd.join(".cordy/skills"));
if let Some(ud) = user_cordy_dir() {
let have: std::collections::HashSet<String> =
skills.iter().map(|s| s.name.clone()).collect();
for s in load_skills(&ud.join("skills")) {
if !have.contains(&s.name) {
skills.push(s);
}
}
}
let skill_list: Vec<(String, String)> = skills
.iter()
.map(|s| (s.name.clone(), s.description.clone()))
.collect();
#[cfg_attr(not(feature = "mcp"), allow(unused_mut))]
let mut sources: Vec<Arc<dyn CapabilitySource>> = vec![Arc::new(SkillSet::new(skills))];
let mut mcp_names: Vec<(String, String)> = Vec::new();
#[cfg(feature = "mcp")]
let _mcp_connections = connect_mcp(&config, &mut sources, &mut mcp_names).await;
#[cfg(not(feature = "mcp"))]
for s in config.mcp_servers.iter().filter(|s| s.enabled) {
mcp_names.push((s.name.clone(), "needs --features mcp".into()));
}
let static_sources = Arc::new(sources);
let make_registry = {
let static_sources = static_sources.clone();
let agent_defs = agent_defs.clone();
let provider = provider.clone();
let ctx = ctx.clone();
let sema = subagent_sema.clone();
let optimizer = optimizer.clone();
let bg = bg.clone();
move |atx: mpsc::UnboundedSender<AgentEvent>,
model: String,
goal: Arc<GoalRuntime>|
-> (Arc<Registry>, Vec<String>, ToolCtx) {
let tab_ctx = ctx.with_own_checkpoints();
let base: Arc<dyn CapabilitySource> =
Arc::new(BuiltinTools::with_bg(optimizer.clone(), bg.clone()).with_goal(goal));
let task_tool: Arc<dyn Tool> = Arc::new(SubAgentTool::new(
provider.clone(),
base.clone(),
tab_ctx.clone(),
sema.clone(),
agent_defs.clone(),
model,
atx,
));
let mut srcs: Vec<Arc<dyn CapabilitySource>> = vec![base];
srcs.extend((*static_sources).iter().cloned());
srcs.push(Arc::new(AgentRegistry::new(agent_defs.clone(), task_tool)));
let mut reg = Registry::new();
let mut frags = Vec::new();
for src in &srcs {
for t in src.tools() {
reg.register(t);
}
if let Some(f) = src.prompt_fragment() {
frags.push(f);
}
}
(Arc::new(reg), frags, tab_ctx)
}
};
let (registry, fragments, ctx0) =
make_registry(agent_tx.clone(), model_name.clone(), goal_runtime.clone());
let tool_names: Vec<String> = registry.specs().into_iter().map(|s| s.name).collect();
let user_dir = user_cordy_dir();
let capabilities = if fragments.is_empty() {
None
} else {
Some(fragments.join("\n"))
};
let system = build_system_prompt(&PromptContext {
cwd: &cwd,
os: std::env::consts::OS,
tool_names: &tool_names,
project_context: load_project_context(&cwd),
custom_prompt: load_system_override(&cwd, user_dir.as_deref()),
append_prompt: load_system_append(&cwd, user_dir.as_deref()),
capabilities,
cognitive_core: config
.model(&model_name)
.map(|m| m.cognitive_core)
.unwrap_or(false),
});
let store = SessionStore::new(session_store_dir(&cwd));
let (session_id, initial_messages, pending_meta) =
open_session(&store, &resume, &provider_kind, &model_name, &cwd);
let mut current_session = session_id.clone(); goal_runtime.rebind(Arc::new(GoalStore::open(goal_path(&cwd, &session_id))));
goal_runtime.restore_after_resume();
let resumed_count = initial_messages.len();
let subtitle = format!("{provider_kind} · {model_name}");
let footer = cwd.display().to_string();
let compact_threshold = config.compact_threshold.unwrap_or(100_000);
let compact_auto = config.compact_mode.as_deref() == Some("auto");
let driver0 = spawn_driver(DriverCfg {
provider: provider.clone(),
registry,
ctx: ctx0,
system: system.clone(),
model_name: model_name.clone(),
provider_kind: provider_kind.clone(),
initial_messages,
session_id: session_id.clone(),
pending_meta,
store,
agent_tx: agent_tx.clone(),
goal: goal_runtime.clone(),
steer: Arc::new(Mutex::new(Vec::new())),
mode: "build".to_string(),
compact_threshold,
compact_auto,
});
const MAX_TABS: usize = 5;
let mut prompt_tx = driver0.prompt_tx.clone();
let mut control_tx = driver0.control_tx.clone();
let mut cancel_slot = driver0.cancel.clone();
let mut goal_runtime = goal_runtime;
let mut active_tab = 0usize;
let mut file_claims: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut last_edited: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
let mut terms = term::Terminals::default();
let mut next_tag = 1usize;
let mut tabs_rt: Vec<TabRt> = vec![TabRt {
tag: 0,
driver: driver0,
goal: goal_runtime.clone(),
session: session_id.clone(),
turn_start: None,
state: super::TabState::default(),
}];
let mouse_default = config.mouse_enabled();
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
if mouse_default {
let _ = execute!(stdout, EnableMouseCapture);
}
let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Event>();
spawn_input_reader(input_tx);
let (git_tx, mut git_rx) = mpsc::unbounded_channel::<Option<chrome::GitStatus>>();
{
let cwd = cwd.clone();
tokio::spawn(async move {
let mut iv = tokio::time::interval(Duration::from_secs(2));
loop {
iv.tick().await;
if git_tx.send(chrome::poll_git(&cwd).await).is_err() {
break;
}
}
});
}
let modes = {
let mut v: Vec<String> = [
"build",
"plan",
"architect",
"socratic",
"challenge",
"review",
]
.iter()
.map(|s| s.to_string())
.collect();
v.extend(agent_names);
v
};
let cache_dir = user_cordy_dir()
.map(|d| d.join("cache"))
.unwrap_or_else(|| cwd.join(".cordy/cache"));
let live_fut = fetch_all_models(&config, &provider_kind, &cwd);
let update_fut = crate::core::update::check(&cache_dir);
let (live_models, catalog, latest_version) =
tokio::join!(live_fut, models_dev::load_catalog(&cache_dir), update_fut);
let theme_dirs = theme_dirs(&cwd);
let mut themes = ThemeRegistry::load(&theme_dirs);
let theme_names = themes.names();
let palette = build_palette(
&config,
&catalog,
&live_models,
&modes,
&provider_kind,
&model_name,
&theme_names,
);
let (init_pin, init_pout) = resolve_price(&catalog, &config, &model_name);
let mut model = Model {
status: "ready".into(),
subtitle,
footer,
modes,
palette,
show_mascot: config.mascot_enabled(),
show_thinking: true,
price_in: init_pin,
price_out: init_pout,
context_window: resolve_context(&catalog, &config, &model_name),
model_name: model_name.clone(),
provider_kind: provider_kind.clone(),
statusline: config.statusline.clone(),
favorites: load_favorites(&cwd),
recents: {
let mut r = load_recents(&cwd);
r.retain(|m| *m != model_name);
r.insert(0, model_name.clone());
r.truncate(12);
r
},
animations: config.mascot_enabled(),
mouse_on: mouse_default,
show_tool_output: true,
skills: skill_list,
mcp_names,
theme_name: config
.theme
.clone()
.filter(|t| themes.index_of(t).is_some())
.unwrap_or_else(|| "cordy".into()),
theme_names,
latest_version,
tabs: vec![chrome::TabMeta {
title: "main".into(),
model: model_name.clone(),
mode: "build".into(),
busy: false,
unread: 0,
}],
..Default::default()
};
if let Some(v) = &model.latest_version {
model.transcript.push(Entry::System(format!(
"↑ cordy v{v} is available (you have v{}) — run: cargo install cordy",
env!("CARGO_PKG_VERSION")
)));
}
if resumed_count > 0 {
model.transcript.push(Entry::System(format!(
"resumed session ({resumed_count} messages)"
)));
}
let mut theme_idx = config
.theme
.as_deref()
.and_then(|t| themes.index_of(t))
.or_else(|| themes.index_of("cordy"))
.unwrap_or(0);
let mut theme = build_theme(&themes, themes.name_at(theme_idx), &config);
let ui_store = SessionStore::new(session_store_dir(&cwd));
let mut pending_reply: Option<oneshot::Sender<bool>> = None;
let mut pending_perm: Option<(String, String)> = None; let mut mouse_on = mouse_default;
let config_paths: Vec<PathBuf> = [user_config_path(), Some(cwd.join(".cordy/config.toml"))]
.into_iter()
.flatten()
.collect();
let mut config_sig = config_mtime(&config_paths);
let mut reload_tick = 0u8;
let mut ticker = tokio::time::interval(Duration::from_millis(120));
let mut dirty = true;
let result = loop {
if dirty {
model.bg_count = bg.running_count();
model.subagent_count = SUBAGENT_SLOTS.saturating_sub(subagent_sema.available_permits());
if let Err(e) = terminal.draw(|f| view(f, &mut model, &theme, &themes, &mut terms)) {
break Err(anyhow::Error::from(e));
}
dirty = false;
}
if model.should_quit {
break Ok(());
}
tokio::select! {
Some(ev) = input_rx.recv() => {
dirty = true;
let mut pending_action: Option<KeyAction> = None;
let mode_before = model.mode().to_string();
if let Event::Mouse(m) = &ev {
let up = matches!(m.kind, MouseEventKind::ScrollUp);
let down = matches!(m.kind, MouseEventKind::ScrollDown);
if up || down {
let step = |sel: &mut usize, len: usize| {
if up {
*sel = sel.saturating_sub(1);
} else if len > 0 {
*sel = (*sel + 1).min(len - 1);
}
};
if model.palette_open {
let n = model.palette_filtered().len();
step(&mut model.palette_sel, n);
} else if model.sessions_open {
let n = model.sessions.len();
step(&mut model.sessions_sel, n);
} else if model.providers_open {
let n = model.providers.len();
step(&mut model.providers_sel, n);
} else if model.theme_open {
let n = model.theme_names.len();
step(&mut model.theme_sel, n);
} else if let Some(c) = &mut model.connect {
if matches!(c.step, ConnectStep::Pick) {
step(&mut c.sel, PRESETS.len());
}
} else if up {
model.scroll = model.scroll.saturating_add(3);
} else {
model.scroll = model.scroll.saturating_sub(3);
}
}
if matches!(m.kind, MouseEventKind::Moved) {
let hit = model.hit_at(m.column, m.row);
if model.hover != hit {
model.hover = hit;
} else {
dirty = false; }
}
if let MouseEventKind::Down(MouseButton::Left) = m.kind {
let hit = model.hit_at(m.column, m.row);
let mut handled = true;
match hit {
Some(super::Hit::Tab(i)) => {
if let Some(a) = exec_pick(&mut model, PickAction::Tab(i)) {
pending_action = Some(a);
}
}
Some(super::Hit::Mode) => open_mode_picker(&mut model),
Some(super::Hit::ModelChip) => open_model_picker(&mut model),
Some(super::Hit::Git) => open_git_picker(&mut model),
Some(super::Hit::Skills) => open_skills_picker(&mut model),
Some(super::Hit::Tabs) => open_tabs_picker(&mut model),
Some(super::Hit::Cost) => open_events_picker(&mut model),
Some(super::Hit::Context) => {
pending_action = Some(KeyAction::InspectContext)
}
Some(super::Hit::Palette) => {
model.palette_open = true;
model.palette_query.clear();
model.palette_sel = 0;
}
Some(super::Hit::Editor) => {
pending_action = Some(KeyAction::OpenEditorPanel)
}
Some(super::Hit::Term) => pending_action = Some(KeyAction::OpenTerm),
Some(super::Hit::Stop) => pending_action = Some(KeyAction::Interrupt),
Some(super::Hit::Mouse) => {
pending_action = Some(KeyAction::ToggleMouse)
}
Some(super::Hit::Keys) => model.leader = !model.leader,
Some(super::Hit::Row(i)) => {
if let Some(p) = &mut model.picker {
p.sel = i;
let act = p.rows.get(i).map(|r| r.action.clone());
match act {
Some(PickAction::Prompt(label)) => {
let key = p.rows[i].label.clone();
p.prompt = Some((key, label, String::new()));
}
Some(PickAction::None) | None => {}
Some(a) => {
model.picker = None;
if let Some(k) = exec_pick(&mut model, a) {
pending_action = Some(k);
}
}
}
}
}
Some(super::Hit::Panel) => {}
Some(super::Hit::Entry(idx)) => {
match model.transcript.get(idx) {
Some(Entry::Tool { id, .. }) => {
let id = id.clone();
if !model.expanded.remove(&id) {
model.expanded.insert(id);
}
}
Some(e) if !menu_actions(e).is_empty() => {
model.msg_menu = Some(super::MsgMenu {
entry: idx,
sel: 0,
col: m.column,
row: m.row,
});
}
_ => {}
}
}
None => handled = false,
}
if !handled {
if model.msg_menu.is_some() {
match menu_item_at(&model, m.column, m.row) {
Some(sel) => {
if let Some(mm) = &mut model.msg_menu {
mm.sel = sel;
}
run_msg_menu(&mut model, &control_tx);
}
None => model.msg_menu = None,
}
} else if model.picker.is_some() {
model.picker = None; }
}
}
}
if let Event::Paste(text) = &ev {
let text = text.clone();
insert_paste(&mut model, &text);
model.suggestions = compute_suggestions(&model.input, &cwd);
model.suggestion_sel = 0;
}
if let Event::Key(k) = ev
&& k.kind != KeyEventKind::Release
{
if terms.focused {
let tctrl = k.modifiers.contains(KeyModifiers::CONTROL);
let talt = k.modifiers.contains(KeyModifiers::ALT);
if tctrl && talt {
match latinize_code(k.code) {
KeyCode::Char('t') => terms.focused = false,
KeyCode::Char('w') => {
terms.close_active();
model.term_count = terms.panels.len();
let _ = terminal.clear();
}
KeyCode::Char('n') => terms.next(),
KeyCode::Up => terms.grow(5),
KeyCode::Down => terms.grow(-5),
_ => {}
}
continue;
}
if let Some(p) = terms.active_panel() {
let bytes = term::encode_key(p, k);
if !bytes.is_empty() {
p.write(&bytes);
}
}
continue;
}
pending_action = handle_key(&mut model, k, &mut pending_reply);
}
{
match pending_action {
Some(KeyAction::Steer(text)) => {
if model.busy {
if let Ok(mut q) = tabs_rt[active_tab].driver.steer.lock() {
q.push(text);
}
} else {
model.transcript.push(Entry::System(
"nothing is running — just send the message".into(),
));
}
}
Some(KeyAction::OpenTerm) => {
if terms.panels.is_empty() {
if let Err(e) = terms.open("shell", None, &[], &cwd) {
model.transcript.push(Entry::System(e));
}
} else {
terms.visible = true;
terms.focused = !terms.focused;
}
model.term_count = terms.panels.len();
let _ = terminal.clear();
}
Some(KeyAction::OpenEditorPanel) => {
let target = editor_target(&model, last_edited.get(&tabs_rt[active_tab].tag));
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| {
if cfg!(windows) { "notepad".into() } else { "vi".into() }
});
if let Err(e) =
terms.open(&format!("editor · {target}"), Some(&editor), &[target], &cwd)
{
model.transcript.push(Entry::System(e));
}
model.term_count = terms.panels.len();
let _ = terminal.clear();
}
Some(KeyAction::Redraw) => {
let _ = terminal.clear();
}
Some(KeyAction::RunGit(args)) => {
let out = run_git(&cwd, &args).await;
model.info = Some((format!("git {args}"), out));
}
Some(KeyAction::InspectContext) => {
let (tx, rx) = oneshot::channel();
let _ = control_tx.send(Control::Inspect(tx));
match tokio::time::timeout(Duration::from_millis(400), rx).await {
Ok(Ok(rep)) => open_context_picker(&mut model, &rep),
_ => model.transcript.push(Entry::System(
"context breakdown is available between turns".into(),
)),
}
}
Some(KeyAction::NewTab) => {
if tabs_rt.len() >= MAX_TABS {
model.transcript.push(Entry::System(format!(
"tab limit reached ({MAX_TABS}) — close one with alt+w"
)));
} else {
let idx = tabs_rt.len();
let tag = next_tag;
next_tag += 1;
let atx = tab_channel(tag, ui_tx.clone());
let goal = Arc::new(GoalRuntime::new(
Arc::new(GoalStore::ephemeral()),
config.goal_enabled(),
));
goal.set_pricing(model_pricing(&config, &model.model_name));
let (reg, _frags, tab_ctx) =
make_registry(atx.clone(), model.model_name.clone(), goal.clone());
let tab_store = SessionStore::new(session_store_dir(&cwd));
let (sid, msgs, meta) = open_session(
&tab_store,
&None,
&model.provider_kind,
&model.model_name,
&cwd,
);
goal.rebind(Arc::new(GoalStore::open(goal_path(&cwd, &sid))));
let driver = spawn_driver(DriverCfg {
provider: build_provider_for(
&model.provider_kind,
&model.model_name,
),
registry: reg,
ctx: tab_ctx,
system: system.clone(),
model_name: model.model_name.clone(),
provider_kind: model.provider_kind.clone(),
initial_messages: msgs,
session_id: sid.clone(),
pending_meta: meta,
store: tab_store,
agent_tx: atx,
goal: goal.clone(),
steer: Arc::new(Mutex::new(Vec::new())),
mode: model.mode().to_string(),
compact_threshold,
compact_auto,
});
let mut state = super::TabState {
status: "ready".into(),
model_name: model.model_name.clone(),
provider_kind: model.provider_kind.clone(),
subtitle: model.subtitle.clone(),
context_window: model.context_window,
price_in: model.price_in,
price_out: model.price_out,
..Default::default()
};
tabs_rt.push(TabRt {
tag,
driver,
goal,
session: sid,
turn_start: None,
state: super::TabState::default(),
});
model.tabs.push(chrome::TabMeta {
title: format!("tab {}", idx + 1),
model: model.model_name.clone(),
mode: model.mode().to_string(),
busy: false,
unread: 0,
});
model.swap_tab(&mut tabs_rt[active_tab].state);
tabs_rt[active_tab].session = current_session.clone();
model.swap_tab(&mut state);
active_tab = idx;
model.tab = idx;
current_session = tabs_rt[idx].session.clone();
goal_runtime = tabs_rt[idx].goal.clone();
prompt_tx = tabs_rt[idx].driver.prompt_tx.clone();
control_tx = tabs_rt[idx].driver.control_tx.clone();
cancel_slot = tabs_rt[idx].driver.cancel.clone();
model.goal_line = None;
model.sync_tab_meta();
}
}
Some(KeyAction::FocusTab(idx)) => {
if idx < tabs_rt.len() && idx != active_tab {
model.swap_tab(&mut tabs_rt[active_tab].state);
tabs_rt[active_tab].session = current_session.clone();
model.swap_tab(&mut tabs_rt[idx].state);
active_tab = idx;
model.tab = idx;
current_session = tabs_rt[idx].session.clone();
goal_runtime = tabs_rt[idx].goal.clone();
prompt_tx = tabs_rt[idx].driver.prompt_tx.clone();
control_tx = tabs_rt[idx].driver.control_tx.clone();
cancel_slot = tabs_rt[idx].driver.cancel.clone();
model.goal_line =
goal_runtime.goal().as_ref().map(goal_status_line);
model.sync_tab_meta();
let _ = terminal.clear();
}
}
Some(KeyAction::CloseTab) => {
if tabs_rt.len() <= 1 {
model.transcript.push(Entry::System(
"the last tab stays open — use ^X q to quit".into(),
));
} else {
let gone = active_tab;
let gone_tag = tabs_rt[gone].tag;
tabs_rt.remove(gone);
model.tabs.remove(gone);
file_claims.retain(|_, owner| *owner != gone_tag);
last_edited.remove(&gone_tag);
let idx = gone.min(tabs_rt.len() - 1);
for (i, meta) in model.tabs.iter_mut().enumerate() {
if meta.title.starts_with("tab ") {
meta.title = format!("tab {}", i + 1);
}
}
let mut fresh = super::TabState::default();
std::mem::swap(&mut fresh, &mut tabs_rt[idx].state);
model.swap_tab(&mut fresh); active_tab = idx;
model.tab = idx;
current_session = tabs_rt[idx].session.clone();
goal_runtime = tabs_rt[idx].goal.clone();
prompt_tx = tabs_rt[idx].driver.prompt_tx.clone();
control_tx = tabs_rt[idx].driver.control_tx.clone();
cancel_slot = tabs_rt[idx].driver.cancel.clone();
model.goal_line =
goal_runtime.goal().as_ref().map(goal_status_line);
model.sync_tab_meta();
let _ = terminal.clear();
}
}
Some(KeyAction::Prompt(text)) => {
let text = expand_pastes(&mut model, &text);
let content = match super::input::parse_user_input(&text, &cwd) {
Ok(blocks) => blocks,
Err(e) => {
update(&mut model, Msg::Agent(AgentEvent::Error(e)));
vec![ContentBlock::text(text)]
}
};
tabs_rt[active_tab].turn_start = Some(std::time::Instant::now());
let _ = prompt_tx.send(content);
}
Some(KeyAction::SwitchModel(name)) => {
model.transcript.push(Entry::System(format!(
"switched to model {name} (context kept)"
)));
(model.price_in, model.price_out) = resolve_price(&catalog, &config, &name);
goal_runtime.set_pricing(Pricing {
input_per_mtok: model.price_in,
output_per_mtok: model.price_out,
});
model.context_window = resolve_context(&catalog, &config, &name);
model.model_name = name.clone();
model.subtitle = format!("{provider_kind} · {name}");
push_recent(&mut model, &name, &cwd);
update_active_model(&cwd, &name);
let _ = control_tx.send(Control::SwitchModel(name));
}
Some(KeyAction::CycleRecent(dir)) => {
if model.recents.len() > 1 {
let cur = model
.recents
.iter()
.position(|m| *m == model.model_name)
.unwrap_or(0);
let n = model.recents.len() as i32;
let idx = (cur as i32 + dir as i32).rem_euclid(n) as usize;
let name = model.recents[idx].clone();
if name != model.model_name {
(model.price_in, model.price_out) =
resolve_price(&catalog, &config, &name);
model.context_window =
resolve_context(&catalog, &config, &name);
model.model_name = name.clone();
model.subtitle = format!("{provider_kind} · {name}");
push_recent(&mut model, &name, &cwd);
update_active_model(&cwd, &name);
model.transcript.push(Entry::System(format!(
"model → {name} (recent)"
)));
let _ = control_tx.send(Control::SwitchModel(name));
}
}
}
Some(KeyAction::ToggleFavorite) => {
let name = model.model_name.clone();
if let Some(pos) = model.favorites.iter().position(|m| *m == name) {
model.favorites.remove(pos);
model
.transcript
.push(Entry::System(format!("unfavorited {name}")));
} else {
model.favorites.push(name.clone());
model
.transcript
.push(Entry::System(format!("★ favorited {name}")));
}
save_favorites(&cwd, &model.favorites);
}
Some(KeyAction::RenameSession(title)) => {
match ui_store.rename(¤t_session, title.trim()) {
Ok(()) => model
.transcript
.push(Entry::System(format!("session renamed: {}", title.trim()))),
Err(e) => model
.transcript
.push(Entry::System(format!("rename failed: {e}"))),
}
}
Some(KeyAction::DeleteSession(id)) => {
let _ = ui_store.delete(&id);
let now = now_unix();
model.sessions = ui_store
.list_summaries()
.into_iter()
.filter(|s| s.messages > 0)
.map(|s| (s.meta.id.clone(), summary_label(&s, now)))
.collect();
model.sessions_sel = model
.sessions_sel
.min(model.sessions.len().saturating_sub(1));
model.transcript.push(Entry::System(format!("deleted session {id}")));
}
Some(KeyAction::ForkSession(id)) => {
match ui_store.fork(&id) {
Ok(new_id) => {
if let Ok((_m, msgs)) = ui_store.load(&new_id) {
model.transcript = transcript_from(&msgs);
model.transcript.push(Entry::System(format!(
"forked → {new_id}"
)));
current_session = new_id.clone();
bind_goal_to_session(&goal_runtime, &mut model, &cwd, &new_id);
let _ = control_tx.send(Control::LoadSession(new_id));
}
model.sessions_open = false;
}
Err(e) => model
.transcript
.push(Entry::System(format!("fork failed: {e}"))),
}
}
Some(KeyAction::SwitchSavedProvider(name)) => {
let found = config.providers.iter().find(|p| p.name == name).cloned();
if let Some(p) = found {
let base = p.base_url.clone().unwrap_or_default();
let key = p
.api_key_env
.as_ref()
.and_then(|e| std::env::var(e).ok())
.or_else(|| key_store(&cwd).get(&name))
.unwrap_or_default();
activate_endpoint(&p.kind, &base, &key);
model.provider_kind = p.kind.clone();
model.subtitle = format!("{} · {}", p.kind, model.model_name);
save_active(&cwd, &name, &p.kind, &base, &model.model_name);
refresh_endpoint(&mut model, &config, &catalog, &p.kind, &base, &key, &name).await;
let _ = control_tx
.send(Control::SwitchProvider(p.kind.clone(), model.model_name.clone()));
} else {
model
.transcript
.push(Entry::System(format!("provider {name} not found")));
}
}
Some(KeyAction::ConnectProvider { name, kind, base_url, key }) => {
if let Some(cfgp) = user_config_path() {
let _ = crate::config::save_provider(&cfgp, &name, &kind, &base_url);
}
if !key.is_empty() {
let _ = key_store(&cwd).set(&name, &key);
}
config.providers.retain(|p| p.name != name);
config.providers.push(crate::config::ProviderProfile {
name: name.clone(),
kind: kind.clone(),
base_url: Some(base_url.clone()),
api_key_env: None,
});
activate_endpoint(&kind, &base_url, &key);
model.provider_kind = kind.clone();
model.subtitle = format!("{kind} · {}", model.model_name);
save_active(&cwd, &name, &kind, &base_url, &model.model_name);
refresh_endpoint(&mut model, &config, &catalog, &kind, &base_url, &key, &name).await;
let _ = control_tx
.send(Control::SwitchProvider(kind, model.model_name.clone()));
}
Some(KeyAction::Goal(cmd)) => {
let started = apply_goal_command(
&mut model,
&goal_runtime,
&config,
cmd,
)
.await;
if started
&& let Some(prompt) = goal_runtime.continue_if_idle().await
{
model.busy = true;
model.status = "goal: working…".into();
tabs_rt[active_tab].turn_start = Some(std::time::Instant::now());
let _ = prompt_tx.send(vec![ContentBlock::text(prompt)]);
}
model.goal_line =
goal_runtime.goal().as_ref().map(goal_status_line);
}
Some(KeyAction::GoalEdit) => match goal_runtime.goal() {
Some(goal) => {
model.input = format!("/goal {}", goal.objective);
model.cursor = model.input.len();
}
None => model
.transcript
.push(Entry::System(format!("no goal set. {GOAL_USAGE}"))),
},
Some(KeyAction::Compact) => {
model.busy = true;
model.status = "compacting…".into();
let _ = control_tx.send(Control::Compact);
}
Some(KeyAction::Interrupt) => {
if let Ok(mut slot) = cancel_slot.lock()
&& let Some(tok) = slot.take()
{
tok.cancel();
}
model.transcript.push(Entry::System("interrupted".into()));
}
Some(KeyAction::NewSession) => {
model.transcript.clear();
model.streaming.clear();
model.total_in = 0;
model.total_out = 0;
model.total_saved = 0;
model.transcript.push(Entry::System("new session — context cleared".into()));
goal_runtime.clear().await;
model.goal_line = None;
let _ = control_tx.send(Control::NewSession);
}
Some(KeyAction::OpenSessions) => {
let now = now_unix();
model.sessions = ui_store
.list_summaries()
.into_iter()
.filter(|s| s.messages > 0)
.map(|s| (s.meta.id.clone(), summary_label(&s, now)))
.collect();
model.sessions_sel = 0;
model.sessions_open = true;
}
Some(KeyAction::OpenProviders) => {
model.providers = config
.providers
.iter()
.map(|p| {
(
p.name.clone(),
p.kind.clone(),
p.base_url.clone().unwrap_or_default(),
)
})
.collect();
model.providers_sel = 0;
model.providers_open = true;
}
Some(KeyAction::DeleteProvider(id)) => {
if let Some(cfgp) = user_config_path() {
let _ = crate::config::remove_provider(&cfgp, &id);
}
config.providers.retain(|p| p.name != id);
model.providers = config
.providers
.iter()
.map(|p| {
(
p.name.clone(),
p.kind.clone(),
p.base_url.clone().unwrap_or_default(),
)
})
.collect();
model.providers_sel = model
.providers_sel
.min(model.providers.len().saturating_sub(1));
model
.transcript
.push(Entry::System(format!("deleted provider {id}")));
}
Some(KeyAction::LoadSession(id)) => {
if let Ok((_meta, msgs)) = ui_store.load(&id) {
model.transcript = transcript_from(&msgs);
model.transcript.push(Entry::System(format!("resumed session {id}")));
current_session = id.clone();
bind_goal_to_session(&goal_runtime, &mut model, &cwd, &id);
let _ = control_tx.send(Control::LoadSession(id));
}
}
Some(KeyAction::SetTheme(i)) => {
theme_idx = i.min(themes.len().saturating_sub(1));
let name = themes.name_at(theme_idx).to_string();
theme = build_theme(&themes, &name, &config);
model.theme_name = name.clone();
model.transcript.push(Entry::System(format!("theme: {name}")));
}
Some(KeyAction::AllowAlways) => {
if let Some((tool, _key)) = pending_perm.take() {
let spec = format!("{tool}:*");
perm_handle.add_rule(&tool, "*", true);
add_perm_allow(&cwd, &spec);
model.transcript.push(Entry::System(format!(
"always allowing {spec} — won't ask again (saved to ~/.cordy/permissions.json)"
)));
}
}
Some(KeyAction::ToggleMouse) => {
mouse_on = !mouse_on;
if mouse_on {
let _ = execute!(terminal.backend_mut(), EnableMouseCapture);
} else {
let _ = execute!(terminal.backend_mut(), DisableMouseCapture);
}
model.mouse_on = mouse_on;
model.hover = None;
model.transcript.push(Entry::System(format!(
"mouse {} — {}",
on_off(mouse_on),
if mouse_on {
"the interface is clickable (Shift+drag still selects text)"
} else {
"drag to select and copy as usual; nothing is clickable"
}
)));
}
Some(KeyAction::ShowPermissions) => {
let mut lines = perm_handle.describe_rules();
if lines.is_empty() {
lines.push("no rules — every risky action asks.".into());
}
lines.push(String::new());
lines.push("· press 'a' at a prompt to allow-always for that tool".into());
lines.push("· edit [permissions] in ~/.cordy/config.toml for globs".into());
lines.push(" allow=[\"bash:git *\"] deny=[\"bash:rm -rf *\"] mode=\"auto\"".into());
model.info = Some(("permissions".into(), lines));
}
Some(KeyAction::OpenEditor) => {
if let Some(text) = edit_in_external_editor(&mut terminal, &model.input) {
model.input = text;
model.cursor = model.input.len();
}
}
Some(KeyAction::ExportSession) => {
let path = cwd.join("cordy-export.md");
match std::fs::write(&path, export_markdown(&model.transcript)) {
Ok(()) => model
.transcript
.push(Entry::System(format!("exported → {}", path.display()))),
Err(e) => model
.transcript
.push(Entry::System(format!("export failed: {e}"))),
}
}
Some(KeyAction::CopyLast) => {
if let Some(text) = last_assistant_entry(&model.transcript) {
copy_osc52(&text);
model
.transcript
.push(Entry::System("copied last reply to clipboard".into()));
}
}
Some(KeyAction::MessagesUndo) => {
let mut group = Vec::new();
while let Some(e) = model.transcript.pop() {
let is_user = matches!(e, Entry::User(_));
group.push(e);
if is_user {
break;
}
}
if !group.is_empty() {
group.reverse();
model.msg_redo.push(group);
let _ = control_tx.send(Control::UndoMessage);
}
}
Some(KeyAction::MessagesRedo) => {
if let Some(group) = model.msg_redo.pop() {
model.transcript.extend(group);
let _ = control_tx.send(Control::RedoMessage);
}
}
Some(KeyAction::PasteClipboard) => {
match paste_clipboard(&mut model, &cwd) {
Ok(Some(msg)) => model.transcript.push(Entry::System(msg)),
Ok(None) => {}
Err(e) => model
.transcript
.push(Entry::System(format!("clipboard paste failed: {e}"))),
}
model.suggestions = compute_suggestions(&model.input, &cwd);
model.suggestion_sel = 0;
}
Some(KeyAction::MsgMenuExec) => {
run_msg_menu(&mut model, &control_tx);
}
None => {}
}
let mode_now = model.mode().to_string();
if mode_now != mode_before {
let mut rules = permission_rules(&config, &cwd);
rules.extend(mode_rules(&mode_now));
perm_handle.set_rules(rules);
let _ = control_tx.send(Control::SetMode(mode_now.clone()));
if read_only_mode(&mode_now) {
model.transcript.push(Entry::System(format!(
"mode → {mode_now} (read-only: file edits are blocked)"
)));
} else {
model
.transcript
.push(Entry::System(format!("mode → {mode_now}")));
}
model.sync_tab_meta();
}
let next = compute_suggestions(&model.input, &cwd);
if next != model.suggestions {
model.suggestion_sel = 0;
} else if !next.is_empty() {
model.suggestion_sel = model.suggestion_sel.min(next.len() - 1);
}
model.suggestions = next;
}
}
Some(g) = git_rx.recv() => {
if model.git != g {
model.git = g;
dirty = true;
}
}
Some((tag, a)) = ui_rx.recv() => {
let Some(tab_id) = tabs_rt.iter().position(|t| t.tag == tag) else { continue };
let focused = tab_id == active_tab;
dirty |= focused;
let done = matches!(a, AgentEvent::TurnComplete { .. });
let failure = match &a {
AgentEvent::Error(e) => Some(e.clone()),
_ => None,
};
let turn_usage = match &a {
AgentEvent::TurnComplete { usage } => Some(*usage),
_ => None,
};
let mut claim_warning = None;
if let AgentEvent::ToolStarted { name, input, .. } = &a
&& matches!(
name.as_str(),
"edit" | "write" | "multiedit" | "apply_patch"
)
&& let Some(path) = input
.get("file_path")
.or_else(|| input.get("path"))
.and_then(|v| v.as_str())
{
match file_claims.get(path) {
Some(&owner) if owner != tag => {
let other = tabs_rt
.iter()
.position(|t| t.tag == owner)
.map(|i| i + 1)
.unwrap_or(0);
claim_warning =
Some(format!("⚠ {path} was last written from tab {other}"));
}
_ => {}
}
file_claims.insert(path.to_string(), tag);
last_edited.insert(tag, path.to_string());
}
with_tab(&mut model, &mut tabs_rt, active_tab, tab_id, |m| {
if let Some(w) = claim_warning {
m.transcript.push(Entry::System(w));
}
update(m, Msg::Agent(a));
});
if focused {
model.goal_line = goal_runtime.goal().as_ref().map(goal_status_line);
} else if let Some(meta) = model.tabs.get_mut(tab_id) {
meta.unread = meta.unread.saturating_add(1);
meta.busy = !done && failure.is_none();
dirty = true; }
if done || failure.is_some() {
let start = tabs_rt[tab_id].turn_start.take();
if let Some(start) = start {
let secs = start.elapsed().as_secs_f64();
with_tab(&mut model, &mut tabs_rt, active_tab, tab_id, |m| {
let mode = m.mode().to_string();
let model_name = m.model_name.clone();
if failure.is_none() {
m.transcript.push(Entry::Turn {
mode: mode.clone(),
model: model_name.clone(),
secs,
});
}
let u = turn_usage.unwrap_or_default();
let ev = super::ModelEvent {
model: model_name,
mode,
secs,
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
cost: usage_cost(&u, m.price_in, m.price_out),
error: failure.clone(),
};
m.events.push(ev);
if m.events.len() > 200 {
m.events.remove(0);
}
});
}
}
if focused && !model.busy && !model.queue.is_empty() {
let text = std::mem::take(&mut model.queue).join("\n\n");
model.input = text;
if let Effect::Submit(t) = update(&mut model, Msg::Submit) {
let t = expand_pastes(&mut model, &t);
let content = match super::input::parse_user_input(&t, &cwd) {
Ok(blocks) => blocks,
Err(e) => {
update(&mut model, Msg::Agent(AgentEvent::Error(e)));
vec![ContentBlock::text(t)]
}
};
tabs_rt[active_tab].turn_start = Some(std::time::Instant::now());
let _ = prompt_tx.send(content);
}
}
model.sync_tab_meta();
}
Some(ask) = perm_rx.recv() => {
dirty = true;
pending_reply = Some(ask.reply);
pending_perm = Some((ask.tool, ask.key));
update(&mut model, Msg::Permission(ask.summary));
}
_ = ticker.tick() => {
update(&mut model, Msg::Tick);
if model.busy || model.mode_flash > 0 || model.bg_count > 0 {
dirty = true;
}
if terms.visible && terms.any_dirty() {
dirty = true;
}
if terms.focused && terms.active_panel().is_some_and(|p| p.has_exited()) {
terms.focused = false;
model
.transcript
.push(Entry::System("terminal exited — ^Alt+W closes the panel".into()));
dirty = true;
}
if !model.busy && model.queue.is_empty() {
let events = bg.take_finished_events();
if !events.is_empty() {
let mut note = String::from(
"[automated event] Background job(s) finished while you were idle. \
Review the outcome below and take any needed follow-up action; if \
nothing is needed, briefly say so.\n",
);
for e in &events {
note.push_str(&format!(
"\n• job {} ({}) — {}\n--- recent output ---\n{}\n",
e.id, e.command, e.status, e.output_tail
));
model.transcript.push(Entry::System(format!(
"⚡ background job {} {} — notifying agent",
e.id, e.status
)));
}
model.busy = true;
model.status = "thinking…".into();
model.scroll = 0;
tabs_rt[active_tab].turn_start = Some(std::time::Instant::now());
let _ = prompt_tx.send(vec![ContentBlock::text(note)]);
dirty = true;
}
}
reload_tick = reload_tick.wrapping_add(1);
if reload_tick.is_multiple_of(8) {
if themes.reload_if_changed(&theme_dirs) {
model.theme_names = themes.names();
theme_idx = themes.index_of(&model.theme_name).unwrap_or(theme_idx);
theme = build_theme(&themes, themes.name_at(theme_idx), &config);
dirty = true;
}
let sig = config_mtime(&config_paths);
if sig != config_sig {
config_sig = sig;
config = Config::load(
user_config_path().as_deref(),
&cwd.join(".cordy/config.toml"),
);
if let Some(t) = config.theme.as_deref()
&& let Some(i) = themes.index_of(t)
{
theme_idx = i;
model.theme_name = themes.name_at(i).to_string();
}
theme = build_theme(&themes, themes.name_at(theme_idx), &config);
model.statusline = config.statusline.clone();
model.show_mascot = config.mascot_enabled();
let mut rules = permission_rules(&config, &cwd);
rules.extend(mode_rules(model.mode()));
perm_handle.set_rules(rules);
let modes = model.modes.clone();
let pk = model.provider_kind.clone();
let mn = model.model_name.clone();
let tn = model.theme_names.clone();
model.palette =
build_palette(&config, &catalog, &live_models, &modes, &pk, &mn, &tn);
(model.price_in, model.price_out) =
resolve_price(&catalog, &config, &model.model_name);
goal_runtime.set_pricing(Pricing {
input_per_mtok: model.price_in,
output_per_mtok: model.price_out,
});
goal_runtime.set_enabled(config.goal_enabled());
model.context_window =
resolve_context(&catalog, &config, &model.model_name);
model.transcript.push(Entry::System("config reloaded".into()));
dirty = true;
}
}
}
}
};
terms.kill_all(); bg.kill_all();
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste
)?;
terminal.show_cursor()?;
result
}
fn is_text_key(ev: &Event) -> bool {
if let Event::Key(k) = ev {
if k.kind == KeyEventKind::Release {
return false;
}
let plain = !k
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
return plain && matches!(k.code, KeyCode::Char(_) | KeyCode::Enter | KeyCode::Tab);
}
false
}
fn is_plain_enter(ev: &Event) -> bool {
matches!(ev, Event::Key(k)
if k.kind != KeyEventKind::Release
&& k.code == KeyCode::Enter
&& !k.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER))
}
fn flush_burst(tx: &mpsc::UnboundedSender<Event>, burst: Vec<Event>) -> bool {
if burst.len() >= 2 {
let mut s = String::new();
for ev in &burst {
if let Event::Key(k) = ev {
match k.code {
KeyCode::Char(c) => s.push(c),
KeyCode::Enter => s.push('\n'),
KeyCode::Tab => s.push('\t'),
_ => {}
}
}
}
return tx.send(Event::Paste(s)).is_ok();
}
for ev in burst {
if tx.send(ev).is_err() {
return false;
}
}
true
}
const PASTE_GRACE: Duration = Duration::from_millis(250);
const PASTE_COALESCE_WAIT: Duration = Duration::from_millis(40);
const PASTE_DETECT_WAIT: Duration = Duration::from_millis(20);
fn spawn_input_reader(tx: mpsc::UnboundedSender<Event>) {
std::thread::spawn(move || {
let mut paste_until: Option<std::time::Instant> = None;
let in_paste = |until: &Option<std::time::Instant>| {
until.is_some_and(|d| std::time::Instant::now() < d)
};
loop {
match event::poll(Duration::from_millis(100)) {
Ok(true) => {
let Ok(ev) = event::read() else { break };
let paste_active = in_paste(&paste_until);
if paste_active && is_plain_enter(&ev) {
if tx.send(Event::Paste("\n".into())).is_err() {
break;
}
paste_until = Some(std::time::Instant::now() + PASTE_GRACE);
continue;
}
if is_text_key(&ev) {
let mut burst = vec![ev];
let mut trailing = None;
loop {
let wait = if burst.len() >= 2 || paste_active {
PASTE_COALESCE_WAIT
} else {
PASTE_DETECT_WAIT
};
match event::poll(wait) {
Ok(true) => {
let Ok(next) = event::read() else { break };
if is_text_key(&next) {
burst.push(next);
} else {
trailing = Some(next);
break;
}
}
_ => break,
}
}
let is_paste = burst.len() >= 2 || paste_active;
if !flush_burst(&tx, burst) {
break;
}
paste_until = is_paste.then(|| std::time::Instant::now() + PASTE_GRACE);
if let Some(t) = trailing
&& tx.send(t).is_err()
{
break;
}
} else {
paste_until = None;
if tx.send(ev).is_err() {
break;
}
}
}
Ok(false) => {
if tx.is_closed() {
break;
}
}
Err(_) => break,
}
}
});
}
fn latinize(c: char) -> char {
let lower = c.to_lowercase().next().unwrap_or(c);
let mapped = match lower {
'й' => 'q',
'ц' => 'w',
'у' => 'e',
'к' => 'r',
'е' => 't',
'н' => 'y',
'г' => 'u',
'ш' => 'i',
'щ' => 'o',
'з' => 'p',
'х' => '[',
'ъ' => ']',
'ф' => 'a',
'ы' => 's',
'в' => 'd',
'а' => 'f',
'п' => 'g',
'р' => 'h',
'о' => 'j',
'л' => 'k',
'д' => 'l',
'ж' => ';',
'э' => '\'',
'я' => 'z',
'ч' => 'x',
'с' => 'c',
'м' => 'v',
'и' => 'b',
'т' => 'n',
'ь' => 'm',
'б' => ',',
'ю' => '.',
'ё' => '`',
_ => return c,
};
if c.is_uppercase() {
mapped.to_ascii_uppercase()
} else {
mapped
}
}
fn latinize_code(code: KeyCode) -> KeyCode {
match code {
KeyCode::Char(c) => KeyCode::Char(latinize(c)),
other => other,
}
}
fn no_overlay(model: &Model) -> bool {
!model.palette_open
&& !model.sessions_open
&& !model.providers_open
&& !model.theme_open
&& !model.status_open
&& model.connect.is_none()
&& model.pending.is_none()
&& model.info.is_none()
&& model.msg_menu.is_none()
}
fn entry_at_click(model: &Model, col: u16, row: u16) -> Option<usize> {
let (rx, ry, rw, rh) = model.transcript_rect?;
if col < rx || col >= rx + rw || row < ry || row >= ry + rh {
return None;
}
let line_idx = model.transcript_start + (row - ry) as usize;
model
.entry_spans
.iter()
.position(|&(start, len)| line_idx >= start && line_idx < start + len)
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum MsgAction {
Copy,
Rewind,
Delete,
}
fn menu_actions(entry: &Entry) -> Vec<(MsgAction, &'static str, char)> {
match entry {
Entry::User(_) => vec![
(MsgAction::Rewind, "rewind & edit", 'r'),
(MsgAction::Copy, "copy", 'c'),
(MsgAction::Delete, "delete from here", 'd'),
],
Entry::Assistant(_) | Entry::Tool { .. } | Entry::System(_) => {
vec![(MsgAction::Copy, "copy", 'c')]
}
Entry::Turn { .. } => Vec::new(),
}
}
fn menu_subject(entry: &Entry) -> &'static str {
match entry {
Entry::User(_) => "your message",
Entry::Assistant(_) => "reply",
Entry::Tool { .. } => "tool output",
Entry::System(_) => "note",
Entry::Turn { .. } => "turn",
}
}
fn entry_text(entry: &Entry) -> Option<String> {
match entry {
Entry::User(s) | Entry::Assistant(s) | Entry::System(s) => Some(s.clone()),
Entry::Tool { text, .. } => Some(text.clone()),
Entry::Turn { .. } => None,
}
}
fn menu_item_at(model: &Model, col: u16, row: u16) -> Option<usize> {
let (rx, ry, rw, rh) = model.msg_menu_rect?;
if col < rx || col >= rx + rw || row <= ry || row >= ry + rh - 1 {
return None; }
let mm = model.msg_menu.as_ref()?;
let n = menu_actions(model.transcript.get(mm.entry)?).len();
let item = (row - ry - 1) as usize;
(item < n).then_some(item)
}
fn run_msg_menu(model: &mut Model, control_tx: &mpsc::UnboundedSender<Control>) {
let Some(mm) = model.msg_menu.take() else {
return;
};
let idx = mm.entry;
let Some(entry) = model.transcript.get(idx) else {
return;
};
let actions = menu_actions(entry);
let Some(&(act, _, _)) = actions.get(mm.sel) else {
return;
};
let copy_text = entry_text(entry);
let user_text = match entry {
Entry::User(t) => t.clone(),
_ => String::new(),
};
match act {
MsgAction::Copy => {
if let Some(t) = copy_text {
copy_osc52(&t);
model
.transcript
.push(Entry::System("copied to clipboard".into()));
}
}
MsgAction::Rewind | MsgAction::Delete => {
let ordinal = model.transcript[..idx]
.iter()
.filter(|e| matches!(e, Entry::User(_)))
.count();
model.transcript.truncate(idx);
model.msg_redo.clear();
model.scroll = 0;
if act == MsgAction::Rewind {
model.input = user_text;
model.cursor = model.input.len();
model.anchor = None;
model.transcript.push(Entry::System(
"↩ rewound — edit and press Enter to resend".into(),
));
} else {
model
.transcript
.push(Entry::System("✕ deleted from here".into()));
}
let _ = control_tx.send(Control::RewindTo(ordinal));
}
}
}
fn editor_target(model: &Model, last: Option<&String>) -> String {
for tok in model.input.split_whitespace() {
if let Some(p) = tok.strip_prefix('@')
&& !p.is_empty()
&& std::path::Path::new(p).exists()
{
return p.to_string();
}
}
last.cloned().unwrap_or_else(|| ".".to_string())
}
fn quote_arg(s: &str) -> String {
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
}
fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let mut started = false; let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
'\'' => {
quoted = !quoted;
started = true;
}
'\\' if quoted => match chars.next() {
Some(next) => cur.push(next),
None => cur.push('\\'),
},
c if c.is_whitespace() && !quoted => {
if started || !cur.is_empty() {
out.push(std::mem::take(&mut cur));
started = false;
}
}
c => {
cur.push(c);
started = true;
}
}
}
if started || !cur.is_empty() {
out.push(cur);
}
out
}
async fn run_git(cwd: &std::path::Path, args: &str) -> Vec<String> {
let argv = split_args(args);
let out = tokio::process::Command::new("git")
.args(&argv)
.current_dir(cwd)
.output()
.await;
let out = match out {
Ok(o) => o,
Err(e) => return vec![format!("git not available: {e}")],
};
let mut lines: Vec<String> = Vec::new();
for chunk in [&out.stdout, &out.stderr] {
for l in String::from_utf8_lossy(chunk).lines() {
lines.push(l.to_string());
}
}
if lines.is_empty() {
lines.push(if out.status.success() {
"(no output)".into()
} else {
format!("exited with {}", out.status)
});
}
lines.truncate(400);
lines
}
fn known_models(model: &Model) -> Vec<(String, String)> {
model
.palette
.iter()
.filter_map(|it| match &it.action {
super::PaletteAction::SwitchModel(n) => Some((n.clone(), it.hint.clone())),
_ => None,
})
.collect()
}
fn open_model_picker(model: &mut Model) {
let all = known_models(model);
let hint_of = |name: &str| {
all.iter()
.find(|(n, _)| n == name)
.map(|(_, h)| h.clone())
.unwrap_or_default()
};
let mut rows: Vec<PickRow> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let section = |rows: &mut Vec<PickRow>,
seen: &mut std::collections::HashSet<String>,
title: &str,
names: &[String],
badge: &str| {
let fresh: Vec<&String> = names.iter().filter(|n| !seen.contains(*n)).collect();
if fresh.is_empty() {
return;
}
rows.push(PickRow::header(title));
for n in fresh {
seen.insert(n.clone());
let mut row = PickRow::new(n.clone(), hint_of(n), PickAction::Model(n.clone()));
if n == &model.model_name {
row = row.badge("● active");
} else if !badge.is_empty() {
row = row.badge(badge.to_string());
}
rows.push(row);
}
};
let favorites = model.favorites.clone();
let recents = model.recents.clone();
section(&mut rows, &mut seen, "favorites", &favorites, "★");
section(&mut rows, &mut seen, "recent", &recents, "");
let rest: Vec<String> = all.iter().map(|(n, _)| n.clone()).collect();
section(&mut rows, &mut seen, "all models", &rest, "");
if rows.is_empty() {
rows.push(PickRow::new(
"no models",
"run /connect to add a provider",
PickAction::None,
));
}
model.picker = Some(
Picker::new(PickerKind::Model, "Models", rows)
.footer("type to filter · enter switch (context kept) · esc close"),
);
}
fn mode_desc(name: &str) -> &'static str {
match name {
"build" => "investigate, then implement — full tool access",
"plan" => "research and propose; no file edits",
"architect" => "design and trade-offs; no code generation",
"socratic" => "asks probing questions before proposing anything",
"challenge" => "adversarial — attacks the assumptions in your request",
"review" => "reads the diff and reports defects; no edits",
_ => "project agent",
}
}
fn open_mode_picker(model: &mut Model) {
let rows: Vec<PickRow> = model
.modes
.iter()
.enumerate()
.map(|(i, m)| {
let row = PickRow::new(m.clone(), mode_desc(m), PickAction::Mode(i));
if i == model.mode_idx {
row.badge("● active")
} else {
row
}
})
.collect();
model.picker = Some(
Picker::new(PickerKind::Mode, "Mode", rows)
.footer("↑↓ move · enter switch · tab also cycles · esc close"),
);
}
fn open_skills_picker(model: &mut Model) {
let rows: Vec<PickRow> = if model.skills.is_empty() {
vec![PickRow::new(
"no skills loaded",
"add SKILL.md folders under .cordy/skills or ~/.cordy/skills",
PickAction::None,
)]
} else {
model
.skills
.iter()
.map(|(n, d)| PickRow::new(n.clone(), d.clone(), PickAction::Insert(n.clone())))
.collect()
};
model.picker = Some(
Picker::new(PickerKind::Skills, "Skills", rows)
.footer("type to filter · enter mention in the prompt · esc close"),
);
}
fn open_events_picker(model: &mut Model) {
let rows: Vec<PickRow> = if model.events.is_empty() {
vec![PickRow::new(
"no calls yet",
"send a message and this fills in",
PickAction::None,
)]
} else {
model
.events
.iter()
.rev()
.map(|e| {
let label = match &e.error {
Some(_) => format!("✗ {}", chrome::short_model(&e.model)),
None => format!("✓ {}", chrome::short_model(&e.model)),
};
let hint = match &e.error {
Some(err) => format!("{:.1}s · {}", e.secs, err),
None => format!(
"{:.1}s · {} in / {} out · {}",
e.secs,
super::fmt_tokens(e.input_tokens),
super::fmt_tokens(e.output_tokens),
e.mode
),
};
PickRow::new(label, hint, PickAction::None).badge(if e.cost > 0.0 {
format!("${:.4}", e.cost)
} else {
String::new()
})
})
.collect()
};
model.picker = Some(
Picker::new(PickerKind::Events, "Model events", rows)
.menu()
.footer("per-call latency, tokens and cost · esc close"),
);
}
fn open_context_picker(model: &mut Model, rep: &ContextReport) {
let total = rep.total.max(1);
let mut rows: Vec<PickRow> = Vec::new();
for (label, tokens) in &rep.sections {
let pct = (*tokens * 100 / total).min(100);
let filled = ((pct * 20) / 100).max(if *tokens > 0 { 1 } else { 0 }) as usize;
let bar = format!(
"{}{}",
"█".repeat(filled),
"░".repeat(20usize.saturating_sub(filled))
);
rows.push(
PickRow::new(label.clone(), format!("{bar} {pct:>3}%"), PickAction::None)
.badge(super::fmt_tokens(*tokens)),
);
}
rows.push(PickRow::header("totals"));
rows.push(
PickRow::new(
"next request",
format!("{} messages in history", rep.messages),
PickAction::None,
)
.badge(super::fmt_tokens(rep.total)),
);
if let Some(w) = model.context_window {
let pct = (rep.total * 100 / w.max(1)).min(100);
rows.push(
PickRow::new(
"context window",
format!("{pct}% of the model's window"),
PickAction::None,
)
.badge(super::fmt_tokens(w)),
);
}
rows.push(PickRow::new(
"compaction",
if rep.native_mgmt {
"handled by the provider — client-side compaction idle"
} else {
"client-side · /compact to summarize now"
},
PickAction::None,
));
model.picker = Some(
Picker::new(PickerKind::Context, "Context", rows)
.menu()
.footer("estimates from serialized payload size · esc close"),
);
}
fn open_tabs_picker(model: &mut Model) {
let active = model.tab;
let rows: Vec<PickRow> = model
.tabs
.iter()
.enumerate()
.map(|(i, t)| {
let hint = format!("{} · {}", t.mode, chrome::short_model(&t.model));
let mut row = PickRow::new(format!("{}. {}", i + 1, t.title), hint, PickAction::Tab(i));
row = if i == active {
row.badge("● focused")
} else if t.busy {
row.badge("working…")
} else if t.unread > 0 {
row.badge(format!("{} new", t.unread))
} else {
row
};
row
})
.collect();
model.picker = Some(
Picker::new(PickerKind::Tabs, "Tabs", rows)
.menu()
.footer("↑↓ move · enter focus · ^N new · alt+w close · alt+1..5 jump"),
);
}
fn open_git_picker(model: &mut Model) {
let row = |label: &str, hint: &str, args: &str| {
PickRow::new(label, hint, PickAction::Git(args.into()))
};
let rows = vec![
PickRow::header("inspect"),
row("status", "working tree", "status -sb"),
row("diff", "unstaged changes", "diff --stat"),
row(
"staged",
"what a commit would include",
"diff --cached --stat",
),
row("log", "recent history", "log --oneline --graph -25"),
row("branches", "local branches and tracking", "branch -vv"),
PickRow::header("change"),
row("add all", "stage every change", "add -A"),
row("unstage", "reset the index, keep the work", "reset"),
PickRow::new(
"commit",
"commit staged changes",
PickAction::Prompt("message".into()),
),
PickRow::new(
"checkout",
"switch to a branch",
PickAction::Prompt("branch".into()),
),
PickRow::header("remote"),
row("push", "push the current branch", "push"),
row("pull", "fast-forward from upstream", "pull --ff-only"),
row("fetch", "update remote refs", "fetch --all --prune"),
PickRow::header("stash"),
row(
"stash",
"shelve everything, untracked included",
"stash push -u",
),
row("stash pop", "restore the newest stash", "stash pop"),
];
model.picker = Some(
Picker::new(PickerKind::Git, "Git", rows)
.menu()
.footer("↑↓ move · enter run · first letter jumps · esc close"),
);
}
fn exec_pick(model: &mut Model, action: PickAction) -> Option<KeyAction> {
match action {
PickAction::Model(name) => Some(KeyAction::SwitchModel(name)),
PickAction::Mode(i) => {
if i < model.modes.len() {
model.mode_idx = i;
model.mode_flash = 6;
}
None
}
PickAction::Git(args) => Some(KeyAction::RunGit(args)),
PickAction::Command(c) => exec_palette(model, super::PaletteAction::Command(c)),
PickAction::Tab(i) => Some(KeyAction::FocusTab(i)),
PickAction::Insert(text) => {
model.input.insert_str(model.cursor, &text);
model.cursor += text.len();
None
}
PickAction::Prompt(_) | PickAction::None => None,
}
}
fn handle_key(
model: &mut Model,
k: KeyEvent,
pending_reply: &mut Option<oneshot::Sender<bool>>,
) -> Option<KeyAction> {
use KeyCode::{
BackTab, Backspace, Char, Delete, Down, End, Enter, Esc, Home, Left, PageDown, PageUp,
Right, Tab, Up,
};
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
let alt = k.modifiers.contains(KeyModifiers::ALT);
let shift = k.modifiers.contains(KeyModifiers::SHIFT);
let code = if ctrl || alt {
latinize_code(k.code)
} else {
k.code
};
if model.leader {
model.leader = false;
return match latinize_code(k.code) {
Char('q') => {
update(model, Msg::Quit);
None
}
Char('n') => Some(KeyAction::NewSession),
Char('l') => Some(KeyAction::OpenSessions),
Char('c') => Some(KeyAction::Compact),
Char('h') => {
model.transcript.push(Entry::System(HELP_TEXT.into()));
None
}
Char('m') => {
open_model_picker(model);
None
}
Char('a') => {
open_mode_picker(model);
None
}
Char('g') => {
model.scroll = u16::MAX;
None
}
Char('z') => Some(KeyAction::Redraw),
Char('M') => Some(KeyAction::ToggleMouse),
Char('v') => Some(KeyAction::PasteClipboard),
Char('w') => Some(KeyAction::CloseTab),
Char('t') => {
model.theme_open = true;
model.theme_sel = model
.theme_names
.iter()
.position(|t| *t == model.theme_name)
.unwrap_or(0);
None
}
Char('b') => {
model.show_mascot = !model.show_mascot;
None
}
Char('e') => Some(KeyAction::OpenEditor),
Char('x') => Some(KeyAction::ExportSession),
Char('y') => Some(KeyAction::CopyLast),
Char('u') => Some(KeyAction::MessagesUndo),
Char('r') => Some(KeyAction::MessagesRedo),
Char('f') => Some(KeyAction::ToggleFavorite),
Char('s') => {
model.status_open = !model.status_open;
None
}
_ => None,
};
}
if model.pending.is_none()
&& let Some(p) = &mut model.picker
{
let kind = p.kind;
match picker::handle_key(p, k.code) {
PickerEvent::None => return None,
PickerEvent::Closed => {
model.picker = None;
return None;
}
PickerEvent::Chose(action) => {
model.picker = None;
return exec_pick(model, action);
}
PickerEvent::Submit(key, text) => {
model.picker = None;
if text.trim().is_empty() {
return None;
}
if kind == PickerKind::Git {
let args = match key.as_str() {
"commit" => format!("commit -m {}", quote_arg(&text)),
"checkout" => format!("checkout {}", quote_arg(text.trim())),
_ => return None,
};
return Some(KeyAction::RunGit(args));
}
return None;
}
}
}
if model.status_open {
model.status_open = false;
return None;
}
if model.info.is_some() {
model.info = None;
return None;
}
if model.msg_menu.is_some() {
let acts: Vec<(MsgAction, &'static str, char)> = model
.msg_menu
.as_ref()
.and_then(|mm| model.transcript.get(mm.entry))
.map(menu_actions)
.unwrap_or_default();
let Some(mm) = &mut model.msg_menu else {
return None;
};
match code {
Esc => model.msg_menu = None,
Up => mm.sel = mm.sel.saturating_sub(1),
Down => mm.sel = (mm.sel + 1).min(acts.len().saturating_sub(1)),
Enter => return Some(KeyAction::MsgMenuExec),
Char(c) => {
let c = c.to_ascii_lowercase();
if let Some(i) = acts.iter().position(|(_, _, key)| *key == c) {
mm.sel = i;
return Some(KeyAction::MsgMenuExec);
}
}
_ => {}
}
return None;
}
if model.theme_open {
match k.code {
Esc => model.theme_open = false,
KeyCode::Up => model.theme_sel = model.theme_sel.saturating_sub(1),
KeyCode::Down => {
model.theme_sel =
(model.theme_sel + 1).min(model.theme_names.len().saturating_sub(1))
}
Enter => {
model.theme_open = false;
return Some(KeyAction::SetTheme(model.theme_sel));
}
_ => {}
}
return None;
}
if model.connect.is_some() {
return handle_connect_key(model, k);
}
if model.sessions_open {
match k.code {
Esc => model.sessions_open = false,
KeyCode::Up => model.sessions_sel = model.sessions_sel.saturating_sub(1),
KeyCode::Down => {
model.sessions_sel =
(model.sessions_sel + 1).min(model.sessions.len().saturating_sub(1))
}
Enter => {
model.sessions_open = false;
if let Some((id, _)) = model.sessions.get(model.sessions_sel) {
return Some(KeyAction::LoadSession(id.clone()));
}
}
Char('d') => {
if let Some((id, _)) = model.sessions.get(model.sessions_sel) {
return Some(KeyAction::DeleteSession(id.clone()));
}
}
Char('f') => {
if let Some((id, _)) = model.sessions.get(model.sessions_sel) {
return Some(KeyAction::ForkSession(id.clone()));
}
}
_ => {}
}
return None;
}
if model.providers_open {
match k.code {
Esc => model.providers_open = false,
KeyCode::Up => model.providers_sel = model.providers_sel.saturating_sub(1),
KeyCode::Down => {
model.providers_sel =
(model.providers_sel + 1).min(model.providers.len().saturating_sub(1))
}
Enter => {
model.providers_open = false;
if let Some((id, _, _)) = model.providers.get(model.providers_sel) {
return Some(KeyAction::SwitchSavedProvider(id.clone()));
}
}
Char('c') => {
model.providers_open = false;
model.connect = Some(Connect::default());
}
Char('d') => {
if let Some((id, _, _)) = model.providers.get(model.providers_sel) {
return Some(KeyAction::DeleteProvider(id.clone()));
}
}
_ => {}
}
return None;
}
if model.palette_open {
let filtered = model.palette_filtered();
match k.code {
Esc => model.palette_open = false,
KeyCode::Up => model.palette_sel = model.palette_sel.saturating_sub(1),
KeyCode::Down => {
model.palette_sel = (model.palette_sel + 1).min(filtered.len().saturating_sub(1))
}
Backspace => {
model.palette_query.pop();
model.palette_sel = 0;
}
Enter => {
model.palette_open = false;
if let Some(&idx) = filtered.get(model.palette_sel) {
let action = model.palette[idx].action.clone();
return exec_palette(model, action);
}
}
Char(c) if !ctrl && !alt => {
model.palette_query.push(c);
model.palette_sel = 0;
}
_ => {}
}
return None;
}
if model.pending.is_some() {
match k.code {
Char('a') | Char('A') => {
if let Some(reply) = pending_reply.take() {
let _ = reply.send(true);
}
update(model, Msg::PermissionResolved);
return Some(KeyAction::AllowAlways);
}
Char('y') | Char('Y') | Enter => {
if let Some(reply) = pending_reply.take() {
let _ = reply.send(true);
}
update(model, Msg::PermissionResolved);
}
Char('n') | Char('N') | Esc => {
if let Some(reply) = pending_reply.take() {
let _ = reply.send(false);
}
update(model, Msg::PermissionResolved);
}
_ => {}
}
return None;
}
if ctrl && matches!(k.code, Char('p')) {
model.palette_open = true;
model.palette_query.clear();
model.palette_sel = 0;
return None;
}
if ctrl && matches!(k.code, Char('x')) {
model.leader = true;
return None;
}
if ctrl && alt {
match k.code {
Char('b') => model.scroll = model.scroll.saturating_add(20),
Char('f') => model.scroll = model.scroll.saturating_sub(20),
Char('u') => model.scroll = model.scroll.saturating_add(10),
Char('d') => model.scroll = model.scroll.saturating_sub(10),
Char('y') => model.scroll = model.scroll.saturating_add(1),
Char('e') => model.scroll = model.scroll.saturating_sub(1),
Char('g') => model.scroll = 0, Char('k') => model.leader = !model.leader, Char('s') => {
let text = std::mem::take(&mut model.input);
model.cursor = 0;
model.anchor = None;
if !text.trim().is_empty() {
return Some(KeyAction::Steer(text));
}
}
_ => {}
}
return None;
}
if (ctrl || alt) && matches!(k.code, KeyCode::Up) {
update(model, Msg::HistoryPrev);
return None;
}
if (ctrl || alt) && matches!(k.code, KeyCode::Down) {
update(model, Msg::HistoryNext);
return None;
}
if ctrl && !shift && !alt {
match code {
Char('l') => {
open_model_picker(model);
return None;
}
Char('g') => {
open_git_picker(model);
return None;
}
Char('s') => {
open_skills_picker(model);
return None;
}
Char('b') => {
open_tabs_picker(model);
return None;
}
Char('n') => return Some(KeyAction::NewTab),
Char('k') => return Some(KeyAction::InspectContext),
Char('t') => return Some(KeyAction::OpenTerm),
Char('e') => return Some(KeyAction::OpenEditorPanel),
_ => {}
}
}
if alt && !ctrl {
if let Char(c @ '1'..='5') = code {
return Some(KeyAction::FocusTab(c as usize - '1' as usize));
}
if matches!(code, Char('w')) {
return Some(KeyAction::CloseTab);
}
}
if ctrl && matches!(k.code, Char('r')) {
model.input = "/rename ".into();
model.cursor = model.input.len();
return None;
}
if matches!(k.code, KeyCode::F(2)) {
return Some(KeyAction::CycleRecent(if shift { -1 } else { 1 }));
}
if ctrl && shift && matches!(code, Char('d') | Char('D')) {
update(model, Msg::KillLine);
return None;
}
if ctrl && matches!(code, Char('d')) {
open_mode_picker(model);
return None;
}
if ctrl && matches!(code, Char('c')) {
update(
model,
if model.input.is_empty() {
Msg::Quit
} else {
Msg::ClearInput
},
);
return None;
}
if alt && !ctrl && matches!(code, Char('v')) {
return Some(KeyAction::PasteClipboard);
}
match code {
Esc => {
if !model.suggestions.is_empty() {
model.suggestions.clear();
model.suggestion_sel = 0;
return None;
}
if model.busy {
return Some(KeyAction::Interrupt);
}
None
}
Tab => {
if !model.suggestions.is_empty() {
apply_completion(model);
} else {
update(model, Msg::CycleMode(1));
}
None
}
BackTab => {
update(model, Msg::CycleMode(-1));
None
}
Char('j') if ctrl => {
update(model, Msg::Newline);
None
}
Enter if alt || shift => {
update(model, Msg::Newline);
None
}
Enter => {
if !model.suggestions.is_empty() {
apply_completion(model);
return None;
}
let raw = model.input.trim().to_string();
if raw.starts_with('/') {
update(model, Msg::ClearInput);
return handle_command(model, &raw);
}
match update(model, Msg::Submit) {
Effect::Submit(text) => Some(KeyAction::Prompt(text)),
_ => None,
}
}
Left => {
let m = if ctrl || alt {
Msg::WordBackward
} else {
Msg::Left
};
move_with_sel(model, m, shift);
None
}
Right => {
let m = if ctrl || alt {
Msg::WordForward
} else {
Msg::Right
};
move_with_sel(model, m, shift);
None
}
Up => {
let w = model.input_width as usize;
if !model.suggestions.is_empty() {
model.suggestion_sel = model.suggestion_sel.saturating_sub(1);
} else if super::move_vertical(&model.input, model.cursor, -1, w).is_some() {
move_with_sel(model, Msg::CursorUp, shift);
} else if model.scroll < model.max_scroll {
model.scroll = model.scroll.saturating_add(1).min(model.max_scroll);
} else {
update(model, Msg::HistoryPrev);
}
None
}
Down => {
let w = model.input_width as usize;
if !model.suggestions.is_empty() {
let last = model.suggestions.len().saturating_sub(1);
model.suggestion_sel = (model.suggestion_sel + 1).min(last);
} else if super::move_vertical(&model.input, model.cursor, 1, w).is_some() {
move_with_sel(model, Msg::CursorDown, shift);
} else if model.scroll > 0 {
model.scroll = model.scroll.saturating_sub(1);
} else {
update(model, Msg::HistoryNext);
}
None
}
Home if model.input.is_empty() => {
model.scroll = model.max_scroll;
None
}
End if model.input.is_empty() => {
model.scroll = 0;
None
}
Home => {
move_with_sel(model, Msg::Home, shift);
None
}
End => {
move_with_sel(model, Msg::End, shift);
None
}
Char('b') | Char('B') if alt => {
move_with_sel(model, Msg::WordBackward, shift);
None
}
Char('f') | Char('F') if alt => {
move_with_sel(model, Msg::WordForward, shift);
None
}
Char('b') | Char('B') if ctrl => {
move_with_sel(model, Msg::Left, shift);
None
}
Char('f') | Char('F') if ctrl => {
move_with_sel(model, Msg::Right, shift);
None
}
Char('a') | Char('A') if ctrl => {
move_with_sel(model, Msg::LineHome, shift);
None
}
Char('e') | Char('E') if alt => {
move_with_sel(model, Msg::LineEnd, shift);
None
}
Backspace => {
update(
model,
if ctrl || alt {
Msg::KillWordBack
} else {
Msg::Backspace
},
);
None
}
Delete => {
update(
model,
if ctrl || alt {
Msg::KillWordForward
} else {
Msg::Delete
},
);
None
}
Char('d') if alt => {
update(model, Msg::KillWordForward);
None
}
Char('w') if ctrl => {
update(model, Msg::KillWordBack);
None
}
Char('u') if ctrl => {
update(model, Msg::KillToStart);
None
}
Char('k') if ctrl => {
update(model, Msg::KillToEnd);
None
}
Char('-') if ctrl => {
update(model, Msg::Undo);
None
}
Char('.') if ctrl => {
update(model, Msg::Redo);
None
}
PageUp => {
let page = model.viewport_h.saturating_sub(2).max(1);
model.scroll = model.scroll.saturating_add(page).min(model.max_scroll);
None
}
PageDown => {
let page = model.viewport_h.saturating_sub(2).max(1);
model.scroll = model.scroll.saturating_sub(page);
None
}
Char(c) if !ctrl && !alt => {
update(model, Msg::Insert(c));
None
}
_ => None,
}
}
fn handle_connect_key(model: &mut Model, k: KeyEvent) -> Option<KeyAction> {
use KeyCode::{Backspace, Char, Down, Enter, Esc, Up};
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
let alt = k.modifiers.contains(KeyModifiers::ALT);
let mut c = model.connect.take()?;
if matches!(k.code, Esc) || (ctrl && matches!(k.code, Char('c'))) {
return None; }
match c.step {
ConnectStep::Pick => match k.code {
Up => c.sel = c.sel.saturating_sub(1),
Down => c.sel = (c.sel + 1).min(PRESETS.len() - 1),
Enter => {
c.preset = c.sel;
match PRESETS[c.preset].base_url {
Some(b) => {
c.base = b.to_string();
c.input = PRESETS[c.preset].name.to_string(); c.step = ConnectStep::Name;
}
None => c.step = ConnectStep::Url,
}
}
_ => {}
},
ConnectStep::Url => match k.code {
Char(ch) if !ctrl && !alt => c.input.push(ch),
Backspace => {
c.input.pop();
}
Enter if !c.input.trim().is_empty() => {
c.base = c.input.trim().to_string();
c.input = PRESETS[c.preset].name.to_string(); c.step = ConnectStep::Name;
}
_ => {}
},
ConnectStep::Name => match k.code {
Char(ch) if !ctrl && !alt => c.input.push(ch),
Backspace => {
c.input.pop();
}
Enter if !c.input.trim().is_empty() => {
c.name = c.input.trim().to_string();
c.input.clear();
c.step = ConnectStep::Key;
}
_ => {}
},
ConnectStep::Key => match k.code {
Char(ch) if !ctrl && !alt => c.input.push(ch),
Backspace => {
c.input.pop();
}
Enter => {
let p = &PRESETS[c.preset];
return Some(KeyAction::ConnectProvider {
name: provider_id(&c.name),
kind: p.kind.to_string(),
base_url: c.base.clone(),
key: c.input.trim().to_string(),
}); }
_ => {}
},
}
model.connect = Some(c);
None
}
fn provider_id(name: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for ch in name.trim().to_lowercase().chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch);
prev_dash = false;
} else if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
}
let trimmed = out.trim_end_matches('-').to_string();
if trimmed.is_empty() {
"provider".into()
} else {
trimmed
}
}
fn move_with_sel(model: &mut Model, msg: Msg, shift: bool) {
if shift {
if model.anchor.is_none() {
model.anchor = Some(model.cursor);
}
} else {
model.anchor = None;
}
update(model, msg);
}
fn open_palette_scoped(model: &mut Model, query: &str) {
model.palette_open = true;
model.palette_query = query.to_string();
model.palette_sel = 0;
}
fn exec_palette(model: &mut Model, action: super::PaletteAction) -> Option<KeyAction> {
use super::PaletteAction;
match action {
PaletteAction::Command(cmd) => match cmd.as_str() {
"/new" => Some(KeyAction::NewSession),
"/model" | "/goal" | "/rename" => {
model.input = format!("{cmd} ");
model.cursor = model.input.len();
None
}
_ => handle_command(model, &cmd),
},
PaletteAction::SwitchModel(m) => Some(KeyAction::SwitchModel(m)),
PaletteAction::SwitchMode(i) => {
model.mode_idx = i;
model.mode_flash = 6;
None
}
PaletteAction::SwitchProvider(name) => Some(KeyAction::SwitchSavedProvider(name)),
PaletteAction::SwitchTheme(i) => Some(KeyAction::SetTheme(i)),
}
}
const BUILTIN_MODELS: [&str; 6] = [
"meta/llama-3.3-70b-instruct",
"meta/llama-3.1-8b-instruct",
"qwen/qwen2.5-coder-32b-instruct",
"deepseek-ai/deepseek-r1",
"gpt-4o",
"gpt-4o-mini",
];
async fn refresh_endpoint(
model: &mut Model,
config: &Config,
catalog: &Catalog,
kind: &str,
base_url: &str,
key: &str,
label: &str,
) {
let modes = model.modes.clone();
let mname = model.model_name.clone();
if kind == "anthropic" {
model.transcript.push(Entry::System(format!(
"connected {label} (anthropic) — set a model with /model <name>"
)));
model.palette = build_palette(
config,
catalog,
&[],
&modes,
kind,
&mname,
&model.theme_names,
);
return;
}
match list_models(base_url, key).await {
Ok(models) if !models.is_empty() => {
model.transcript.push(Entry::System(format!(
"✓ {label}: {} models available — ^P to pick",
models.len()
)));
model.palette = build_palette(
config,
catalog,
&models,
&modes,
kind,
&mname,
&model.theme_names,
);
}
Ok(_) => {
model.transcript.push(Entry::System(format!(
"connected {label}, but it returned no models — check the base URL / key"
)));
model.palette = build_palette(
config,
catalog,
&[],
&modes,
kind,
&mname,
&model.theme_names,
);
}
Err(e) => {
model.transcript.push(Entry::System(format!(
"⚠ {label} unreachable: {e} — saved anyway; verify key / URL"
)));
}
}
}
fn build_palette(
config: &Config,
catalog: &Catalog,
live_models: &[String],
modes: &[String],
provider_kind: &str,
model_name: &str,
theme_names: &[String],
) -> Vec<super::PaletteItem> {
use super::{PaletteAction, PaletteItem};
let mut items: Vec<PaletteItem> = Vec::new();
let cmd = |label: &str, hint: &str| PaletteItem {
label: label.into(),
hint: hint.into(),
action: PaletteAction::Command(label.into()),
};
items.push(cmd("/help", "keybinds & commands"));
items.push(cmd("/connect", "add a provider (API key / endpoint)"));
items.push(cmd("/permissions", "view permission rules"));
items.push(cmd("/mouse", "toggle mouse capture (off to copy text)"));
items.push(cmd(
"/providers",
"manage providers (switch/connect/delete)",
));
items.push(cmd("/sessions", "switch · delete (d) · fork (f)"));
items.push(cmd("/rename", "rename the current session"));
items.push(cmd("/new", "start a fresh session"));
items.push(cmd("/compact", "summarize history to reclaim context"));
items.push(cmd(
"/goal",
"set or steer the session goal (autonomous loop)",
));
items.push(cmd("/thinking", "toggle showing model reasoning"));
items.push(cmd("/tooloutput", "toggle tool output visibility"));
items.push(cmd("/animations", "toggle UI animations"));
items.push(cmd("/stash", "stash the current draft"));
items.push(cmd("/unstash", "restore a stashed draft"));
items.push(cmd("/skills", "list loaded skills"));
items.push(cmd("/mcp", "list MCP servers"));
items.push(cmd("/clear", "clear the transcript"));
items.push(cmd("/quit", "exit cordy"));
for (i, m) in modes.iter().enumerate() {
items.push(PaletteItem {
label: format!("mode: {m}"),
hint: "switch active mode/agent".into(),
action: PaletteAction::SwitchMode(i),
});
}
for (i, name) in theme_names.iter().enumerate() {
items.push(PaletteItem {
label: format!("theme: {name}"),
hint: "switch UI theme".into(),
action: PaletteAction::SwitchTheme(i),
});
}
let model_names: Vec<String> = if !live_models.is_empty() {
live_models.to_vec()
} else if !config.models.is_empty() {
config.models.iter().map(|m| m.name.clone()).collect()
} else {
BUILTIN_MODELS.iter().map(|s| s.to_string()).collect()
};
for m in model_names {
if m == model_name {
continue;
}
items.push(PaletteItem {
label: format!("model: {m}"),
hint: model_hint(catalog, config, &m),
action: PaletteAction::SwitchModel(m),
});
}
for p in &config.providers {
let active = p.kind == provider_kind;
items.push(PaletteItem {
label: format!("provider: {}", p.name),
hint: if active {
format!("{} · active", p.kind)
} else {
format!("connect {} endpoint", p.kind)
},
action: PaletteAction::SwitchProvider(p.name.clone()),
});
}
items
}
const HELP_TEXT: &str = "\
panels: ^D mode · ^L model · ^G git · ^S skills · ^K context · ^T terminal · ^E editor · ^P palette
tabs: ^N new tab · Alt+W close tab · Alt+1..5 focus · ^B tab list
term: ^T open/focus · ^Alt+T back to chat · ^Alt+W close · ^Alt+N next · ^Alt+↑↓ resize
turn: ^Alt+S steer the running turn (/steer <msg>) · Esc interrupt
keys: Tab/Shift+Tab cycle agent · Enter send · ^J/Alt+Enter newline · Esc interrupt
^X leader: q quit · n new · l sessions · c compact · m models · a agents · t theme
b mascot · e editor · x export · y copy · s status · f favorite · u/r undo/redo msg
g jump to top · z force redraw · M mouse capture · v paste image · h help
move: ←/→ char · ^←/Alt+←→ word · ↑/↓ line-or-history · ^A/Alt+E line home/end · Home/End buffer
edit: ^W/Alt+⌫ del word back · Alt+D del word fwd · ^U/^K kill to start/end · ^Shift+D kill line
^- undo · ^. redo · ^C clear/quit · paste = your terminal's paste (Alt+V pastes an image)
models: F2/Shift+F2 cycle recent · ^X f favorite
scroll: ↑/↓ (empty composer) · PgUp/PgDn · Home/End · ^X g top · ^Alt+G bottom
history: ^↑/^↓ always · ↑/↓ once the transcript is at its end
mouse: ON — the hint bar, header chips, tabs, picker rows and tool rows are all clickable.
Shift+drag still selects text; ^X M or /mouse hands the mouse back entirely.
session: ^R rename · in /sessions: d delete · f fork
commands: /help /clear /quit /model <name> /goal [obj|edit|pause|resume|clear] /compact
/connect /sessions /rename <title> /new /tabs /context /events /term /git
/thinking /tooloutput /animations (toggles) · /stash /unstash · /skills /mcp
input: @image <path> attaches an image · @<path> injects a file";
fn handle_command(model: &mut Model, raw: &str) -> Option<KeyAction> {
use super::input::{Command, GoalCmd, parse_command};
if raw.trim() == "/new" {
return Some(KeyAction::NewSession);
}
if raw.trim() == "/sessions" {
return Some(KeyAction::OpenSessions);
}
if raw.trim() == "/connect" {
model.connect = Some(Connect::default());
return None;
}
if raw.trim() == "/permissions" {
return Some(KeyAction::ShowPermissions);
}
if raw.trim() == "/mouse" {
return Some(KeyAction::ToggleMouse);
}
if raw.trim() == "/providers" {
return Some(KeyAction::OpenProviders);
}
if let Some(rest) = raw.trim().strip_prefix("/steer ")
&& !rest.trim().is_empty()
{
return Some(KeyAction::Steer(rest.trim().to_string()));
}
match raw.trim() {
"/tabs" => {
open_tabs_picker(model);
return None;
}
"/models" => {
open_model_picker(model);
return None;
}
"/modes" | "/personas" => {
open_mode_picker(model);
return None;
}
"/git" => {
open_git_picker(model);
return None;
}
"/events" => {
open_events_picker(model);
return None;
}
"/term" | "/terminal" => return Some(KeyAction::OpenTerm),
"/steer" => {
model.transcript.push(Entry::System(
"usage: /steer <message> — redirects the running turn without aborting it".into(),
));
return None;
}
"/editor" => return Some(KeyAction::OpenEditorPanel),
"/newtab" => return Some(KeyAction::NewTab),
"/closetab" => return Some(KeyAction::CloseTab),
"/context" => return Some(KeyAction::InspectContext),
_ => {}
}
if let Some(rest) = raw.trim().strip_prefix("/rename") {
let title = rest.trim();
if title.is_empty() {
model
.transcript
.push(Entry::System("usage: /rename <title>".into()));
return None;
}
return Some(KeyAction::RenameSession(title.to_string()));
}
match raw.trim() {
"/thinking" => {
model.show_thinking = !model.show_thinking;
model.transcript.push(Entry::System(format!(
"display thinking: {}",
on_off(model.show_thinking)
)));
return None;
}
"/tooloutput" => {
model.show_tool_output = !model.show_tool_output;
model.transcript.push(Entry::System(format!(
"tool output: {}",
on_off(model.show_tool_output)
)));
return None;
}
"/animations" => {
model.animations = !model.animations;
model.transcript.push(Entry::System(format!(
"animations: {}",
on_off(model.animations)
)));
return None;
}
"/stash" => {
if model.input.trim().is_empty() {
model
.transcript
.push(Entry::System("nothing to stash".into()));
} else {
let draft = std::mem::take(&mut model.input);
model.cursor = 0;
model.stash.push(draft);
model.transcript.push(Entry::System(format!(
"stashed draft ({} in stash)",
model.stash.len()
)));
}
return None;
}
"/unstash" => {
match model.stash.pop() {
Some(draft) => {
model.input = draft;
model.cursor = model.input.len();
}
None => model
.transcript
.push(Entry::System("stash is empty".into())),
}
return None;
}
"/skills" => {
let lines = if model.skills.is_empty() {
vec![
"no skills loaded.".to_string(),
"add them under .cordy/skills/<name>/SKILL.md (project)".to_string(),
"or ~/.cordy/skills/<name>/SKILL.md (global)".to_string(),
]
} else {
model
.skills
.iter()
.map(|(n, d)| format!("• {n} — {d}"))
.collect()
};
model.info = Some(("skills".into(), lines));
return None;
}
"/mcp" => {
let lines = if model.mcp_names.is_empty() {
vec!["no MCP servers configured — add [[mcp]] to config.toml".to_string()]
} else {
model
.mcp_names
.iter()
.map(|(n, s)| format!("• {n} {s}"))
.collect()
};
model.info = Some(("mcp servers".into(), lines));
return None;
}
_ => {}
}
match parse_command(raw) {
Some(Command::Help) => model.transcript.push(Entry::System(HELP_TEXT.into())),
Some(Command::Clear) => model.transcript.clear(),
Some(Command::Quit) => model.should_quit = true,
Some(Command::Compact) => return Some(KeyAction::Compact),
Some(Command::Model(Some(name))) => return Some(KeyAction::SwitchModel(name)),
Some(Command::Model(None)) => model
.transcript
.push(Entry::System("usage: /model <name> to hot-swap".into())),
Some(Command::Goal(GoalCmd::Edit)) => return Some(KeyAction::GoalEdit),
Some(Command::Goal(cmd)) => return Some(KeyAction::Goal(cmd)),
Some(Command::Unknown(u)) => model
.transcript
.push(Entry::System(format!("unknown command: /{u}"))),
None => {}
}
None
}
fn bind_goal_to_session(
goal: &Arc<GoalRuntime>,
model: &mut Model,
cwd: &std::path::Path,
session_id: &str,
) {
goal.rebind(Arc::new(GoalStore::open(goal_path(cwd, session_id))));
goal.restore_after_resume();
model.goal_line = goal.goal().as_ref().map(goal_status_line);
}
async fn apply_goal_command(
model: &mut Model,
goal: &Arc<GoalRuntime>,
config: &Config,
cmd: super::input::GoalCmd,
) -> bool {
use super::input::GoalCmd;
if !goal.is_enabled() {
model.transcript.push(Entry::System(
"goals are disabled — set `goal = true` in config.toml".into(),
));
return false;
}
let note = |model: &mut Model, text: String| model.transcript.push(Entry::System(text));
match cmd {
GoalCmd::Show => {
match goal.goal() {
Some(g) => note(
model,
format!(
"goal ({}): {}",
goal_status_label(g.status),
goal_usage_summary(&g)
),
),
None => note(model, format!("no goal set. {GOAL_USAGE}")),
}
false
}
GoalCmd::Set { objective, limits } => {
if objective.trim().is_empty() {
if limits.is_empty() {
note(model, GOAL_USAGE.to_string());
return false;
}
let existing = goal.goal().map(|g| g.limits).unwrap_or_default();
let merged = GoalLimits {
token_budget: limits.token_budget.or(existing.token_budget),
cost_cap_usd: limits.cost_cap_usd.or(existing.cost_cap_usd),
max_iterations: limits.max_iterations.or(existing.max_iterations),
};
return match goal.set_limits(merged).await {
Ok(g) => {
note(
model,
format!("goal budget updated. {}", goal_usage_summary(&g)),
);
false
}
Err(e) => {
note(model, format!("goal: {e}"));
false
}
};
}
let defaults = config_goal_limits(config);
let limits = GoalLimits {
token_budget: limits.token_budget.or(defaults.token_budget),
cost_cap_usd: limits.cost_cap_usd.or(defaults.cost_cap_usd),
max_iterations: limits.max_iterations.or(defaults.max_iterations),
};
let result = match goal.goal() {
Some(g) if g.status != crate::core::goal::GoalStatus::Complete => {
match goal.set_objective(&objective).await {
Ok(g) if limits != g.limits => goal.set_limits(limits).await,
other => other,
}
}
_ => goal.create_goal(&objective, limits).await,
};
match result {
Ok(g) => {
note(model, format!("goal set: {}", goal_usage_summary(&g)));
true
}
Err(e) => {
note(model, format!("goal: {e}"));
false
}
}
}
GoalCmd::Pause => match goal.pause().await {
Ok(g) => {
note(model, format!("goal paused. {}", goal_usage_summary(&g)));
false
}
Err(e) => {
note(model, format!("goal: {e}"));
false
}
},
GoalCmd::Resume => match goal.resume().await {
Ok(g) => {
note(model, format!("goal resumed. {}", goal_usage_summary(&g)));
true
}
Err(e) => {
note(model, format!("goal: {e}"));
false
}
},
GoalCmd::Clear => {
match goal.clear().await {
Some(g) => note(model, format!("goal cleared: {}", g.objective)),
None => note(model, "no goal to clear".into()),
}
false
}
GoalCmd::Edit => false,
}
}
fn usage_cost(u: &Usage, price_in: Option<f64>, price_out: Option<f64>) -> f64 {
match (price_in, price_out) {
(Some(i), Some(o)) => {
(u.input_tokens as f64) / 1e6 * i + (u.output_tokens as f64) / 1e6 * o
}
_ => 0.0,
}
}
fn last_assistant_text(messages: &[Message]) -> String {
for m in messages.iter().rev() {
if m.role == Role::Assistant {
return m
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
}
}
String::new()
}
async fn compact_session(
agent: &AgentLoop,
session: &mut Session,
threshold: u64,
agent_tx: &mpsc::UnboundedSender<AgentEvent>,
) {
let native = agent.provider.caps().native_context_mgmt;
let cm = ContextManager::new(threshold, CompactMode::Manual, native);
let (old_owned, keep_owned, n) = match cm.plan_compaction(&session.messages) {
Some((old, keep)) => (old.to_vec(), keep.to_vec(), old.len()),
None => {
let _ = agent_tx.send(AgentEvent::Error("compact: not enough history yet".into()));
return;
}
};
let req = ChatRequest {
model: session.model.clone(),
system: "Summarize the conversation so far concisely: preserve decisions, code changes, \
file paths, and open tasks. Output only the summary."
.into(),
messages: old_owned,
tools: Vec::new(),
max_tokens: Some(1024),
temperature: None,
};
let summary = match agent.provider.stream(req).await {
Ok(stream) => match assemble(stream).await {
Ok((msg, _)) => last_assistant_text(&[msg]),
Err(_) => String::new(),
},
Err(e) => {
let _ = agent_tx.send(AgentEvent::Error(format!("compact: {e}")));
return;
}
};
let mut compacted = vec![Message {
role: Role::User,
content: vec![ContentBlock::text(format!(
"Summary of earlier conversation:\n{summary}"
))],
}];
compacted.extend(keep_owned);
session.messages = compacted;
let _ = agent_tx.send(AgentEvent::Error(format!(
"compacted {n} older messages into a summary"
)));
}
const LOGO: [&str; 6] = [
" ██████╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗",
"██╔════╝██╔═══██╗██╔══██╗██╔══██╗╚██╗ ██╔╝",
"██║ ██║ ██║██████╔╝██║ ██║ ╚████╔╝ ",
"██║ ██║ ██║██╔══██╗██║ ██║ ╚██╔╝ ",
"╚██████╗╚██████╔╝██║ ██║██████╔╝ ██║ ",
" ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ",
];
fn view(
f: &mut Frame,
model: &mut Model,
theme: &Theme,
themes: &ThemeRegistry,
terms: &mut term::Terminals,
) {
let area = f.area();
f.render_widget(
Block::default().style(Style::default().bg(theme.base)),
area,
);
let shell = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(area);
model.hits.clear();
let tick = model.tick;
chrome::render_header(f, shell[0], model, theme, tick);
let body_area = shell[1];
let wide = body_area.width >= 132;
let (main_area, panel_area) = if wide {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(60), Constraint::Length(34)])
.split(body_area);
(cols[0], Some(cols[1]))
} else {
(body_area, None)
};
let input_rows = model.input.split('\n').count().max(1) as u16;
let input_h = (input_rows + 1).clamp(2, 12);
let term_room = main_area.height.saturating_sub(input_h + 2 + 3);
let term_h = if terms.visible && !terms.panels.is_empty() && term_room >= 4 {
(main_area.height * terms.height_pct / 100).clamp(4, term_room)
} else {
0
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(term_h), Constraint::Length(input_h), Constraint::Length(1), ])
.split(main_area);
let term_area = (term_h > 0).then_some(rows[1]);
let chunks = [rows[0], rows[2], rows[3]];
let has_convo = !model.transcript.is_empty() || !model.streaming.is_empty();
if has_convo || model.busy {
let cw = (chunks[0].width as usize).saturating_sub(2).max(14);
let mut lines: Vec<Line> = Vec::new();
let mut entry_spans: Vec<(usize, usize)> = Vec::with_capacity(model.transcript.len());
for (i, e) in model.transcript.iter().enumerate() {
let start_line = lines.len();
let unfolded = match e {
Entry::Tool { id, .. } => model.expanded.contains(id),
_ => false,
};
lines.extend(render_entry(e, theme, model.show_tool_output, cw, unfolded));
let next = model.transcript.get(i + 1);
let gap = match (e, next) {
(_, None) => false,
(Entry::Tool { .. }, Some(Entry::Tool { .. })) => false,
(Entry::System(_), Some(Entry::System(_) | Entry::Tool { .. })) => false,
(Entry::Tool { .. }, Some(Entry::System(_))) => false,
_ => true,
};
if gap {
lines.push(Line::raw(""));
}
entry_spans.push((start_line, lines.len() - start_line));
}
if model.show_thinking && !model.thinking.is_empty() {
for l in model.thinking.lines() {
let line = Line::from(Span::styled(
format!(" 💭 {l}"),
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
));
lines.extend(wrap_line(&line, cw));
}
}
if !model.streaming.is_empty() {
lines.extend(assistant_block(&model.streaming, theme, true, cw));
lines.push(Line::raw(""));
}
if model.busy {
lines.push(live_status_line(model, theme));
}
let max = chunks[0].height as usize;
let total = lines.len();
let max_scroll = total.saturating_sub(max) as u16;
model.scroll = model.scroll.min(max_scroll);
model.max_scroll = max_scroll;
model.viewport_h = chunks[0].height;
let end = total
.saturating_sub(model.scroll as usize)
.max(max.min(total));
let start = end.saturating_sub(max);
model.transcript_start = start;
model.transcript_rect = Some((chunks[0].x, chunks[0].y, chunks[0].width, chunks[0].height));
for (i, (s0, n)) in entry_spans.iter().enumerate() {
let (a, b) = (*s0, s0 + n);
let (vis_a, vis_b) = (a.max(start), b.min(end));
if vis_a < vis_b {
model.push_hit(
chunks[0].x,
chunks[0].y + (vis_a - start) as u16,
chunks[0].width,
(vis_b - vis_a) as u16,
super::Hit::Entry(i),
);
}
}
model.entry_spans = entry_spans;
let view_lines = lines[start..end].to_vec();
f.render_widget(
Paragraph::new(view_lines).block(Block::default().padding(Padding::new(1, 1, 0, 0))),
chunks[0],
);
if total > max {
let mut sb_state = ScrollbarState::new(total.saturating_sub(max)).position(start);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.thumb_style(Style::default().fg(theme.accent))
.track_style(Style::default().fg(theme.border)),
chunks[0],
&mut sb_state,
);
}
} else {
model.transcript_rect = None;
model.entry_spans.clear();
f.render_widget(splash(chunks[0].height, theme), chunks[0]);
}
if model.show_mascot && panel_area.is_none() {
render_mascot(f, chunks[0], model);
}
if let Some(p) = panel_area {
render_side_panel(f, p, model, theme);
}
model.input_width = chunks[1].width.saturating_sub(6).max(8);
let mut input_content = build_input_lines(model, theme);
if !model.queue.is_empty() {
let mut q: Vec<Line> = model
.queue
.iter()
.map(|m| {
let preview: String = m.chars().take(60).collect();
let ell = if m.chars().count() > 60 { "…" } else { "" };
Line::from(Span::styled(
format!("⧗ queued: {preview}{ell}"),
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
))
})
.collect();
q.push(Line::raw(""));
q.extend(input_content);
input_content = q;
}
let input = Paragraph::new(input_content).block(
Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(if model.mode_flash > 0 {
theme.accent2
} else {
theme.accent
}))
.style(Style::default().bg(theme.surface))
.padding(Padding::new(2, 1, 0, 0)),
);
f.render_widget(input, chunks[1]);
if !model.suggestions.is_empty()
&& !model.palette_open
&& !model.sessions_open
&& model.pending.is_none()
{
let n = model.suggestions.len().min(8);
let h = n as u16;
let panel_w = 62u16.min(chunks[1].width.saturating_sub(2)).max(20);
let rect = Rect {
x: chunks[1].x + 1,
y: chunks[1].y.saturating_sub(h),
width: panel_w,
height: h,
};
f.render_widget(Clear, rect);
let block = Block::default()
.style(Style::default().bg(theme.surface))
.padding(Padding::horizontal(1));
let inner = block.inner(rect);
f.render_widget(block, rect);
let iw = inner.width as usize;
let rows: Vec<Line> = model
.suggestions
.iter()
.take(n)
.enumerate()
.map(|(i, s)| {
modal_row(
iw,
"",
s,
slash_desc(s),
"",
i == model.suggestion_sel,
theme,
)
})
.collect();
f.render_widget(Paragraph::new(rows), inner);
}
if let Some(rect) = term_area {
let focused = terms.focused;
let active = terms.active;
if let Some(p) = terms.panels.get_mut(active) {
term::render(f, rect, p, theme, focused);
}
}
if let Some(tpl) = &model.statusline {
let spin = if model.busy {
format!("{} ", super::spinner_frame(model.tick))
} else {
String::new()
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {}", render_statusline(tpl, model, &spin)),
Style::default().fg(theme.dim),
))),
chunks[2],
);
} else {
chrome::render_hints(f, chunks[2], model, theme, tick);
}
if model.sessions_open {
let popup = centered_rect(64, 62, area);
let (body, w) = modal_shell(f, popup, "Sessions", theme);
let surf = theme.surface;
let mut rows: Vec<Line> = Vec::new();
if model.sessions.is_empty() {
rows.push(Line::from(Span::styled(
" no saved sessions",
Style::default().fg(theme.dim).bg(surf),
)));
} else {
let sel = model
.sessions_sel
.min(model.sessions.len().saturating_sub(1));
let avail = body.height as usize;
let scroll = (sel + 1).saturating_sub(avail);
for (i, (_id, label)) in model.sessions.iter().enumerate().skip(scroll).take(avail) {
rows.push(modal_row(w, "", label, "", "", i == sel, theme));
}
}
f.render_widget(Paragraph::new(rows), body);
}
if model.providers_open {
let popup = centered_rect(66, 62, area);
let (body, w) = modal_shell(f, popup, "Providers", theme);
let surf = theme.surface;
let mut rows: Vec<Line> = Vec::new();
if model.providers.is_empty() {
rows.push(Line::from(Span::styled(
"no providers — press c to connect one",
Style::default().fg(theme.dim).bg(surf),
)));
} else {
let sel = model
.providers_sel
.min(model.providers.len().saturating_sub(1));
for (i, (id, kind, base)) in model.providers.iter().enumerate() {
let hint = format!("{kind} · {base}");
rows.push(modal_row(w, "", id, &hint, "", i == sel, theme));
}
}
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
rows.push(Line::from(Span::styled(
"Enter switch · c connect · d delete · esc close",
Style::default().fg(theme.dim).bg(surf),
)));
f.render_widget(Paragraph::new(rows), body);
}
if let Some(c) = &model.connect {
render_connect(f, area, c, theme);
}
if model.leader {
render_which_key(f, area, theme);
}
if model.status_open {
render_status(f, area, model, theme);
}
if model.theme_open {
render_theme_picker(f, area, model, theme, themes);
}
if let Some((title, lines)) = &model.info {
render_info(f, area, title, lines, theme);
}
if let Some(p) = &model.picker {
let hits = picker::render(f, area, p, theme);
model.hits.extend(hits);
}
if model.palette_open {
render_palette(f, area, model, theme);
}
if model.msg_menu.is_some() {
render_msg_menu(f, area, model, theme);
}
if let Some(summary) = &model.pending {
let surf = theme.surface;
let mut body: Vec<Line> = summary
.lines()
.take(14)
.map(|l| {
Line::from(Span::styled(
l.to_string(),
Style::default().fg(diff_color(l, theme)).bg(surf),
))
})
.collect();
if summary.lines().count() > 14 {
body.push(Line::from(Span::styled(
format!(" … {} more lines", summary.lines().count() - 14),
Style::default().fg(theme.border).bg(surf),
)));
}
body.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
let hint = |key: &str, label: &str, c: Color| {
vec![
Span::styled(
key.to_string(),
Style::default().fg(c).bg(surf).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {label} "),
Style::default().fg(theme.dim).bg(surf),
),
]
};
let mut row = hint(" y ", "approve", theme.assistant);
row.extend(hint(" a ", "always (this tool)", theme.accent));
row.extend(hint(" n ", "deny", theme.system));
body.push(Line::from(row));
let max_h = (area.height * 7 / 10).clamp(1, area.height.max(1));
let h = (body.len() as u16 + 4).max(7).min(max_h);
let longest = body.iter().map(|l| l.width() as u16).max().unwrap_or(40);
let max_w = area.width.saturating_sub(4).max(1);
let w = longest.saturating_add(6).max(44).min(max_w);
let rect = Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height.saturating_sub(h) / 2,
width: w,
height: h,
};
let (bodyrect, _w) = modal_shell(f, rect, "Permission", theme);
f.render_widget(Paragraph::new(body).wrap(Wrap { trim: false }), bodyrect);
}
}
fn edit_in_external_editor(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
initial: &str,
) -> Option<String> {
let file = std::env::temp_dir().join("cordy-input.md");
std::fs::write(&file, initial).ok()?;
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| {
if cfg!(windows) {
"notepad".into()
} else {
"vi".into()
}
});
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let status = std::process::Command::new(&editor).arg(&file).status();
let _ = enable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
);
let _ = terminal.clear();
match status {
Ok(_) => std::fs::read_to_string(&file)
.ok()
.map(|s| s.trim_end_matches('\n').to_string()),
Err(_) => None,
}
}
fn export_markdown(transcript: &[Entry]) -> String {
let mut out = String::from("# Cordy session\n\n");
for e in transcript {
match e {
Entry::User(t) => out.push_str(&format!("## You\n\n{t}\n\n")),
Entry::Assistant(t) => out.push_str(&format!("## Assistant\n\n{t}\n\n")),
Entry::Tool { name, text, .. } => {
out.push_str(&format!("### tool: {name}\n\n```\n{text}\n```\n\n"))
}
Entry::System(t) => out.push_str(&format!("> {t}\n\n")),
Entry::Turn { .. } => {}
}
}
out
}
fn last_assistant_entry(transcript: &[Entry]) -> Option<String> {
transcript.iter().rev().find_map(|e| match e {
Entry::Assistant(t) => Some(t.clone()),
_ => None,
})
}
fn copy_osc52(text: &str) {
use base64::Engine;
use std::io::Write;
let b64 = base64::engine::general_purpose::STANDARD.encode(text);
let seq = format!("\x1b]52;c;{b64}\x07");
let mut out = io::stdout();
let _ = out.write_all(seq.as_bytes());
let _ = out.flush();
}
fn render_info(f: &mut Frame, area: Rect, title: &str, lines: &[String], theme: &Theme) {
let popup = centered_rect(68, 62, area);
let (body, _w) = modal_shell(f, popup, title, theme);
let surf = theme.surface;
let cap = body.height.saturating_sub(2) as usize;
let skipped = lines.len().saturating_sub(cap);
let mut rows: Vec<Line> = lines
.iter()
.skip(skipped)
.map(|l| {
Line::from(Span::styled(
l.clone(),
Style::default().fg(diff_color(l, theme)).bg(surf),
))
})
.collect();
if skipped > 0 {
rows.insert(
0,
Line::from(Span::styled(
format!("… {skipped} earlier lines"),
Style::default().fg(theme.border).bg(surf),
)),
);
}
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
rows.push(Line::from(Span::styled(
"any key to close",
Style::default().fg(theme.dim).bg(surf),
)));
f.render_widget(Paragraph::new(rows).wrap(Wrap { trim: false }), body);
}
const HL_BG: Color = Color::Rgb(232, 174, 128);
const HL_FG: Color = Color::Rgb(24, 26, 32);
fn render_theme_picker(
f: &mut Frame,
area: Rect,
model: &Model,
theme: &Theme,
themes: &ThemeRegistry,
) {
let popup = centered_rect(52, 62, area);
let (body, w) = modal_shell(f, popup, "Theme", theme);
let surf = theme.surface;
let total = model.theme_names.len();
let avail = body.height as usize;
let sel = model.theme_sel.min(total.saturating_sub(1));
let scroll = sel
.saturating_sub(avail.saturating_sub(2))
.min(total.saturating_sub(avail.min(total)));
let name_w = 20usize;
let rows: Vec<Line> = model
.theme_names
.iter()
.enumerate()
.skip(scroll)
.take(avail)
.map(|(i, name)| {
let selected = i == sel;
let (bg, fg) = if selected {
(HL_BG, HL_FG)
} else {
(surf, theme.user)
};
let t = themes.at(i);
let bullet = if selected { "▍" } else { " " };
let label: String = name.chars().take(name_w - 2).collect();
let mut spans = vec![Span::styled(
format!("{bullet} {label:<w$}", w = name_w - 2),
Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD),
)];
spans.push(Span::styled(" ", Style::default().bg(t.base)));
for c in [t.accent, t.accent2, t.user, t.tool, t.success, t.danger] {
spans.push(Span::styled("▄▄", Style::default().fg(c).bg(t.base)));
}
spans.push(Span::styled(" ", Style::default().bg(t.base)));
let used = name_w + 2 + 12 + 2;
spans.push(Span::styled(
" ".repeat(w.saturating_sub(used)),
Style::default().bg(bg),
));
Line::from(spans)
})
.collect();
f.render_widget(Paragraph::new(rows), body);
}
fn render_status(f: &mut Frame, area: Rect, model: &Model, theme: &Theme) {
let popup = centered_rect(56, 60, area);
let (body, _w) = modal_shell(f, popup, "Status", theme);
let surf = theme.surface;
let label = Style::default().fg(theme.dim).bg(surf);
let value = Style::default().fg(theme.user).bg(surf);
let row = |k: &str, v: String| {
Line::from(vec![
Span::styled(format!("{k:<12}"), label),
Span::styled(v, value),
])
};
let dash = |s: String| if s.is_empty() { "—".into() } else { s };
let fav = if model.favorites.contains(&model.model_name) {
" ★"
} else {
""
};
let rows = vec![
row("model", format!("{}{fav}", model.model_name)),
row("provider", model.provider_kind.clone()),
row("mode", model.mode().to_string()),
row("context", dash(model.ctx_line())),
row(
"tokens",
format!("{} in / {} out", model.total_in, model.total_out),
),
row("saved", format!("~{}", model.total_saved)),
row("cost", dash(model.cost_str())),
row("cwd", model.footer.clone()),
row("history", format!("{} prompts", model.history.len())),
Line::from(Span::styled(" ", Style::default().bg(surf))),
Line::from(Span::styled("any key to close", label)),
];
f.render_widget(Paragraph::new(rows).wrap(Wrap { trim: false }), body);
}
fn render_which_key(f: &mut Frame, area: Rect, theme: &Theme) {
const CHORDS: [(&str, &str); 20] = [
("q", "quit"),
("n", "new session"),
("l", "sessions"),
("c", "compact"),
("m", "models"),
("a", "agents"),
("t", "theme"),
("b", "mascot"),
("e", "editor"),
("x", "export"),
("y", "copy reply"),
("s", "status"),
("f", "favorite"),
("h", "help"),
("u", "undo msg"),
("r", "redo msg"),
("g", "jump to top"),
("z", "force redraw"),
("M", "mouse capture"),
("v", "paste image"),
];
let surf = theme.surface;
let cols = 2;
let rows_per_col = CHORDS.len().div_ceil(cols);
let mut lines: Vec<Line> = Vec::new();
for r in 0..rows_per_col {
let mut spans = Vec::new();
for c in 0..cols {
if let Some((key, label)) = CHORDS.get(c * rows_per_col + r) {
spans.push(Span::styled(
format!(" {key} "),
Style::default()
.fg(theme.accent)
.bg(surf)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!(" {label:<16}"),
Style::default().fg(theme.dim).bg(surf),
));
}
}
lines.push(Line::from(spans));
}
let h = rows_per_col as u16 + 3;
let w = 46u16.min(area.width);
let rect = Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height.saturating_sub(h + 6),
width: w,
height: h,
};
let (body, _w) = modal_shell(f, rect, "Leader ^X", theme);
f.render_widget(Paragraph::new(lines), body);
}
fn render_connect(f: &mut Frame, area: Rect, c: &Connect, theme: &Theme) {
let popup = centered_rect(62, 66, area);
let (body, w) = modal_shell(f, popup, "Connect provider", theme);
let surf = theme.surface;
let dim = Style::default().fg(theme.dim).bg(surf);
let bright = Style::default().fg(theme.user).bg(surf);
let bold = bright.add_modifier(Modifier::BOLD);
let mut rows: Vec<Line> = Vec::new();
match c.step {
ConnectStep::Pick => {
for (i, p) in PRESETS.iter().enumerate() {
let url = p.base_url.unwrap_or("custom endpoint");
rows.push(modal_row(w, "", p.label, url, "", i == c.sel, theme));
}
}
ConnectStep::Url => {
rows.push(Line::from(Span::styled(
format!("{} · custom endpoint", PRESETS[c.preset].label),
dim,
)));
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
rows.push(Line::from(vec![
Span::styled("Base URL ", bold),
Span::styled(c.input.clone(), bright),
Span::styled("▌", Style::default().fg(theme.accent).bg(surf)),
]));
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
rows.push(Line::from(Span::styled(
"e.g. https://api.example.com/v1",
dim,
)));
}
ConnectStep::Name => {
rows.push(Line::from(Span::styled(c.base.clone(), dim)));
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
rows.push(Line::from(vec![
Span::styled("Name ", bold),
Span::styled(c.input.clone(), bright),
Span::styled("▌", Style::default().fg(theme.accent).bg(surf)),
]));
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
rows.push(Line::from(Span::styled(
format!("id → {}", provider_id(&c.input)),
dim,
)));
}
ConnectStep::Key => {
rows.push(Line::from(vec![
Span::styled(format!("{} ", c.name), bright),
Span::styled(c.base.clone(), dim),
]));
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
let masked = "•".repeat(c.input.chars().count());
rows.push(Line::from(vec![
Span::styled("API key ", bold),
Span::styled(masked, bright),
Span::styled("▌", Style::default().fg(theme.accent).bg(surf)),
]));
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
rows.push(Line::from(Span::styled(
"Enter to connect · key stored in ~/.cordy/keys.json",
dim,
)));
}
}
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
rows.push(Line::from(Span::styled(
"↑↓ select · Enter next · Esc cancel",
dim,
)));
f.render_widget(Paragraph::new(rows).wrap(Wrap { trim: false }), body);
}
fn render_statusline(tpl: &str, model: &Model, spin: &str) -> String {
tpl.replace("{cwd}", &model.footer)
.replace("{model}", &model.model_name)
.replace("{provider}", &model.provider_kind)
.replace("{mode}", model.mode())
.replace(
"{tokens}",
&format!("{} in / {} out", model.total_in, model.total_out),
)
.replace("{cost}", &model.cost_str())
.replace("{ctx}", &model.ctx_line())
.replace("{saved}", &format!("~{}", model.total_saved))
.replace("{status}", &model.status)
.replace("{spinner}", spin.trim_end())
.replace("{goal}", model.goal_line.as_deref().unwrap_or(""))
.replace("{bg}", &model.bg_count.to_string())
.replace("{agents}", &model.subagent_count.to_string())
.replace("{version}", env!("CARGO_PKG_VERSION"))
}
fn live_status_line(model: &Model, theme: &Theme) -> Line<'static> {
let base = model.status.trim_end_matches(['.', '…', ' ']);
let base = if base.is_empty() { "thinking" } else { base };
let dots = ".".repeat(1 + (model.tick as usize / 3) % 3);
Line::from(vec![
Span::styled(
format!(" {} ", super::spinner_frame(model.tick)),
Style::default().fg(theme.accent),
),
Span::styled(
format!("{base}{dots}"),
Style::default()
.fg(theme.dim)
.add_modifier(Modifier::ITALIC),
),
])
}
fn slash_desc(cmd: &str) -> &'static str {
match cmd {
"/help" => "keybinds & commands",
"/model" => "hot-swap model",
"/goal" => "set or steer the session goal",
"/compact" => "summarize history",
"/clear" => "clear the transcript",
"/quit" => "exit cordy",
"/new" => "start a fresh session",
"/sessions" => "switch session",
"/connect" => "add a provider",
"/rename" => "rename session",
"/thinking" => "toggle reasoning",
"/tooloutput" => "toggle tool output",
"/animations" => "toggle animations",
"/stash" => "stash draft",
"/unstash" => "restore draft",
"/skills" => "list skills",
"/mcp" => "list MCP servers",
"/permissions" => "view permissions",
"/mouse" => "toggle mouse (off = select/copy)",
"/providers" => "manage providers",
"/tabs" => "list and focus tabs",
"/newtab" => "open a new workspace tab",
"/closetab" => "close the focused tab",
"/models" => "model picker",
"/modes" | "/personas" => "mode / persona picker",
"/git" => "git menu",
"/context" => "token breakdown of the next request",
"/events" => "per-call latency, tokens and cost",
"/term" => "open a terminal panel",
"/steer" => "redirect the running turn without aborting",
"/editor" => "open the editor panel",
_ => "",
}
}
const SLASH_COMMANDS: [&str; 31] = [
"/help",
"/model",
"/goal",
"/compact",
"/clear",
"/quit",
"/new",
"/sessions",
"/connect",
"/rename",
"/thinking",
"/tooloutput",
"/animations",
"/stash",
"/unstash",
"/skills",
"/mcp",
"/permissions",
"/mouse",
"/providers",
"/tabs",
"/newtab",
"/closetab",
"/models",
"/modes",
"/personas",
"/git",
"/context",
"/events",
"/term",
"/steer",
];
fn on_off(b: bool) -> &'static str {
if b { "on" } else { "off" }
}
fn rel_time(then: u64, now: u64) -> String {
let d = now.saturating_sub(then);
if d < 60 {
"just now".into()
} else if d < 3600 {
format!("{}m ago", d / 60)
} else if d < 86400 {
format!("{}h ago", d / 3600)
} else {
format!("{}d ago", d / 86400)
}
}
fn summary_label(s: &SessionSummary, now: u64) -> String {
let when = rel_time(s.updated, now);
let title = if !s.meta.title.is_empty() {
s.meta.title.clone()
} else if !s.last_user.is_empty() {
let mut t: String = s.last_user.chars().take(48).collect();
if s.last_user.chars().count() > 48 {
t.push('…');
}
t
} else {
format!("{} msgs", s.messages)
};
format!("{when:<10} · {title} · {}", s.meta.model)
}
fn transcript_from(messages: &[Message]) -> Vec<Entry> {
let mut v = Vec::new();
for m in messages {
let text: String = m
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
match m.role {
Role::User if !text.is_empty() => v.push(Entry::User(text)),
Role::Assistant if !text.is_empty() => v.push(Entry::Assistant(text)),
_ => {}
}
}
v
}
fn compute_suggestions(input: &str, cwd: &std::path::Path) -> Vec<String> {
if input.starts_with('/') && !input.contains(char::is_whitespace) {
return SLASH_COMMANDS
.iter()
.filter(|c| c.starts_with(input) && **c != input)
.map(|c| c.to_string())
.collect();
}
if let Some(tok) = input.rsplit(char::is_whitespace).next()
&& let Some(partial) = tok.strip_prefix('@')
{
return file_suggestions(cwd, partial);
}
Vec::new()
}
fn file_suggestions(cwd: &std::path::Path, partial: &str) -> Vec<String> {
let (dir, base) = match partial.rfind('/') {
Some(i) => (partial[..=i].to_string(), partial[i + 1..].to_string()),
None => (String::new(), partial.to_string()),
};
let scan = if dir.is_empty() {
cwd.to_path_buf()
} else {
cwd.join(&dir)
};
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(&scan) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') && !base.starts_with('.') {
continue;
}
if name.to_lowercase().starts_with(&base.to_lowercase()) {
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
let suffix = if is_dir { "/" } else { "" };
out.push(format!("@{dir}{name}{suffix}"));
}
}
}
out.sort();
out.truncate(8);
out
}
const PASTE_COLLAPSE_MIN: usize = 300;
fn insert_paste(model: &mut Model, text: &str) {
let norm = text.replace("\r\n", "\n").replace('\r', "\n");
let count = norm.chars().count();
if count >= PASTE_COLLAPSE_MIN {
let id = model.paste_seq;
model.paste_seq = model.paste_seq.wrapping_add(1);
let token = format!("[#{id} pasted {count} chars]");
model.pastes.insert(id, norm);
for ch in token.chars() {
update(model, Msg::Insert(ch));
}
} else {
for ch in norm.chars() {
update(
model,
if ch == '\n' {
Msg::Newline
} else {
Msg::Insert(ch)
},
);
}
}
}
fn expand_pastes(model: &mut Model, text: &str) -> String {
if !text.contains("[#") || model.pastes.is_empty() {
return text.to_string();
}
let mut out = text.to_string();
let ids: Vec<u32> = model.pastes.keys().copied().collect();
for id in ids {
if let Some(blob) = model.pastes.get(&id) {
let count = blob.chars().count();
let token = format!("[#{id} pasted {count} chars]");
if out.contains(&token) {
let blob = model.pastes.remove(&id).unwrap();
out = out.replace(&token, &blob);
}
}
}
out
}
fn paste_clipboard(model: &mut Model, cwd: &std::path::Path) -> anyhow::Result<Option<String>> {
let mut cb = arboard::Clipboard::new()?;
if let Ok(img) = cb.get_image() {
let dir = user_cordy_dir()
.map(|d| d.join("cache/pasted"))
.unwrap_or_else(|| cwd.join(".cordy/cache/pasted"));
std::fs::create_dir_all(&dir)?;
let id = model.paste_seq;
model.paste_seq = model.paste_seq.wrapping_add(1);
let path = dir.join(format!("clip-{id}.png"));
write_png(&path, img.width, img.height, &img.bytes)?;
let token = format!(" @image {}", path.display());
for ch in token.chars() {
update(model, Msg::Insert(ch));
}
return Ok(Some(format!(
"📎 image attached ({}×{}) — send to include it",
img.width, img.height
)));
}
Ok(Some(
"no image in the clipboard — paste text with your terminal's own paste".into(),
))
}
fn write_png(
path: &std::path::Path,
width: usize,
height: usize,
rgba: &[u8],
) -> anyhow::Result<()> {
let file = std::fs::File::create(path)?;
let mut enc = png::Encoder::new(std::io::BufWriter::new(file), width as u32, height as u32);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
enc.write_header()?.write_image_data(rgba)?;
Ok(())
}
fn apply_completion(model: &mut Model) {
let sel = model
.suggestion_sel
.min(model.suggestions.len().saturating_sub(1));
let Some(first) = model.suggestions.get(sel).cloned() else {
return;
};
if model.input.starts_with('/') && !model.input.contains(char::is_whitespace) {
model.input = format!("{first} ");
} else {
let idx = model
.input
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
model.input.truncate(idx);
model.input.push_str(&first);
if !first.ends_with('/') {
model.input.push(' ');
}
}
model.cursor = model.input.len();
}
fn build_input_lines(model: &Model, theme: &Theme) -> Vec<Line<'static>> {
if model.input.is_empty() && !model.busy {
return vec![Line::from(vec![
Span::styled("❯ ", Style::default().fg(theme.accent)),
Span::styled(
"Ask Cordy to build something…",
Style::default().fg(theme.dim),
),
])];
}
let cur = model.cursor.min(model.input.len());
let sel = model.anchor.map(|a| (a.min(cur), a.max(cur)));
let blink_on = true;
let cursor_style = Style::default().add_modifier(Modifier::REVERSED);
let sel_style = Style::default()
.fg(theme.accent)
.add_modifier(Modifier::UNDERLINED);
let prompt = |r: usize| {
Span::styled(
if r == 0 { "❯ " } else { " " },
Style::default().fg(theme.accent),
)
};
let width = if model.input_width == 0 {
usize::MAX
} else {
model.input_width as usize
};
let mut out: Vec<Line> = Vec::new();
let mut row = 0usize;
let mut col = 0usize;
let mut spans = vec![prompt(0)];
for (i, ch) in model.input.char_indices() {
if ch == '\n' {
if i == cur && blink_on {
spans.push(Span::styled(" ", cursor_style));
}
out.push(Line::from(std::mem::take(&mut spans)));
row += 1;
col = 0;
spans.push(prompt(row));
continue;
}
if col == width {
out.push(Line::from(std::mem::take(&mut spans)));
row += 1;
col = 0;
spans.push(prompt(row));
}
let in_sel = sel.is_some_and(|(s, e)| i >= s && i < e);
let style = if i == cur && blink_on {
cursor_style
} else if in_sel {
sel_style
} else {
Style::default()
};
spans.push(Span::styled(ch.to_string(), style));
col += 1;
}
if cur == model.input.len() && blink_on {
spans.push(Span::styled(" ", cursor_style));
}
out.push(Line::from(spans));
out
}
fn mascot_face(tick: u64, busy: bool) -> &'static str {
if !busy {
"[o_o]"
} else {
match (tick / 4) % 4 {
0 => "[o_o]",
1 => "[o_O]",
2 => "[-_-]",
_ => "[^_^]",
}
}
}
fn render_mascot(f: &mut Frame, area: Rect, model: &Model) {
const W: u16 = 5;
if area.width < W + 2 || area.height < 2 {
return;
}
let rect = Rect {
x: area.x + area.width - W - 1,
y: area.y,
width: W,
height: 1,
};
let color = if !model.busy {
Color::Rgb(120, 130, 145)
} else if (model.tick / 2).is_multiple_of(2) {
Color::Rgb(245, 160, 80)
} else {
Color::Rgb(200, 110, 50)
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
mascot_face(model.tick, model.busy),
Style::default().fg(color).add_modifier(Modifier::BOLD),
))),
rect,
);
}
fn render_side_panel(f: &mut Frame, area: Rect, model: &Model, theme: &Theme) {
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(theme.surface))
.padding(Padding::new(2, 2, 1, 1));
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(2)])
.split(inner);
let dim = Style::default().fg(theme.dim);
let bright = Style::default().fg(theme.user);
let head = |label: &str| {
Line::from(Span::styled(
label.to_string(),
Style::default().fg(theme.user).add_modifier(Modifier::BOLD),
))
};
let mut lines: Vec<Line> = Vec::new();
lines.push(head("Spend"));
let cost = model.cost_str();
lines.push(Line::from(Span::styled(
if cost.is_empty() {
"$0.00".to_string()
} else {
cost
},
Style::default()
.fg(theme.accent2)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(
"{} in · {} out",
group_thousands(model.total_in),
group_thousands(model.total_out)
),
dim,
)));
if model.total_saved > 0 {
lines.push(Line::from(Span::styled(
format!("~{} saved by optimizer", group_thousands(model.total_saved)),
Style::default().fg(theme.success),
)));
}
lines.push(Line::raw(""));
if model.tabs.len() > 1 {
lines.push(head("Tabs"));
for (i, t) in model.tabs.iter().enumerate() {
let (mark, style) = if i == model.tab {
("▍", Style::default().fg(theme.user))
} else if t.busy {
("◇", Style::default().fg(theme.accent))
} else if t.unread > 0 {
("•", Style::default().fg(theme.accent2))
} else {
(" ", dim)
};
let suffix = if i == model.tab {
String::new()
} else if t.busy {
" working".into()
} else if t.unread > 0 {
format!(" {} new", t.unread)
} else {
String::new()
};
lines.push(Line::from(vec![
Span::styled(format!("{mark}{} {}", i + 1, t.title), style),
Span::styled(suffix, dim),
]));
}
lines.push(Line::raw(""));
}
if model.bg_count > 0 || model.subagent_count > 0 || model.term_count > 0 {
lines.push(head("Activity"));
for (n, label, color) in [
(model.bg_count, "background job", theme.tool),
(model.subagent_count, "sub-agent", theme.accent2),
(model.term_count, "terminal", theme.dim),
] {
if n > 0 {
lines.push(Line::from(vec![
Span::styled("• ", Style::default().fg(color)),
Span::styled(
format!("{n} {label}{}", if n == 1 { "" } else { "s" }),
bright,
),
]));
}
}
lines.push(Line::raw(""));
}
if !model.events.is_empty() {
lines.push(head("Last calls"));
for e in model.events.iter().rev().take(3) {
let (glyph, color) = match e.error {
Some(_) => ("✗", theme.danger),
None => ("✓", theme.success),
};
lines.push(Line::from(vec![
Span::styled(format!("{glyph} "), Style::default().fg(color)),
Span::styled(format!("{:.1}s", e.secs), bright),
Span::styled(format!(" {} out", super::fmt_tokens(e.output_tokens)), dim),
]));
}
lines.push(Line::raw(""));
}
lines.push(head("Capabilities"));
lines.push(Line::from(Span::styled(
format!(
"{} skill{} · {} tool source{}",
model.skills.len(),
if model.skills.len() == 1 { "" } else { "s" },
model.mcp_names.len(),
if model.mcp_names.len() == 1 { "" } else { "s" }
),
dim,
)));
if model.mcp_names.is_empty() {
lines.push(Line::from(Span::styled("no MCP servers", dim)));
} else {
for (name, status) in &model.mcp_names {
let dot = if status.starts_with("Connected") {
theme.success
} else {
theme.danger
};
lines.push(Line::from(vec![
Span::styled("• ", Style::default().fg(dot)),
Span::styled(name.clone(), bright),
Span::styled(format!(" {status}"), dim),
]));
}
}
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), rows[0]);
let mut version_spans = vec![
Span::styled("• ", Style::default().fg(theme.accent)),
Span::styled(
format!("cordy v{}", env!("CARGO_PKG_VERSION")),
Style::default().fg(theme.user).add_modifier(Modifier::BOLD),
),
];
if let Some(v) = &model.latest_version {
version_spans.push(Span::styled(
format!(" ↑ v{v}"),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
));
}
let footer = vec![Line::raw(""), Line::from(version_spans)];
f.render_widget(Paragraph::new(footer), rows[1]);
}
fn fmt_num(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1e6)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1e3)
} else {
n.to_string()
}
}
fn group_thousands(n: u64) -> String {
let s = n.to_string();
let b = s.as_bytes();
let mut out = String::new();
for (i, c) in b.iter().enumerate() {
if i > 0 && (b.len() - i).is_multiple_of(3) {
out.push(' ');
}
out.push(*c as char);
}
out
}
fn splash(height: u16, theme: &Theme) -> Paragraph<'static> {
let hints: [(&str, &str); 4] = [
("^D", "mode"),
("^L", "model"),
("^G", "git"),
("^P", "everything"),
];
let block_h = LOGO.len() + 6;
let top = (height as usize).saturating_sub(block_h) / 2;
let mut lines: Vec<Line> = vec![Line::raw(""); top];
let ramp = theme.ramp(LOGO.len());
for (l, col) in LOGO.iter().zip(ramp) {
lines.push(Line::styled(l.to_string(), Style::default().fg(col)));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled(
"your terminal coding agent",
Style::default().fg(theme.user).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" v{}", env!("CARGO_PKG_VERSION")),
Style::default().fg(theme.border),
),
]));
lines.push(Line::raw(""));
let mut row: Vec<Span> = Vec::new();
for (k, label) in hints {
row.push(Span::styled(
k.to_string(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
));
row.push(Span::styled(
format!(" {label} "),
Style::default().fg(theme.dim),
));
}
lines.push(Line::from(row));
Paragraph::new(lines).alignment(Alignment::Center)
}
fn diff_color(line: &str, theme: &Theme) -> Color {
if line.starts_with("@@") {
return theme.accent2;
}
if line.starts_with("diff ") || line.starts_with("commit ") {
return theme.accent;
}
match line.chars().next() {
Some('+') => theme.success,
Some('-') => theme.danger,
Some('?') => theme.dim,
Some('M') | Some('A') | Some('D') | Some('R') => theme.warning,
_ => theme.dim,
}
}
fn wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
let cells: Vec<(char, Style)> = line
.spans
.iter()
.flat_map(|sp| sp.content.chars().map(move |c| (c, sp.style)))
.collect();
if cells.is_empty() {
return vec![Line::raw("")];
}
if width == 0 {
return vec![cells_to_line(&cells)];
}
let mut out = Vec::new();
let mut start = 0;
while start < cells.len() {
let hard = (start + width).min(cells.len());
let mut end = hard;
let mut skip = 0;
if hard < cells.len()
&& let Some(pos) = (start..hard).rev().find(|&i| cells[i].0 == ' ')
&& pos > start
{
end = pos;
skip = 1;
}
out.push(cells_to_line(&cells[start..end]));
start = end + skip;
}
if out.is_empty() {
out.push(Line::raw(""));
}
out
}
fn cells_to_line(cells: &[(char, Style)]) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let mut cur: Option<Style> = None;
for (ch, st) in cells {
match cur {
Some(s) if s == *st => buf.push(*ch),
_ => {
if let Some(s) = cur {
spans.push(Span::styled(std::mem::take(&mut buf), s));
}
buf.push(*ch);
cur = Some(*st);
}
}
}
if let Some(s) = cur {
spans.push(Span::styled(buf, s));
}
Line::from(spans)
}
const MAX_CARD: usize = 96;
fn user_block(text: &str, theme: &Theme, width: usize) -> Vec<Line<'static>> {
let w = width.max(4);
let inner = w.saturating_sub(2); let surf = theme.surface;
let fill = Style::default().bg(surf);
let txt = Style::default().fg(theme.user).bg(surf);
let mut body: Vec<Line<'static>> = Vec::new();
for raw in text.lines() {
let line = Line::from(Span::styled(raw.to_string(), txt));
body.extend(wrap_line(&line, inner));
}
let rows = body.len().max(1);
let ramp = theme.ramp(rows);
let bar = |i: usize, s: &str| {
Span::styled(
s.to_string(),
Style::default().fg(ramp[i.min(rows - 1)]).bg(surf),
)
};
let mut out = Vec::with_capacity(rows);
for (i, wl) in body.into_iter().enumerate() {
let used = wl.width();
let mut spans = vec![bar(i, "▌ ")];
spans.extend(wl.spans.into_iter().map(|mut s| {
s.style = s.style.bg(surf);
s
}));
spans.push(Span::styled(" ".repeat(inner.saturating_sub(used)), fill));
out.push(Line::from(spans));
}
if out.is_empty() {
out.push(Line::from(vec![
bar(0, "▌"),
Span::styled(" ".repeat(w - 1), fill),
]));
}
out
}
fn assistant_block(text: &str, theme: &Theme, streaming: bool, width: usize) -> Vec<Line<'static>> {
let inner = width.clamp(16, MAX_CARD).saturating_sub(2);
let md = super::markdown::render_markdown(text, theme);
let md = if md.is_empty() {
vec![Line::raw("")]
} else {
md
};
let mut out: Vec<Line<'static>> = Vec::new();
let n = md.len();
for (i, ml) in md.into_iter().enumerate() {
let wrapped = wrap_line(&ml, inner);
let wl = wrapped.len();
for (j, w_line) in wrapped.into_iter().enumerate() {
let mut spans = vec![Span::raw(" ")];
spans.extend(w_line.spans);
if streaming && i + 1 == n && j + 1 == wl {
spans.push(Span::styled("▌", Style::default().fg(theme.accent)));
}
out.push(Line::from(spans));
}
}
out
}
fn render_entry(
e: &Entry,
theme: &Theme,
show_tool_output: bool,
width: usize,
unfolded: bool,
) -> Vec<Line<'static>> {
match e {
Entry::User(t) => user_block(t, theme, width),
Entry::Assistant(t) => assistant_block(t, theme, false, width),
Entry::Tool {
name,
text,
saved,
running,
error,
..
} => {
let inner = width.clamp(16, MAX_CARD).saturating_sub(4);
let (glyph, gcol) = match (*running, *error) {
(true, _) => ("◇", theme.accent),
(false, true) => ("✗", theme.danger),
(false, false) => ("✓", theme.success),
};
let mut head = vec![
Span::styled(" ", Style::default()),
Span::styled(format!("{glyph} "), Style::default().fg(gcol)),
Span::styled(
name.clone(),
Style::default().fg(theme.tool).add_modifier(Modifier::BOLD),
),
];
let one_liner = (!*running && text.lines().count() == 1 && text.len() <= 70)
.then(|| text.trim().to_string())
.filter(|t| !t.is_empty());
if let Some(t) = &one_liner {
head.push(Span::styled(
format!(" {t}"),
Style::default().fg(theme.dim),
));
}
if *running {
head.push(Span::styled(
format!(" {} running…", text.trim()),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::ITALIC),
));
}
if *saved > 0 {
head.push(Span::styled(
format!(" · {saved} saved"),
Style::default().fg(theme.border),
));
}
if !unfolded && (!show_tool_output || one_liner.is_some() || *running) {
if !show_tool_output && one_liner.is_none() && !*running {
head.push(Span::styled(
format!(" · {} lines", text.lines().count()),
Style::default().fg(theme.border),
));
}
return wrap_line(&Line::from(head), width.max(14));
}
let cap = if unfolded { 400 } else { 12 };
let mut out = vec![Line::from(head)];
for raw in text.lines().take(cap) {
let line = Line::from(Span::styled(
raw.to_string(),
Style::default().fg(theme.dim),
));
for wl in wrap_line(&line, inner) {
let mut spans = vec![Span::styled(" ", Style::default())];
spans.extend(wl.spans);
out.push(Line::from(spans));
}
}
let extra = text.lines().count().saturating_sub(cap);
if extra > 0 {
out.push(Line::from(Span::styled(
format!(" … {extra} more lines"),
Style::default().fg(theme.border),
)));
}
out
}
Entry::System(t) => {
let line = Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
t.clone(),
Style::default()
.fg(theme.system)
.add_modifier(Modifier::ITALIC),
),
]);
wrap_line(&line, width.max(14))
}
Entry::Turn { mode, model, secs } => {
vec![Line::from(vec![
Span::raw(" "),
Span::styled(
format!(" {mode} "),
Style::default()
.fg(theme.on_accent)
.bg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", chrome::short_model(model)),
Style::default().fg(theme.dim),
),
Span::styled(format!(" {secs:.1}s"), Style::default().fg(theme.border)),
])]
}
}
}
fn palette_group(label: &str) -> &'static str {
if label.starts_with("model:") {
"Models"
} else if label.starts_with("mode:") {
"Agents"
} else if label.starts_with("provider:") {
"Providers"
} else if label.starts_with("theme:") {
"Themes"
} else {
"Commands"
}
}
fn palette_display(label: &str) -> String {
for p in ["model: ", "mode: ", "provider: ", "theme: "] {
if let Some(r) = label.strip_prefix(p) {
return r.to_string();
}
}
label.to_string()
}
fn palette_shortcut(label: &str) -> &'static str {
match label {
"/new" => "^X n",
"/sessions" => "^X l",
"/model" => "^X m",
"/compact" => "^X c",
"/rename" => "^R",
"/help" => "^X h",
"/connect" => "^X f",
_ => "",
}
}
fn modal_shell(f: &mut Frame, popup: Rect, title: &str, theme: &Theme) -> (Rect, usize) {
f.render_widget(Clear, popup);
let block = Block::default()
.style(Style::default().bg(theme.surface))
.padding(Padding::new(2, 2, 1, 1));
let inner = block.inner(popup);
f.render_widget(block, popup);
let w = inner.width as usize;
let surf = theme.surface;
let hpad = w.saturating_sub(title.chars().count() + 3);
let header = Paragraph::new(vec![
Line::from(vec![
Span::styled(
title.to_string(),
Style::default()
.fg(theme.user)
.bg(surf)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ".repeat(hpad), Style::default().bg(surf)),
Span::styled("esc", Style::default().fg(theme.dim).bg(surf)),
]),
Line::from(Span::styled(" ", Style::default().bg(surf))),
]);
f.render_widget(
header,
Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 2.min(inner.height),
},
);
let body = Rect {
x: inner.x,
y: inner.y.saturating_add(2),
width: inner.width,
height: inner.height.saturating_sub(2),
};
(body, w)
}
fn modal_row(
w: usize,
star: &str,
disp: &str,
hint: &str,
sc: &str,
selected: bool,
theme: &Theme,
) -> Line<'static> {
let name = format!("{star}{disp}");
let sc_w = sc.chars().count();
if selected {
const PEACH: Color = Color::Rgb(232, 174, 128);
const DARK: Color = Color::Rgb(24, 26, 32);
let hl = Style::default().bg(PEACH).fg(DARK);
let mut left = format!("● {name}");
if !hint.is_empty() {
let avail = w.saturating_sub(left.chars().count() + sc_w + 4);
let h: String = hint.chars().take(avail).collect();
left.push_str(&format!(" {h}"));
}
let pad = w.saturating_sub(left.chars().count() + sc_w);
Line::from(vec![
Span::styled(left, hl.add_modifier(Modifier::BOLD)),
Span::styled(" ".repeat(pad), hl),
Span::styled(sc.to_string(), hl),
])
} else {
let surf = theme.surface;
let mut spans = vec![Span::styled(
format!(" {name}"),
Style::default()
.fg(theme.user)
.bg(surf)
.add_modifier(Modifier::BOLD),
)];
let mut used = 2 + name.chars().count();
if !hint.is_empty() {
let avail = w.saturating_sub(used + sc_w + 4);
let h: String = hint.chars().take(avail).collect();
used += 2 + h.chars().count();
spans.push(Span::styled(
format!(" {h}"),
Style::default().fg(theme.dim).bg(surf),
));
}
let pad = w.saturating_sub(used + sc_w);
spans.push(Span::styled(" ".repeat(pad), Style::default().bg(surf)));
spans.push(Span::styled(
sc.to_string(),
Style::default().fg(theme.dim).bg(surf),
));
Line::from(spans)
}
}
fn render_msg_menu(f: &mut Frame, area: Rect, model: &mut Model, theme: &Theme) {
let (entry_idx, col, row, sel0) = match &model.msg_menu {
Some(mm) => (mm.entry, mm.col, mm.row, mm.sel),
None => return,
};
let actions = match model.transcript.get(entry_idx) {
Some(e) => menu_actions(e),
None => {
model.msg_menu = None;
return;
}
};
if actions.is_empty() {
model.msg_menu = None;
return;
}
let sel = sel0.min(actions.len() - 1);
let subject = model
.transcript
.get(entry_idx)
.map(menu_subject)
.unwrap_or("message");
let label_w = actions
.iter()
.map(|(_, l, _)| l.chars().count())
.max()
.unwrap_or(8)
.max(subject.chars().count());
let w = ((label_w + 8) as u16).clamp(1, area.width.max(1));
let h = (actions.len() as u16 + 2).min(area.height.max(1));
let x = col.min(area.x + area.width.saturating_sub(w));
let y = row.min(area.y + area.height.saturating_sub(h));
let rect = Rect {
x,
y,
width: w,
height: h,
};
f.render_widget(Clear, rect);
let surf = theme.surface;
f.render_widget(Block::default().style(Style::default().bg(surf)), rect);
let iw = rect.width as usize;
let mut rows: Vec<Line> = vec![Line::from(Span::styled(
format!(" {subject:<w$}", w = iw.saturating_sub(1)),
Style::default().fg(theme.border).bg(surf),
))];
for (i, (_, label, key)) in actions.iter().enumerate() {
let selected = i == sel;
let bg = if selected {
super::theme::blend(surf, theme.accent, 0.28)
} else {
surf
};
let mark = if selected { "▍" } else { " " };
let used = 1 + 1 + label.chars().count();
rows.push(Line::from(vec![
Span::styled(mark.to_string(), Style::default().fg(theme.accent).bg(bg)),
Span::styled(
format!(" {label}"),
Style::default()
.fg(if selected {
theme.user
} else {
theme.assistant
})
.bg(bg),
),
Span::styled(
" ".repeat(iw.saturating_sub(used + 2)),
Style::default().bg(bg),
),
Span::styled(format!("{key} "), Style::default().fg(theme.border).bg(bg)),
]));
}
rows.push(Line::from(Span::styled(
format!(" {:<w$}", "esc close", w = iw.saturating_sub(1)),
Style::default().fg(theme.border).bg(surf),
)));
f.render_widget(Paragraph::new(rows), rect);
model.msg_menu_rect = Some((rect.x, rect.y, rect.width, rect.height));
if let Some(mm) = &mut model.msg_menu {
mm.sel = sel;
}
}
fn render_palette(f: &mut Frame, area: Rect, model: &Model, theme: &Theme) {
let popup = centered_rect(62, 74, area);
let title = if model.palette_query.starts_with("model") {
"Select model"
} else if model.palette_query.starts_with("mode") {
"Select agent"
} else {
"Commands"
};
let (body, w) = modal_shell(f, popup, title, theme);
let surf = theme.surface;
let dim = Style::default().fg(theme.dim).bg(surf);
let bright = Style::default().fg(theme.user).bg(surf);
let mut rows: Vec<Line> = Vec::new();
if model.palette_query.is_empty() {
rows.push(Line::from(Span::styled("Search", dim)));
} else {
rows.push(Line::from(vec![
Span::styled(model.palette_query.clone(), bright),
Span::styled("▌", Style::default().fg(theme.accent).bg(surf)),
]));
}
rows.push(Line::from(Span::styled(" ", Style::default().bg(surf))));
let filtered = model.palette_filtered();
if filtered.is_empty() {
rows.push(Line::from(Span::styled(" no matches", dim)));
}
let sel = model.palette_sel.min(filtered.len().saturating_sub(1));
let mut items: Vec<Line> = Vec::new();
let mut sel_line = 0usize;
let mut prev_group = "";
for (i, &idx) in filtered.iter().enumerate() {
let it = &model.palette[idx];
let g = palette_group(&it.label);
if g != prev_group {
items.push(Line::from(Span::styled(
g.to_string(),
Style::default()
.fg(theme.accent)
.bg(surf)
.add_modifier(Modifier::BOLD),
)));
prev_group = g;
}
if i == sel {
sel_line = items.len();
}
let disp = palette_display(&it.label);
let star = if let Some(name) = it.label.strip_prefix("model: ") {
if model.favorites.iter().any(|fav| fav == name) {
"★ "
} else if model.recents.iter().any(|r| r == name) {
"● "
} else {
""
}
} else {
""
};
items.push(modal_row(
w,
star,
&disp,
&it.hint,
palette_shortcut(&it.label),
i == sel,
theme,
));
}
let avail = (body.height as usize).saturating_sub(rows.len()).max(1);
let scroll = (sel_line + 1).saturating_sub(avail);
rows.extend(items.into_iter().skip(scroll).take(avail));
f.render_widget(Paragraph::new(rows), body);
}
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(vertical[1])[1]
}
#[cfg(test)]
mod tests {
use super::*;
fn draw_at(w: u16, h: u16, model: &mut Model) {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let themes = ThemeRegistry::builtin();
let theme = themes.at(0);
let mut terms = term::Terminals::default();
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(|f| view(f, model, &theme, &themes, &mut terms))
.unwrap();
}
fn populated() -> Model {
let mut m = Model {
status: "ready".into(),
footer: "/work/project".into(),
modes: vec!["build".into(), "plan".into(), "review".into()],
model_name: "anthropic/claude-opus-5".into(),
provider_kind: "anthropic".into(),
subtitle: "anthropic · claude-opus-5".into(),
theme_names: ThemeRegistry::builtin().names(),
theme_name: "cordy".into(),
context_window: Some(200_000),
last_in: 61_000,
total_in: 61_000,
total_out: 4_200,
total_saved: 900,
price_in: Some(3.0),
price_out: Some(15.0),
skills: vec![("pdf".into(), "read pdfs".into())],
mcp_names: vec![("fs".into(), "Connected (12 tools)".into())],
bg_count: 1,
subagent_count: 2,
term_count: 1,
git: Some(chrome::GitStatus {
branch: "feature/very-long-branch-name".into(),
staged: 2,
unstaged: 3,
untracked: 1,
ahead: 4,
behind: 5,
}),
tabs: vec![
chrome::TabMeta {
title: "main".into(),
..Default::default()
},
chrome::TabMeta {
title: "tests".into(),
unread: 7,
busy: true,
..Default::default()
},
chrome::TabMeta {
title: "docs".into(),
..Default::default()
},
],
tab: 1,
input: "explain @src/main.rs and then
fix the bug"
.into(),
..Default::default()
};
m.cursor = m.input.len();
m.transcript = vec![
Entry::User(
"a fairly long user message that will need wrapping at narrow widths".into(),
),
Entry::Assistant(
"# Heading
Some **markdown** with `code` and a list:
- one
- two"
.into(),
),
Entry::Tool {
id: "1".into(),
name: "bash".into(),
text: "line one
line two"
.into(),
saved: 40,
running: false,
error: false,
},
Entry::Tool {
id: "2".into(),
name: "edit".into(),
text: "boom".into(),
saved: 0,
running: false,
error: true,
},
Entry::System("resumed session (3 messages)".into()),
Entry::Turn {
mode: "build".into(),
model: "claude-opus-5".into(),
secs: 3.4,
},
];
m.events.push(crate::tui::ModelEvent {
model: "claude-opus-5".into(),
mode: "build".into(),
secs: 2.5,
input_tokens: 1000,
output_tokens: 200,
cost: 0.01,
error: None,
});
m
}
#[test]
fn renders_at_every_plausible_terminal_size() {
for w in 6u16..=160 {
for h in [3u16, 4, 5, 6, 8, 12, 24, 40, 60] {
let mut m = populated();
draw_at(w, h, &mut m);
}
}
}
#[test]
fn renders_every_overlay_without_panicking() {
let sizes = [
(160u16, 50u16),
(120, 40),
(80, 24),
(46, 12),
(30, 8),
(16, 5),
(8, 3),
];
let openers: Vec<fn(&mut Model)> = vec![
|m| open_model_picker(m),
|m| open_mode_picker(m),
|m| open_skills_picker(m),
|m| open_git_picker(m),
|m| open_tabs_picker(m),
|m| open_events_picker(m),
|m| {
open_context_picker(
m,
&ContextReport {
sections: vec![
("system prompt".into(), 1200),
("tool schemas (14)".into(), 5400),
],
total: 6600,
messages: 12,
native_mgmt: false,
},
)
},
|m| m.theme_open = true,
|m| m.status_open = true,
|m| m.leader = true,
|m| m.palette_open = true,
|m| m.sessions_open = true,
|m| m.providers_open = true,
|m| m.connect = Some(Connect::default()),
|m| {
m.info = Some((
"git status".into(),
vec!["M src/a.rs".into(), "?? b".into()],
))
},
|m| {
m.pending = Some(
"+ added
- removed
@@ hunk"
.into(),
)
},
];
for open in openers {
for (w, h) in sizes {
let mut m = populated();
open(&mut m);
draw_at(w, h, &mut m);
}
}
}
fn rows_of(w: u16, h: u16, model: &mut Model, terms: &mut term::Terminals) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let themes = ThemeRegistry::builtin();
let theme = themes.at(0);
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(|f| view(f, model, &theme, &themes, terms)).unwrap();
let buf = t.backend().buffer().clone();
(0..h)
.map(|y| {
(0..w)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
}
#[test]
fn header_and_footer_actually_show_what_they_promise() {
let mut m = populated();
let mut terms = term::Terminals::default();
let rows = rows_of(150, 40, &mut m, &mut terms);
let header = &rows[0];
assert!(header.contains("cordy"), "{header}");
assert!(header.contains("tests"), "focused tab missing: {header}");
assert!(
header.contains("docs") || header.contains('…'),
"tab strip neither complete nor marked as truncated: {header}"
);
assert!(
header.contains("feature/very-long-branch-name"),
"git branch missing: {header}"
);
assert!(
header.contains("claude-opus-5"),
"model chip missing: {header}"
);
assert!(header.contains('%'), "context meter missing: {header}");
assert!(header.contains('$'), "cost missing: {header}");
let hints = &rows[rows.len() - 1];
assert!(hints.contains("build"), "mode chip missing: {hints}");
for key in ["^P", "^L", "^D", "^G"] {
assert!(hints.contains(key), "hint {key} missing: {hints}");
}
let last_row = rows.len() as u16 - 1;
let bar: Vec<crate::tui::Hit> = m
.hits
.iter()
.filter(|(_, y, _, _, _)| *y == last_row)
.map(|(_, _, _, _, h)| *h)
.collect();
for want in [
crate::tui::Hit::Mode,
crate::tui::Hit::ModelChip,
crate::tui::Hit::Git,
crate::tui::Hit::Palette,
] {
assert!(bar.contains(&want), "{want:?} is not clickable: {bar:?}");
}
let head: Vec<crate::tui::Hit> = m
.hits
.iter()
.filter(|(_, y, _, _, _)| *y == 0)
.map(|(_, _, _, _, h)| *h)
.collect();
assert!(
head.contains(&crate::tui::Hit::Context),
"meter not clickable"
);
assert!(head.contains(&crate::tui::Hit::Cost), "cost not clickable");
assert!(
head.contains(&crate::tui::Hit::Tab(0)),
"tabs not clickable"
);
let body = rows.join("\n");
assert!(body.contains("✗"), "failed tool marker missing");
assert!(body.contains("✓"), "succeeded tool marker missing");
}
#[test]
fn chrome_below_the_transcript_stays_three_rows() {
let mut m = populated();
m.input.clear();
let mut terms = term::Terminals::default();
let rows = rows_of(100, 24, &mut m, &mut terms);
let n = rows.len();
assert!(rows[n - 1].trim_start().starts_with("build"), "status row");
assert!(
rows[n - 2].trim_matches([' ', '│']).is_empty(),
"composer breathing row: {:?}",
rows[n - 2]
);
assert!(rows[n - 3].contains('❯'), "prompt row");
assert!(
!rows[n - 4].contains('❯') && !rows[n - 4].contains("mode"),
"chrome grew past three rows: {:?}",
&rows[n - 4]
);
let first = rows
.iter()
.position(|r| r.contains("a fairly long user message"))
.expect("user message rendered");
assert!(
rows[first].trim_start().starts_with('▌'),
"user text should sit on the bar row: {:?}",
rows[first]
);
assert!(
!rows[first - 1].contains('▌'),
"no padding row above the user block"
);
}
#[test]
fn a_live_terminal_panel_renders_into_the_chat_column() {
let mut m = populated();
let mut terms = term::Terminals::default();
let prog = if cfg!(windows) { "cmd.exe" } else { "sh" };
let args = if cfg!(windows) {
vec!["/C".to_string(), "exit".to_string()]
} else {
vec!["-c".to_string(), "exit".to_string()]
};
terms
.open("shell", Some(prog), &args, std::path::Path::new("."))
.unwrap();
let rows = rows_of(120, 40, &mut m, &mut terms);
assert!(
rows.iter().any(|r| r.contains("▸ shell")),
"terminal panel header missing"
);
for (w, h) in [(80u16, 20u16), (40, 12), (20, 8), (10, 4)] {
rows_of(w, h, &mut m, &mut terms);
}
terms.kill_all();
}
#[test]
fn renders_the_empty_splash_and_a_prompt_in_flight() {
let mut m = Model {
theme_names: ThemeRegistry::builtin().names(),
..Default::default()
};
draw_at(100, 30, &mut m); m.busy = true;
m.status = "thinking…".into();
m.streaming = "partial reply".into();
m.thinking = "reasoning…".into();
m.show_thinking = true;
m.queue = vec!["queued message".into()];
draw_at(100, 30, &mut m);
draw_at(30, 8, &mut m);
}
#[test]
fn personas_compose_and_only_read_only_ones_are_locked_down() {
let base = "BASE";
assert_eq!(compose_system(base, "build"), "BASE");
assert!(mode_rules("build").is_empty());
assert_eq!(compose_system(base, "my-agent"), "BASE");
for mode in ["plan", "architect", "socratic", "challenge", "review"] {
let sys = compose_system(base, mode);
assert!(sys.starts_with("BASE\n\n<mode>"), "{mode}: {sys}");
assert!(read_only_mode(mode), "{mode} should be read-only");
assert_eq!(mode_rules(mode).len(), 4, "{mode}");
}
}
#[test]
fn git_arguments_survive_quoting_round_trip() {
assert_eq!(
split_args(&format!("commit -m {}", quote_arg("fix: it's broken"))),
vec!["commit", "-m", "fix: it's broken"]
);
assert_eq!(
split_args("log --oneline --graph -25"),
vec!["log", "--oneline", "--graph", "-25"]
);
assert!(split_args(" ").is_empty());
assert_eq!(split_args("commit -m ''"), vec!["commit", "-m", ""]);
let path = r"feat\weird'name";
assert_eq!(
split_args(&format!("checkout {}", quote_arg(path))),
vec!["checkout", path]
);
}
#[test]
fn editor_target_prefers_an_at_path_then_the_last_edit() {
let last = "src/main.rs".to_string();
let m = Model::default();
assert_eq!(editor_target(&m, Some(&last)), "src/main.rs");
assert_eq!(editor_target(&m, None), ".");
let m = Model {
input: "look at @Cargo.toml please".into(),
..Default::default()
};
assert_eq!(editor_target(&m, Some(&last)), "Cargo.toml");
let m = Model {
input: "@does/not/exist".into(),
..Default::default()
};
assert_eq!(editor_target(&m, Some(&last)), "src/main.rs");
}
#[test]
fn context_report_splits_the_payload_by_section() {
let mut session = Session::new("system prompt text", "test-model");
session.messages.push(Message::user("hello there"));
let rep = context_report(&session, &Registry::new(), false);
assert_eq!(rep.messages, 1);
assert_eq!(rep.sections.len(), 5);
assert!(rep.total > 0);
let sum: u64 = rep.sections.iter().map(|(_, n)| *n).sum();
assert_eq!(sum, rep.total);
assert!(rep.sections[0].1 > 0, "system prompt");
assert!(rep.sections[2].1 > 0, "user messages");
}
}