use crate::host_ui::UiCommand;
use crate::runtime::{BuiltRuntime, ModelState, RuntimeHandles, StartupInfo};
use anyhow::Result;
use crossterm::event::{
self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,
};
use everruns_core::command::{CommandDescriptor, CommandSource};
use everruns_core::events::{Event as RuntimeEvent, EventData, ToolCompletedData};
use everruns_core::message::{ContentPart, Message, MessageRole};
use everruns_core::typed_id::SessionId;
use ratatui::Terminal;
use ratatui::backend::Backend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use ratatui_textarea::{CursorMove, TextArea, WrapMode};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
use tokio::sync::mpsc;
#[derive(Clone, Debug)]
pub enum Author {
User,
Assistant,
Narration,
Tool,
ToolDetail,
Diff,
System,
}
impl Author {
pub fn label(&self) -> &'static str {
match self {
Author::User => "you",
Author::Assistant => "agent",
Author::Narration => "note",
Author::Tool => "tool",
Author::ToolDetail => "",
Author::Diff => "diff",
Author::System => "system",
}
}
pub fn color(&self) -> Color {
match self {
Author::User => ACCENT_BLUE,
Author::Assistant => ACCENT_GOLD,
Author::Narration => TEXT_MUTED,
Author::Tool => TEXT_MUTED,
Author::ToolDetail => TEXT_MUTED,
Author::Diff => ACCENT_BLUE,
Author::System => TEXT_DIM,
}
}
}
#[derive(Clone, Debug)]
pub struct ChatLine {
pub author: Author,
pub text: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CommandSuggestion {
completion: String,
label: String,
}
pub const COMPOSER_VIEWPORT_HEIGHT: u16 = 18;
const MAX_TERMINAL_IO_FAILURES: usize = 5;
const COMPACT_CHROME_HEIGHT: u16 = 5;
const MAX_INPUT_HEIGHT: u16 = 12;
const RECENT_TRANSCRIPT_SOURCE_LINES: usize = 80;
const RECENT_TRANSCRIPT_MAX_TEXT_BYTES: usize = 4_000;
const ACCENT_BLUE: Color = Color::Rgb(45, 91, 158);
const ACCENT_GOLD: Color = Color::Rgb(126, 94, 19);
const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 232);
const TEXT_MUTED: Color = Color::Rgb(140, 140, 145);
const TEXT_DIM: Color = Color::Rgb(72, 72, 78);
const DIFF_ADD: Color = Color::Rgb(132, 166, 142);
const DIFF_DELETE: Color = Color::Rgb(180, 132, 136);
const DIFF_META: Color = Color::Rgb(108, 132, 188);
const CODE_BG: Color = Color::Rgb(18, 18, 20);
const PANEL_BG: Color = Color::Rgb(28, 28, 34);
pub struct App {
handles: RuntimeHandles,
startup: StartupInfo,
model: ModelState,
pub lines: Vec<ChatLine>,
printed_lines: usize,
pub input: TextArea<'static>,
pub busy: bool,
pub should_quit: bool,
ctrl_c_exit: bool,
busy_frame: u64,
turn_activity: Option<String>,
stream_preview: Option<StreamPreview>,
rx: Option<mpsc::UnboundedReceiver<TurnEvent>>,
setup: Option<SetupStep>,
ui_rx: mpsc::UnboundedReceiver<UiCommand>,
settings: Arc<crate::settings::SettingsStore>,
model_catalog: HashMap<String, Vec<ModelOption>>,
model_fetches_in_flight: HashSet<String>,
model_discovery_enabled: bool,
models_tx: mpsc::UnboundedSender<ModelDiscovery>,
models_rx: mpsc::UnboundedReceiver<ModelDiscovery>,
}
struct ModelDiscovery {
provider: String,
result: Result<Option<Vec<ModelOption>>, String>,
}
#[derive(Clone, Debug)]
enum SetupStep {
Provider {
selected: usize,
},
BaseUrlInput {
value: String,
error: Option<String>,
},
Credential {
provider: String,
selected: usize,
error: Option<String>,
},
TokenInput {
provider: String,
token: String,
error: Option<String>,
},
PickModel {
provider: String,
selected: usize,
custom: Option<String>,
error: Option<String>,
},
PickEffort {
selected: usize,
error: Option<String>,
},
}
struct ProviderOption {
name: &'static str,
label: &'static str,
hint: &'static str,
}
const PROVIDER_OPTIONS: &[ProviderOption] = &[
ProviderOption {
name: "openai",
label: "OpenAI",
hint: "GPT models",
},
ProviderOption {
name: "anthropic",
label: "Anthropic",
hint: "Claude",
},
ProviderOption {
name: "google",
label: "Google Gemini",
hint: "Gemini models",
},
ProviderOption {
name: "openrouter",
label: "OpenRouter",
hint: "many hosted models",
},
ProviderOption {
name: "ollama",
label: "Ollama local",
hint: "local OpenAI-compatible server",
},
ProviderOption {
name: "custom",
label: "Custom endpoint",
hint: "any OpenAI-compatible URL",
},
ProviderOption {
name: "llmsim",
label: "Offline demo mode",
hint: "canned offline responses",
},
];
struct CredentialOption {
id: CredentialAction,
label: String,
hint: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CredentialAction {
UseEnv,
PasteKey,
Skip,
ClearSaved,
}
#[derive(Clone, Debug)]
struct ModelOption {
spec: Option<String>,
label: String,
hint: String,
}
struct EffortOption {
value: &'static str,
label: &'static str,
hint: &'static str,
}
const EFFORT_OPTIONS: &[EffortOption] = &[
EffortOption {
value: "minimal",
label: "Minimal",
hint: "least reasoning",
},
EffortOption {
value: "low",
label: "Low",
hint: "faster responses",
},
EffortOption {
value: "medium",
label: "Medium",
hint: "balanced default",
},
EffortOption {
value: "high",
label: "High",
hint: "more reasoning for hard tasks",
},
];
#[derive(Clone, Debug)]
pub(crate) struct ViewState {
pub stream_preview: Option<StreamPreview>,
pub command_suggestions: Vec<CommandSuggestion>,
pub busy: bool,
pub busy_frame: u64,
pub turn_activity: Option<String>,
pub model_label: String,
pub workspace_root: std::path::PathBuf,
pub session_id: SessionId,
pub lines_count: usize,
pub approval_mode: String,
}
#[derive(Clone, Debug)]
pub struct StreamPreview {
pub kind: StreamKind,
pub text: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StreamKind {
Assistant,
Thinking,
Tool,
}
impl StreamKind {
fn label(self) -> &'static str {
match self {
StreamKind::Assistant => "agent",
StreamKind::Thinking => "thinking",
StreamKind::Tool => "tool",
}
}
fn color(self) -> Color {
match self {
StreamKind::Assistant => ACCENT_GOLD,
StreamKind::Thinking => TEXT_MUTED,
StreamKind::Tool => TEXT_MUTED,
}
}
}
#[derive(Clone, Debug)]
pub struct ActivityStatus {
pub text: String,
fallback: bool,
}
#[derive(Debug)]
pub(crate) enum TurnEvent {
Lines(Vec<ChatLine>),
Activity(ActivityStatus),
Stream(Option<StreamPreview>),
Done,
Failed(String),
}
impl App {
pub fn new(runtime: BuiltRuntime) -> Self {
let should_setup = runtime.startup.setup_recommended;
let (models_tx, models_rx) = mpsc::unbounded_channel::<ModelDiscovery>();
let mut app = Self {
handles: runtime.handles,
startup: runtime.startup,
model: runtime.model,
lines: Vec::new(),
printed_lines: 0,
input: new_input_area(vec![String::new()]),
busy: false,
should_quit: false,
ctrl_c_exit: false,
busy_frame: 0,
turn_activity: None,
stream_preview: None,
rx: None,
setup: None,
ui_rx: runtime.ui_rx,
settings: runtime.settings,
model_catalog: HashMap::new(),
model_fetches_in_flight: HashSet::new(),
model_discovery_enabled: true,
models_tx,
models_rx,
};
app.emit_system_banner();
if should_setup {
app.start_first_run_setup();
}
app
}
pub fn should_show_resume_hint(&self) -> bool {
self.ctrl_c_exit
}
pub fn session_id(&self) -> SessionId {
self.handles.session_id
}
pub(crate) fn view_state(&self) -> ViewState {
ViewState {
stream_preview: self.stream_preview.clone(),
command_suggestions: if !self.busy && self.setup.is_none() {
self.suggestions()
} else {
Vec::new()
},
busy: self.busy,
busy_frame: self.busy_frame,
turn_activity: self.turn_activity.clone(),
model_label: self.model.provider_label(),
workspace_root: self.startup.workspace_root.clone(),
session_id: self.handles.session_id,
lines_count: self.lines.len(),
approval_mode: self
.settings
.snapshot()
.approval_mode()
.as_str()
.to_string(),
}
}
fn emit_system_banner(&mut self) {
self.push_system(format!(
"workspace: {}",
self.startup.workspace_root.display()
));
self.push_system(format!("model: {}", self.model.provider_label()));
self.push_system(format!("tools: {}", self.startup.tool_names.join(", ")));
if !self.startup.capability_commands.is_empty() {
let names: Vec<String> = self
.startup
.capability_commands
.iter()
.map(|c| format!("/{}", c.name))
.collect();
self.push_system(format!("commands: {}", names.join(", ")));
}
self.push_system("type /help for commands, Ctrl-C or Ctrl-D to exit".into());
}
fn push_user(&mut self, text: String) {
self.lines.push(ChatLine {
author: Author::User,
text,
});
}
fn push_system(&mut self, text: String) {
self.lines.push(ChatLine {
author: Author::System,
text,
});
}
pub async fn run<B>(&mut self, terminal: &mut Terminal<B>) -> Result<()>
where
B: Backend,
B::Error: std::error::Error + Send + Sync + 'static,
{
self.emit_replayed_transcript().await;
let mut io_failures = 0usize;
loop {
if self.busy {
self.busy_frame = self.busy_frame.wrapping_add(1);
}
match self.run_loop_iteration(terminal).await {
Ok(()) => io_failures = 0,
Err(err) => {
io_failures += 1;
if io_failures >= MAX_TERMINAL_IO_FAILURES {
return Err(err);
}
tracing::warn!(
"terminal i/o failed ({io_failures}/{MAX_TERMINAL_IO_FAILURES}): {err:#}"
);
}
}
if self.should_quit {
return Ok(());
}
}
}
async fn run_loop_iteration<B>(&mut self, terminal: &mut Terminal<B>) -> Result<()>
where
B: Backend,
B::Error: std::error::Error + Send + Sync + 'static,
{
self.flush_transcript(terminal)?;
terminal.draw(|f| draw(f, self))?;
if let Some(rx) = self.rx.as_mut() {
match rx.try_recv() {
Ok(TurnEvent::Lines(lines)) => {
self.lines.extend(lines);
return Ok(());
}
Ok(TurnEvent::Activity(activity)) => {
if !activity.fallback || self.turn_activity.is_none() {
self.turn_activity = Some(activity.text);
}
return Ok(());
}
Ok(TurnEvent::Stream(preview)) => {
self.stream_preview = preview;
return Ok(());
}
Ok(TurnEvent::Done) => {
self.busy = false;
self.busy_frame = 0;
self.turn_activity = None;
self.stream_preview = None;
self.rx = None;
return Ok(());
}
Ok(TurnEvent::Failed(err)) => {
self.busy = false;
self.busy_frame = 0;
self.turn_activity = None;
self.stream_preview = None;
self.rx = None;
self.push_system(format!("turn failed: {err}"));
return Ok(());
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
self.busy = false;
self.turn_activity = None;
self.stream_preview = None;
self.rx = None;
}
}
}
let mut applied_ui_command = false;
while let Ok(command) = self.ui_rx.try_recv() {
self.apply_ui_command(command);
applied_ui_command = true;
}
if applied_ui_command {
return Ok(());
}
let mut applied_model_discovery = false;
while let Ok(discovery) = self.models_rx.try_recv() {
self.apply_model_discovery(discovery);
applied_model_discovery = true;
}
if applied_model_discovery {
return Ok(());
}
let mut poll_timeout = Duration::from_millis(80);
while event::poll(poll_timeout)? {
poll_timeout = Duration::ZERO;
if let CrosstermEvent::Key(key) = event::read()? {
if key.kind == KeyEventKind::Release {
continue;
}
if key.code == KeyCode::Esc && self.handle_escape_prefixed_enter().await? {
continue;
}
self.handle_key(key).await;
}
if self.should_quit {
break;
}
}
Ok(())
}
async fn handle_escape_prefixed_enter(&mut self) -> Result<bool> {
if !event::poll(Duration::from_millis(25))? {
return Ok(false);
}
match event::read()? {
CrosstermEvent::Key(next) if next.kind == KeyEventKind::Release => Ok(false),
CrosstermEvent::Key(next) if next.code == KeyCode::Enter => {
self.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT))
.await;
Ok(true)
}
CrosstermEvent::Key(next) => {
self.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
.await;
self.handle_key(next).await;
Ok(true)
}
_ => {
self.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()))
.await;
Ok(true)
}
}
}
async fn emit_replayed_transcript(&mut self) {
if self.startup.replayed_events == 0 {
return;
}
let events = match self.handles.runtime.events().await {
Ok(events) => events,
Err(err) => {
self.push_system(format!("load replayed transcript: {err}"));
return;
}
};
let replayed_lines = events
.iter()
.take(self.startup.replayed_events)
.flat_map(lines_for_replayed_event)
.collect::<Vec<_>>();
self.lines.extend(replayed_lines);
}
fn flush_transcript<B>(&mut self, terminal: &mut Terminal<B>) -> Result<()>
where
B: Backend,
B::Error: std::error::Error + Send + Sync + 'static,
{
if self.printed_lines >= self.lines.len() {
return Ok(());
}
let width = terminal.size()?.width.saturating_sub(2).max(20) as usize;
let mut rendered: Vec<Line<'static>> = Vec::new();
for (index, chat) in self.lines[self.printed_lines..].iter().enumerate() {
append_chat_lines(&mut rendered, chat, width);
let absolute = self.printed_lines + index;
if should_insert_chat_gap(
&chat.author,
self.lines.get(absolute + 1).map(|line| &line.author),
) {
rendered.push(Line::from(""));
}
}
if rendered.is_empty() {
self.printed_lines = self.lines.len();
return Ok(());
}
for chunk in rendered.chunks(u16::MAX as usize) {
terminal.insert_before(chunk.len() as u16, |buf| {
Paragraph::new(chunk.to_vec()).render(buf.area, buf);
})?;
}
self.printed_lines = self.lines.len();
Ok(())
}
async fn handle_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => {
self.ctrl_c_exit = true;
self.should_quit = true;
return;
}
KeyCode::Char('d') => {
self.should_quit = true;
return;
}
_ => {}
}
}
if self.busy {
return;
}
if self.setup.is_some() {
self.handle_setup_key(key).await;
return;
}
match key.code {
KeyCode::Enter if key.modifiers == KeyModifiers::SHIFT => {
self.input.insert_newline();
}
KeyCode::Enter => {
self.submit_input().await;
}
KeyCode::Tab => {
if let Some(suggestion) = self.suggestions().first() {
self.set_input_text(suggestion.completion.clone());
} else {
let _ = self.input.input(key);
}
}
_ => {
let _ = self.input.input(normalize_printable_key(key));
}
}
}
fn suggestions(&self) -> Vec<CommandSuggestion> {
command_suggestions(self.suggestion_input(), &self.startup.capability_commands)
}
fn suggestion_input(&self) -> &str {
self.input
.lines()
.first()
.map(String::as_str)
.unwrap_or_default()
}
fn input_text(&self) -> String {
self.input.lines().join("\n")
}
fn set_input_text(&mut self, text: String) {
self.input = new_input_area(vec![text]);
self.input.move_cursor(CursorMove::End);
}
fn reset_input(&mut self) {
self.input = new_input_area(vec![String::new()]);
}
fn input_height(&self) -> u16 {
self.input.lines().len().clamp(1, MAX_INPUT_HEIGHT as usize) as u16
}
async fn submit_input(&mut self) {
let raw = self.input_text();
self.reset_input();
let text = raw.trim().to_string();
if let Some(rest) = text.strip_prefix('/') {
self.handle_command(rest).await;
return;
}
if text.is_empty() {
return;
}
self.push_user(text.clone());
self.start_turn(text);
}
async fn handle_command(&mut self, cmd: &str) {
let cmd = cmd.trim();
let mut parts = cmd.splitn(2, char::is_whitespace);
let head = parts.next().unwrap_or_default();
let arg = parts.next().unwrap_or_default().trim();
let name = if head == "exit" { "quit" } else { head };
if let Some(descriptor) = self
.startup
.capability_commands
.iter()
.find(|c| c.name == name)
.cloned()
{
self.invoke_capability_command(descriptor, arg.to_string())
.await;
} else {
self.push_system(format!("unknown command: /{head}"));
}
}
fn apply_ui_command(&mut self, command: UiCommand) {
match command {
UiCommand::ShowHelp => self.show_help(),
UiCommand::ShowTools => {
self.push_system(format!("tools: {}", self.startup.tool_names.join(", ")));
}
UiCommand::ShowMcp => {
if self.startup.mcp_server_names.is_empty() {
let global = crate::mcp_config::global_mcp_config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "the yolop config dir".to_string());
self.push_system(format!(
"no MCP servers configured (add them to .mcp.json in the workspace \
root or {global})"
));
} else {
self.push_system(format!(
"MCP servers: {}",
self.startup.mcp_server_names.join(", ")
));
}
}
UiCommand::ShowCwd => {
self.push_system(format!(
"workspace root: {}",
self.startup.workspace_root.display()
));
}
UiCommand::ClearTranscript => {
self.lines.clear();
self.printed_lines = 0;
self.emit_system_banner();
}
UiCommand::Quit => self.should_quit = true,
UiCommand::OpenModelOverlay { arg } => match arg {
Some(arg) => self.start_model_setup_with_arg(&arg),
None => self.start_model_setup(),
},
UiCommand::OpenEffortOverlay { arg } => {
self.start_effort_setup(arg.as_deref().unwrap_or(""))
}
}
}
fn show_help(&mut self) {
if !self.startup.capability_commands.is_empty() {
let caps = self
.startup
.capability_commands
.iter()
.map(capability_command_usage)
.collect::<Vec<_>>()
.join(" · ");
self.push_system(format!("commands: {caps}"));
}
self.push_system(format!(
"input: ←/→ edit · {} newline · scroll: use the terminal scrollback",
newline_shortcut_hint()
));
self.push_system("exit: Ctrl-C / Ctrl-D".into());
}
async fn invoke_capability_command(&mut self, descriptor: CommandDescriptor, args: String) {
let trimmed = args.trim();
let required_missing = descriptor
.args
.iter()
.any(|a| a.required && trimmed.is_empty());
if required_missing {
let needed: Vec<&str> = descriptor
.args
.iter()
.filter(|a| a.required)
.map(|a| a.name.as_str())
.collect();
self.push_system(format!(
"/{} requires: {}",
descriptor.name,
needed.join(", ")
));
return;
}
match descriptor.source {
CommandSource::System => {
if descriptor.name == "setup" && trimmed.is_empty() {
self.start_setup();
return;
}
let request = everruns_core::command::ExecuteCommandRequest {
name: descriptor.name.clone(),
arguments: if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
},
controls: None,
};
let result = self
.handles
.runtime
.execute_command(self.handles.session_id, request)
.await;
match result {
Ok(result) => {
if !result.message.is_empty() {
let prefix = if result.success { "" } else { "error: " };
self.push_system(format!("{prefix}{}", result.message));
}
}
Err(err) => self.push_system(format!("/{} failed: {err}", descriptor.name)),
}
}
CommandSource::Skill => {
let text = if trimmed.is_empty() {
format!("/{}", descriptor.name)
} else {
format!("/{} {trimmed}", descriptor.name)
};
self.push_user(text.clone());
self.start_turn(text);
}
}
}
fn start_setup(&mut self) {
self.setup = Some(SetupStep::Provider {
selected: self.current_provider_index(),
});
}
fn start_first_run_setup(&mut self) {
self.start_setup();
}
fn start_model_setup(&mut self) {
let provider = self.current_provider_name();
self.open_model_step(&provider);
}
fn start_model_setup_with_arg(&mut self, raw: &str) {
let spec = raw.trim();
if spec.is_empty() {
self.start_model_setup();
return;
}
let provider = self.current_provider_name();
self.request_model_discovery(&provider);
let options = self.model_options(&provider);
let selected = model_index_for_label(spec, &options);
let custom = if options
.get(selected)
.and_then(|option| option.spec.as_deref())
== Some(spec)
{
None
} else {
Some(spec.to_string())
};
self.setup = Some(SetupStep::PickModel {
provider,
selected,
custom,
error: None,
});
}
fn start_effort_setup(&mut self, raw: &str) {
let raw = raw.trim();
let selected = effort_index(raw).unwrap_or_else(|| self.current_effort_index());
self.setup = Some(SetupStep::PickEffort {
selected,
error: if raw.is_empty() || effort_index(raw).is_some() {
None
} else {
Some(format!("unknown effort: {raw}"))
},
});
}
fn current_effort_index(&self) -> usize {
let label = self.model.provider_label();
if !label.starts_with("openai/")
&& !label.starts_with("openrouter/")
&& !label.starts_with("custom/")
{
return effort_index("medium").unwrap_or(0);
}
label
.split_whitespace()
.nth(1)
.and_then(effort_index)
.unwrap_or_else(|| effort_index("medium").unwrap_or(0))
}
fn current_provider_name(&self) -> String {
self.model
.provider_label()
.split('/')
.next()
.unwrap_or("openai")
.trim()
.to_string()
}
fn current_provider_index(&self) -> usize {
let provider = self.current_provider_name();
PROVIDER_OPTIONS
.iter()
.position(|option| option.name == provider)
.unwrap_or(0)
}
fn provider_label(name: &str) -> &'static str {
PROVIDER_OPTIONS
.iter()
.find(|option| option.name == name)
.map(|option| option.label)
.unwrap_or("provider")
}
fn provider_env_names(provider: &str) -> &'static [&'static str] {
match provider {
"openai" => &["OPENAI_API_KEY"],
"anthropic" => &["ANTHROPIC_API_KEY"],
"google" => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
"openrouter" => &["OPENROUTER_API_KEY"],
"ollama" => &["OLLAMA_BASE_URL", "OLLAMA_API_KEY"],
"custom" => &["CUSTOM_API_KEY"],
_ => &[],
}
}
fn provider_status(settings: &crate::settings::Settings, provider: &str) -> (bool, String) {
match provider {
"llmsim" | "ollama" => (true, "✓ no key needed".to_string()),
"custom" => {
if crate::runtime::custom_base_url(settings).is_some() {
(true, "✓ base URL set".to_string())
} else {
(false, "needs base URL".to_string())
}
}
_ => {
if let Some(env) = Self::detected_env_var(provider) {
(true, format!("✓ {env}"))
} else if settings.has_token(provider) {
(true, "✓ saved key".to_string())
} else {
(false, "needs API key".to_string())
}
}
}
}
fn detected_env_var(provider: &str) -> Option<&'static str> {
Self::provider_env_names(provider)
.iter()
.copied()
.find(|name| {
std::env::var(name)
.map(|value| !value.is_empty())
.unwrap_or(false)
})
}
fn credential_options(provider: &str) -> Vec<CredentialOption> {
let env_names = Self::provider_env_names(provider);
let env_label = if provider == "custom" {
"Continue without key".to_string()
} else {
match env_names {
[] => "Use environment".to_string(),
[one] => format!("Use {one} from environment"),
many => format!("Use {} from environment", many.join(" / ")),
}
};
let env_hint = Self::detected_env_var(provider)
.map(|name| format!("{name} detected"))
.unwrap_or_else(|| {
if provider == "custom" {
"fine for endpoints without auth".to_string()
} else {
"not detected yet".to_string()
}
});
vec![
CredentialOption {
id: CredentialAction::UseEnv,
label: env_label,
hint: env_hint,
},
CredentialOption {
id: CredentialAction::PasteKey,
label: "Paste API key".to_string(),
hint: "saved to settings.toml".to_string(),
},
CredentialOption {
id: CredentialAction::Skip,
label: "Skip for now".to_string(),
hint: "leave setup unchanged".to_string(),
},
CredentialOption {
id: CredentialAction::ClearSaved,
label: "Clear saved key".to_string(),
hint: "remove this provider token".to_string(),
},
]
}
fn model_options(&self, provider: &str) -> Vec<ModelOption> {
match self.model_catalog.get(provider) {
Some(options) => options.clone(),
None => Self::fallback_model_options(provider),
}
}
fn fallback_model_options(provider: &str) -> Vec<ModelOption> {
let mut models = match provider {
"openai" => vec![
ModelOption {
spec: Some("gpt-5.5".to_string()),
label: "gpt-5.5".to_string(),
hint: "frontier model for complex coding".to_string(),
},
ModelOption {
spec: Some("gpt-5.4".to_string()),
label: "gpt-5.4".to_string(),
hint: "strong everyday model".to_string(),
},
ModelOption {
spec: Some("gpt-5.4-mini".to_string()),
label: "gpt-5.4-mini".to_string(),
hint: "fast and cost-efficient".to_string(),
},
ModelOption {
spec: Some("gpt-5.3-codex".to_string()),
label: "gpt-5.3-codex".to_string(),
hint: "coding-optimized model".to_string(),
},
ModelOption {
spec: Some("gpt-5.2".to_string()),
label: "gpt-5.2".to_string(),
hint: "optimized for long-running agents".to_string(),
},
],
"anthropic" => vec![
ModelOption {
spec: Some("claude-sonnet-4-5".to_string()),
label: "claude-sonnet-4-5".to_string(),
hint: "best default Claude model".to_string(),
},
ModelOption {
spec: Some("claude-opus-4-5".to_string()),
label: "claude-opus-4-5".to_string(),
hint: "more capable for complex work".to_string(),
},
ModelOption {
spec: Some("claude-haiku-4-5".to_string()),
label: "claude-haiku-4-5".to_string(),
hint: "fast answers".to_string(),
},
ModelOption {
spec: Some("claude-sonnet-4-6".to_string()),
label: "claude-sonnet-4-6".to_string(),
hint: "newer Sonnet option".to_string(),
},
ModelOption {
spec: Some("claude-fable-5".to_string()),
label: "claude-fable-5".to_string(),
hint: "most powerful Claude model".to_string(),
},
],
"google" => vec![
ModelOption {
spec: Some("gemini-2.5-flash".to_string()),
label: "gemini-2.5-flash".to_string(),
hint: "fast Gemini default".to_string(),
},
ModelOption {
spec: Some("gemini-2.5-pro".to_string()),
label: "gemini-2.5-pro".to_string(),
hint: "more capable Gemini model".to_string(),
},
],
"openrouter" => vec![
ModelOption {
spec: Some("openai/gpt-5.2".to_string()),
label: "openai/gpt-5.2".to_string(),
hint: "default OpenRouter model".to_string(),
},
ModelOption {
spec: Some("nvidia/nemotron-3-super-120b-a12b high".to_string()),
label: "nvidia/nemotron-3-super-120b-a12b".to_string(),
hint: "reasoning model through OpenRouter".to_string(),
},
ModelOption {
spec: Some("anthropic/claude-sonnet-4-5".to_string()),
label: "anthropic/claude-sonnet-4-5".to_string(),
hint: "Claude through OpenRouter".to_string(),
},
],
"ollama" => vec![ModelOption {
spec: Some("llama3.2".to_string()),
label: "llama3.2".to_string(),
hint: "local default model".to_string(),
}],
"custom" => Vec::new(),
_ => vec![ModelOption {
spec: Some("llmsim-yolop".to_string()),
label: "llmsim-yolop".to_string(),
hint: "offline demo model".to_string(),
}],
};
models.push(custom_model_option());
models
}
fn is_fetching_models(&self, provider: &str) -> bool {
self.model_fetches_in_flight.contains(provider)
}
fn request_model_discovery(&mut self, provider: &str) {
if !self.model_discovery_enabled
|| provider == "llmsim"
|| self.model_catalog.contains_key(provider)
|| self.model_fetches_in_flight.contains(provider)
{
return;
}
let choice = if self.current_provider_name() == provider {
self.model.provider_choice()
} else {
match crate::runtime::ProviderChoice::default_for_provider_name(provider) {
Ok(choice) => choice,
Err(_) => return,
}
};
self.model_fetches_in_flight.insert(provider.to_string());
let tx = self.models_tx.clone();
let settings = self.settings.clone();
let provider_name = provider.to_string();
tokio::spawn(async move {
let result = match tokio::time::timeout(
Duration::from_secs(10),
crate::capabilities::model_discovery::discover_provider_models(
&choice,
&settings.snapshot(),
),
)
.await
{
Ok(Ok(Some(models))) => Ok(Some(model_options_from_discovered(models))),
Ok(Ok(None)) => Ok(None),
Ok(Err(err)) => Err(err.to_string()),
Err(_) => Err("models API request timed out".to_string()),
};
let _ = tx.send(ModelDiscovery {
provider: provider_name,
result,
});
});
}
fn apply_model_discovery(&mut self, discovery: ModelDiscovery) {
self.model_fetches_in_flight.remove(&discovery.provider);
match discovery.result {
Ok(Some(options)) if options.len() > 1 => {
self.model_catalog
.insert(discovery.provider.clone(), options);
if let Some(SetupStep::PickModel {
provider,
custom,
error,
..
}) = self.setup.clone()
&& provider == discovery.provider
&& custom.is_none()
{
let selected = model_index_for_label(
&self.model.model_id(),
&self.model_options(&provider),
);
self.setup = Some(SetupStep::PickModel {
provider,
selected,
custom,
error,
});
}
}
Ok(_) => {
self.model_catalog.insert(
discovery.provider.clone(),
Self::fallback_model_options(&discovery.provider),
);
}
Err(mut err) => {
if let Some(SetupStep::PickModel {
provider, error, ..
}) = self.setup.as_mut()
&& *provider == discovery.provider
{
err.truncate(120);
*error = Some(format!("model list unavailable: {err}"));
}
}
}
}
async fn handle_setup_key(&mut self, key: KeyEvent) {
let Some(step) = self.setup.clone() else {
return;
};
match step {
SetupStep::Provider { selected } => {
self.handle_provider_key(key, selected).await;
}
SetupStep::BaseUrlInput { value, .. } => {
self.handle_base_url_key(key, value).await;
}
SetupStep::Credential {
provider, selected, ..
} => {
self.handle_credential_key(key, provider, selected).await;
}
SetupStep::TokenInput {
provider, token, ..
} => {
self.handle_token_key(key, provider, token).await;
}
SetupStep::PickModel {
provider,
selected,
custom,
..
} => {
self.handle_model_key(key, provider, selected, custom).await;
}
SetupStep::PickEffort { selected, .. } => {
self.handle_effort_key(key, selected).await;
}
}
}
async fn handle_provider_key(&mut self, key: KeyEvent, selected: usize) {
match key.code {
KeyCode::Esc => {
self.setup = None;
}
KeyCode::Up | KeyCode::Char('k') => {
self.setup = Some(SetupStep::Provider {
selected: selected.saturating_sub(1),
});
}
KeyCode::Down | KeyCode::Char('j') => {
self.setup = Some(SetupStep::Provider {
selected: (selected + 1).min(PROVIDER_OPTIONS.len().saturating_sub(1)),
});
}
KeyCode::Char('c') => {
self.open_provider_config(selected);
}
KeyCode::Char(ch) if ch.is_ascii_digit() => {
if let Some(index) = digit_index(ch, PROVIDER_OPTIONS.len()) {
self.confirm_provider(index).await;
}
}
KeyCode::Enter => {
self.confirm_provider(selected).await;
}
_ => {}
}
}
async fn confirm_provider(&mut self, selected: usize) {
let option = PROVIDER_OPTIONS
.get(selected)
.unwrap_or(&PROVIDER_OPTIONS[0]);
if option.name == "llmsim" {
match self.run_setup_command(Some("provider llmsim")).await {
Ok(()) => {
self.setup = None;
self.push_system("setup complete: offline demo mode".into());
}
Err(error) => {
self.setup = Some(SetupStep::Provider { selected });
self.push_system(format!("setup failed: {error}"));
}
}
return;
}
let (connected, _) = Self::provider_status(&self.settings.snapshot(), option.name);
if !connected {
self.open_provider_config(selected);
return;
}
if option.name == "custom" {
let _ = self.run_setup_command(Some("provider custom")).await;
self.open_model_step("custom");
return;
}
match self
.run_setup_command(Some(&format!("provider {}", option.name)))
.await
{
Ok(()) => self.open_model_step(option.name),
Err(error) => {
self.setup = Some(SetupStep::Credential {
provider: option.name.to_string(),
selected: 0,
error: Some(error),
});
}
}
}
fn open_provider_config(&mut self, selected: usize) {
let option = PROVIDER_OPTIONS
.get(selected)
.unwrap_or(&PROVIDER_OPTIONS[0]);
match option.name {
"llmsim" => {}
"custom" => self.open_base_url_step(),
_ => {
self.setup = Some(SetupStep::Credential {
provider: option.name.to_string(),
selected: 0,
error: None,
});
}
}
}
fn open_base_url_step(&mut self) {
let value = crate::runtime::custom_base_url(&self.settings.snapshot()).unwrap_or_default();
self.setup = Some(SetupStep::BaseUrlInput { value, error: None });
}
fn open_model_step(&mut self, provider: &str) {
self.request_model_discovery(provider);
let selected = model_index_for_label(&self.model.model_id(), &self.model_options(provider));
self.setup = Some(SetupStep::PickModel {
provider: provider.to_string(),
selected,
custom: None,
error: None,
});
}
fn custom_model_prefill(&self) -> String {
if self.current_provider_name() == "custom" {
return self.model.model_id();
}
self.settings
.snapshot()
.model_for("custom")
.unwrap_or_default()
.to_string()
}
async fn handle_base_url_key(&mut self, key: KeyEvent, mut value: String) {
match key.code {
KeyCode::Esc => {
self.setup = Some(SetupStep::Provider {
selected: PROVIDER_OPTIONS
.iter()
.position(|option| option.name == "custom")
.unwrap_or(0),
});
}
KeyCode::Enter => {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
self.setup = Some(SetupStep::BaseUrlInput {
value,
error: Some(
"enter a base URL like http://localhost:8000/v1, or press Esc"
.to_string(),
),
});
return;
}
match self
.run_setup_command(Some(&format!("url custom {trimmed}")))
.await
{
Ok(()) => {
self.setup = Some(SetupStep::Credential {
provider: "custom".to_string(),
selected: 0,
error: None,
});
}
Err(error) => {
self.setup = Some(SetupStep::BaseUrlInput {
value,
error: Some(error),
});
}
}
}
KeyCode::Backspace => {
value.pop();
self.setup = Some(SetupStep::BaseUrlInput { value, error: None });
}
KeyCode::Char(_) => {
if let KeyCode::Char(ch) = normalize_printable_key(key).code {
value.push(ch);
}
self.setup = Some(SetupStep::BaseUrlInput { value, error: None });
}
_ => {}
}
}
async fn handle_credential_key(&mut self, key: KeyEvent, provider: String, selected: usize) {
let options = Self::credential_options(&provider);
match key.code {
KeyCode::Esc => {
self.setup = Some(SetupStep::Provider {
selected: PROVIDER_OPTIONS
.iter()
.position(|option| option.name == provider)
.unwrap_or(0),
});
}
KeyCode::Up | KeyCode::Char('k') => {
self.setup = Some(SetupStep::Credential {
provider,
selected: selected.saturating_sub(1),
error: None,
});
}
KeyCode::Down | KeyCode::Char('j') => {
self.setup = Some(SetupStep::Credential {
provider,
selected: (selected + 1).min(options.len().saturating_sub(1)),
error: None,
});
}
KeyCode::Char(ch) if ch.is_ascii_digit() => {
if let Some(index) = digit_index(ch, options.len()) {
self.confirm_credential(provider, index).await;
}
}
KeyCode::Enter => {
self.confirm_credential(provider, selected).await;
}
_ => {}
}
}
async fn confirm_credential(&mut self, provider: String, selected: usize) {
let options = Self::credential_options(&provider);
let action = options
.get(selected)
.map(|option| option.id)
.unwrap_or(CredentialAction::UseEnv);
match action {
CredentialAction::UseEnv => {
if provider == "custom" {
self.open_model_step("custom");
return;
}
match self
.run_setup_command(Some(&format!("provider {provider}")))
.await
{
Ok(()) => self.open_model_step(&provider),
Err(error) => {
self.setup = Some(SetupStep::Credential {
provider,
selected,
error: Some(error),
});
}
}
}
CredentialAction::PasteKey => {
self.setup = Some(SetupStep::TokenInput {
provider,
token: String::new(),
error: None,
});
}
CredentialAction::Skip => {
self.setup = None;
self.push_system("setup skipped".into());
}
CredentialAction::ClearSaved => {
let result = self
.run_setup_command(Some(&format!("token {provider} clear")))
.await;
match result {
Ok(()) => {
self.setup = None;
self.push_system(format!(
"setup complete: cleared saved key for {provider}"
));
}
Err(error) => {
self.setup = Some(SetupStep::Credential {
provider,
selected,
error: Some(error),
});
}
}
}
}
}
async fn handle_token_key(&mut self, key: KeyEvent, provider: String, mut token: String) {
match key.code {
KeyCode::Esc => {
self.setup = Some(SetupStep::Credential {
provider,
selected: 1,
error: None,
});
}
KeyCode::Enter => {
let trimmed = token.trim().to_string();
if trimmed.is_empty() {
self.setup = Some(SetupStep::TokenInput {
provider,
token,
error: Some("API key is empty — paste a key, or press Esc".to_string()),
});
return;
}
let save_arg = format!("token {provider} {trimmed}");
if let Err(error) = self.run_setup_command(Some(&save_arg)).await {
self.setup = Some(SetupStep::TokenInput {
provider,
token: String::new(),
error: Some(error),
});
return;
}
if provider == "custom" {
self.open_model_step("custom");
return;
}
match self
.run_setup_command(Some(&format!("provider {provider}")))
.await
{
Ok(()) => self.open_model_step(&provider),
Err(error) => {
self.setup = Some(SetupStep::TokenInput {
provider,
token: String::new(),
error: Some(error),
});
}
}
}
KeyCode::Backspace => {
token.pop();
self.setup = Some(SetupStep::TokenInput {
provider,
token,
error: None,
});
}
KeyCode::Char(_) => {
if let KeyCode::Char(ch) = normalize_printable_key(key).code {
token.push(ch);
}
self.setup = Some(SetupStep::TokenInput {
provider,
token,
error: None,
});
}
_ => {}
}
}
async fn handle_model_key(
&mut self,
key: KeyEvent,
provider: String,
selected: usize,
custom: Option<String>,
) {
let options = self.model_options(&provider);
if let Some(mut value) = custom {
match key.code {
KeyCode::Esc => {
self.setup = Some(SetupStep::PickModel {
provider,
selected,
custom: None,
error: None,
});
}
KeyCode::Enter => {
let value = value.trim().to_string();
if value.is_empty() {
self.setup = Some(SetupStep::PickModel {
provider,
selected,
custom: Some(String::new()),
error: Some("paste a model id, or press Esc to go back".to_string()),
});
return;
}
self.save_model_and_finish(&provider, &value, selected)
.await;
}
KeyCode::Backspace => {
value.pop();
self.setup = Some(SetupStep::PickModel {
provider,
selected,
custom: Some(value),
error: None,
});
}
KeyCode::Char(_) => {
if let KeyCode::Char(ch) = normalize_printable_key(key).code {
value.push(ch);
}
self.setup = Some(SetupStep::PickModel {
provider,
selected,
custom: Some(value),
error: None,
});
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc => {
self.setup = Some(SetupStep::Provider {
selected: PROVIDER_OPTIONS
.iter()
.position(|option| option.name == provider)
.unwrap_or(0),
});
}
KeyCode::Up | KeyCode::Char('k') => {
self.setup = Some(SetupStep::PickModel {
provider,
selected: selected.saturating_sub(1),
custom: None,
error: None,
});
}
KeyCode::Down | KeyCode::Char('j') => {
self.setup = Some(SetupStep::PickModel {
provider,
selected: (selected + 1).min(options.len().saturating_sub(1)),
custom: None,
error: None,
});
}
KeyCode::Char(ch) if ch.is_ascii_digit() => {
if let Some(index) = digit_index(ch, options.len()) {
self.confirm_model(provider, index).await;
}
}
KeyCode::Enter => {
self.confirm_model(provider, selected).await;
}
_ => {}
}
}
async fn confirm_model(&mut self, provider: String, selected: usize) {
let options = self.model_options(&provider);
let Some(option) = options.get(selected) else {
self.setup = Some(SetupStep::PickModel {
provider,
selected: 0,
custom: None,
error: None,
});
return;
};
if let Some(spec) = &option.spec {
self.save_model_and_finish(&provider, spec, selected).await;
} else {
let prefill = if provider == "custom" {
self.custom_model_prefill()
} else {
String::new()
};
self.setup = Some(SetupStep::PickModel {
provider,
selected,
custom: Some(prefill),
error: None,
});
}
}
async fn save_model_and_finish(&mut self, provider: &str, spec: &str, selected: usize) {
let command = if provider == "custom" {
format!("provider custom {spec}")
} else {
format!("model {spec}")
};
match self.run_setup_command(Some(&command)).await {
Ok(()) => {
self.setup = None;
self.push_system(format!("setup complete: {}", self.model.provider_label()));
}
Err(error) => {
self.setup = Some(SetupStep::PickModel {
provider: provider.to_string(),
selected,
custom: None,
error: Some(error),
});
}
}
}
async fn handle_effort_key(&mut self, key: KeyEvent, selected: usize) {
match key.code {
KeyCode::Esc => {
self.setup = None;
}
KeyCode::Up | KeyCode::Char('k') => {
self.setup = Some(SetupStep::PickEffort {
selected: selected.saturating_sub(1),
error: None,
});
}
KeyCode::Down | KeyCode::Char('j') => {
self.setup = Some(SetupStep::PickEffort {
selected: (selected + 1).min(EFFORT_OPTIONS.len().saturating_sub(1)),
error: None,
});
}
KeyCode::Char(ch) if ch.is_ascii_digit() => {
if let Some(index) = digit_index(ch, EFFORT_OPTIONS.len()) {
self.confirm_effort(index).await;
}
}
KeyCode::Enter => {
self.confirm_effort(selected).await;
}
_ => {}
}
}
async fn confirm_effort(&mut self, selected: usize) {
let Some(option) = EFFORT_OPTIONS.get(selected) else {
self.setup = Some(SetupStep::PickEffort {
selected: 0,
error: None,
});
return;
};
match self
.run_setup_command(Some(&format!("effort {}", option.value)))
.await
{
Ok(()) => {
self.setup = None;
self.push_system(format!("setup complete: {}", self.model.provider_label()));
}
Err(error) => {
self.setup = Some(SetupStep::PickEffort {
selected,
error: Some(error),
});
}
}
}
async fn execute_setup_command(
&mut self,
arg: Option<&str>,
) -> Result<everruns_core::command::CommandResult, String> {
let request = everruns_core::command::ExecuteCommandRequest {
name: "setup".to_string(),
arguments: arg.map(str::to_string),
controls: None,
};
self.handles
.runtime
.execute_command(self.handles.session_id, request)
.await
.map_err(|err| err.to_string())
}
async fn run_setup_command(&mut self, arg: Option<&str>) -> Result<(), String> {
match self.execute_setup_command(arg).await {
Ok(result) if result.success => Ok(()),
Ok(result) => Err(result.message),
Err(err) => Err(err),
}
}
fn start_turn(&mut self, prompt: String) {
let handles = self.handles.clone();
let model = self.model.clone();
let (tx, rx) = mpsc::unbounded_channel::<TurnEvent>();
self.rx = Some(rx);
self.busy = true;
self.turn_activity = None;
self.stream_preview = None;
let mut live = handles.events.subscribe();
tokio::spawn(async move {
let session_id = handles.session_id;
let before = match handles.runtime.messages(session_id).await {
Ok(m) => m.len(),
Err(e) => {
let _ = tx.send(TurnEvent::Failed(format!("load history: {e}")));
let _ = tx.send(TurnEvent::Done);
return;
}
};
let events_before = match handles.runtime.events().await {
Ok(e) => e.len(),
Err(_) => 0,
};
let input = model.input_message(prompt);
let runtime = handles.runtime.clone();
let turn = tokio::spawn(async move { runtime.run_turn(session_id, input).await });
let mut emitted_events = HashSet::new();
let mut delta_router = DeltaRouter::default();
loop {
tokio::select! {
biased;
recv = live.recv() => match recv {
Ok(event) => {
if event.session_id != session_id {
continue;
}
handle_live_event(
&event,
&mut emitted_events,
&mut delta_router,
&tx,
);
}
Err(broadcast::error::RecvError::Lagged(_)) => {
live = handles.events.subscribe();
catch_up_events(
&handles,
events_before,
&mut emitted_events,
&tx,
)
.await;
}
Err(broadcast::error::RecvError::Closed) => break,
},
_ = tokio::time::sleep(Duration::from_millis(200)) => {
if turn.is_finished() {
break;
}
}
}
}
catch_up_events(&handles, events_before, &mut emitted_events, &tx).await;
let _ = tx.send(TurnEvent::Stream(None));
let result = match turn.await {
Ok(result) => result,
Err(e) => {
let _ = tx.send(TurnEvent::Failed(format!("turn task: {e}")));
let _ = tx.send(TurnEvent::Done);
return;
}
};
let response = match result {
Ok(r) => r,
Err(e) => {
let _ = tx.send(TurnEvent::Failed(format!("{e}")));
let _ = tx.send(TurnEvent::Done);
return;
}
};
let messages = handles
.runtime
.messages(session_id)
.await
.unwrap_or_default();
let mut out = Vec::new();
for msg in messages.iter().skip(before) {
if msg.role == MessageRole::Agent
&& !msg.has_tool_calls()
&& let Some(text) = msg.text()
{
let trimmed = text.trim();
if !trimmed.is_empty() {
out.push(ChatLine {
author: Author::Assistant,
text: trimmed.to_string(),
});
}
}
}
if out.is_empty() && !response.response.is_empty() {
out.push(ChatLine {
author: Author::Assistant,
text: response.response,
});
}
if !response.success
&& let Some(err) = response.error
{
out.push(ChatLine {
author: Author::System,
text: format!("turn error: {err}"),
});
}
let _ = tx.send(TurnEvent::Lines(out));
let _ = tx.send(TurnEvent::Done);
});
}
}
#[derive(Default)]
pub(crate) struct DeltaRouter {
last_assistant_turn: Option<everruns_core::typed_id::TurnId>,
last_thinking_turn: Option<everruns_core::typed_id::TurnId>,
last_tool_call: Option<String>,
}
pub(crate) fn handle_live_event(
event: &RuntimeEvent,
emitted_events: &mut HashSet<String>,
router: &mut DeltaRouter,
tx: &mpsc::UnboundedSender<TurnEvent>,
) {
if !emitted_events.insert(event.id.to_string()) {
return;
}
match &event.data {
EventData::OutputMessageDelta(data) => {
router.last_assistant_turn = Some(data.turn_id);
let _ = tx.send(TurnEvent::Stream(Some(StreamPreview {
kind: StreamKind::Assistant,
text: data.accumulated.clone(),
})));
return;
}
EventData::OutputMessageCompleted(_) | EventData::OutputMessageReplaced(_)
if router.last_assistant_turn.is_some() =>
{
router.last_assistant_turn = None;
let _ = tx.send(TurnEvent::Stream(None));
}
EventData::ReasonThinkingDelta(data) => {
router.last_thinking_turn = Some(data.turn_id);
let _ = tx.send(TurnEvent::Stream(Some(StreamPreview {
kind: StreamKind::Thinking,
text: data.accumulated.clone(),
})));
return;
}
EventData::ReasonThinkingCompleted(_) if router.last_thinking_turn.is_some() => {
router.last_thinking_turn = None;
let _ = tx.send(TurnEvent::Stream(None));
}
EventData::ToolOutputDelta(data) => {
router.last_tool_call = Some(data.tool_call_id.clone());
let text = format!(
"{} [{}] {}",
data.tool_name,
data.stream,
data.delta.trim_end()
);
let _ = tx.send(TurnEvent::Stream(Some(StreamPreview {
kind: StreamKind::Tool,
text,
})));
return;
}
EventData::ToolCompleted(data)
if router.last_tool_call.as_deref() == Some(data.tool_call_id.as_str()) =>
{
router.last_tool_call = None;
let _ = tx.send(TurnEvent::Stream(None));
}
_ => {}
}
if let Some(activity) = status_for_event(event) {
let _ = tx.send(TurnEvent::Activity(activity));
}
let lines = lines_for_event(event);
if !lines.is_empty() {
let _ = tx.send(TurnEvent::Lines(lines));
}
}
async fn catch_up_events(
handles: &RuntimeHandles,
events_before: usize,
emitted_events: &mut HashSet<String>,
tx: &mpsc::UnboundedSender<TurnEvent>,
) {
let events = handles.runtime.events().await.unwrap_or_default();
let mut lines = Vec::new();
for event in events.iter().skip(events_before) {
let event_id = event.id.to_string();
if !emitted_events.insert(event_id) {
continue;
}
if let Some(activity) = status_for_event(event) {
let _ = tx.send(TurnEvent::Activity(activity));
}
lines.extend(lines_for_event(event));
}
if !lines.is_empty() {
let _ = tx.send(TurnEvent::Lines(lines));
}
}
pub fn lines_for_event(event: &RuntimeEvent) -> Vec<ChatLine> {
match &event.data {
EventData::ReasonStarted(_) => Vec::new(),
EventData::ReasonCompleted(data) => {
if data.success && data.has_tool_calls {
let mut lines = Vec::new();
if let Some(text) = data
.text_preview
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
{
lines.push(ChatLine {
author: Author::Narration,
text: text.to_string(),
});
}
lines
} else {
Vec::new()
}
}
EventData::ReasonItem(data) => data
.summary
.iter()
.filter_map(|segment| {
let trimmed = segment.trim();
(!trimmed.is_empty()).then(|| ChatLine {
author: Author::Narration,
text: trimmed.to_string(),
})
})
.collect(),
EventData::OutputMessageCompleted(_) => Vec::new(),
EventData::ToolCompleted(data) => {
if data.tool_name == "write_todos" {
return todo_lines_for_result(data);
}
let marker = if data.success { "✓" } else { "✗" };
let label = data
.narration
.as_deref()
.or(data.display_name.as_deref())
.unwrap_or(data.tool_name.as_str());
let summary = summarize_tool_result(data);
let mut lines = vec![ChatLine {
author: Author::Tool,
text: if summary.is_empty() {
format!("{marker} {label}")
} else {
format!("{marker} {label} {summary}")
},
}];
if data.tool_name == "edit_file"
&& let Some(diff) = extract_field(data, "diff")
{
for line in diff.lines().take(40) {
lines.push(ChatLine {
author: Author::Diff,
text: line.to_string(),
});
}
}
lines
}
_ => Vec::new(),
}
}
fn lines_for_replayed_event(event: &RuntimeEvent) -> Vec<ChatLine> {
match &event.data {
EventData::InputMessage(data) => message_line(Author::User, &data.message)
.into_iter()
.collect(),
EventData::OutputMessageCompleted(data) => {
if data.message.role == MessageRole::Agent {
message_line(Author::Assistant, &data.message)
.into_iter()
.collect()
} else {
Vec::new()
}
}
_ => lines_for_event(event),
}
}
fn message_line(author: Author, message: &Message) -> Option<ChatLine> {
let text = message.text()?;
let text = text.trim();
if text.is_empty() {
return None;
}
Some(ChatLine {
author,
text: text.to_string(),
})
}
pub fn status_for_event(event: &RuntimeEvent) -> Option<ActivityStatus> {
match &event.data {
EventData::ReasonStarted(_) => Some(fallback_status("thinking")),
EventData::ReasonCompleted(data) => {
if !data.success {
let err = data.error.as_deref().unwrap_or("reasoning failed");
return Some(activity_status(format!(
"reasoning failed: {}",
first_line(err, 100)
)));
}
data.has_tool_calls
.then(|| activity_status(format!("planned {} tool call(s)", data.tool_call_count)))
}
EventData::ActStarted(data) => data
.headline
.clone()
.or_else(|| Some(format!("running {} tool(s)", data.tool_calls.len())))
.map(activity_status),
EventData::ActCompleted(data) => data
.headline
.clone()
.or_else(|| {
Some(format!(
"tools finished: {} ok, {} failed",
data.success_count, data.error_count
))
})
.map(activity_status),
EventData::ToolStarted(data) => Some(activity_status(format!(
"→ {}",
data.narration
.as_deref()
.or(data.display_name.as_deref())
.unwrap_or(data.tool_call.name.as_str())
))),
EventData::ToolProgress(data) => Some(activity_status(format!(
"… {}: {}",
data.display_name
.as_deref()
.unwrap_or(data.tool_name.as_str()),
first_line(&data.message, 100)
))),
EventData::ToolCallRequested(data) => Some(activity_status(format!(
"waiting for {} client tool result(s)",
data.tool_calls.len()
))),
EventData::OutputMessageStarted(data) => {
let iteration = data.iteration.unwrap_or(1);
Some(activity_status(format!(
"iteration {iteration}: writing response"
)))
}
EventData::ReasonThinkingStarted(_) => Some(fallback_status("thinking deeply")),
EventData::TurnCancelled(_) => Some(activity_status("turn cancelled")),
EventData::TurnFailed(data) => Some(activity_status(format!(
"turn failed: {}",
first_line(&data.error, 100)
))),
_ => None,
}
}
fn activity_status(text: impl Into<String>) -> ActivityStatus {
ActivityStatus {
text: text.into(),
fallback: false,
}
}
fn fallback_status(text: impl Into<String>) -> ActivityStatus {
ActivityStatus {
text: text.into(),
fallback: true,
}
}
fn command_suggestions(
input: &str,
capability_commands: &[CommandDescriptor],
) -> Vec<CommandSuggestion> {
let Some(rest) = input.strip_prefix('/') else {
return Vec::new();
};
if let Some((head, arg_prefix)) = rest.split_once(' ')
&& let Some(descriptor) = capability_commands.iter().find(|c| c.name == head)
&& let Some(arg) = descriptor.args.first()
&& !arg.suggestions.is_empty()
{
let prefix = arg_prefix.trim_start();
return arg
.suggestions
.iter()
.filter(|s| s.starts_with(prefix))
.take(8)
.map(|s| CommandSuggestion {
completion: format!("/{} {s}", descriptor.name),
label: format!("/{} {s} {}", descriptor.name, descriptor.description),
})
.collect();
}
let mut out: Vec<CommandSuggestion> = Vec::new();
for descriptor in capability_commands {
if !descriptor.name.starts_with(rest) {
continue;
}
let usage = capability_command_usage(descriptor);
let completion = if descriptor.args.is_empty() {
format!("/{}", descriptor.name)
} else {
format!("/{} ", descriptor.name)
};
out.push(CommandSuggestion {
completion,
label: format!("{usage} {}", descriptor.description),
});
}
out.truncate(12);
out
}
fn capability_command_usage(descriptor: &CommandDescriptor) -> String {
if descriptor.args.is_empty() {
format!("/{}", descriptor.name)
} else {
let args = descriptor
.args
.iter()
.map(|a| {
if a.required {
format!("<{}>", a.name)
} else {
format!("[{}]", a.name)
}
})
.collect::<Vec<_>>()
.join(" ");
format!("/{} {args}", descriptor.name)
}
}
fn new_input_area(lines: Vec<String>) -> TextArea<'static> {
let mut input = TextArea::new(lines);
input.set_wrap_mode(WrapMode::Word);
input.set_style(Style::default().fg(TEXT_PRIMARY));
input.set_cursor_line_style(Style::default());
input.set_cursor_style(Style::default().add_modifier(Modifier::REVERSED));
input
}
fn normalize_printable_key(mut key: KeyEvent) -> KeyEvent {
if !key.modifiers.contains(KeyModifiers::SHIFT)
|| key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return key;
}
let KeyCode::Char(ch) = key.code else {
return key;
};
let Some(ch) = shifted_char(ch) else {
return key;
};
key.code = KeyCode::Char(ch);
key.modifiers.remove(KeyModifiers::SHIFT);
key
}
fn shifted_char(ch: char) -> Option<char> {
let shifted = match ch {
'a'..='z' => ch.to_ascii_uppercase(),
'A'..='Z' | ' ' => ch,
'`' => '~',
'1' => '!',
'2' => '@',
'3' => '#',
'4' => '$',
'5' => '%',
'6' => '^',
'7' => '&',
'8' => '*',
'9' => '(',
'0' => ')',
'-' => '_',
'=' => '+',
'[' => '{',
']' => '}',
'\\' => '|',
';' => ':',
'\'' => '"',
',' => '<',
'.' => '>',
'/' => '?',
'~' | '!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '_' | '+' | '{' | '}'
| '|' | ':' | '"' | '<' | '>' | '?' => ch,
_ => return None,
};
Some(shifted)
}
fn result_value(data: &ToolCompletedData) -> Option<Value> {
let parts = data.result.as_ref()?;
for part in parts {
if let ContentPart::Text(t) = part
&& let Ok(v) = serde_json::from_str::<Value>(&t.text)
{
return Some(v);
}
}
None
}
fn extract_field(data: &ToolCompletedData, field: &str) -> Option<String> {
let v = result_value(data)?;
v.get(field).and_then(|s| s.as_str()).map(str::to_string)
}
const MAX_RENDERED_TODOS: usize = 20;
const MAX_TODO_TEXT_CHARS: usize = 160;
fn truncate_chars(value: &str, max_chars: usize) -> String {
let mut chars = value.chars();
let truncated: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
fn todo_lines_for_result(data: &ToolCompletedData) -> Vec<ChatLine> {
let Some(v) = result_value(data) else {
return vec![ChatLine {
author: Author::Tool,
text: format!(
"✓ {}",
data.display_name.as_deref().unwrap_or("Write Todos")
),
}];
};
let Some(todos) = v.get("todos").and_then(Value::as_array) else {
return vec![ChatLine {
author: Author::Tool,
text: summarize_tool_result(data),
}];
};
let total = todos.len();
let completed = todos
.iter()
.filter(|todo| todo.get("status").and_then(Value::as_str) == Some("completed"))
.count();
let summary = format!("{completed} of {total} todos completed");
let mut rendered_todos = Vec::new();
for todo in todos.iter().take(MAX_RENDERED_TODOS) {
let status = todo
.get("status")
.and_then(Value::as_str)
.unwrap_or("pending");
let content = todo.get("content").and_then(Value::as_str).unwrap_or("");
let active_form = todo
.get("activeForm")
.and_then(Value::as_str)
.unwrap_or(content);
let (icon, text) = match status {
"completed" => ("✓", content),
"in_progress" => ("›", active_form),
_ => ("○", content),
};
rendered_todos.push(format!(
"{icon} {}",
truncate_chars(text, MAX_TODO_TEXT_CHARS)
));
}
let mut lines = if rendered_todos.len() <= 3 {
let inline_todos = rendered_todos.join(" ");
vec![ChatLine {
author: Author::Tool,
text: if inline_todos.is_empty() {
summary
} else {
format!("{summary} {inline_todos}")
},
}]
} else {
let mut lines = vec![ChatLine {
author: Author::Tool,
text: summary,
}];
lines.extend(rendered_todos.into_iter().map(|text| ChatLine {
author: Author::ToolDetail,
text,
}));
lines
};
let omitted = total.saturating_sub(MAX_RENDERED_TODOS);
if omitted > 0 {
lines.push(ChatLine {
author: Author::ToolDetail,
text: format!("… {omitted} more todo(s) omitted"),
});
}
if let Some(warning) = v.get("warning").and_then(Value::as_str) {
lines.push(ChatLine {
author: Author::ToolDetail,
text: format!("warning: {}", truncate_chars(warning, MAX_TODO_TEXT_CHARS)),
});
}
lines
}
pub fn summarize_tool_result(data: &ToolCompletedData) -> String {
let Some(v) = result_value(data) else {
if let Some(err) = &data.error {
return format!("error: {}", first_line(err, 120));
}
return String::new();
};
match data.tool_name.as_str() {
"write_todos" => {
let completed = v.get("completed").and_then(Value::as_u64).unwrap_or(0);
let total = v.get("total_tasks").and_then(Value::as_u64).unwrap_or(0);
format!("{completed}/{total} completed")
}
"read_file" => {
let path = v.get("path").and_then(Value::as_str).unwrap_or("");
let total = v.get("total_lines").and_then(Value::as_u64).unwrap_or(0);
let shown = v.get("lines_shown");
let start = shown
.and_then(|s| s.get("start"))
.and_then(Value::as_u64)
.unwrap_or(0);
let end = shown
.and_then(|s| s.get("end"))
.and_then(Value::as_u64)
.unwrap_or(0);
let count = end.saturating_sub(start.saturating_sub(1));
format!("{path} ({count}/{total} lines)")
}
"write_file" => {
let path = v.get("path").and_then(Value::as_str).unwrap_or("");
let bytes = v.get("size_bytes").and_then(Value::as_u64).unwrap_or(0);
format!("{path} ({bytes} bytes)")
}
"edit_file" => {
let path = v.get("path").and_then(Value::as_str).unwrap_or("");
let n = v.get("applied_edits").and_then(Value::as_u64).unwrap_or(0);
format!("{path} ({n} edit(s))")
}
"list_directory" => {
let path = v.get("path").and_then(Value::as_str).unwrap_or("");
let n = v.get("count").and_then(Value::as_u64).unwrap_or(0);
format!("{path} ({n} entries)")
}
"grep_files" => {
let pattern = v.get("pattern").and_then(Value::as_str).unwrap_or("");
let n = v.get("match_count").and_then(Value::as_u64).unwrap_or(0);
format!("/{pattern}/ ({n} match(es))")
}
"delete_file" => {
let path = v.get("path").and_then(Value::as_str).unwrap_or("");
format!("{path} (deleted)")
}
"stat_file" => {
let path = v.get("path").and_then(Value::as_str).unwrap_or("");
let size = v.get("size_bytes").and_then(Value::as_u64).unwrap_or(0);
format!("{path} ({size} bytes)")
}
"bash" => {
let cmd = v
.get("command")
.and_then(Value::as_str)
.map(|c| first_line(c, 80))
.unwrap_or_default();
let code = v
.get("exit_code")
.and_then(Value::as_i64)
.map(|c| c.to_string())
.unwrap_or_else(|| "?".into());
format!("`{cmd}` exit={code}")
}
_ => String::new(),
}
}
fn first_line(s: &str, max: usize) -> String {
let l = s.lines().next().unwrap_or("");
if l.len() > max {
format!("{}…", &l[..max])
} else {
l.to_string()
}
}
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let input_height = app.input_height();
let area = f.area();
let state = app.view_state();
let chrome_area = bottom_rect(area, chrome_height(input_height));
let transcript_area = Rect {
height: area.height.saturating_sub(chrome_area.height),
..area
};
clear_transcript_viewport(f, transcript_area);
draw_recent_transcript(f, transcript_area, app);
let input_rect = draw_chrome(f, chrome_area, input_height, &state);
draw_input(f, input_rect, app);
draw_setup_overlay(f, area, app);
}
fn bottom_rect(area: Rect, height: u16) -> Rect {
let height = height.min(area.height);
Rect {
y: area.y + area.height.saturating_sub(height),
height,
..area
}
}
fn chrome_height(input_height: u16) -> u16 {
COMPACT_CHROME_HEIGHT.max(input_height.saturating_add(2))
}
fn clear_transcript_viewport(f: &mut ratatui::Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
f.render_widget(Clear, area);
}
fn draw_recent_transcript(f: &mut ratatui::Frame, area: Rect, app: &App) {
if area.width < 4 || area.height == 0 {
return;
}
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y,
width: area.width.saturating_sub(2),
height: area.height,
};
if inner.width == 0 || inner.height == 0 {
return;
}
let rendered = recent_transcript_lines(app, inner.width as usize, inner.height as usize);
if rendered.is_empty() {
return;
}
f.render_widget(Paragraph::new(rendered), inner);
}
fn recent_transcript_lines(app: &App, width: usize, max_lines: usize) -> Vec<Line<'static>> {
if max_lines == 0 {
return Vec::new();
}
let mut chunks = Vec::new();
let mut total_lines = 0;
let mut newer_author: Option<Author> = None;
for chat in app
.lines
.iter()
.rev()
.filter(|line| !matches!(line.author, Author::System))
.take(RECENT_TRANSCRIPT_SOURCE_LINES)
{
let chat = bounded_recent_chat_line(chat);
let mut chunk = Vec::new();
append_chat_lines(&mut chunk, &chat, width);
if should_insert_chat_gap(&chat.author, newer_author.as_ref()) {
chunk.push(Line::from(""));
}
if total_lines + chunk.len() > max_lines {
let remaining = max_lines.saturating_sub(total_lines);
if remaining > 0 {
chunks.push(chunk.split_off(chunk.len().saturating_sub(remaining)));
}
break;
}
total_lines += chunk.len();
newer_author = Some(chat.author);
chunks.push(chunk);
}
chunks.reverse();
chunks.into_iter().flatten().collect()
}
fn bounded_recent_chat_line(chat: &ChatLine) -> ChatLine {
if chat.text.len() <= RECENT_TRANSCRIPT_MAX_TEXT_BYTES {
return chat.clone();
}
ChatLine {
author: chat.author.clone(),
text: truncate_tail_bytes(&chat.text, RECENT_TRANSCRIPT_MAX_TEXT_BYTES),
}
}
pub(crate) fn draw_chrome(
f: &mut ratatui::Frame,
area: Rect,
input_height: u16,
state: &ViewState,
) -> Rect {
let preview_height = u16::from(input_height == 1);
let status_height = u16::from(input_height < 3);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(preview_height), Constraint::Length(1), Constraint::Length(input_height), Constraint::Length(status_height), Constraint::Length(1), ])
.split(area);
if state.command_suggestions.is_empty() {
draw_stream_preview(f, chunks[0], state);
} else {
draw_suggestions(f, chunks[0], &state.command_suggestions);
}
draw_message_separator(f, chunks[1], state);
draw_status_separator(f, chunks[3]);
draw_session_status(f, chunks[4], state);
chunks[2]
}
fn draw_setup_overlay(f: &mut ratatui::Frame, area: Rect, app: &App) {
if app.setup.is_none() || area.width == 0 || area.height == 0 {
return;
}
let panel = setup_panel_rect(area);
if panel.width == 0 || panel.height == 0 {
return;
}
f.render_widget(Clear, panel);
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(PANEL_BG).fg(TEXT_PRIMARY));
f.render_widget(block, panel);
let inner = Rect {
x: panel.x.saturating_add(2),
y: panel.y.saturating_add(1),
width: panel.width.saturating_sub(4),
height: panel.height.saturating_sub(2),
};
let (lines, cursor) = setup_overlay_content(app);
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(PANEL_BG)),
inner,
);
if let Some((row, col)) = cursor
&& inner.height > 0
&& inner.width > 0
{
f.set_cursor_position((
inner
.x
.saturating_add((col as u16).min(inner.width.saturating_sub(1))),
inner
.y
.saturating_add((row as u16).min(inner.height.saturating_sub(1))),
));
}
}
fn setup_panel_rect(area: Rect) -> Rect {
let width = area.width.saturating_sub(4).min(104).max(area.width.min(1));
let height = area
.height
.saturating_sub(2)
.min(18)
.max(area.height.min(1));
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
}
}
fn setup_overlay_content(app: &App) -> (Vec<Line<'static>>, Option<(usize, usize)>) {
let mut lines = Vec::new();
let mut cursor = None;
match app.setup.as_ref() {
Some(SetupStep::Provider { selected }) => {
lines.push(setup_title("Set Up Yolop"));
lines.push(setup_hint(
"Connected providers jump straight to model selection.",
));
lines.push(Line::from(""));
let current = app.current_provider_name();
let snapshot = app.settings.snapshot();
for (idx, option) in PROVIDER_OPTIONS.iter().enumerate() {
let (_, status) = App::provider_status(&snapshot, option.name);
let mut hint = format!("{} · {status}", option.hint);
if option.name == current {
hint.push_str(" · current");
}
lines.push(setup_row(idx == *selected, idx + 1, option.label, &hint));
}
lines.push(Line::from(""));
lines.push(setup_footer(
"Enter select · c configure key/URL · ↑/↓ move · Esc cancel",
));
}
Some(SetupStep::BaseUrlInput { value, error }) => {
lines.push(setup_title("Custom OpenAI-Compatible Endpoint"));
lines.push(setup_hint(
"Base URL of the API, e.g. http://localhost:8000/v1 — saved to settings.toml.",
));
lines.push(Line::from(""));
let input = format!("› {value}");
cursor = Some((3, input.chars().count()));
lines.push(Line::from(vec![
Span::styled(
"› ",
Style::default()
.fg(ACCENT_BLUE)
.add_modifier(Modifier::BOLD),
),
Span::styled(value.clone(), Style::default().fg(TEXT_PRIMARY)),
]));
push_setup_error(&mut lines, error.as_deref());
lines.push(setup_footer("Enter save · Esc back"));
}
Some(SetupStep::Credential {
provider,
selected,
error,
..
}) => {
lines.push(setup_title(&format!(
"API Key for {}",
App::provider_label(provider)
)));
lines.push(setup_hint(
"Choose how yolop should authenticate this provider.",
));
lines.push(Line::from(""));
for (idx, option) in App::credential_options(provider).iter().enumerate() {
lines.push(setup_row(
idx == *selected,
idx + 1,
&option.label,
&option.hint,
));
}
push_setup_error(&mut lines, error.as_deref());
lines.push(setup_footer("Enter confirm · ↑/↓ move · Esc back"));
}
Some(SetupStep::TokenInput {
provider,
token,
error,
..
}) => {
lines.push(setup_title(&format!(
"Paste API Key for {}",
App::provider_label(provider)
)));
lines.push(setup_hint(
"The key is masked and is never written to the transcript.",
));
lines.push(Line::from(""));
let masked = if token.is_empty() {
String::new()
} else {
"•".repeat(token.chars().count())
};
let input = format!("› {masked}");
cursor = Some((3, input.chars().count()));
lines.push(Line::from(vec![
Span::styled(
"› ",
Style::default()
.fg(ACCENT_BLUE)
.add_modifier(Modifier::BOLD),
),
Span::styled(masked, Style::default().fg(TEXT_PRIMARY)),
]));
push_setup_error(&mut lines, error.as_deref());
lines.push(setup_footer("Enter save · Esc back"));
}
Some(SetupStep::PickModel {
provider,
selected,
custom,
error,
}) => {
lines.push(setup_title("Select Model"));
lines.push(setup_hint(&if provider == "custom" {
"Model id served by your endpoint. Applies to this session and future sessions."
.to_string()
} else {
format!(
"{} models. Applies to this session and future sessions.",
App::provider_label(provider)
)
}));
lines.push(Line::from(""));
let options = app.model_options(provider);
if let Some(value) = custom {
let input = format!("› {value}");
cursor = Some((3, input.chars().count()));
lines.push(Line::from(vec![
Span::styled(
"› ",
Style::default()
.fg(ACCENT_BLUE)
.add_modifier(Modifier::BOLD),
),
Span::styled(value.clone(), Style::default().fg(TEXT_PRIMARY)),
]));
} else {
if app.is_fetching_models(provider) {
lines.push(setup_hint("fetching models from the provider API…"));
}
let total = options.len();
let (start, end) = model_window(*selected, total, MAX_VISIBLE_MODEL_ROWS);
if start > 0 {
lines.push(setup_hint(&format!("↑ {start} more")));
}
let current = app.model.model_id();
for (idx, option) in options.iter().enumerate().take(end).skip(start) {
let mut hint = option.hint.to_string();
if option.spec.as_deref() == Some(current.as_str()) {
hint.push_str(" · current");
}
lines.push(setup_row(idx == *selected, idx + 1, &option.label, &hint));
}
if end < total {
lines.push(setup_hint(&format!("↓ {} more", total - end)));
}
}
push_setup_error(&mut lines, error.as_deref());
lines.push(setup_footer("Enter confirm · ↑/↓ move · Esc back"));
}
Some(SetupStep::PickEffort { selected, error }) => {
lines.push(setup_title("Select Reasoning Effort"));
lines.push(setup_hint(
"Applies to OpenAI, OpenRouter, and custom endpoints — this session and future sessions.",
));
lines.push(Line::from(""));
let label = app.model.provider_label();
let current = if label.starts_with("openai/") {
label
.split_whitespace()
.nth(1)
.unwrap_or("medium")
.to_string()
} else if label.starts_with("openrouter/") || label.starts_with("custom/") {
label
.split_whitespace()
.nth(1)
.unwrap_or_default()
.to_string()
} else {
String::new()
};
for (idx, option) in EFFORT_OPTIONS.iter().enumerate() {
let mut hint = option.hint.to_string();
if option.value == current {
hint.push_str(" · current");
}
lines.push(setup_row(idx == *selected, idx + 1, option.label, &hint));
}
push_setup_error(&mut lines, error.as_deref());
lines.push(setup_footer("Enter confirm · ↑/↓ move · Esc cancel"));
}
None => {}
}
(lines, cursor)
}
fn setup_title(text: &str) -> Line<'static> {
Line::from(Span::styled(
text.to_string(),
Style::default()
.fg(TEXT_PRIMARY)
.bg(PANEL_BG)
.add_modifier(Modifier::BOLD),
))
}
fn setup_hint(text: &str) -> Line<'static> {
Line::from(Span::styled(
text.to_string(),
Style::default().fg(TEXT_MUTED).bg(PANEL_BG),
))
}
fn setup_footer(text: &str) -> Line<'static> {
Line::from(Span::styled(
text.to_string(),
Style::default().fg(TEXT_MUTED).bg(PANEL_BG),
))
}
fn setup_row(selected: bool, index: usize, label: &str, hint: &str) -> Line<'static> {
let marker = if selected { "›" } else { " " };
let marker_style = if selected {
Style::default()
.fg(ACCENT_BLUE)
.bg(PANEL_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(TEXT_DIM).bg(PANEL_BG)
};
let label_style = if selected {
Style::default()
.fg(Color::Rgb(135, 220, 205))
.bg(PANEL_BG)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(TEXT_PRIMARY).bg(PANEL_BG)
};
Line::from(vec![
Span::styled(format!("{marker} "), marker_style),
Span::styled(
format!("{index}. "),
Style::default().fg(TEXT_MUTED).bg(PANEL_BG),
),
Span::styled(
{
let pad = 28usize.saturating_sub(label.chars().count()).max(2);
format!("{label}{}", " ".repeat(pad))
},
label_style,
),
Span::styled(
hint.to_string(),
Style::default().fg(TEXT_MUTED).bg(PANEL_BG),
),
])
}
fn push_setup_error(lines: &mut Vec<Line<'static>>, error: Option<&str>) {
if let Some(error) = error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("error: {error}"),
Style::default().fg(Color::Rgb(220, 120, 90)).bg(PANEL_BG),
)));
} else {
lines.push(Line::from(""));
}
}
fn draw_suggestions(f: &mut ratatui::Frame, area: Rect, suggestions: &[CommandSuggestion]) {
if area.height == 0 || area.width == 0 {
return;
}
f.render_widget(
Paragraph::new(suggestion_preview_line(suggestions, area.width)),
area,
);
}
fn suggestion_preview_line(suggestions: &[CommandSuggestion], width: u16) -> Line<'static> {
let body = suggestions
.iter()
.map(|suggestion| suggestion.label.as_str())
.collect::<Vec<_>>()
.join(" · ");
let prefix = "Tab ";
let max_body = (width as usize)
.saturating_sub(prefix.chars().count() + 1)
.max(8);
Line::from(vec![
Span::styled(
prefix,
Style::default()
.fg(ACCENT_BLUE)
.add_modifier(Modifier::BOLD),
),
Span::styled(
truncate_end_chars(&body, max_body),
Style::default().fg(TEXT_MUTED),
),
])
}
fn draw_stream_preview(f: &mut ratatui::Frame, area: Rect, state: &ViewState) {
if area.height == 0 {
return;
}
let Some(preview) = state.stream_preview.as_ref() else {
return;
};
let inner_width = area.width as usize;
if inner_width == 0 {
return;
}
let tail = preview
.text
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
let label = preview.kind.label();
let prefix = format!("{label} › ");
let prefix_w = prefix.chars().count();
let max_text = inner_width.saturating_sub(prefix_w + 1).max(8);
let truncated = truncate_tail_chars(tail, max_text);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
prefix,
Style::default()
.fg(preview.kind.color())
.add_modifier(Modifier::DIM),
),
Span::styled(truncated, Style::default().fg(TEXT_MUTED)),
])),
area,
);
}
fn truncate_tail_chars(text: &str, max_chars: usize) -> String {
let count = text.chars().count();
if count <= max_chars {
return text.to_string();
}
let skip = count - max_chars.saturating_sub(1);
let mut out = String::with_capacity(max_chars);
out.push('…');
out.extend(text.chars().skip(skip));
out
}
fn truncate_tail_bytes(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
if max_bytes == 0 {
return String::new();
}
if max_bytes <= '…'.len_utf8() {
return "…".to_string();
}
let mut start = text.len().saturating_sub(max_bytes - '…'.len_utf8());
while start < text.len() && !text.is_char_boundary(start) {
start += 1;
}
format!("…{}", &text[start..])
}
fn truncate_end_chars(text: &str, max_chars: usize) -> String {
let count = text.chars().count();
if count <= max_chars {
return text.to_string();
}
if max_chars == 0 {
return String::new();
}
if max_chars == 1 {
return "…".to_string();
}
let mut out = String::with_capacity(max_chars);
out.extend(text.chars().take(max_chars - 1));
out.push('…');
out
}
fn should_insert_chat_gap(current: &Author, next: Option<&Author>) -> bool {
let Some(next) = next else {
return false;
};
!matches!(
(current, next),
(&Author::Tool, &Author::Tool)
| (&Author::Tool, &Author::ToolDetail)
| (&Author::ToolDetail, &Author::Tool)
| (&Author::ToolDetail, &Author::ToolDetail)
)
}
fn append_chat_lines<'a>(lines: &mut Vec<Line<'a>>, chat: &ChatLine, inner_width: usize) {
if matches!(chat.author, Author::ToolDetail) {
append_wrapped_plain(
lines,
" ",
Style::default().fg(TEXT_MUTED),
&chat.text,
inner_width,
);
return;
}
let header_text = format!("{} › ", chat.author.label());
let header_style = Style::default()
.fg(chat.author.color())
.add_modifier(Modifier::BOLD);
if matches!(chat.author, Author::Assistant) {
append_markdown_lines(lines, &header_text, header_style, &chat.text, inner_width);
} else if matches!(chat.author, Author::Narration) {
append_wrapped_styled(
lines,
&header_text,
header_style,
&chat.text,
inner_width,
Style::default().fg(TEXT_MUTED),
);
} else if matches!(chat.author, Author::Diff) {
append_wrapped_diff(lines, &header_text, header_style, &chat.text, inner_width);
} else {
append_wrapped_plain(lines, &header_text, header_style, &chat.text, inner_width);
}
}
fn append_wrapped_plain<'a>(
lines: &mut Vec<Line<'a>>,
first_prefix: &str,
prefix_style: Style,
text: &str,
inner_width: usize,
) {
append_wrapped_styled(
lines,
first_prefix,
prefix_style,
text,
inner_width,
Style::default(),
);
}
fn append_wrapped_styled<'a>(
lines: &mut Vec<Line<'a>>,
first_prefix: &str,
prefix_style: Style,
text: &str,
inner_width: usize,
content_style: Style,
) {
let continuation = " ".repeat(first_prefix.chars().count());
let wrap_width = inner_width
.saturating_sub(first_prefix.chars().count())
.max(20);
let mut emitted = false;
for raw in text.lines() {
let wrapped = textwrap::wrap(raw, wrap_width);
if wrapped.is_empty() {
let prefix = if emitted {
continuation.as_str()
} else {
first_prefix
};
lines.push(Line::from(vec![Span::styled(
prefix.to_string(),
prefix_style,
)]));
emitted = true;
continue;
}
for piece in wrapped {
let prefix = if emitted {
continuation.as_str()
} else {
first_prefix
};
lines.push(Line::from(vec![
Span::styled(prefix.to_string(), prefix_style),
Span::styled(piece.into_owned(), content_style),
]));
emitted = true;
}
}
if !emitted {
lines.push(Line::from(vec![Span::styled(
first_prefix.to_string(),
prefix_style,
)]));
}
}
fn append_wrapped_diff<'a>(
lines: &mut Vec<Line<'a>>,
first_prefix: &str,
prefix_style: Style,
text: &str,
inner_width: usize,
) {
let continuation = " ".repeat(first_prefix.chars().count());
let wrap_width = inner_width
.saturating_sub(first_prefix.chars().count())
.max(20);
let mut emitted = false;
for raw in text.lines() {
let content_style = diff_line_style(raw);
let wrapped = textwrap::wrap(raw, wrap_width);
if wrapped.is_empty() {
let prefix = if emitted {
continuation.as_str()
} else {
first_prefix
};
lines.push(Line::from(vec![Span::styled(
prefix.to_string(),
prefix_style,
)]));
emitted = true;
continue;
}
for piece in wrapped {
let prefix = if emitted {
continuation.as_str()
} else {
first_prefix
};
lines.push(Line::from(vec![
Span::styled(prefix.to_string(), prefix_style),
Span::styled(piece.into_owned(), content_style),
]));
emitted = true;
}
}
if !emitted {
lines.push(Line::from(vec![Span::styled(
first_prefix.to_string(),
prefix_style,
)]));
}
}
fn diff_line_style(line: &str) -> Style {
let color = if line.starts_with('+') {
DIFF_ADD
} else if line.starts_with('-') {
DIFF_DELETE
} else if line.starts_with("@@") || line.starts_with('\\') {
DIFF_META
} else {
TEXT_PRIMARY
};
Style::default().fg(color)
}
fn append_markdown_lines<'a>(
lines: &mut Vec<Line<'a>>,
first_prefix: &str,
prefix_style: Style,
text: &str,
inner_width: usize,
) {
let continuation = " ".repeat(first_prefix.chars().count());
let wrap_width = inner_width
.saturating_sub(first_prefix.chars().count())
.max(20);
let mut first = true;
let mut in_code = false;
for raw in text.lines() {
let trimmed = raw.trim_end();
if let Some(lang) = trimmed.trim_start().strip_prefix("```") {
in_code = !in_code;
let code_lang = lang.trim();
let label = if in_code {
if code_lang.is_empty() {
"code".to_string()
} else {
format!("code: {code_lang}")
}
} else {
String::new()
};
push_markdown_line(
lines,
first_prefix,
&continuation,
prefix_style,
&mut first,
vec![Span::styled(
label,
Style::default().fg(TEXT_DIM).add_modifier(Modifier::ITALIC),
)],
);
continue;
}
let content_spans = if in_code {
markdown_code_spans(trimmed)
} else {
markdown_text_spans(trimmed)
};
let plain = spans_plain_text(&content_spans);
let wrapped = textwrap::wrap(&plain, wrap_width);
if wrapped.is_empty() {
push_markdown_line(
lines,
first_prefix,
&continuation,
prefix_style,
&mut first,
vec![],
);
continue;
}
if content_spans.len() == 1 {
let style = content_spans[0].style;
for piece in wrapped {
push_markdown_line(
lines,
first_prefix,
&continuation,
prefix_style,
&mut first,
vec![Span::styled(piece.into_owned(), style)],
);
}
} else {
push_markdown_line(
lines,
first_prefix,
&continuation,
prefix_style,
&mut first,
content_spans,
);
}
}
}
fn push_markdown_line<'a>(
lines: &mut Vec<Line<'a>>,
first_prefix: &str,
continuation: &str,
prefix_style: Style,
first: &mut bool,
mut spans: Vec<Span<'a>>,
) {
let prefix = if *first { first_prefix } else { continuation };
let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
line_spans.append(&mut spans);
lines.push(Line::from(line_spans));
*first = false;
}
fn markdown_text_spans(text: &str) -> Vec<Span<'static>> {
let trimmed = text.trim_start();
if trimmed.starts_with('#') {
let heading = trimmed.trim_start_matches('#').trim_start();
return vec![Span::styled(
heading.to_string(),
Style::default()
.fg(TEXT_PRIMARY)
.add_modifier(Modifier::BOLD),
)];
}
if let Some(rest) = trimmed.strip_prefix("> ") {
return vec![
Span::styled("| ", Style::default().fg(ACCENT_BLUE)),
Span::styled(rest.to_string(), Style::default().fg(TEXT_MUTED)),
];
}
if let Some(rest) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
{
return vec![
Span::styled("- ", Style::default().fg(ACCENT_GOLD)),
Span::raw(rest.to_string()),
];
}
if let Some((marker, rest)) = numbered_marker(trimmed) {
return vec![
Span::styled(marker, Style::default().fg(ACCENT_GOLD)),
Span::raw(rest.to_string()),
];
}
inline_code_spans(text)
}
fn markdown_code_spans(text: &str) -> Vec<Span<'static>> {
let mut spans = vec![Span::styled(" ", Style::default().fg(TEXT_DIM))];
spans.extend(simple_code_highlight(text));
spans
}
fn inline_code_spans(text: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut rest = text;
let mut code = false;
while let Some((before, after_tick)) = rest.split_once('`') {
if !before.is_empty() {
spans.push(Span::raw(before.to_string()));
}
if let Some((inside, after)) = after_tick.split_once('`') {
spans.push(Span::styled(
inside.to_string(),
Style::default().fg(TEXT_PRIMARY).bg(CODE_BG),
));
rest = after;
code = true;
} else {
spans.push(Span::raw("`".to_string()));
rest = after_tick;
break;
}
}
if !rest.is_empty() {
spans.push(Span::raw(rest.to_string()));
}
if spans.is_empty() || !code {
vec![Span::raw(text.to_string())]
} else {
spans
}
}
fn simple_code_highlight(text: &str) -> Vec<Span<'static>> {
let keywords = [
"async", "await", "const", "enum", "fn", "impl", "let", "match", "pub", "return", "struct",
"use",
];
let mut spans = Vec::new();
let mut token = String::new();
for ch in text.chars() {
if ch.is_alphanumeric() || ch == '_' {
token.push(ch);
continue;
}
if !token.is_empty() {
let style = if keywords.contains(&token.as_str()) {
Style::default()
.fg(ACCENT_GOLD)
.add_modifier(Modifier::BOLD)
} else if token.chars().all(|c| c.is_ascii_digit()) {
Style::default().fg(TEXT_MUTED)
} else {
Style::default().fg(ACCENT_BLUE)
};
spans.push(Span::styled(std::mem::take(&mut token), style));
}
spans.push(Span::styled(ch.to_string(), Style::default().fg(TEXT_DIM)));
}
if !token.is_empty() {
let style = if keywords.contains(&token.as_str()) {
Style::default()
.fg(ACCENT_GOLD)
.add_modifier(Modifier::BOLD)
} else if token.chars().all(|c| c.is_ascii_digit()) {
Style::default().fg(TEXT_MUTED)
} else {
Style::default().fg(ACCENT_BLUE)
};
spans.push(Span::styled(token, style));
}
spans
}
fn numbered_marker(text: &str) -> Option<(String, &str)> {
let dot = text.find(". ")?;
if dot == 0 || !text[..dot].chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
Some((text[..dot + 2].to_string(), &text[dot + 2..]))
}
fn spans_plain_text(spans: &[Span]) -> String {
spans.iter().map(|span| span.content.as_ref()).collect()
}
fn digit_index(ch: char, len: usize) -> Option<usize> {
let digit = ch.to_digit(10)? as usize;
if digit == 0 || digit > len {
None
} else {
Some(digit - 1)
}
}
fn model_index_for_label(label: &str, options: &[ModelOption]) -> usize {
options
.iter()
.position(|option| option.spec.as_deref() == Some(label))
.unwrap_or(0)
}
fn custom_model_option() -> ModelOption {
ModelOption {
spec: None,
label: "Custom...".to_string(),
hint: "paste a model id".to_string(),
}
}
fn model_options_from_discovered(
models: Vec<crate::capabilities::model_discovery::DiscoveredProviderModel>,
) -> Vec<ModelOption> {
const MAX_HINT_CHARS: usize = 72;
let mut options: Vec<ModelOption> = models
.into_iter()
.map(|model| {
let mut hint = match (model.display_name, model.description) {
(Some(name), Some(description)) => format!("{name} · {description}"),
(Some(name), None) => name,
(None, Some(description)) => description,
(None, None) => String::new(),
};
if hint.chars().count() > MAX_HINT_CHARS {
hint = hint.chars().take(MAX_HINT_CHARS - 1).collect::<String>() + "…";
}
ModelOption {
spec: Some(model.model_id.clone()),
label: model.model_id,
hint,
}
})
.collect();
options.push(custom_model_option());
options
}
const MAX_VISIBLE_MODEL_ROWS: usize = 8;
fn model_window(selected: usize, total: usize, max_rows: usize) -> (usize, usize) {
if total <= max_rows {
return (0, total);
}
let start = selected.saturating_sub(max_rows / 2).min(total - max_rows);
(start, start + max_rows)
}
fn effort_index(value: &str) -> Option<usize> {
EFFORT_OPTIONS
.iter()
.position(|option| option.value.eq_ignore_ascii_case(value))
}
fn inset_x(area: Rect, pad: u16) -> Rect {
let total = pad.saturating_mul(2);
if area.width <= total {
return area;
}
Rect {
x: area.x.saturating_add(pad),
width: area.width.saturating_sub(total),
..area
}
}
fn line_width(line: &Line) -> usize {
line.spans
.iter()
.map(|span| span.content.chars().count())
.sum()
}
fn separator_line(mut title: Line<'static>, width: u16, style: Style) -> Line<'static> {
let fill_width = (width as usize).saturating_sub(line_width(&title));
title
.spans
.push(Span::styled("─".repeat(fill_width), style));
title
}
fn draw_separator(f: &mut ratatui::Frame, area: Rect, title: Line<'static>, style: Style) {
if area.height == 0 {
return;
}
f.render_widget(
Paragraph::new(separator_line(title, area.width, style)),
area,
);
}
fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &mut App) {
let area = inset_x(area, 0);
let prompt_width = area.width.min(2);
let prompt_area = Rect {
width: prompt_width,
..area
};
let input_area = Rect {
x: area.x.saturating_add(prompt_width),
width: area.width.saturating_sub(prompt_width),
..area
};
f.render_widget(
Paragraph::new(Span::styled(
"> ",
Style::default()
.fg(ACCENT_BLUE)
.add_modifier(Modifier::BOLD),
)),
prompt_area,
);
app.input.set_block(ratatui::widgets::Block::default());
f.render_widget(&app.input, input_area);
draw_input_cursor(f, input_area, app);
}
fn draw_input_cursor(f: &mut ratatui::Frame, area: Rect, app: &App) {
if app.busy || app.setup.is_some() {
return;
}
let inner_width = area.width;
let inner_height = area.height;
if inner_width == 0 || inner_height == 0 {
return;
}
let cursor = app.input.screen_cursor();
let x = area
.x
.saturating_add((cursor.col as u16).min(inner_width.saturating_sub(1)));
let y = area
.y
.saturating_add((cursor.row as u16).min(inner_height.saturating_sub(1)));
f.set_cursor_position((x, y));
}
fn message_separator_title(state: &ViewState) -> Line<'static> {
if state.busy {
return thinking_title(
state.busy_frame,
state.turn_activity.as_deref().unwrap_or("thinking"),
);
}
Line::from(vec![
Span::styled("─── ", Style::default().fg(ACCENT_BLUE)),
Span::styled(
format!("(Enter to send, {} for newline) ", newline_shortcut_hint()),
Style::default().fg(TEXT_MUTED),
),
])
}
fn newline_shortcut_hint() -> &'static str {
"Shift-Enter"
}
fn thinking_title(frame: u64, activity: &str) -> Line<'static> {
const SPINNER: [&str; 4] = ["-", "\\", "|", "/"];
let spinner = SPINNER[((frame / 2) as usize) % SPINNER.len()];
let text = format!("{activity}...");
let text_style = Style::default().fg(TEXT_MUTED).add_modifier(Modifier::BOLD);
let spans = vec![
Span::styled("─── ", Style::default().fg(ACCENT_BLUE)),
Span::styled(spinner.to_string(), Style::default().fg(ACCENT_GOLD)),
Span::raw(" "),
Span::styled(text, text_style),
Span::styled(" (input disabled) ", Style::default().fg(TEXT_DIM)),
];
Line::from(spans)
}
fn draw_message_separator(f: &mut ratatui::Frame, area: Rect, state: &ViewState) {
draw_separator(
f,
area,
message_separator_title(state),
Style::default().fg(ACCENT_BLUE),
);
}
fn draw_status_separator(f: &mut ratatui::Frame, area: Rect) {
draw_separator(f, area, Line::from(""), Style::default().fg(ACCENT_GOLD));
}
fn draw_session_status(f: &mut ratatui::Frame, area: Rect, state: &ViewState) {
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default().fg(TEXT_MUTED)),
Span::styled(state.model_label.clone(), Style::default().fg(TEXT_MUTED)),
Span::styled(" · ", Style::default().fg(TEXT_DIM)),
Span::styled(
display_path(&state.workspace_root),
Style::default().fg(TEXT_MUTED),
),
Span::styled(" · ", Style::default().fg(TEXT_DIM)),
Span::styled(
format!("{} msgs", state.lines_count),
Style::default().fg(TEXT_MUTED),
),
Span::styled(" · approval ", Style::default().fg(TEXT_DIM)),
Span::styled(state.approval_mode.clone(), Style::default().fg(TEXT_MUTED)),
Span::styled(" · session ", Style::default().fg(TEXT_DIM)),
Span::styled(
state.session_id.to_string(),
Style::default().fg(TEXT_MUTED),
),
Span::styled(" ", Style::default().fg(TEXT_MUTED)),
])),
area,
);
}
fn display_path(path: &std::path::Path) -> String {
if let Ok(home) = std::env::var("HOME") {
let home = std::path::Path::new(&home);
if let Ok(rest) = path.strip_prefix(home) {
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capabilities::model_discovery::DiscoveredProviderModel;
use everruns_core::events::{
EventContext, InputMessageData, OutputMessageCompletedData, OutputMessageStartedData,
ReasonCompletedData,
};
use everruns_core::message::Message;
use everruns_core::tool_types::ToolCall;
use everruns_core::{SessionId, TurnId};
use everruns_core::command::{CommandArg, CommandDescriptor, CommandSource};
fn setup_capability_command() -> CommandDescriptor {
CommandDescriptor {
name: "setup".to_string(),
description: "Configure provider, API key, and model.".to_string(),
source: CommandSource::System,
args: vec![],
}
}
fn client_command_descriptors() -> Vec<CommandDescriptor> {
use everruns_core::capabilities::Capability;
struct NoopUi;
impl crate::host_ui::HostUi for NoopUi {
fn send(&self, _: crate::host_ui::UiCommand) {}
}
crate::capabilities::client_commands::ClientCommandsCapability::new(std::sync::Arc::new(
NoopUi,
))
.commands()
}
fn caps_with_client_commands(extra: Vec<CommandDescriptor>) -> Vec<CommandDescriptor> {
let mut caps = client_command_descriptors();
caps.extend(extra);
caps
}
fn command_with_arg_suggestions() -> CommandDescriptor {
CommandDescriptor {
name: "pick".to_string(),
description: "Pick a value.".to_string(),
source: CommandSource::System,
args: vec![CommandArg {
name: "value".to_string(),
description: "value".to_string(),
required: false,
suggestions: vec![
"alpha-one".to_string(),
"alpha-two".to_string(),
"beta-one".to_string(),
],
}],
}
}
#[test]
fn command_suggestions_list_commands_for_slash() {
let caps = caps_with_client_commands(vec![setup_capability_command()]);
let suggestions = command_suggestions("/", &caps);
assert!(suggestions.iter().any(|s| s.completion == "/help"));
assert!(
suggestions
.iter()
.any(|s| s.completion == "/setup" || s.completion == "/setup "),
"capability-provided /setup should appear in suggestions: {suggestions:?}"
);
}
#[test]
fn suggestion_preview_line_shows_command_dropdown() {
let caps = vec![setup_capability_command()];
let suggestions = command_suggestions("/s", &caps);
let rendered = line_text(&suggestion_preview_line(&suggestions, 96));
assert!(rendered.starts_with("Tab /setup"));
assert!(rendered.contains("/setup"));
}
#[test]
fn suggestion_preview_line_keeps_first_match_when_truncated() {
let caps = caps_with_client_commands(vec![setup_capability_command()]);
let suggestions = command_suggestions("/", &caps);
let rendered = line_text(&suggestion_preview_line(&suggestions, 18));
assert!(rendered.starts_with("Tab /help"));
assert!(rendered.ends_with('…'));
}
#[test]
fn command_suggestions_filter_first_arg_by_prefix() {
let caps = vec![command_with_arg_suggestions()];
let suggestions = command_suggestions("/pick alpha-", &caps);
assert_eq!(
suggestions
.iter()
.map(|s| s.completion.as_str())
.collect::<Vec<_>>(),
vec!["/pick alpha-one", "/pick alpha-two"]
);
}
#[test]
fn command_suggestions_no_arg_suggestions_means_free_form() {
let caps = vec![CommandDescriptor {
name: "echo".to_string(),
description: "echo".to_string(),
source: CommandSource::System,
args: vec![CommandArg {
name: "text".to_string(),
description: "text".to_string(),
required: true,
suggestions: vec![],
}],
}];
let suggestions = command_suggestions("/echo hello", &caps);
assert!(suggestions.is_empty(), "got: {suggestions:?}");
}
#[test]
fn capability_commands_appear_in_suggestions() {
let caps = vec![CommandDescriptor {
name: "btw".to_string(),
description: "Ask a side question.".to_string(),
source: CommandSource::System,
args: vec![CommandArg {
name: "question".to_string(),
description: "the question".to_string(),
required: true,
suggestions: vec![],
}],
}];
let suggestions = command_suggestions("/b", &caps);
let btw = suggestions
.iter()
.find(|s| s.completion == "/btw ")
.expect("capability command surfaced in suggestions");
assert!(btw.label.starts_with("/btw <question>"));
}
#[test]
fn suggestions_come_solely_from_the_command_registry() {
let caps = vec![CommandDescriptor {
name: "help".to_string(),
description: "show commands".to_string(),
source: CommandSource::System,
args: vec![],
}];
let suggestions = command_suggestions("/help", &caps);
let help_entries: Vec<_> = suggestions
.iter()
.filter(|s| s.completion.starts_with("/help"))
.collect();
assert_eq!(help_entries.len(), 1);
assert_eq!(help_entries[0].completion, "/help");
}
#[test]
fn input_area_supports_multiline_and_cursor_editing() {
let mut input = new_input_area(vec![String::new()]);
for key in [
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Left, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Right, KeyModifiers::empty()),
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::empty()),
] {
let _ = input.input(key);
}
assert_eq!(input.lines(), ["abc", "d"]);
}
#[test]
fn newline_shortcut_hint_uses_shift_enter_only() {
assert_eq!(newline_shortcut_hint(), "Shift-Enter");
}
#[test]
fn replayed_events_render_user_assistant_and_tool_lines() {
let session_id = SessionId::new();
let user_event = RuntimeEvent::new(
session_id,
EventContext::empty(),
InputMessageData::new(Message::user("What changed?")),
);
let assistant_event = RuntimeEvent::new(
session_id,
EventContext::empty(),
OutputMessageCompletedData::new(Message::assistant("I updated the renderer.")),
);
let mut tool_data = ToolCompletedData::success(
"call_bash".to_string(),
"bash".to_string(),
vec![ContentPart::text(
serde_json::json!({
"command": "cargo test",
"exit_code": 0
})
.to_string(),
)],
None,
);
tool_data.narration = Some("Ran tests".to_string());
let tool_event = RuntimeEvent::new(session_id, EventContext::empty(), tool_data);
let lines = [user_event, assistant_event, tool_event]
.iter()
.flat_map(lines_for_replayed_event)
.map(|line| (line.author, line.text))
.collect::<Vec<_>>();
assert!(matches!(lines[0].0, Author::User));
assert_eq!(lines[0].1, "What changed?");
assert!(matches!(lines[1].0, Author::Assistant));
assert_eq!(lines[1].1, "I updated the renderer.");
assert!(matches!(lines[2].0, Author::Tool));
assert!(lines[2].1.contains("Ran tests"));
}
#[test]
fn lines_for_event_surfaces_tool_call_monologue() {
let event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
ReasonCompletedData::success("I'll check the manifests first.", true, 2, None, None),
);
let lines = lines_for_event(&event);
assert_eq!(lines.len(), 1);
assert!(matches!(lines[0].author, Author::Narration));
assert_eq!(lines[0].text, "I'll check the manifests first.");
assert_eq!(lines[0].author.label(), "note");
assert_eq!(
status_for_event(&event)
.map(|status| status.text)
.as_deref(),
Some("planned 2 tool call(s)")
);
}
#[test]
fn lines_for_event_renders_reason_item_summary_segments() {
use everruns_core::events::ReasonItemData;
let event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
ReasonItemData {
turn_id: TurnId::new(),
provider: "openai".to_string(),
model: Some("gpt-5".to_string()),
item_id: "rs_abc".to_string(),
encrypted_content: Some("opaque".to_string()),
summary: vec![
"Considering file layout".to_string(),
"".to_string(),
" Plan the read order ".to_string(),
],
token_count: None,
},
);
let lines = lines_for_event(&event);
assert_eq!(lines.len(), 2, "blank summary segments are dropped");
assert!(matches!(lines[0].author, Author::Narration));
assert_eq!(lines[0].text, "Considering file layout");
assert_eq!(lines[1].text, "Plan the read order");
}
#[test]
fn lines_for_event_hides_output_message_thinking() {
let mut message = everruns_core::Message::assistant_with_tools(
"",
vec![ToolCall {
id: "call_read".to_string(),
name: "read_file".to_string(),
arguments: serde_json::json!({ "path": "/workspace/Cargo.toml" }),
}],
);
message.thinking = Some(
"**Inspecting package files**\n\nI should read the package manifest first.".to_string(),
);
let event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
OutputMessageCompletedData::new(message),
);
let lines = lines_for_event(&event);
assert!(lines.is_empty(), "thinking must not be rendered: {lines:?}");
}
#[test]
fn status_for_event_labels_output_iteration() {
let event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
OutputMessageStartedData {
turn_id: TurnId::new(),
model: None,
iteration: Some(3),
},
);
assert!(lines_for_event(&event).is_empty());
assert_eq!(
status_for_event(&event)
.map(|status| status.text)
.as_deref(),
Some("iteration 3: writing response")
);
}
#[test]
fn lines_for_event_renders_short_write_todos_inline() {
let event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
ToolCompletedData::success(
"call_todos".to_string(),
"write_todos".to_string(),
vec![ContentPart::text(
serde_json::json!({
"success": true,
"total_tasks": 3,
"pending": 1,
"in_progress": 1,
"completed": 1,
"todos": [
{
"content": "Read current CLI renderer",
"activeForm": "Reading current CLI renderer",
"status": "completed"
},
{
"content": "Render todos in transcript",
"activeForm": "Rendering todos in transcript",
"status": "in_progress"
},
{
"content": "Run focused tests",
"activeForm": "Running focused tests",
"status": "pending"
}
]
})
.to_string(),
)],
None,
),
);
let lines = lines_for_event(&event)
.into_iter()
.map(|line| (line.author, line.text))
.collect::<Vec<_>>();
assert!(matches!(lines[0].0, Author::Tool));
assert_eq!(
lines[0].1,
"1 of 3 todos completed ✓ Read current CLI renderer › Rendering todos in transcript ○ Run focused tests"
);
assert_eq!(lines.len(), 1);
}
#[test]
fn lines_for_event_renders_long_write_todos_as_rows() {
let event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
ToolCompletedData::success(
"call_todos".to_string(),
"write_todos".to_string(),
vec![ContentPart::text(
serde_json::json!({
"success": true,
"total_tasks": 4,
"pending": 2,
"in_progress": 1,
"completed": 1,
"todos": [
{
"content": "Read current CLI renderer",
"activeForm": "Reading current CLI renderer",
"status": "completed"
},
{
"content": "Render todos in transcript",
"activeForm": "Rendering todos in transcript",
"status": "in_progress"
},
{
"content": "Run focused tests",
"activeForm": "Running focused tests",
"status": "pending"
},
{
"content": "Summarize changes",
"activeForm": "Summarizing changes",
"status": "pending"
}
]
})
.to_string(),
)],
None,
),
);
let lines = lines_for_event(&event)
.into_iter()
.map(|line| (line.author, line.text))
.collect::<Vec<_>>();
assert!(matches!(lines[0].0, Author::Tool));
assert_eq!(lines[0].1, "1 of 4 todos completed");
assert!(
lines
.iter()
.any(|(author, line)| matches!(author, Author::ToolDetail)
&& line == "✓ Read current CLI renderer")
);
assert!(
lines
.iter()
.any(|(author, line)| matches!(author, Author::ToolDetail)
&& line == "› Rendering todos in transcript")
);
assert!(
lines
.iter()
.any(|(author, line)| matches!(author, Author::ToolDetail)
&& line == "○ Run focused tests")
);
}
#[test]
fn lines_for_event_limits_write_todo_rows_and_truncates_text() {
let total = MAX_RENDERED_TODOS + 5;
let long_text = "x".repeat(MAX_TODO_TEXT_CHARS + 60);
let todos = (0..total)
.map(|_| {
serde_json::json!({
"content": &long_text,
"activeForm": &long_text,
"status": "pending"
})
})
.collect::<Vec<_>>();
let event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
ToolCompletedData::success(
"call_todos".to_string(),
"write_todos".to_string(),
vec![ContentPart::text(
serde_json::json!({
"success": true,
"todos": todos,
"warning": "w".repeat(MAX_TODO_TEXT_CHARS + 60)
})
.to_string(),
)],
None,
),
);
let lines = lines_for_event(&event);
let detail_lines = lines
.iter()
.filter(|line| matches!(line.author, Author::ToolDetail))
.map(|line| line.text.as_str())
.collect::<Vec<_>>();
let omitted = total - MAX_RENDERED_TODOS;
assert_eq!(
detail_lines
.iter()
.filter(|line| line.starts_with("○ "))
.count(),
MAX_RENDERED_TODOS
);
assert!(
detail_lines
.iter()
.any(|line| *line == format!("… {omitted} more todo(s) omitted"))
);
assert!(
detail_lines
.iter()
.any(|line| line.starts_with("warning: "))
);
assert!(
detail_lines
.iter()
.filter(|line| line.starts_with("○ "))
.all(|line| line.ends_with('…'))
);
}
#[test]
fn handle_live_event_routes_assistant_delta_to_stream_preview() {
use everruns_core::events::{OutputMessageDeltaData, ToolOutputDeltaData};
use everruns_core::typed_id::TurnId;
let (tx, mut rx) = mpsc::unbounded_channel::<TurnEvent>();
let mut emitted = HashSet::new();
let mut router = DeltaRouter::default();
let turn_id = TurnId::new();
let delta_event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
OutputMessageDeltaData {
turn_id,
delta: "Hel".to_string(),
accumulated: "Hel".to_string(),
},
);
handle_live_event(&delta_event, &mut emitted, &mut router, &tx);
let more = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
OutputMessageDeltaData {
turn_id,
delta: "lo, world".to_string(),
accumulated: "Hello, world".to_string(),
},
);
handle_live_event(&more, &mut emitted, &mut router, &tx);
let completed = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
OutputMessageCompletedData::new(Message::assistant("Hello, world")),
);
handle_live_event(&completed, &mut emitted, &mut router, &tx);
let tool_delta = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
ToolOutputDeltaData {
tool_call_id: "call-99".to_string(),
tool_name: "bash".to_string(),
delta: "compiling...\n".to_string(),
stream: "stdout".to_string(),
},
);
handle_live_event(&tool_delta, &mut emitted, &mut router, &tx);
let mut previews = Vec::new();
while let Ok(event) = rx.try_recv() {
if let TurnEvent::Stream(preview) = event {
previews.push(preview);
}
}
assert_eq!(previews.len(), 4);
match &previews[0] {
Some(p) => {
assert_eq!(p.kind, StreamKind::Assistant);
assert_eq!(p.text, "Hel");
}
None => panic!("expected first preview to be Some"),
}
match &previews[1] {
Some(p) => {
assert_eq!(p.kind, StreamKind::Assistant);
assert_eq!(p.text, "Hello, world");
}
None => panic!("expected second preview to be Some"),
}
assert!(previews[2].is_none(), "completed must clear preview");
match &previews[3] {
Some(p) => {
assert_eq!(p.kind, StreamKind::Tool);
assert!(
p.text.contains("bash") && p.text.contains("compiling"),
"tool preview text: {:?}",
p.text
);
}
None => panic!("expected tool delta to surface preview"),
}
}
#[test]
fn handle_live_event_deduplicates_by_event_id() {
let (tx, mut rx) = mpsc::unbounded_channel::<TurnEvent>();
let mut emitted = HashSet::new();
let mut router = DeltaRouter::default();
let event = RuntimeEvent::new(
SessionId::new(),
EventContext::empty(),
ReasonCompletedData::success("plan", true, 1, None, None),
);
handle_live_event(&event, &mut emitted, &mut router, &tx);
handle_live_event(&event, &mut emitted, &mut router, &tx);
let mut count = 0;
while rx.try_recv().is_ok() {
count += 1;
}
assert_eq!(
count, 2,
"first dispatch yields Activity + Lines; second is suppressed"
);
}
#[test]
fn truncate_tail_keeps_visible_cursor() {
assert_eq!(truncate_tail_chars("hello", 10), "hello");
let out = truncate_tail_chars("0123456789abcdef", 8);
assert!(out.starts_with('…'), "expected ellipsis prefix: {out:?}");
assert!(
out.ends_with("cdef"),
"expected tail of the text to survive: {out:?}"
);
}
#[test]
fn truncate_end_handles_tiny_limits() {
assert_eq!(truncate_end_chars("hello", 0), "");
assert_eq!(truncate_end_chars("hello", 1), "…");
assert_eq!(truncate_end_chars("hello", 99), "hello");
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
fn line_content_color(line: &Line<'_>) -> Option<Color> {
line.spans.get(1).and_then(|span| span.style.fg)
}
#[test]
fn diff_lines_style_adds_deletes_and_metadata() {
let mut lines = Vec::new();
append_chat_lines(
&mut lines,
&ChatLine {
author: Author::Diff,
text: "--- /workspace/src/app.rs (before)\n+++ /workspace/src/app.rs (after)\n@@ -1 +1 @@\n-old\n+new\n unchanged".to_string(),
},
96,
);
assert_eq!(line_content_color(&lines[0]), Some(DIFF_DELETE));
assert_eq!(line_content_color(&lines[1]), Some(DIFF_ADD));
assert_eq!(line_content_color(&lines[2]), Some(DIFF_META));
assert_eq!(line_content_color(&lines[3]), Some(DIFF_DELETE));
assert_eq!(line_content_color(&lines[4]), Some(DIFF_ADD));
assert_eq!(line_content_color(&lines[5]), Some(TEXT_PRIMARY));
}
#[test]
fn narration_lines_use_note_label_and_muted_text() {
let mut lines = Vec::new();
append_chat_lines(
&mut lines,
&ChatLine {
author: Author::Narration,
text: "Considering installation steps".to_string(),
},
96,
);
assert_eq!(
line_text(&lines[0]),
"note › Considering installation steps"
);
assert_eq!(lines[0].spans[0].style.fg, Some(TEXT_MUTED));
assert_eq!(line_content_color(&lines[0]), Some(TEXT_MUTED));
}
#[test]
fn should_not_insert_chat_gap_inside_tool_blocks() {
assert!(!should_insert_chat_gap(&Author::Tool, Some(&Author::Tool)));
assert!(!should_insert_chat_gap(
&Author::Tool,
Some(&Author::ToolDetail)
));
assert!(!should_insert_chat_gap(
&Author::ToolDetail,
Some(&Author::Tool)
));
assert!(!should_insert_chat_gap(
&Author::ToolDetail,
Some(&Author::ToolDetail)
));
assert!(should_insert_chat_gap(
&Author::ToolDetail,
Some(&Author::Assistant)
));
assert!(!should_insert_chat_gap(&Author::ToolDetail, None));
}
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn view_state_idle() -> ViewState {
ViewState {
stream_preview: None,
command_suggestions: Vec::new(),
busy: false,
busy_frame: 0,
turn_activity: None,
model_label: "openai/gpt-5.5".to_string(),
workspace_root: std::path::PathBuf::from("/tmp/ws"),
session_id: SessionId::from_seed(770001),
lines_count: 3,
approval_mode: "normal".to_string(),
}
}
fn render_chrome_lines(state: &ViewState, width: u16, height: u16) -> Vec<String> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|f| {
let _input_rect = draw_chrome(f, f.area(), 1, state);
})
.expect("draw");
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
let mut row = String::with_capacity(buffer.area.width as usize);
for x in 0..buffer.area.width {
let cell = &buffer[(x, y)];
row.push_str(cell.symbol());
}
row.trim_end().to_string()
})
.collect()
}
fn render_app_lines(app: &mut App, width: u16, height: u16) -> Vec<String> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw(f, app)).expect("draw");
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
let mut row = String::with_capacity(buffer.area.width as usize);
for x in 0..buffer.area.width {
let cell = &buffer[(x, y)];
row.push_str(cell.symbol());
}
row.trim_end().to_string()
})
.collect()
}
fn setup_overlay_text(app: &App) -> Vec<String> {
setup_overlay_content(app)
.0
.iter()
.map(|line| spans_plain_text(&line.spans))
.collect()
}
struct TestApp {
app: App,
_workspace: tempfile::TempDir,
_sessions: tempfile::TempDir,
}
async fn app_with_llmsim() -> TestApp {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let sessions = tempfile::tempdir().expect("sessions tempdir");
let settings = std::sync::Arc::new(crate::settings::SettingsStore::open(
sessions.path().join("settings.toml"),
));
let runtime = crate::runtime::build_with_options(
workspace.path().to_path_buf(),
crate::runtime::ProviderChoice::Sim,
None,
sessions.path().to_path_buf(),
settings,
crate::runtime::BuildOptions {
client_commands: true,
..crate::runtime::BuildOptions::default()
},
)
.await
.expect("build llmsim runtime");
let mut app = App::new(runtime);
app.model_discovery_enabled = false;
TestApp {
app,
_workspace: workspace,
_sessions: sessions,
}
}
impl App {
async fn dispatch_command_for_test(&mut self, cmd: &str) {
self.handle_command(cmd).await;
while let Ok(command) = self.ui_rx.try_recv() {
self.apply_ui_command(command);
}
}
async fn pump_turn_until_idle_for_test(&mut self) {
use std::time::Duration;
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
while self.busy {
if tokio::time::Instant::now() >= deadline {
panic!(
"turn did not complete within 15s: busy={} lines={:?}",
self.busy, self.lines
);
}
if let Some(rx) = self.rx.as_mut() {
match rx.try_recv() {
Ok(TurnEvent::Lines(lines)) => self.lines.extend(lines),
Ok(TurnEvent::Activity(activity)) => {
if !activity.fallback || self.turn_activity.is_none() {
self.turn_activity = Some(activity.text);
}
}
Ok(TurnEvent::Stream(preview)) => self.stream_preview = preview,
Ok(TurnEvent::Done) => {
self.busy = false;
self.busy_frame = 0;
self.turn_activity = None;
self.stream_preview = None;
self.rx = None;
}
Ok(TurnEvent::Failed(err)) => {
self.busy = false;
self.busy_frame = 0;
self.turn_activity = None;
self.stream_preview = None;
self.rx = None;
self.push_system(format!("turn failed: {err}"));
}
Err(mpsc::error::TryRecvError::Empty) => {}
Err(mpsc::error::TryRecvError::Disconnected) => {
self.busy = false;
self.turn_activity = None;
self.stream_preview = None;
self.rx = None;
}
}
}
if self.busy {
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
}
async fn replay_transcript_for_test(&mut self) {
self.emit_replayed_transcript().await;
}
}
async fn llmsim_settings(
sessions: &tempfile::TempDir,
) -> std::sync::Arc<crate::settings::SettingsStore> {
let settings_path = sessions.path().join("settings.toml");
std::fs::write(settings_path, "provider = \"llmsim\"\n").expect("write settings");
std::sync::Arc::new(crate::settings::SettingsStore::open(
sessions.path().join("settings.toml"),
))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn enter_submit_completes_llmsim_turn_in_transcript() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
app.lines.clear();
for ch in "hello turn".chars() {
app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty()))
.await;
}
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(app.busy, "submit should start a background turn");
app.pump_turn_until_idle_for_test().await;
assert!(
app.lines
.iter()
.any(|line| matches!(line.author, Author::User) && line.text == "hello turn"),
"user prompt should land in the transcript: {:?}",
app.lines
);
assert!(
app.lines.iter().any(|line| {
matches!(line.author, Author::Assistant) && line.text.contains("offline mode")
}),
"assistant reply should finalize into the transcript: {:?}",
app.lines
);
assert!(!app.busy);
assert!(app.stream_preview.is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_replays_prior_turn_into_transcript() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let sessions = tempfile::tempdir().expect("sessions tempdir");
let settings = llmsim_settings(&sessions).await;
let first = crate::runtime::build_with_options(
workspace.path().to_path_buf(),
crate::runtime::ProviderChoice::Sim,
None,
sessions.path().to_path_buf(),
settings.clone(),
crate::runtime::BuildOptions {
client_commands: true,
..crate::runtime::BuildOptions::default()
},
)
.await
.expect("build first runtime");
let session_id = first.handles.session_id;
let prompt = "prior turn";
let input = first.model.input_message(prompt.to_string());
first
.handles
.runtime
.run_turn(session_id, input)
.await
.expect("first turn");
drop(first);
let mut resumed = None;
for _ in 0..20 {
match crate::runtime::build_with_options(
workspace.path().to_path_buf(),
crate::runtime::ProviderChoice::Sim,
Some(session_id),
sessions.path().to_path_buf(),
settings.clone(),
crate::runtime::BuildOptions {
client_commands: true,
..crate::runtime::BuildOptions::default()
},
)
.await
{
Ok(runtime) => {
resumed = Some(runtime);
break;
}
Err(err) if err.to_string().contains("another yolop process") => {
tokio::time::sleep(Duration::from_millis(25)).await;
}
Err(err) => panic!("build resumed runtime: {err}"),
}
}
let resumed = resumed.expect("build resumed runtime after releasing first log lock");
assert!(
resumed.startup.replayed_events > 0,
"resume should report replayed events"
);
let mut app = App::new(resumed);
app.setup = None;
app.lines.clear();
app.replay_transcript_for_test().await;
assert!(
app.lines
.iter()
.any(|line| matches!(line.author, Author::User) && line.text == prompt),
"replayed transcript should include the prior user prompt: {:?}",
app.lines
);
assert!(
app.lines.iter().any(|line| {
matches!(line.author, Author::Assistant) && line.text.contains("offline mode")
}),
"replayed transcript should include the prior assistant reply: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn startup_banner_names_ctrl_c_and_ctrl_d_as_exit_keys() {
let fixture = app_with_llmsim().await;
assert!(
fixture
.app
.lines
.iter()
.any(|line| line.text.contains("Ctrl-C or Ctrl-D to exit")),
"startup banner should name Ctrl-C/Ctrl-D exits: {:?}",
fixture.app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn startup_banner_is_kept_out_of_inline_viewport() {
let mut fixture = app_with_llmsim().await;
let rows = render_app_lines(&mut fixture.app, 96, COMPOSER_VIEWPORT_HEIGHT);
assert!(
!rows.iter().any(|row| row.contains("workspace:")),
"startup transcript should render through scrollback, not the inline viewport: {rows:?}"
);
assert!(
!rows.iter().any(|row| row.contains("type /help")),
"startup transcript should not be mirrored above the composer: {rows:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inline_viewport_shows_recent_transcript_lines() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
app.lines.clear();
app.push_user("What changed last time?".into());
app.lines.push(ChatLine {
author: Author::Assistant,
text: "The renderer now mirrors resumed history.".into(),
});
let rows = render_app_lines(app, 96, COMPOSER_VIEWPORT_HEIGHT);
assert!(
rows.iter()
.any(|row| row.contains("What changed last time?")),
"inline viewport should show recent user transcript: {rows:?}"
);
assert!(
rows.iter()
.any(|row| row.contains("mirrors resumed history")),
"inline viewport should show recent assistant transcript: {rows:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inline_viewport_uses_recent_transcript_tail() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
app.lines.clear();
for index in 0..20 {
app.push_user(format!("old line {index}"));
}
app.lines.push(ChatLine {
author: Author::Assistant,
text: "newest resumed line".into(),
});
let rows = render_app_lines(app, 96, COMPOSER_VIEWPORT_HEIGHT);
assert!(
!rows.iter().any(|row| row.contains("old line 0")),
"inline viewport should drop old transcript head: {rows:?}"
);
assert!(
rows.iter().any(|row| row.contains("newest resumed line")),
"inline viewport should keep transcript tail: {rows:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inline_viewport_bounds_large_recent_entries() {
let bounded = bounded_recent_chat_line(&ChatLine {
author: Author::Assistant,
text: format!(
"{} visible-tail",
"hidden-head ".repeat(RECENT_TRANSCRIPT_MAX_TEXT_BYTES)
),
});
assert!(
bounded.text.len() <= RECENT_TRANSCRIPT_MAX_TEXT_BYTES,
"bounded text should fit the inline render budget"
);
assert!(bounded.text.starts_with('…'), "bounded text: {bounded:?}");
assert!(
bounded.text.ends_with("visible-tail"),
"recent transcript should keep the tail: {bounded:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn inline_viewport_stops_rendering_after_visible_tail_is_full() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
app.lines.clear();
app.lines.push(ChatLine {
author: Author::Assistant,
text: "older invisible line".repeat(200),
});
for index in 0..20 {
app.push_user(format!("new line {index}"));
}
let rows = render_app_lines(app, 96, COMPOSER_VIEWPORT_HEIGHT);
assert!(
!rows.iter().any(|row| row.contains("older invisible")),
"inline viewport should avoid rendering invisible older entries: {rows:?}"
);
assert!(
rows.iter().any(|row| row.contains("new line 19")),
"inline viewport should keep newest entries: {rows:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resumed_session_renders_replayed_history_in_inline_viewport() {
let workspace = tempfile::tempdir().expect("workspace tempdir");
let sessions = tempfile::tempdir().expect("sessions tempdir");
let session_id = SessionId::from_seed(321987);
let session_dir = crate::session_log::session_dir_path(sessions.path(), session_id);
std::fs::create_dir_all(&session_dir).expect("session dir");
let log_path = crate::session_log::session_log_path(&session_dir);
let events = [
RuntimeEvent::new(
session_id,
EventContext::empty(),
InputMessageData::new(Message::user("previous question")),
),
RuntimeEvent::new(
session_id,
EventContext::empty(),
OutputMessageCompletedData::new(Message::assistant("previous answer")),
),
];
let jsonl = events
.iter()
.map(|event| serde_json::to_string(event).expect("serialize event"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&log_path, format!("{jsonl}\n")).expect("session log");
let settings = std::sync::Arc::new(crate::settings::SettingsStore::open(
sessions.path().join("settings.toml"),
));
let runtime = crate::runtime::build_with_options(
workspace.path().to_path_buf(),
crate::runtime::ProviderChoice::Sim,
Some(session_id),
sessions.path().to_path_buf(),
settings,
crate::runtime::BuildOptions {
client_commands: true,
..crate::runtime::BuildOptions::default()
},
)
.await
.expect("build resumed runtime");
let mut app = App::new(runtime);
app.setup = None;
app.emit_replayed_transcript().await;
let rows = render_app_lines(&mut app, 96, COMPOSER_VIEWPORT_HEIGHT);
assert!(
rows.iter().any(|row| row.contains("previous question")),
"inline viewport should show replayed user message: {rows:?}"
);
assert!(
rows.iter().any(|row| row.contains("previous answer")),
"inline viewport should show replayed assistant message: {rows:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn help_command_names_ctrl_c_and_ctrl_d_as_exit_keys() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.dispatch_command_for_test("help").await;
assert!(
app.lines
.iter()
.any(|line| line.text.contains("exit: Ctrl-C / Ctrl-D")),
"help output should name Ctrl-C/Ctrl-D exits: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cwd_command_prints_workspace_root() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.dispatch_command_for_test("cwd").await;
let root = app.startup.workspace_root.display().to_string();
assert!(
app.lines
.iter()
.any(|line| line.text.contains("workspace root:") && line.text.contains(&root)),
"cwd should print the workspace root: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tools_command_lists_available_tools() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.dispatch_command_for_test("tools").await;
assert!(
app.lines
.iter()
.any(|line| line.text.starts_with("tools:") && line.text.contains("bash")),
"tools should list available tools: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn clear_command_wipes_transcript_and_re_emits_banner() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.push_system("sentinel line that must be cleared".into());
app.printed_lines = app.lines.len();
assert_ne!(app.printed_lines, 0);
app.dispatch_command_for_test("clear").await;
assert!(
!app.lines
.iter()
.any(|line| line.text.contains("sentinel line")),
"clear should wipe prior transcript lines: {:?}",
app.lines
);
assert_eq!(app.printed_lines, 0, "clear should reset the print cursor");
assert!(
app.lines
.iter()
.any(|line| line.text.contains("type /help")),
"clear should re-emit the startup banner: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn quit_command_and_exit_alias_request_shutdown() {
for command in ["quit", "exit"] {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
assert!(!app.should_quit);
app.dispatch_command_for_test(command).await;
assert!(
app.should_quit,
"/{command} should request shutdown via the UI channel"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_slash_input_renders_command_suggestions_end_to_end() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::empty()))
.await;
let state = app.view_state();
assert!(
state
.command_suggestions
.iter()
.any(|suggestion| suggestion.completion == "/help"),
"expected /help suggestion in view state: {:?}",
state.command_suggestions
);
let rows = render_chrome_lines(&state, 80, 5);
assert!(
rows[0].contains("Tab /help"),
"slash input should render command suggestions in chrome row: {:?}",
rows
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shift_enter_inserts_newline_without_submitting() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
for key in [
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()),
] {
app.handle_key(key).await;
}
assert_eq!(app.input_text(), "a\nb\nc");
assert_eq!(app.input_height(), 3);
assert!(
app.lines
.iter()
.all(|line| !matches!(line.author, Author::User)),
"Shift-Enter should edit the composer, not submit: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn alt_shift_enter_submits_instead_of_inserting_newline() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()))
.await;
app.handle_key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::ALT | KeyModifiers::SHIFT,
))
.await;
assert_eq!(app.input_text(), "");
assert!(
app.lines
.iter()
.any(|line| matches!(line.author, Author::User) && line.text == "a"),
"Alt-Shift-Enter should submit the composer: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shifted_printable_chars_insert_literal_character() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
for key in [
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::SHIFT),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT),
KeyEvent::new(KeyCode::Char('1'), KeyModifiers::SHIFT),
] {
app.handle_key(key).await;
}
assert_eq!(app.input_text(), "?A!");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multiline_input_height_is_bounded() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
assert_eq!(app.input_height(), 1);
for expected in 2..=MAX_INPUT_HEIGHT {
app.input.insert_newline();
assert_eq!(app.input_height(), expected);
}
app.input.insert_newline();
assert_eq!(app.input_height(), MAX_INPUT_HEIGHT);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_command_starts_guided_wizard() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
app.lines.clear();
app.handle_command("setup").await;
let llmsim_index = PROVIDER_OPTIONS
.iter()
.position(|option| option.name == "llmsim")
.expect("llmsim provider option");
assert!(matches!(
app.setup,
Some(SetupStep::Provider { selected }) if selected == llmsim_index
));
assert!(
app.lines.is_empty(),
"plain /setup should open the overlay without transcript chatter: {:?}",
app.lines
);
let rendered = setup_overlay_text(app);
assert!(rendered.iter().any(|line| line.contains("Set Up Yolop")));
assert!(rendered.iter().any(|line| line.contains("OpenAI")));
assert!(rendered.iter().any(|line| line.contains("Offline demo")));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_overlay_renders_full_provider_picker() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.setup = Some(SetupStep::Provider { selected: 0 });
let rows = render_app_lines(app, 110, COMPOSER_VIEWPORT_HEIGHT);
assert!(
rows.iter().any(|line| line.contains("Set Up Yolop")),
"setup title should be visible: {rows:?}"
);
assert!(
rows.iter().any(|line| line.contains("OpenAI")),
"provider choices should be visible: {rows:?}"
);
assert!(
!rows.iter().any(|line| line.contains("recommended")),
"setup should not recommend a specific provider: {rows:?}"
);
assert!(
rows.iter().any(|line| line.contains("Offline demo mode")),
"last provider choice should not be clipped: {rows:?}"
);
assert!(
rows.iter().any(|line| line.contains("Esc cancel")),
"footer should not be clipped: {rows:?}"
);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_provider_picker_enters_credential_panel() {
let _guard = crate::test_env::lock();
unsafe {
std::env::remove_var("OPENAI_API_KEY");
}
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.setup = Some(SetupStep::Provider { selected: 0 });
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(matches!(
app.setup,
Some(SetupStep::Credential {
ref provider,
selected: 0,
..
}) if provider == "openai"
));
let rendered = setup_overlay_text(app);
assert!(
rendered
.iter()
.any(|line| line.contains("API Key for OpenAI"))
);
assert!(
rendered
.iter()
.any(|line| line.contains("Use OPENAI_API_KEY from environment"))
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_row_keeps_a_gap_when_label_overflows_column() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = Some(SetupStep::Credential {
provider: "openai".to_string(),
selected: 0,
error: None,
});
let rendered = setup_overlay_text(app);
let env_row = rendered
.iter()
.find(|line| line.contains("from environment"))
.expect("credential panel should render the use-env row");
assert!(
env_row.contains("environment "),
"hint must stay separated from the overflowing label: {env_row:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_token_input_masks_secret_and_moves_to_model_picker() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.setup = Some(SetupStep::TokenInput {
provider: "openai".to_string(),
token: String::new(),
error: None,
});
for ch in "test-token".chars() {
app.handle_setup_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty()))
.await;
}
let rendered = setup_overlay_text(app);
assert!(
!rendered.iter().any(|line| line.contains("test-token")),
"raw token should never render: {rendered:?}"
);
assert!(
rendered.iter().any(|line| line.contains("••••••••••")),
"masked token should render: {rendered:?}"
);
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(matches!(
app.setup,
Some(SetupStep::PickModel {
ref provider,
..
}) if provider == "openai"
));
assert!(
!app.lines
.iter()
.any(|line| line.text.starts_with("setup token stored for")),
"wizard should hide internal setup command success output"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_wizard_can_select_offline_provider() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
let llmsim_index = PROVIDER_OPTIONS
.iter()
.position(|option| option.name == "llmsim")
.expect("llmsim provider option");
app.setup = Some(SetupStep::Provider {
selected: llmsim_index,
});
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(app.setup.is_none());
assert_eq!(app.model.provider_label(), "llmsim/llmsim-yolop");
assert!(
app.lines
.iter()
.any(|line| line.text == "setup complete: offline demo mode")
);
assert!(
!app.lines
.iter()
.any(|line| line.text.starts_with("setup provider changed:")),
"wizard should hide internal setup command success output"
);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_provider_picker_shows_connection_status() {
let _guard = crate::test_env::lock();
unsafe {
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("CUSTOM_BASE_URL");
}
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = Some(SetupStep::Provider { selected: 0 });
let rendered = setup_overlay_text(app);
let openai_row = rendered
.iter()
.find(|line| line.contains("OpenAI") && !line.contains("compatible"))
.expect("openai row");
assert!(
openai_row.contains("needs API key"),
"unconnected provider should say so: {openai_row:?}"
);
let custom_row = rendered
.iter()
.find(|line| line.contains("Custom endpoint"))
.expect("custom row");
assert!(
custom_row.contains("needs base URL"),
"custom without URL should say so: {custom_row:?}"
);
app.settings
.set_token("openai".to_string(), "sk-test".to_string())
.expect("save token");
let rendered = setup_overlay_text(app);
let openai_row = rendered
.iter()
.find(|line| line.contains("OpenAI") && !line.contains("compatible"))
.expect("openai row");
assert!(
openai_row.contains("✓ saved key"),
"saved key should mark the provider connected: {openai_row:?}"
);
}
fn discovered(model_id: &str, display_name: Option<&str>) -> DiscoveredProviderModel {
DiscoveredProviderModel {
model_id: model_id.to_string(),
display_name: display_name.map(str::to_string),
description: None,
}
}
#[test]
fn model_window_centers_selection_in_long_lists() {
assert_eq!(model_window(0, 5, 8), (0, 5));
assert_eq!(model_window(0, 300, 8), (0, 8));
assert_eq!(model_window(150, 300, 8), (146, 154));
assert_eq!(model_window(299, 300, 8), (292, 300));
}
#[test]
fn discovered_models_convert_to_options_with_custom_escape_hatch() {
let mut described = discovered("openai/gpt-5.2", Some("OpenAI: GPT-5.2"));
described.description = Some("optimized for long-running agents".to_string());
let options = model_options_from_discovered(vec![
described,
discovered("nvidia/nemotron-3-super-120b-a12b", None),
]);
assert_eq!(options.len(), 3);
assert_eq!(options[0].spec.as_deref(), Some("openai/gpt-5.2"));
assert_eq!(options[0].label, "openai/gpt-5.2");
assert_eq!(
options[0].hint,
"OpenAI: GPT-5.2 · optimized for long-running agents"
);
assert_eq!(
options[1].spec.as_deref(),
Some("nvidia/nemotron-3-super-120b-a12b")
);
assert!(options[2].spec.is_none(), "last option must stay Custom...");
}
#[test]
fn discovered_model_hints_are_truncated_for_one_row_display() {
let mut model = discovered("verbose/model", None);
model.description = Some("x".repeat(200));
let options = model_options_from_discovered(vec![model]);
assert!(
options[0].hint.chars().count() <= 72,
"hint must fit one picker row: {} chars",
options[0].hint.chars().count()
);
assert!(options[0].hint.ends_with('…'));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn discovered_models_replace_fallback_options_in_open_picker() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = Some(SetupStep::PickModel {
provider: "openrouter".to_string(),
selected: 0,
custom: None,
error: None,
});
app.apply_model_discovery(ModelDiscovery {
provider: "openrouter".to_string(),
result: Ok(Some(model_options_from_discovered(vec![
discovered("zai/glm-5", None),
discovered("moon/kimi-k3", None),
]))),
});
let options = app.model_options("openrouter");
assert_eq!(options.len(), 3);
assert_eq!(options[0].spec.as_deref(), Some("zai/glm-5"));
let rendered = setup_overlay_text(app);
assert!(
rendered.iter().any(|line| line.contains("zai/glm-5")),
"open picker should render discovered models: {rendered:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unsupported_model_discovery_keeps_fallback_options() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.apply_model_discovery(ModelDiscovery {
provider: "ollama".to_string(),
result: Ok(None),
});
let options = app.model_options("ollama");
assert_eq!(options[0].spec.as_deref(), Some("llama3.2"));
app.model_discovery_enabled = true;
app.request_model_discovery("ollama");
assert!(!app.is_fetching_models("ollama"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_model_discovery_surfaces_error_in_open_picker() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = Some(SetupStep::PickModel {
provider: "openai".to_string(),
selected: 0,
custom: None,
error: None,
});
app.apply_model_discovery(ModelDiscovery {
provider: "openai".to_string(),
result: Err("connection refused".to_string()),
});
assert!(matches!(
app.setup,
Some(SetupStep::PickModel { ref error, .. })
if error.as_deref() == Some("model list unavailable: connection refused")
));
let options = app.model_options("openai");
assert_eq!(options[0].spec.as_deref(), Some("gpt-5.5"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_connected_provider_jumps_straight_to_model_picker() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.settings
.set_token("openai".to_string(), "sk-test".to_string())
.expect("save token");
app.setup = Some(SetupStep::Provider { selected: 0 });
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(
matches!(
app.setup,
Some(SetupStep::PickModel { ref provider, .. }) if provider == "openai"
),
"connected provider should skip the credential step: {:?}",
app.setup
);
assert_eq!(app.model.provider_label(), "openai/gpt-5.5 medium");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_model_picker_preset_selection_applies_and_persists() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.settings
.set_token("openai".to_string(), "sk-test".to_string())
.expect("save token");
app.setup = Some(SetupStep::Provider { selected: 0 });
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(
matches!(app.setup, Some(SetupStep::PickModel { .. })),
"fast path should open the model picker: {:?}",
app.setup
);
app.handle_setup_key(KeyEvent::new(KeyCode::Down, KeyModifiers::empty()))
.await;
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(app.setup.is_none(), "wizard should finish: {:?}", app.setup);
assert_eq!(app.model.provider_label(), "openai/gpt-5.4 medium");
assert!(
app.lines
.iter()
.any(|line| line.text == "setup complete: openai/gpt-5.4 medium"),
"completion line should report the picked model: {:?}",
app.lines
);
let snapshot = app.settings.snapshot();
assert_eq!(snapshot.provider.as_deref(), Some("openai"));
assert_eq!(snapshot.model_for("openai"), Some("gpt-5.4 medium"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_c_key_opens_credential_panel_even_when_connected() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.settings
.set_token("openai".to_string(), "sk-test".to_string())
.expect("save token");
app.setup = Some(SetupStep::Provider { selected: 0 });
app.handle_setup_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()))
.await;
assert!(
matches!(
app.setup,
Some(SetupStep::Credential { ref provider, .. }) if provider == "openai"
),
"c should open credential config: {:?}",
app.setup
);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn setup_custom_endpoint_flow_collects_url_and_model() {
let _guard = crate::test_env::lock();
unsafe {
std::env::remove_var("CUSTOM_BASE_URL");
std::env::remove_var("CUSTOM_API_KEY");
}
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
let custom_index = PROVIDER_OPTIONS
.iter()
.position(|option| option.name == "custom")
.expect("custom provider option");
app.setup = Some(SetupStep::Provider {
selected: custom_index,
});
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(
matches!(app.setup, Some(SetupStep::BaseUrlInput { .. })),
"custom without URL should ask for one: {:?}",
app.setup
);
for ch in "http://localhost:8000/v1".chars() {
app.handle_setup_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty()))
.await;
}
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(
matches!(
app.setup,
Some(SetupStep::Credential { ref provider, selected: 0, .. }) if provider == "custom"
),
"saved URL should advance to the credential step: {:?}",
app.setup
);
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(
matches!(
app.setup,
Some(SetupStep::PickModel { ref provider, custom: None, .. })
if provider == "custom"
),
"custom credential step should advance to the model picker: {:?}",
app.setup
);
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(
matches!(
app.setup,
Some(SetupStep::PickModel { ref provider, custom: Some(_), .. })
if provider == "custom"
),
"Custom... should open the free-form model input: {:?}",
app.setup
);
for ch in "qwen3-coder".chars() {
app.handle_setup_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty()))
.await;
}
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(app.setup.is_none(), "wizard should finish: {:?}", app.setup);
assert_eq!(app.model.provider_label(), "custom/qwen3-coder");
let snapshot = app.settings.snapshot();
assert_eq!(
snapshot.base_url_for("custom"),
Some("http://localhost:8000/v1")
);
assert_eq!(snapshot.provider.as_deref(), Some("custom"));
assert_eq!(snapshot.model_for("custom"), Some("qwen3-coder"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_command_opens_model_picker_overlay() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.dispatch_command_for_test("model").await;
assert!(matches!(
app.setup,
Some(SetupStep::PickModel {
ref provider,
..
}) if provider == "llmsim"
));
let rendered = setup_overlay_text(app);
assert!(rendered.iter().any(|line| line.contains("Select Model")));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_command_preselects_current_raw_model_id() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.setup = None;
app.handle_command("setup token openai sk-test").await;
app.run_setup_command(Some("provider openai"))
.await
.expect("set openai provider");
app.run_setup_command(Some("model gpt-5.4"))
.await
.expect("set openai model");
app.lines.clear();
app.dispatch_command_for_test("model").await;
assert!(matches!(
app.setup,
Some(SetupStep::PickModel {
ref provider,
selected,
..
}) if provider == "openai" && selected == 1
));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_command_with_arg_opens_prefilled_model_modal() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.setup = None;
app.handle_command("setup token openai sk-test").await;
app.run_setup_command(Some("provider openai"))
.await
.expect("set openai provider");
app.lines.clear();
app.dispatch_command_for_test("model gpt-5.4 high").await;
assert_eq!(app.model.provider_label(), "openai/gpt-5.5 medium");
assert!(matches!(
app.setup,
Some(SetupStep::PickModel {
ref provider,
ref custom,
..
}) if provider == "openai" && custom.as_deref() == Some("gpt-5.4 high")
));
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(app.setup.is_none());
assert_eq!(app.model.provider_label(), "openai/gpt-5.4 high");
assert!(
app.lines
.iter()
.any(|line| line.text == "setup complete: openai/gpt-5.4 high"),
"model modal should report completion: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn effort_command_opens_effort_modal_and_confirms_selection() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.setup = None;
app.handle_command("setup token openai sk-test").await;
app.run_setup_command(Some("provider openai"))
.await
.expect("set openai provider");
app.run_setup_command(Some("model gpt-5.4"))
.await
.expect("set openai model");
app.lines.clear();
app.dispatch_command_for_test("effort high").await;
assert_eq!(app.model.provider_label(), "openai/gpt-5.4 medium");
assert!(matches!(
app.setup,
Some(SetupStep::PickEffort { selected: 3, .. })
));
let rendered = setup_overlay_text(app);
assert!(
rendered
.iter()
.any(|line| line.contains("Select Reasoning Effort"))
);
app.handle_setup_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()))
.await;
assert!(app.setup.is_none());
assert_eq!(app.model.provider_label(), "openai/gpt-5.4 high");
assert!(
app.lines
.iter()
.any(|line| line.text == "setup complete: openai/gpt-5.4 high"),
"effort modal should report completion: {:?}",
app.lines
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn effort_modal_does_not_mark_unset_openrouter_effort_current() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.lines.clear();
app.setup = None;
app.handle_command("setup token openrouter sk-test").await;
app.run_setup_command(Some("provider openrouter"))
.await
.expect("set openrouter provider");
app.run_setup_command(Some("model nvidia/nemotron-3-super-120b-a12b"))
.await
.expect("set openrouter model");
app.lines.clear();
app.dispatch_command_for_test("effort").await;
assert_eq!(
app.model.provider_label(),
"openrouter/nvidia/nemotron-3-super-120b-a12b"
);
let rendered = setup_overlay_text(app);
assert!(
!rendered.iter().any(|line| line.contains("· current")),
"unset OpenRouter effort should not render a current marker: {rendered:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_view_state_hides_command_suggestions_when_input_disabled() {
let mut fixture = app_with_llmsim().await;
let app = &mut fixture.app;
app.setup = None;
app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::empty()))
.await;
assert!(
!app.view_state().command_suggestions.is_empty(),
"slash input should produce suggestions before input is disabled"
);
app.busy = true;
assert!(
app.view_state().command_suggestions.is_empty(),
"busy turns should not render suggestions"
);
}
#[test]
fn chrome_command_suggestions_override_stream_preview_row() {
let state = ViewState {
stream_preview: Some(StreamPreview {
kind: StreamKind::Assistant,
text: "streaming response".to_string(),
}),
command_suggestions: vec![CommandSuggestion {
completion: "/help".to_string(),
label: "/help show commands".to_string(),
}],
..view_state_idle()
};
let rows = render_chrome_lines(&state, 80, 5);
assert!(
rows[0].contains("Tab /help"),
"suggestions should render in the top chrome row: {:?}",
rows
);
assert!(
!rows[0].contains("agent"),
"command suggestions should replace the stream preview row: {}",
rows[0]
);
}
#[test]
fn draw_suggestions_ignores_empty_areas() {
let suggestions = vec![CommandSuggestion {
completion: "/help".to_string(),
label: "/help show commands".to_string(),
}];
let backend = TestBackend::new(4, 1);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|f| {
draw_suggestions(
f,
Rect {
x: 0,
y: 0,
width: 0,
height: 1,
},
&suggestions,
);
draw_suggestions(
f,
Rect {
x: 0,
y: 0,
width: 4,
height: 0,
},
&suggestions,
);
})
.expect("draw");
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 0)].symbol(), " ");
}
#[test]
fn chrome_idle_shows_enter_to_send_hint() {
let state = view_state_idle();
let rows = render_chrome_lines(&state, 80, 5);
assert!(
rows[1].contains("Enter to send"),
"idle separator missing Enter hint: rows={rows:?}"
);
}
#[test]
fn chrome_busy_shows_thinking_spinner_and_activity() {
let state = ViewState {
busy: true,
busy_frame: 4,
turn_activity: Some("reading files".to_string()),
..view_state_idle()
};
let rows = render_chrome_lines(&state, 80, 5);
assert!(
rows[1].contains("reading files"),
"busy separator should display turn activity: {}",
rows[1]
);
assert!(
rows[1].contains("input disabled"),
"busy separator should signal input is disabled: {}",
rows[1]
);
}
#[test]
fn chrome_busy_falls_back_to_thinking_when_activity_unset() {
let state = ViewState {
busy: true,
..view_state_idle()
};
let rows = render_chrome_lines(&state, 80, 5);
assert!(
rows[1].contains("thinking"),
"busy separator without activity should show 'thinking': {}",
rows[1]
);
}
#[test]
fn chrome_renders_stream_preview_tail_with_kind_label() {
let state = ViewState {
stream_preview: Some(StreamPreview {
kind: StreamKind::Assistant,
text: "first line\nsecond line tail".to_string(),
}),
..view_state_idle()
};
let rows = render_chrome_lines(&state, 80, 5);
assert!(
rows[0].contains("agent"),
"stream preview should show kind label 'agent': {}",
rows[0]
);
assert!(
rows[0].contains("second line tail"),
"stream preview should show the tail, not the head: {}",
rows[0]
);
assert!(
!rows[0].contains("first line"),
"stream preview should not show earlier lines: {}",
rows[0]
);
}
#[test]
fn chrome_stream_preview_thinking_uses_thinking_label() {
let state = ViewState {
stream_preview: Some(StreamPreview {
kind: StreamKind::Thinking,
text: "weighing options".to_string(),
}),
..view_state_idle()
};
let rows = render_chrome_lines(&state, 80, 5);
assert!(
rows[0].contains("thinking"),
"thinking-kind preview should use 'thinking' label: {}",
rows[0]
);
}
#[test]
fn chrome_session_status_shows_model_workspace_msgs_and_session() {
let state = ViewState {
model_label: "anthropic/claude-sonnet-4-5".to_string(),
workspace_root: std::path::PathBuf::from("/tmp/some-workspace"),
session_id: SessionId::from_seed(99887766),
lines_count: 42,
..view_state_idle()
};
let rows = render_chrome_lines(&state, 160, 5);
let status = &rows[4];
assert!(
status.contains("anthropic/claude-sonnet-4-5"),
"status should include model label: {status}"
);
assert!(
status.contains("/tmp/some-workspace"),
"status should include workspace path: {status}"
);
assert!(
status.contains("42 msgs"),
"status should include message count: {status}"
);
assert!(
status.contains("approval normal"),
"status should include the soft-approval level: {status}"
);
assert!(
status.contains("session "),
"status should include 'session' label: {status}"
);
let session_id_str = state.session_id.to_string();
assert!(
status.contains(&session_id_str),
"status should include the session id ({session_id_str}): {status}"
);
}
#[test]
fn chrome_session_status_collapses_home_prefix_with_tilde() {
let prior = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", "/tmp/fake-home");
}
let state = ViewState {
workspace_root: std::path::PathBuf::from("/tmp/fake-home/projects/yolop"),
..view_state_idle()
};
let rows = render_chrome_lines(&state, 120, 5);
let status = &rows[4];
assert!(
status.contains("~/projects/yolop"),
"status should collapse $HOME to ~: {status}"
);
unsafe {
match prior {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
fn chrome_stream_preview_row_is_empty_when_none() {
let state = view_state_idle();
let rows = render_chrome_lines(&state, 80, 5);
assert!(
rows[0].is_empty(),
"stream preview row should be empty when no preview is set: {:?}",
rows[0]
);
}
}