use std::{
collections::VecDeque,
future::Future,
io::Write,
path::PathBuf,
pin::Pin,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
use futures_util::{task::noop_waker_ref, FutureExt};
use tokio::sync::{mpsc, oneshot};
use crossterm::{
event::{
self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
execute,
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Position, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
DefaultTerminal, Frame, Terminal, TerminalOptions, Viewport,
};
mod config_picker;
mod login;
mod markdown;
mod model_picker;
mod picker;
mod provider_picker;
mod render;
mod session_picker;
mod skill_picker;
mod statusline;
mod stream;
mod theme;
use markdown::push_wrapped_markdown;
use picker::{PickerAction, PickerBadge, PickerBadgeTone, PickerItem, UiPicker};
use render::{
display_width, entry_lines, input_cursor_position, input_visual_lines, picker_lines,
push_wrapped_text, push_wrapped_text_with, session_header_lines, styled_line,
truncate_one_line, wrap_line_at_whitespace, LineFill,
};
use statusline::{statusline_lines, StatusLineState};
use stream::{AppendOnlyStream, StreamFragment};
use theme::Theme;
use crate::{
agent::{
Agent, AgentEvent, QuestionnaireAnswer, QuestionnaireQuestion, QuestionnaireQuestionKind,
QuestionnaireRequest, QuestionnaireResponse, SessionHistorySink,
},
auth::{codex_oauth, github_copilot_device},
clipboard_image::read_clipboard_image,
commands::{self, CommandId, CommandInvocation, CommandSpec},
config::Config,
credentials::{
available_auth_modes, delete_provider_credentials, provider_has_credentials,
provider_has_env_override, save_codex_tokens, save_github_copilot_tokens,
save_provider_api_key, CodexTokens, CredentialStore, GitHubCopilotTokens,
OsCredentialStore,
},
herdr::{HerdrReporter, HerdrState},
model::{
build_provider,
catalog::{self, LoginTarget, ModelSelection},
image_summary,
models_dev::{cached_model_metadata, fetch_model_metadata},
provider_models::refresh_provider_models_with_store,
registry::{self, ProviderAuthKind},
ContentBlock, ContextUsage, ImageContent, Message, ModelMetadata, ModelRequest,
ModelResponse, ModelUsage, UnavailableProvider,
},
reasoning::ReasoningLevel,
session::Session,
tool::ToolDisplayStyle,
};
const INLINE_VIEWPORT_HEIGHT: u16 = 18;
const PASTE_BURST_GAP: Duration = Duration::from_millis(12);
const PASTE_ENTER_SUPPRESSION: Duration = Duration::from_millis(120);
const PASTE_BURST_MIN_CHARS: usize = 2;
const PASTE_COLLAPSE_MIN_LINES: usize = 2;
const PASTE_COLLAPSE_MIN_CHARS: usize = 1000;
const MAX_COMMAND_SUGGESTIONS: usize = 5;
const RECOVERED_HISTORY_LINE_LIMIT: usize = 200;
const STREAM_PREVIEW_DELAY: Duration = Duration::from_millis(24);
const STREAM_PREVIEW_MIN_CHARS: usize = 2;
pub struct TuiInfo {
pub cwd: PathBuf,
pub provider: String,
pub model: String,
pub reasoning: ReasoningLevel,
pub show_reasoning_output: bool,
pub auth: String,
pub title_provider: Option<String>,
pub title_model: Option<String>,
pub title_auth: Option<String>,
pub max_tool_output_lines: usize,
pub questionnaire_enabled: bool,
pub session_id: Option<String>,
pub open_resume_picker: bool,
pub config_path: Option<PathBuf>,
pub auth_unavailable: Option<String>,
pub update_notice: Option<String>,
pub herdr: HerdrReporter,
}
pub struct TuiResult {
pub resume_session_id: Option<String>,
exit_lines: Vec<Line<'static>>,
}
pub async fn run(agent: &mut Agent, info: TuiInfo) -> anyhow::Result<TuiResult> {
agent.set_session_id(info.session_id.clone());
let mut terminal = ratatui::init_with_options(TerminalOptions {
viewport: Viewport::Inline(INLINE_VIEWPORT_HEIGHT),
});
Theme::initialize_from_terminal();
let bracketed_paste_enabled = enable_bracketed_paste().is_ok();
let modified_keys_enabled = enable_modified_keys().is_ok();
let keyboard_enhancements_enabled = enable_keyboard_enhancements().is_ok();
let herdr = info.herdr.clone();
let initial_state = if info.auth_unavailable.is_some() {
HerdrState::Blocked
} else {
HerdrState::Idle
};
herdr
.report_state(
initial_state,
info.auth_unavailable.as_deref(),
info.session_id.as_deref(),
)
.await;
let result = App::new(info).run(&mut terminal, agent).await;
herdr.release().await;
if keyboard_enhancements_enabled {
let _ = disable_keyboard_enhancements();
}
if modified_keys_enabled {
let _ = disable_modified_keys();
}
if bracketed_paste_enabled {
let _ = disable_bracketed_paste();
}
ratatui::restore();
if let Ok(result) = &result {
print_exit_lines(&result.exit_lines)?;
}
result
}
struct ActiveFrame {
lines: Vec<Line<'static>>,
cursor: Position,
}
struct LiveStreamPreview {
kind: StreamKind,
text: String,
include_leading_blank: bool,
}
struct App {
info: TuiInfo,
input: String,
input_cursor: usize,
status: String,
should_quit: bool,
ctrl_c_streak: u8,
assistant_stream: AppendOnlyStream,
assistant_stream_in_code_block: bool,
reasoning_stream: AppendOnlyStream,
current_stream_kind: Option<StreamKind>,
stream_preview_deadline: Option<Instant>,
live_stream_preview: Option<LiveStreamPreview>,
current_turn_start: Option<usize>,
active_turn_show_reasoning_output: bool,
running: bool,
loading_spinner: LoadingSpinner,
active_tool_call: bool,
pending_tool_call: Option<ToolEntry>,
steering_prompts: VecDeque<String>,
queued_prompts: VecDeque<QueuedPrompt>,
pending_images: Vec<ImageContent>,
input_history: Vec<String>,
input_history_cursor: Option<usize>,
input_history_draft: Option<InputDraft>,
paste_burst: PasteBurst,
paste_segments: Vec<PasteSegment>,
transcript: Vec<Entry>,
last_status_notice: Option<String>,
last_inserted_was_tool: bool,
command_selection: usize,
command_prefix: Option<String>,
command_palette_dismissed: bool,
composer: ComposerMode,
credential_store: Arc<dyn CredentialStore>,
available_auths: Vec<String>,
using_unavailable_provider: bool,
pending_oauth_login: Option<PendingOAuthLogin>,
cumulative_usage: Option<ModelUsage>,
latest_usage: Option<ModelUsage>,
current_context: Option<ContextUsage>,
model_metadata: Option<ModelMetadata>,
pending_model_metadata: Option<tokio::task::JoinHandle<Option<ModelMetadata>>>,
pending_session_title: Option<Pin<Box<dyn Future<Output = SessionTitleResult>>>>,
inline_viewport_height: u16,
inline_viewport_width: Option<u16>,
}
#[derive(Debug)]
enum ComposerMode {
Input,
Picker(UiPicker),
SecretInput(SecretInput),
ConfigNumberInput(ConfigNumberInput),
ConfigTextInput(ConfigTextInput),
OAuthPending(LoginTarget),
Questionnaire(QuestionnaireComposer),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum QuestionnaireCancelReason {
UserCancelled,
UiUnavailable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum QuestionnaireReply {
Answer(QuestionnaireResponse),
Cancelled(QuestionnaireCancelReason),
}
struct QuestionnaireResponseChannel {
reply_tx: Option<oneshot::Sender<QuestionnaireReply>>,
}
impl std::fmt::Debug for QuestionnaireResponseChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QuestionnaireResponseChannel")
.field("reply_pending", &self.reply_tx.is_some())
.finish()
}
}
impl QuestionnaireResponseChannel {
fn new(reply_tx: oneshot::Sender<QuestionnaireReply>) -> Self {
Self {
reply_tx: Some(reply_tx),
}
}
fn send_response(&mut self, response: QuestionnaireResponse) {
if let Some(reply_tx) = self.reply_tx.take() {
let _ = reply_tx.send(QuestionnaireReply::Answer(response));
}
}
fn cancel(&mut self, reason: QuestionnaireCancelReason) {
if let Some(reply_tx) = self.reply_tx.take() {
let _ = reply_tx.send(QuestionnaireReply::Cancelled(reason));
}
}
}
impl Drop for QuestionnaireResponseChannel {
fn drop(&mut self) {
self.cancel(QuestionnaireCancelReason::UiUnavailable);
}
}
#[derive(Debug)]
struct QuestionAnswerRequest {
request: QuestionnaireRequest,
response: QuestionnaireResponseChannel,
}
#[derive(Debug)]
struct QuestionnaireComposer {
request: QuestionnaireRequest,
response: QuestionnaireResponseChannel,
fields: Vec<QuestionnaireFieldState>,
active_index: usize,
}
#[derive(Debug)]
struct QuestionnaireFieldState {
selection: FieldSelection,
choice_cursor: usize,
other_value: String,
other_cursor: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum FieldSelection {
Text,
None,
Single(usize),
Multi { selected: Vec<usize>, other: bool },
Other,
}
impl QuestionnaireComposer {
fn new(request: QuestionnaireRequest, response: QuestionnaireResponseChannel) -> Self {
let fields = request
.questions
.iter()
.map(QuestionnaireFieldState::new)
.collect::<Vec<_>>();
Self {
request,
response,
fields,
active_index: 0,
}
}
fn active_question(&self) -> &QuestionnaireQuestion {
&self.request.questions[self.active_index]
}
fn active_field(&self) -> &QuestionnaireFieldState {
&self.fields[self.active_index]
}
fn active_field_mut(&mut self) -> &mut QuestionnaireFieldState {
&mut self.fields[self.active_index]
}
fn active_char_len(&self) -> usize {
self.active_field().char_len()
}
fn move_to_previous_field(&mut self) {
self.active_index = self.active_index.saturating_sub(1);
}
fn move_to_next_field(&mut self) {
self.active_index = (self.active_index + 1).min(self.fields.len().saturating_sub(1));
}
fn move_active_choice_previous(&mut self) {
let question = self.active_question().clone();
self.active_field_mut().move_choice_previous(&question);
}
fn move_active_choice_next(&mut self) {
let question = self.active_question().clone();
self.active_field_mut().move_choice_next(&question);
}
fn toggle_active_choice(&mut self) {
let question = self.active_question().clone();
self.active_field_mut().toggle_highlighted(&question);
}
fn clear_active_answer(&mut self) {
let question = self.active_question().clone();
*self.active_field_mut() = QuestionnaireFieldState::empty(&question);
}
fn active_text_entry_active(&self) -> bool {
self.active_field()
.text_entry_active(self.active_question())
}
fn insert_char(&mut self, ch: char) -> bool {
if !self.active_text_entry_active() && !self.activate_other_for_typing() {
return false;
}
self.active_field_mut().insert_char(ch);
true
}
fn insert_text(&mut self, text: &str) -> bool {
if !self.active_text_entry_active() && !self.activate_other_for_typing() {
return false;
}
self.active_field_mut().insert_text(text);
true
}
fn backspace(&mut self) {
if self.active_text_entry_active() {
self.active_field_mut().backspace();
}
}
fn delete(&mut self) {
if self.active_text_entry_active() {
self.active_field_mut().delete();
}
}
fn activate_other_for_typing(&mut self) -> bool {
let question = self.active_question().clone();
if !question.allow_other {
return false;
}
match &mut self.active_field_mut().selection {
FieldSelection::Text | FieldSelection::Other => true,
FieldSelection::None | FieldSelection::Single(_) => {
let field = self.active_field_mut();
field.selection = FieldSelection::Other;
field.choice_cursor = question.choices.len();
true
}
FieldSelection::Multi { .. } => {
let field = self.active_field_mut();
if let FieldSelection::Multi { other, .. } = &mut field.selection {
*other = true;
}
field.choice_cursor = question.choices.len();
true
}
}
}
}
impl QuestionnaireFieldState {
fn empty(question: &QuestionnaireQuestion) -> Self {
let selection = match question.kind {
QuestionnaireQuestionKind::Text => FieldSelection::Text,
QuestionnaireQuestionKind::Choice => FieldSelection::None,
QuestionnaireQuestionKind::MultiSelect => FieldSelection::Multi {
selected: Vec::new(),
other: false,
},
QuestionnaireQuestionKind::Confirm => FieldSelection::None,
};
Self {
selection,
choice_cursor: 0,
other_value: String::new(),
other_cursor: 0,
}
}
fn new(question: &QuestionnaireQuestion) -> Self {
let (selection, choice_cursor, other_value) = match question.kind {
QuestionnaireQuestionKind::Text => (
FieldSelection::Text,
0,
question
.default
.as_ref()
.map(questionnaire_default_string)
.unwrap_or_default(),
),
QuestionnaireQuestionKind::Choice => default_choice_selection(question),
QuestionnaireQuestionKind::MultiSelect => default_multi_selection(question),
QuestionnaireQuestionKind::Confirm => default_confirm_selection(question),
};
let other_cursor = other_value.chars().count();
Self {
selection,
choice_cursor,
other_value,
other_cursor,
}
}
fn char_len(&self) -> usize {
self.other_value.chars().count()
}
fn byte_index(&self, char_index: usize) -> usize {
self.other_value
.char_indices()
.nth(char_index)
.map(|(index, _)| index)
.unwrap_or(self.other_value.len())
}
fn insert_char(&mut self, ch: char) {
let byte_index = self.byte_index(self.other_cursor);
self.other_value.insert(byte_index, ch);
self.other_cursor += 1;
}
fn insert_text(&mut self, text: &str) {
let byte_index = self.byte_index(self.other_cursor);
self.other_value.insert_str(byte_index, text);
self.other_cursor += text.chars().count();
}
fn backspace(&mut self) {
if self.other_cursor == 0 {
return;
}
let start = self.byte_index(self.other_cursor - 1);
let end = self.byte_index(self.other_cursor);
self.other_value.replace_range(start..end, "");
self.other_cursor -= 1;
}
fn delete(&mut self) {
if self.other_cursor >= self.char_len() {
return;
}
let start = self.byte_index(self.other_cursor);
let end = self.byte_index(self.other_cursor + 1);
self.other_value.replace_range(start..end, "");
}
fn text_entry_active(&self, question: &QuestionnaireQuestion) -> bool {
match &self.selection {
FieldSelection::Text | FieldSelection::Other => true,
FieldSelection::None | FieldSelection::Single(_) => false,
FieldSelection::Multi { other, .. } => {
*other && self.choice_cursor == question.choices.len()
}
}
}
fn move_choice_previous(&mut self, question: &QuestionnaireQuestion) {
let count = choice_count(question);
if count == 0 || matches!(self.selection, FieldSelection::Text) {
return;
}
self.choice_cursor = self.choice_cursor.saturating_sub(1);
self.select_highlighted_for_single(question);
}
fn move_choice_next(&mut self, question: &QuestionnaireQuestion) {
let count = choice_count(question);
if count == 0 || matches!(self.selection, FieldSelection::Text) {
return;
}
self.choice_cursor = (self.choice_cursor + 1).min(count.saturating_sub(1));
self.select_highlighted_for_single(question);
}
fn toggle_highlighted(&mut self, question: &QuestionnaireQuestion) {
match &mut self.selection {
FieldSelection::Text => {}
FieldSelection::None | FieldSelection::Single(_) => {
if question.allow_other && self.choice_cursor == question.choices.len() {
self.selection = FieldSelection::Other;
} else {
self.selection = FieldSelection::Single(
self.choice_cursor
.min(choice_count(question).saturating_sub(1)),
);
}
}
FieldSelection::Multi { selected, other } => {
if question.allow_other && self.choice_cursor == question.choices.len() {
*other = !*other;
} else {
let cursor = self
.choice_cursor
.min(question.choices.len().saturating_sub(1));
if selected.contains(&cursor) {
selected.retain(|index| *index != cursor);
} else {
selected.push(cursor);
selected.sort_unstable();
selected.dedup();
}
}
}
FieldSelection::Other => {
if self.choice_cursor < question.choices.len() {
self.selection = FieldSelection::Single(self.choice_cursor);
}
}
}
}
fn select_highlighted_for_single(&mut self, question: &QuestionnaireQuestion) {
match &mut self.selection {
FieldSelection::None | FieldSelection::Single(_) => {
if question.allow_other && self.choice_cursor == question.choices.len() {
self.selection = FieldSelection::Other;
} else {
self.selection = FieldSelection::Single(
self.choice_cursor
.min(choice_count(question).saturating_sub(1)),
);
}
}
FieldSelection::Other if self.choice_cursor < question.choices.len() => {
self.selection = FieldSelection::Single(self.choice_cursor);
}
FieldSelection::Text | FieldSelection::Multi { .. } | FieldSelection::Other => {}
}
}
}
fn default_choice_selection(question: &QuestionnaireQuestion) -> (FieldSelection, usize, String) {
let Some(default) = question.default.as_ref().map(questionnaire_default_string) else {
return (FieldSelection::None, 0, String::new());
};
if let Some(index) = question
.choices
.iter()
.position(|choice| choice.eq_ignore_ascii_case(&default))
{
return (FieldSelection::Single(index), index, String::new());
}
if question.allow_other {
return (FieldSelection::Other, question.choices.len(), default);
}
(FieldSelection::None, 0, String::new())
}
fn default_multi_selection(question: &QuestionnaireQuestion) -> (FieldSelection, usize, String) {
let mut selected = Vec::new();
let mut other_values = Vec::new();
for value in question
.default
.as_ref()
.map(questionnaire_default_strings)
.unwrap_or_default()
{
if let Some(index) = question
.choices
.iter()
.position(|choice| choice.eq_ignore_ascii_case(&value))
{
selected.push(index);
} else if question.allow_other {
other_values.push(value);
}
}
selected.sort_unstable();
selected.dedup();
let other = !other_values.is_empty();
let choice_cursor = selected
.first()
.copied()
.or_else(|| other.then_some(question.choices.len()))
.unwrap_or(0);
(
FieldSelection::Multi { selected, other },
choice_cursor,
other_values.join(", "),
)
}
fn default_confirm_selection(question: &QuestionnaireQuestion) -> (FieldSelection, usize, String) {
match question
.default
.as_ref()
.map(questionnaire_default_string)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"yes" | "y" | "true" => (FieldSelection::Single(0), 0, String::new()),
"no" | "n" | "false" => (FieldSelection::Single(1), 1, String::new()),
_ => (FieldSelection::None, 0, String::new()),
}
}
fn questionnaire_default_strings(default: &serde_json::Value) -> Vec<String> {
match default {
serde_json::Value::Array(values) => values
.iter()
.map(questionnaire_default_string)
.filter(|value| !value.is_empty())
.collect(),
value => {
let value = questionnaire_default_string(value);
if value.is_empty() {
Vec::new()
} else {
vec![value]
}
}
}
}
fn questionnaire_default_string(default: &serde_json::Value) -> String {
match default {
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::Null => String::new(),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => default.to_string(),
}
}
fn questionnaire_default_display(default: &serde_json::Value) -> String {
match default {
serde_json::Value::Array(values) => values
.iter()
.map(questionnaire_default_display)
.collect::<Vec<_>>()
.join(", "),
value => questionnaire_default_string(value),
}
}
fn choice_count(question: &QuestionnaireQuestion) -> usize {
match question.kind {
QuestionnaireQuestionKind::Text => 0,
QuestionnaireQuestionKind::Confirm => 2,
QuestionnaireQuestionKind::Choice | QuestionnaireQuestionKind::MultiSelect => {
question.choices.len() + usize::from(question.allow_other)
}
}
}
#[derive(Clone, Debug)]
struct ConfigNumberInput {
key: ConfigNumberKey,
value: String,
cursor: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ConfigNumberKey {
MaxOutputBytes,
MaxToolOutputLines,
}
#[derive(Clone, Debug)]
struct ConfigTextInput {
key: ConfigTextKey,
value: String,
cursor: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ConfigTextKey {
OpenAiSearch,
Exa,
Brave,
}
impl ConfigNumberKey {
fn label(self) -> &'static str {
match self {
ConfigNumberKey::MaxOutputBytes => "max output bytes",
ConfigNumberKey::MaxToolOutputLines => "max tool output lines",
}
}
}
impl ConfigTextKey {
fn label(self) -> &'static str {
match self {
ConfigTextKey::OpenAiSearch => "OpenAI web search API key",
ConfigTextKey::Exa => "Exa API key",
ConfigTextKey::Brave => "Brave Search API key",
}
}
fn picker_value(self) -> &'static str {
match self {
ConfigTextKey::OpenAiSearch => config_picker::WEB_SEARCH_OPENAI_KEY_VALUE,
ConfigTextKey::Exa => config_picker::WEB_SEARCH_EXA_KEY_VALUE,
ConfigTextKey::Brave => config_picker::WEB_SEARCH_BRAVE_KEY_VALUE,
}
}
}
#[derive(Clone, Debug)]
struct SecretInput {
target: LoginTarget,
value: String,
cursor: usize,
}
#[derive(Debug)]
struct PendingOAuthLogin {
target: LoginTarget,
handle: tokio::task::JoinHandle<Result<PendingOAuthResult, String>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct PasteSegment {
start: usize,
marker_len: usize,
content: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct QueuedPrompt {
prompt: String,
display_prompt: String,
paste_segments: Vec<PasteSegment>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct InputDraft {
input: String,
paste_segments: Vec<PasteSegment>,
}
impl From<&str> for QueuedPrompt {
fn from(prompt: &str) -> Self {
Self {
prompt: prompt.to_string(),
display_prompt: prompt.to_string(),
paste_segments: Vec::new(),
}
}
}
impl PasteSegment {
fn end(&self) -> usize {
self.start + self.marker_len
}
}
#[derive(Debug)]
enum PendingOAuthResult {
Codex(CodexTokens),
GithubCopilot(GitHubCopilotTokens),
}
#[derive(Debug)]
struct SessionTitleResult {
session_id: String,
title: anyhow::Result<String>,
}
#[derive(Clone, Debug)]
struct CommandChoice {
name: String,
usage: String,
description: String,
kind: CommandChoiceKind,
}
#[derive(Clone, Debug, Default)]
struct LoadingSpinner {
started_at: Option<Instant>,
}
impl LoadingSpinner {
const FRAMES: [&'static str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const FRAME_INTERVAL: Duration = Duration::from_millis(80);
fn start(&mut self) {
self.started_at = Some(Instant::now());
}
fn start_if_needed(&mut self) {
if self.started_at.is_none() {
self.start();
}
}
fn stop(&mut self) {
self.started_at = None;
}
fn frame_at(&self, now: Instant) -> &'static str {
let Some(started_at) = self.started_at else {
return Self::FRAMES[0];
};
let interval_ms = Self::FRAME_INTERVAL.as_millis().max(1);
let frame = now
.saturating_duration_since(started_at)
.as_millis()
.checked_div(interval_ms)
.unwrap_or(0) as usize;
Self::FRAMES[frame % Self::FRAMES.len()]
}
fn line(&self, now: Instant) -> Line<'static> {
Line::from(vec![
Span::styled(self.frame_at(now), Theme::accent()),
Span::styled(" working", Theme::dim()),
])
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum CommandChoiceKind {
Builtin(&'static CommandSpec),
Skill,
}
#[derive(Clone, Debug)]
struct ToolEntry {
state: ToolEntryState,
display_lines: Vec<String>,
expanded: bool,
}
#[derive(Clone, Copy, Debug)]
enum ToolEntryState {
Running,
Finished {
ok: bool,
display_style: ToolDisplayStyle,
},
}
#[derive(Clone, Debug)]
enum Entry {
User(String),
#[allow(dead_code)]
Assistant(String),
Reasoning(String),
Tool(ToolEntry),
Notice(String),
Error(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum StreamKind {
Assistant,
Reasoning,
}
fn is_tool_entry(entry: &Entry) -> bool {
matches!(entry, Entry::Tool(_))
}
fn expandable_tool_entry(entry: &Entry, max_tool_output_lines: usize) -> bool {
matches!(entry, Entry::Tool(tool) if tool_display_line_count(&tool.display_lines) > max_tool_output_lines)
}
#[derive(Debug, PartialEq, Eq)]
enum FinalAnswerDelta<'a> {
None,
Append(&'a str),
Mismatch,
}
fn final_answer_delta<'a>(emitted_text: &str, answer: &'a str) -> FinalAnswerDelta<'a> {
match answer.strip_prefix(emitted_text) {
Some("") => FinalAnswerDelta::None,
Some(suffix) => FinalAnswerDelta::Append(suffix),
None => FinalAnswerDelta::Mismatch,
}
}
impl StreamKind {
fn style(self) -> Style {
match self {
Self::Assistant => Theme::text(),
Self::Reasoning => Theme::dim().add_modifier(Modifier::DIM),
}
}
fn entry(self, text: String) -> Entry {
match self {
Self::Assistant => Entry::Assistant(text),
Self::Reasoning => Entry::Reasoning(text),
}
}
}
#[derive(Default)]
struct PasteBurst {
last_plain_char_at: Option<Instant>,
plain_char_count: usize,
suppress_enter_until: Option<Instant>,
}
impl ConfigNumberInput {
fn new(key: ConfigNumberKey, value: usize) -> Self {
let value = value.to_string();
let cursor = value.chars().count();
Self { key, value, cursor }
}
fn byte_index(&self, char_index: usize) -> usize {
self.value
.char_indices()
.nth(char_index)
.map(|(index, _)| index)
.unwrap_or(self.value.len())
}
fn insert_char(&mut self, ch: char) {
if !ch.is_ascii_digit() {
return;
}
let byte_index = self.byte_index(self.cursor);
self.value.insert(byte_index, ch);
self.cursor += 1;
}
fn insert_text(&mut self, text: &str) {
for ch in text.chars().filter(|ch| ch.is_ascii_digit()) {
self.insert_char(ch);
}
}
fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let start = self.byte_index(self.cursor - 1);
let end = self.byte_index(self.cursor);
self.value.replace_range(start..end, "");
self.cursor -= 1;
}
}
impl ConfigTextInput {
fn new(key: ConfigTextKey, value: Option<String>) -> Self {
let value = value.unwrap_or_default();
let cursor = value.chars().count();
Self { key, value, cursor }
}
fn char_len(&self) -> usize {
self.value.chars().count()
}
fn byte_index(&self, char_index: usize) -> usize {
self.value
.char_indices()
.nth(char_index)
.map(|(index, _)| index)
.unwrap_or(self.value.len())
}
fn insert_char(&mut self, ch: char) {
if ch == '\n' || ch == '\r' {
return;
}
let byte_index = self.byte_index(self.cursor);
self.value.insert(byte_index, ch);
self.cursor += 1;
}
fn insert_text(&mut self, text: &str) {
let sanitized = text.replace(['\n', '\r'], "");
let byte_index = self.byte_index(self.cursor);
self.value.insert_str(byte_index, &sanitized);
self.cursor += sanitized.chars().count();
}
fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let start = self.byte_index(self.cursor - 1);
let end = self.byte_index(self.cursor);
self.value.replace_range(start..end, "");
self.cursor -= 1;
}
fn delete(&mut self) {
if self.cursor >= self.char_len() {
return;
}
let start = self.byte_index(self.cursor);
let end = self.byte_index(self.cursor + 1);
self.value.replace_range(start..end, "");
}
}
impl SecretInput {
fn new(target: LoginTarget) -> Self {
Self {
target,
value: String::new(),
cursor: 0,
}
}
fn char_len(&self) -> usize {
self.value.chars().count()
}
fn byte_index(&self, char_index: usize) -> usize {
self.value
.char_indices()
.nth(char_index)
.map(|(index, _)| index)
.unwrap_or(self.value.len())
}
fn insert_char(&mut self, ch: char) {
let byte_index = self.byte_index(self.cursor);
self.value.insert(byte_index, ch);
self.cursor += 1;
}
fn insert_text(&mut self, text: &str) {
let sanitized = text.replace('\n', "");
let byte_index = self.byte_index(self.cursor);
self.value.insert_str(byte_index, &sanitized);
self.cursor += sanitized.chars().count();
}
fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let start = self.byte_index(self.cursor - 1);
let end = self.byte_index(self.cursor);
self.value.replace_range(start..end, "");
self.cursor -= 1;
}
fn delete(&mut self) {
if self.cursor >= self.char_len() {
return;
}
let start = self.byte_index(self.cursor);
let end = self.byte_index(self.cursor + 1);
self.value.replace_range(start..end, "");
}
}
impl PasteBurst {
fn record_plain_char(&mut self, now: Instant) {
self.plain_char_count = match self.last_plain_char_at {
Some(last) if now.saturating_duration_since(last) <= PASTE_BURST_GAP => {
self.plain_char_count.saturating_add(1)
}
_ => 1,
};
self.last_plain_char_at = Some(now);
if self.plain_char_count >= PASTE_BURST_MIN_CHARS {
self.suppress_enter_until = now.checked_add(PASTE_ENTER_SUPPRESSION);
}
}
fn should_insert_newline_for_enter(&mut self, now: Instant) -> bool {
if self
.suppress_enter_until
.is_some_and(|deadline| now <= deadline)
{
self.suppress_enter_until = now.checked_add(PASTE_ENTER_SUPPRESSION);
true
} else {
self.clear();
false
}
}
fn clear(&mut self) {
self.last_plain_char_at = None;
self.plain_char_count = 0;
self.suppress_enter_until = None;
}
}
impl App {
fn new(info: TuiInfo) -> Self {
Self::new_with_credentials(info, Arc::new(OsCredentialStore))
}
fn new_with_credentials(info: TuiInfo, credential_store: Arc<dyn CredentialStore>) -> Self {
let available_auths = available_auth_modes(credential_store.as_ref());
let using_unavailable_provider = info.auth_unavailable.is_some();
let mut info = info;
info.max_tool_output_lines = info.max_tool_output_lines.max(1);
let status = info
.auth_unavailable
.as_ref()
.map(|_| "no providers configured; run /login to sign in".into())
.unwrap_or_else(|| "ready".into());
let active_turn_show_reasoning_output = info.show_reasoning_output;
Self {
info,
input: String::new(),
input_cursor: 0,
status,
should_quit: false,
ctrl_c_streak: 0,
assistant_stream: AppendOnlyStream::default(),
assistant_stream_in_code_block: false,
reasoning_stream: AppendOnlyStream::default(),
current_stream_kind: None,
stream_preview_deadline: None,
live_stream_preview: None,
current_turn_start: None,
active_turn_show_reasoning_output,
running: false,
loading_spinner: LoadingSpinner::default(),
active_tool_call: false,
pending_tool_call: None,
steering_prompts: VecDeque::new(),
queued_prompts: VecDeque::new(),
pending_images: Vec::new(),
input_history: Vec::new(),
input_history_cursor: None,
input_history_draft: None,
paste_burst: PasteBurst::default(),
paste_segments: Vec::new(),
transcript: Vec::new(),
last_status_notice: None,
last_inserted_was_tool: false,
command_selection: 0,
command_prefix: None,
command_palette_dismissed: false,
composer: ComposerMode::Input,
credential_store,
available_auths,
using_unavailable_provider,
pending_oauth_login: None,
cumulative_usage: None,
latest_usage: None,
current_context: None,
model_metadata: None,
pending_model_metadata: None,
pending_session_title: None,
inline_viewport_height: INLINE_VIEWPORT_HEIGHT,
inline_viewport_width: None,
}
}
async fn run(
mut self,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<TuiResult> {
self.start_model_metadata_fetch(agent);
self.insert_session_intro(terminal)?;
self.insert_recovered_history(terminal, agent)?;
if self.info.open_resume_picker {
self.open_resume_picker(terminal)?;
}
if self.info.auth_unavailable.is_some() {
self.insert_entry(
terminal,
&Entry::Notice("no providers configured. run /login to sign in.".into()),
)?;
}
while !self.should_quit {
self.poll_model_metadata_fetch(agent);
self.poll_pending_session_title(terminal)?;
self.poll_pending_oauth_login(terminal, agent).await?;
self.resize_inline_viewport_if_needed(terminal)?;
terminal.draw(|frame| self.draw(frame))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
self.handle_key(key, terminal, agent).await?;
}
Event::Paste(text) => {
let text = normalize_paste(&text);
match &mut self.composer {
ComposerMode::Input => self.insert_pasted_input_text(&text),
ComposerMode::SecretInput(secret) => secret.insert_text(&text),
ComposerMode::ConfigNumberInput(input) => input.insert_text(&text),
ComposerMode::ConfigTextInput(input) => input.insert_text(&text),
ComposerMode::Questionnaire(questionnaire) => {
questionnaire.insert_text(&text);
}
ComposerMode::Picker(_) | ComposerMode::OAuthPending(_) => {}
}
self.paste_burst.clear();
}
Event::Resize(_, _) => {
self.reflow_history(terminal)?;
}
_ => {}
}
}
}
let width = terminal.size()?.width as usize;
let exit_lines = self.exit_lines(width);
Ok(TuiResult {
resume_session_id: self.info.session_id,
exit_lines,
})
}
async fn handle_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
if self.handle_oauth_pending_key(key, terminal)? {
return Ok(());
}
if self.handle_questionnaire_key(key, terminal)? {
return Ok(());
}
if self.handle_secret_key(key, terminal, agent).await? {
return Ok(());
}
if self.handle_config_number_key(key, terminal)? {
return Ok(());
}
if self.handle_config_text_key(key, terminal)? {
return Ok(());
}
if self.handle_reasoning_cycle_key(key, terminal, agent)? {
return Ok(());
}
if self.handle_picker_key(key, terminal, agent).await? {
return Ok(());
}
if self
.handle_command_palette_key(key, terminal, agent)
.await?
{
return Ok(());
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => {
if self.ctrl_c_streak == 0 {
self.input.clear();
self.paste_segments.clear();
self.pending_images.clear();
self.input_cursor = 0;
self.clamp_command_selection();
self.notify_status(terminal, "input cleared; press ctrl-c again to quit")?;
self.ctrl_c_streak = 1;
} else {
self.should_quit = true;
}
}
(_, KeyCode::Esc) => {
self.ctrl_c_streak = 0;
}
(KeyModifiers::CONTROL, KeyCode::Char('v'))
| (KeyModifiers::ALT, KeyCode::Char('v')) => {
self.paste_clipboard_image(terminal)?;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(KeyModifiers::CONTROL, KeyCode::Char('o')) => {
self.toggle_latest_tool_output(terminal)?;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(KeyModifiers::CONTROL, KeyCode::Char('r')) => {
agent.reset();
self.info.session_id = None;
agent.set_session_id(None);
agent.clear_history_sink();
self.cumulative_usage = None;
self.latest_usage = None;
self.current_context = None;
self.insert_entry(
terminal,
&Entry::Notice("conversation reset; next message starts a new session".into()),
)?;
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Backspace) => {
self.delete_word_before_cursor();
self.ctrl_c_streak = 0;
}
(_, KeyCode::Backspace) => {
self.backspace_input();
self.ctrl_c_streak = 0;
}
(_, KeyCode::Delete) => {
self.delete_input();
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Left) => {
self.input_cursor = previous_word_boundary(&self.input, self.input_cursor);
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Right) => {
self.input_cursor = next_word_boundary(&self.input, self.input_cursor);
self.ctrl_c_streak = 0;
}
(_, KeyCode::Left) => {
self.input_cursor = self.input_cursor.saturating_sub(1);
self.ctrl_c_streak = 0;
}
(_, KeyCode::Right) => {
self.input_cursor = (self.input_cursor + 1).min(self.input_char_len());
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Up) => {
if self.recall_last_queued_prompt() {
self.notify_status(
terminal,
format!(
"editing queued message; {} queued message(s) remain",
self.queued_prompts.len()
),
)?;
}
self.ctrl_c_streak = 0;
}
(_, KeyCode::Up) => {
let width = terminal.size()?.width as usize;
self.recall_input_history_or_move_cursor(HistoryDirection::Previous, width);
self.ctrl_c_streak = 0;
}
(_, KeyCode::Down) => {
let width = terminal.size()?.width as usize;
self.recall_input_history_or_move_cursor(HistoryDirection::Next, width);
self.ctrl_c_streak = 0;
}
(_, KeyCode::Home) => {
self.reset_input_history_navigation();
self.input_cursor = 0;
self.ctrl_c_streak = 0;
}
(_, KeyCode::End) => {
self.reset_input_history_navigation();
self.input_cursor = self.input_char_len();
self.ctrl_c_streak = 0;
}
(KeyModifiers::CONTROL, KeyCode::Char('j')) => {
self.insert_input_char('\n');
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Enter) => {
self.insert_input_char('\n');
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(modifiers, KeyCode::Enter) if modifiers.contains(KeyModifiers::SHIFT) => {
self.insert_input_char('\n');
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(_, KeyCode::Enter) => {
if self
.paste_burst
.should_insert_newline_for_enter(Instant::now())
{
self.insert_input_char('\n');
} else {
self.submit(terminal, agent).await?;
}
self.ctrl_c_streak = 0;
}
(modifiers, KeyCode::Char(ch))
if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.insert_input_char(ch);
self.paste_burst.record_plain_char(Instant::now());
self.ctrl_c_streak = 0;
}
_ => {
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
}
self.clamp_command_selection();
Ok(())
}
fn start_model_metadata_fetch(&mut self, agent: &mut Agent) {
if let Some(handle) = self.pending_model_metadata.take() {
handle.abort();
}
if let Some(metadata) = cached_model_metadata(&self.info.provider, &self.info.model) {
agent.set_context_window(metadata.display_context_window());
self.model_metadata = Some(metadata);
return;
}
agent.set_context_window(None);
self.model_metadata = None;
let provider = self.info.provider.clone();
let model = self.info.model.clone();
self.pending_model_metadata = Some(tokio::spawn(async move {
fetch_model_metadata(&provider, &model).await
}));
}
fn poll_model_metadata_fetch(&mut self, agent: &mut Agent) {
let Some(handle) = self.pending_model_metadata.as_mut() else {
return;
};
if !handle.is_finished() {
return;
}
if let Some(handle) = self.pending_model_metadata.take() {
if let Some(Ok(Some(metadata))) = handle.now_or_never() {
agent.set_context_window(metadata.display_context_window());
self.model_metadata = Some(metadata);
}
}
}
fn poll_pending_session_title(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let Some(future) = self.pending_session_title.as_mut() else {
return Ok(());
};
let waker = noop_waker_ref();
let mut context = std::task::Context::from_waker(waker);
let std::task::Poll::Ready(result) = future.as_mut().poll(&mut context) else {
return Ok(());
};
self.pending_session_title = None;
let Ok(title) = result.title else {
return Ok(());
};
if Session::set_title(&self.info.cwd, &result.session_id, &title).is_err() {
return Ok(());
}
if self.info.session_id.as_deref() == Some(result.session_id.as_str()) {
self.insert_entry(terminal, &Entry::Notice(format!("session titled: {title}")))?;
}
Ok(())
}
fn handle_oauth_pending_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<bool> {
if !matches!(self.composer, ComposerMode::OAuthPending(_)) {
return Ok(false);
}
match key.code {
KeyCode::Esc => {
let provider = if let Some(pending) = self.pending_oauth_login.take() {
let provider = pending.target.provider;
pending.handle.abort();
provider
} else {
"OAuth".into()
};
self.composer = ComposerMode::Input;
self.status = "login cancelled".into();
self.insert_entry(
terminal,
&Entry::Notice(format!("{provider} login cancelled")),
)?;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
_ => Ok(true),
}
}
fn handle_questionnaire_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<bool> {
if !matches!(self.composer, ComposerMode::Questionnaire(_)) {
return Ok(false);
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => {
if self.ctrl_c_streak == 0 {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.clear_active_answer();
}
self.status = "answer cleared; press ctrl-c again to cancel".into();
self.ctrl_c_streak = 1;
} else {
self.cancel_questionnaire_answer();
self.should_quit = true;
}
self.paste_burst.clear();
Ok(true)
}
(KeyModifiers::ALT, KeyCode::Up) | (_, KeyCode::BackTab) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.move_to_previous_field();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::ALT, KeyCode::Down) | (_, KeyCode::Tab) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.move_to_next_field();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::ALT, KeyCode::Backspace) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.active_text_entry_active() {
let field = questionnaire.active_field_mut();
let new_cursor =
previous_word_boundary(&field.other_value, field.other_cursor);
let start = field.byte_index(new_cursor);
let end = field.byte_index(field.other_cursor);
field.other_value.replace_range(start..end, "");
field.other_cursor = new_cursor;
}
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Enter) => {
if self
.paste_burst
.should_insert_newline_for_enter(Instant::now())
{
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.insert_char('\n');
}
} else {
self.submit_questionnaire_answer(terminal)?;
}
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Esc) => {
self.cancel_questionnaire_answer();
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Up) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.move_active_choice_previous();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Down) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.move_active_choice_next();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Backspace) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.backspace();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Delete) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.delete();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::ALT, KeyCode::Left) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.active_text_entry_active() {
let field = questionnaire.active_field_mut();
field.other_cursor =
previous_word_boundary(&field.other_value, field.other_cursor);
}
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::ALT, KeyCode::Right) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.active_text_entry_active() {
let field = questionnaire.active_field_mut();
field.other_cursor =
next_word_boundary(&field.other_value, field.other_cursor);
}
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Left) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.active_text_entry_active() {
let field = questionnaire.active_field_mut();
field.other_cursor = field.other_cursor.saturating_sub(1);
} else {
questionnaire.move_active_choice_previous();
}
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Right) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.active_text_entry_active() {
let char_len = questionnaire.active_char_len();
let field = questionnaire.active_field_mut();
field.other_cursor = (field.other_cursor + 1).min(char_len);
} else {
questionnaire.move_active_choice_next();
}
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Home) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.active_text_entry_active() {
questionnaire.active_field_mut().other_cursor = 0;
}
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::End) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.active_text_entry_active() {
let char_len = questionnaire.active_char_len();
questionnaire.active_field_mut().other_cursor = char_len;
}
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::CONTROL, KeyCode::Char('j')) | (KeyModifiers::ALT, KeyCode::Enter) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.insert_char('\n');
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(modifiers, KeyCode::Enter) if modifiers.contains(KeyModifiers::SHIFT) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
questionnaire.insert_char('\n');
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Char(' ')) => {
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.active_text_entry_active() {
questionnaire.insert_char(' ');
} else {
questionnaire.toggle_active_choice();
}
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(modifiers, KeyCode::Char(ch))
if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
if let ComposerMode::Questionnaire(questionnaire) = &mut self.composer {
if questionnaire.insert_char(ch) {
self.paste_burst.record_plain_char(Instant::now());
}
}
self.ctrl_c_streak = 0;
Ok(true)
}
_ => {
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
}
}
fn submit_questionnaire_answer(
&mut self,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
if let Some(display) = self.prepare_questionnaire_answer()? {
self.insert_entry(terminal, &Entry::User(display))?;
}
Ok(())
}
fn prepare_questionnaire_answer(&mut self) -> anyhow::Result<Option<String>> {
let ComposerMode::Questionnaire(mut questionnaire) =
std::mem::replace(&mut self.composer, ComposerMode::Input)
else {
return Ok(None);
};
match questionnaire_answers(&questionnaire) {
Ok(answers) => {
let response = QuestionnaireResponse { answers };
let display = submitted_questionnaire_entry(&questionnaire.request, &response);
questionnaire.response.send_response(response);
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.command_palette_dismissed = false;
self.clamp_command_selection();
self.status = "answers submitted".into();
Ok(Some(display))
}
Err(error) => {
self.composer = ComposerMode::Questionnaire(questionnaire);
self.status = error;
Ok(None)
}
}
}
fn cancel_questionnaire_answer(&mut self) {
let ComposerMode::Questionnaire(mut questionnaire) =
std::mem::replace(&mut self.composer, ComposerMode::Input)
else {
return;
};
questionnaire
.response
.cancel(QuestionnaireCancelReason::UserCancelled);
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.command_palette_dismissed = false;
self.clamp_command_selection();
self.status = "answer cancelled".into();
}
fn open_questionnaire(
&mut self,
request: QuestionAnswerRequest,
terminal: &mut DefaultTerminal,
) -> std::io::Result<()> {
self.finish_streams(terminal)?;
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.command_palette_dismissed = false;
self.clamp_command_selection();
self.insert_entry(
terminal,
&Entry::Notice(questionnaire_notice_text(&request.request)),
)?;
self.composer = ComposerMode::Questionnaire(QuestionnaireComposer::new(
request.request,
request.response,
));
self.status = "waiting for your answers".into();
Ok(())
}
async fn handle_secret_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<bool> {
let ComposerMode::SecretInput(secret) = &mut self.composer else {
return Ok(false);
};
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Enter) => {
let target = secret.target.clone();
let value = secret.value.trim().to_string();
self.composer = ComposerMode::Input;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
self.submit_api_key_login(target, value, terminal, agent)
.await?;
Ok(true)
}
(_, KeyCode::Esc) => {
self.composer = ComposerMode::Input;
self.status = "login cancelled".into();
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Backspace) => {
secret.backspace();
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Delete) => {
secret.delete();
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Left) => {
secret.cursor = secret.cursor.saturating_sub(1);
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Right) => {
secret.cursor = (secret.cursor + 1).min(secret.char_len());
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::Home) => {
secret.cursor = 0;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(_, KeyCode::End) => {
secret.cursor = secret.char_len();
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(modifiers, KeyCode::Char(ch))
if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
secret.insert_char(ch);
self.paste_burst.record_plain_char(Instant::now());
self.ctrl_c_streak = 0;
Ok(true)
}
_ => Ok(true),
}
}
fn handle_config_number_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<bool> {
if !matches!(self.composer, ComposerMode::ConfigNumberInput(_)) {
return Ok(false);
}
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Enter) => {
let ComposerMode::ConfigNumberInput(input) = &self.composer else {
return Ok(true);
};
let Ok(mut value) = input.value.parse::<usize>() else {
self.insert_entry(
terminal,
&Entry::Error(format!(
"{} must be a positive whole number",
input.key.label()
)),
)?;
self.status = "config save failed".into();
return Ok(true);
};
value = value.max(1);
match input.key {
ConfigNumberKey::MaxOutputBytes => {
Config::load(self.info.config_path.clone()).and_then(|mut config| {
config.max_output_bytes = value;
config.save(self.info.config_path.clone())
})?;
self.composer = ComposerMode::Picker(config_picker::config_picker(
&self.info,
value,
self.info.max_tool_output_lines,
));
self.insert_entry(
terminal,
&Entry::Notice(format!(
"max output bytes set to {value}; applies next session"
)),
)?;
self.status = "config saved".into();
}
ConfigNumberKey::MaxToolOutputLines => {
Config::load(self.info.config_path.clone()).and_then(|mut config| {
config.max_tool_output_lines = value;
config.save(self.info.config_path.clone())
})?;
self.info.max_tool_output_lines = value;
self.composer = ComposerMode::Picker(config_picker::config_picker(
&self.info,
Config::load(self.info.config_path.clone())?.max_output_bytes,
value,
));
self.reflow_history(terminal)?;
self.insert_entry(
terminal,
&Entry::Notice(format!("max tool output lines set to {value}")),
)?;
self.status = "config saved".into();
}
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Backspace) => {
if let ComposerMode::ConfigNumberInput(input) = &mut self.composer {
input.backspace();
}
Ok(true)
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(ch)) => {
if let ComposerMode::ConfigNumberInput(input) = &mut self.composer {
input.insert_char(ch);
}
Ok(true)
}
(_, KeyCode::Esc) => {
let config = Config::load(self.info.config_path.clone())?;
self.info.show_reasoning_output = config.show_reasoning_output;
self.composer = ComposerMode::Picker(config_picker::config_picker(
&self.info,
config.max_output_bytes,
config.max_tool_output_lines,
));
self.status = "config".into();
Ok(true)
}
_ => Ok(true),
}
}
fn handle_config_text_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<bool> {
if !matches!(self.composer, ComposerMode::ConfigTextInput(_)) {
return Ok(false);
}
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Enter) => {
let ComposerMode::ConfigTextInput(input) = &self.composer else {
return Ok(true);
};
let key = input.key;
let value = input.value.trim().to_string();
let save_result =
Config::load(self.info.config_path.clone()).and_then(|mut config| {
let value = (!value.is_empty()).then_some(value);
match key {
ConfigTextKey::OpenAiSearch => config.web_search_openai_api_key = value,
ConfigTextKey::Exa => config.web_search_exa_api_key = value,
ConfigTextKey::Brave => config.web_search_brave_api_key = value,
}
config.save(self.info.config_path.clone())
});
match save_result {
Ok(()) => {
self.refresh_web_search_config_picker(key.picker_value());
self.status = format!("{} saved", key.label());
}
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!("could not save {}: {err}", key.label())),
)?;
self.status = "config save failed".into();
}
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Backspace) => {
if let ComposerMode::ConfigTextInput(input) = &mut self.composer {
input.backspace();
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Delete) => {
if let ComposerMode::ConfigTextInput(input) = &mut self.composer {
input.delete();
}
Ok(true)
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(ch)) => {
if let ComposerMode::ConfigTextInput(input) = &mut self.composer {
input.insert_char(ch);
}
Ok(true)
}
(_, KeyCode::Esc) => {
let ComposerMode::ConfigTextInput(input) = &self.composer else {
return Ok(true);
};
self.refresh_web_search_config_picker(input.key.picker_value());
self.status = "web search config".into();
Ok(true)
}
_ => Ok(true),
}
}
fn handle_reasoning_cycle_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<bool> {
let is_shift_tab = matches!(key.code, KeyCode::BackTab)
|| (matches!(key.code, KeyCode::Tab) && key.modifiers.contains(KeyModifiers::SHIFT));
if !is_shift_tab {
return Ok(false);
}
self.cycle_reasoning(terminal, agent)?;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
async fn handle_picker_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<bool> {
if !matches!(self.composer, ComposerMode::Picker(_)) {
return Ok(false);
}
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Up) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.select_previous();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Down) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.select_next();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Tab) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.complete_filter();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Backspace) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.pop_filter_char();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Char(' ')) if self.picker_space_confirms_selection() => {
self.paste_burst.clear();
self.ctrl_c_streak = 0;
self.submit_picker_selection(terminal, agent).await?;
Ok(true)
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(ch)) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.push_filter_char(ch);
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Enter) => {
self.paste_burst.clear();
self.ctrl_c_streak = 0;
self.submit_picker_selection(terminal, agent).await?;
Ok(true)
}
(_, KeyCode::Esc) => {
self.handle_picker_escape( false)?;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
_ => Ok(true),
}
}
async fn handle_command_palette_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<bool> {
if !self.command_palette_visible() {
return Ok(false);
}
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Up) => {
let matches = self.command_matches();
if !matches.is_empty() {
self.command_selection = if self.command_selection == 0 {
matches.len() - 1
} else {
self.command_selection - 1
};
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Down) => {
let matches = self.command_matches();
if !matches.is_empty() {
self.command_selection = (self.command_selection + 1) % matches.len();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Tab) => {
if let Some(choice) = self.selected_command() {
let (input, cursor) = self.complete_command_choice(&choice);
self.input = input;
self.input_cursor = cursor;
self.command_palette_dismissed = false;
self.clamp_command_selection();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Enter) => {
if let Some(choice) = self.selected_command() {
let (input, cursor) = self.complete_command_choice(&choice);
self.input = input;
self.input_cursor = cursor;
self.clamp_command_selection();
}
self.paste_burst.clear();
self.ctrl_c_streak = 0;
self.submit(terminal, agent).await?;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Esc) => {
self.command_palette_dismissed = true;
self.command_selection = 0;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
Ok(true)
}
_ => Ok(false),
}
}
fn input_char_len(&self) -> usize {
self.input.chars().count()
}
fn input_byte_index(&self, char_index: usize) -> usize {
self.input
.char_indices()
.nth(char_index)
.map(|(index, _)| index)
.unwrap_or(self.input.len())
}
fn reset_input_history_navigation(&mut self) {
self.input_history_cursor = None;
self.input_history_draft = None;
}
fn push_input_history(&mut self, prompt: &str) {
if prompt.is_empty() || self.input_history.last().is_some_and(|last| last == prompt) {
return;
}
self.input_history.push(prompt.to_string());
}
fn recall_input_history(&mut self, direction: HistoryDirection) -> bool {
if self.input_history.is_empty() {
return false;
}
let next_cursor = match (direction, self.input_history_cursor) {
(HistoryDirection::Previous, None) => {
self.input_history_draft = Some(InputDraft {
input: self.input.clone(),
paste_segments: self.paste_segments.clone(),
});
self.input_history.len() - 1
}
(HistoryDirection::Previous, Some(0)) => 0,
(HistoryDirection::Previous, Some(cursor)) => cursor - 1,
(HistoryDirection::Next, None) => return false,
(HistoryDirection::Next, Some(cursor)) if cursor + 1 < self.input_history.len() => {
cursor + 1
}
(HistoryDirection::Next, Some(_)) => {
let draft = self.input_history_draft.take().unwrap_or(InputDraft {
input: String::new(),
paste_segments: Vec::new(),
});
self.input = draft.input;
self.paste_segments = draft.paste_segments;
self.input_cursor = self.input_char_len();
self.input_history_cursor = None;
self.input_changed();
return true;
}
};
self.input = self.input_history[next_cursor].clone();
self.paste_segments.clear();
self.input_cursor = self.input_char_len();
self.input_history_cursor = Some(next_cursor);
self.input_changed();
true
}
fn recall_input_history_or_move_cursor(
&mut self,
direction: HistoryDirection,
terminal_width: usize,
) {
let visual_lines = input_visual_lines(&self.input, terminal_width);
let cursor_position = input_cursor_position(&self.input, self.input_cursor, terminal_width);
let can_recall = match direction {
HistoryDirection::Previous => cursor_position.y == 0,
HistoryDirection::Next => cursor_position.y as usize + 1 >= visual_lines.len(),
};
if can_recall && self.recall_input_history(direction) {
return;
}
let width = terminal_width.max(1);
match direction {
HistoryDirection::Previous => {
self.input_cursor = self.input_cursor.saturating_sub(width);
}
HistoryDirection::Next => {
self.input_cursor = (self.input_cursor + width).min(self.input_char_len());
}
}
}
fn recall_last_queued_prompt(&mut self) -> bool {
let Some(prompt) = self.queued_prompts.pop_back() else {
return false;
};
self.input = prompt.display_prompt;
self.paste_segments = prompt.paste_segments;
self.input_cursor = self.input_char_len();
self.reset_input_history_navigation();
self.input_changed();
true
}
fn insert_input_char(&mut self, ch: char) {
self.reset_input_history_navigation();
self.adjust_paste_segments_for_edit(self.input_cursor, 0, 1);
let byte_index = self.input_byte_index(self.input_cursor);
self.input.insert(byte_index, ch);
self.input_cursor += 1;
self.input_changed();
}
fn insert_input_text(&mut self, text: &str) {
self.insert_input_text_with_paste_content(text, None);
}
fn insert_pasted_input_text(&mut self, text: &str) {
let Some(marker) = paste_marker_for(text) else {
self.insert_input_text(text);
return;
};
self.insert_input_text_with_paste_content(&marker, Some(text.to_string()));
}
fn insert_input_text_with_paste_content(&mut self, text: &str, paste_content: Option<String>) {
self.reset_input_history_navigation();
let start = self.input_cursor;
let inserted_len = text.chars().count();
self.adjust_paste_segments_for_edit(start, 0, inserted_len);
let byte_index = self.input_byte_index(start);
self.input.insert_str(byte_index, text);
self.input_cursor += inserted_len;
if let Some(content) = paste_content {
self.paste_segments.push(PasteSegment {
start,
marker_len: inserted_len,
content,
});
self.paste_segments.sort_by_key(|segment| segment.start);
}
self.input_changed();
}
fn expanded_input(&self) -> String {
expand_paste_segments(&self.input, &self.paste_segments)
}
fn adjust_paste_segments_for_edit(
&mut self,
start: usize,
deleted_len: usize,
inserted_len: usize,
) {
let end = start + deleted_len;
let shift = inserted_len as isize - deleted_len as isize;
self.paste_segments.retain_mut(|segment| {
if start < segment.end() && end > segment.start {
return false;
}
if start <= segment.start {
segment.start = segment.start.saturating_add_signed(shift);
}
true
});
}
fn backspace_input(&mut self) {
if self.input_cursor == 0 {
if self.input.is_empty() && self.pending_images.pop().is_some() {
self.status = format!("attached images: {}", self.pending_images.len());
}
return;
}
self.reset_input_history_navigation();
let edit_start = self.input_cursor - 1;
self.adjust_paste_segments_for_edit(edit_start, 1, 0);
let start = self.input_byte_index(edit_start);
let end = self.input_byte_index(self.input_cursor);
self.input.replace_range(start..end, "");
self.input_cursor -= 1;
self.input_changed();
}
fn delete_input(&mut self) {
if self.input_cursor >= self.input_char_len() {
return;
}
self.reset_input_history_navigation();
self.adjust_paste_segments_for_edit(self.input_cursor, 1, 0);
let start = self.input_byte_index(self.input_cursor);
let end = self.input_byte_index(self.input_cursor + 1);
self.input.replace_range(start..end, "");
self.input_changed();
}
fn delete_word_before_cursor(&mut self) {
self.reset_input_history_navigation();
let start_cursor = previous_word_boundary(&self.input, self.input_cursor);
self.adjust_paste_segments_for_edit(start_cursor, self.input_cursor - start_cursor, 0);
let start = self.input_byte_index(start_cursor);
let end = self.input_byte_index(self.input_cursor);
self.input.replace_range(start..end, "");
self.input_cursor = start_cursor;
self.input_changed();
}
fn input_changed(&mut self) {
self.command_palette_dismissed = false;
self.clamp_command_selection();
}
fn command_matches(&self) -> Vec<CommandChoice> {
let Some(prefix) = commands::command_prefix(&self.input) else {
return Vec::new();
};
let prefix = prefix
.strip_prefix('/')
.unwrap_or(prefix)
.to_ascii_lowercase();
let mut matches = commands::matching_commands(&prefix)
.into_iter()
.map(|command| CommandChoice {
name: command.name.to_string(),
usage: command.usage.to_string(),
description: command.description.to_string(),
kind: CommandChoiceKind::Builtin(command),
})
.collect::<Vec<_>>();
matches.extend(
crate::skills::discover(&self.info.cwd)
.into_iter()
.filter(|skill| {
skill.name.starts_with(&prefix)
|| format!("skill:{}", skill.name).starts_with(&prefix)
})
.map(|skill| {
let command_name = format!("skill:{}", skill.name);
CommandChoice {
usage: format!("/{command_name}"),
name: command_name,
description: skill.description,
kind: CommandChoiceKind::Skill,
}
}),
);
matches
}
fn selected_command(&self) -> Option<CommandChoice> {
let matches = self.command_matches();
matches
.get(self.command_selection.min(matches.len().saturating_sub(1)))
.cloned()
}
fn complete_command_choice(&self, choice: &CommandChoice) -> (String, usize) {
match &choice.kind {
CommandChoiceKind::Builtin(spec) => {
commands::complete_command(&self.input, self.input_cursor, spec)
}
CommandChoiceKind::Skill => {
complete_slash_command(&self.input, self.input_cursor, &choice.name)
}
}
}
fn command_palette_visible(&self) -> bool {
matches!(self.composer, ComposerMode::Input)
&& !self.command_palette_dismissed
&& self.cursor_in_command_token()
&& !self.command_matches().is_empty()
}
fn cursor_in_command_token(&self) -> bool {
if !self.input.starts_with('/') {
return false;
}
let token_len = self
.input
.chars()
.position(char::is_whitespace)
.unwrap_or_else(|| self.input_char_len());
self.input_cursor <= token_len
}
fn clamp_command_selection(&mut self) {
let prefix = self
.cursor_in_command_token()
.then(|| commands::command_prefix(&self.input).map(str::to_ascii_lowercase))
.flatten();
if self.command_prefix != prefix {
self.command_prefix = prefix;
self.command_selection = 0;
}
let match_count = self.command_matches().len();
if match_count == 0 {
self.command_selection = 0;
} else if self.command_selection >= match_count {
self.command_selection = match_count - 1;
}
}
fn ensure_session(&mut self, agent: &mut Agent) -> anyhow::Result<()> {
if self.info.session_id.is_none() {
let session = Session::create(&self.info.cwd)?;
let session_id = session.id().to_string();
self.info.session_id = Some(session_id.clone());
agent.set_session_id(Some(session_id));
agent.set_history_sink(SessionHistorySink::new(session));
}
Ok(())
}
fn title_model_selection(&self) -> (String, String, String) {
(
self.info
.title_provider
.clone()
.unwrap_or_else(|| self.info.provider.clone()),
self.info
.title_model
.clone()
.unwrap_or_else(|| self.info.model.clone()),
self.info
.title_auth
.clone()
.unwrap_or_else(|| self.info.auth.clone()),
)
}
fn start_session_title_generation(&mut self, first_user_message: String) {
let Some(session_id) = self.info.session_id.clone() else {
return;
};
self.pending_session_title = None;
let (provider, model, _auth) = self.title_model_selection();
self.pending_session_title = Some(Box::pin(async move {
let title = generate_session_title(provider, model, first_user_message).await;
SessionTitleResult { session_id, title }
}));
}
async fn submit(
&mut self,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
let mut prompt = self.expanded_input().trim().to_string();
let mut display_prompt = self.input.trim().to_string();
if prompt.is_empty() && self.pending_images.is_empty() {
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
return Ok(());
}
match commands::parse_command(&self.input) {
Ok(Some(invocation)) => {
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
self.execute_command(invocation, terminal, agent).await?;
return Ok(());
}
Ok(None) => {}
Err(commands::CommandParseError::Unknown(name)) => {
let expanded_input = self.expanded_input();
let trailing_prompt = slash_command_args(&expanded_input).trim().to_string();
let trailing_display_prompt = slash_command_args(&self.input).trim().to_string();
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
if self.execute_skill_command(&name, terminal, agent)? {
if trailing_prompt.is_empty() {
return Ok(());
}
prompt = trailing_prompt;
display_prompt = trailing_display_prompt;
} else {
self.insert_entry(
terminal,
&Entry::Error(format!(
"unknown command '/{name}'. Type / to choose one of: {}",
commands::COMMANDS
.iter()
.map(|command| command.usage)
.collect::<Vec<_>>()
.join(", ")
)),
)?;
self.status = "unknown command".into();
return Ok(());
}
}
}
let images = std::mem::take(&mut self.pending_images);
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
self.run_prompt_turn(prompt, display_prompt, images, terminal, agent)
.await?;
while !self.should_quit {
let Some(prompt) = self.queued_prompts.pop_front() else {
break;
};
self.run_prompt_turn(
prompt.prompt,
prompt.display_prompt,
Vec::new(),
terminal,
agent,
)
.await?;
}
Ok(())
}
async fn run_prompt_turn(
&mut self,
prompt: String,
display_prompt: String,
images: Vec<ImageContent>,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
if !prompt.is_empty() {
self.push_input_history(&prompt);
}
self.reset_input_history_navigation();
self.ensure_session(agent)?;
self.info
.herdr
.report_session(self.info.session_id.as_deref())
.await;
if !agent
.messages()
.iter()
.any(|message| matches!(message, Message::User(_)))
{
self.start_session_title_generation(prompt.clone());
}
self.insert_entry(
terminal,
&Entry::User(render_user_entry(&display_prompt, &images)),
)?;
self.current_turn_start = Some(self.transcript.len());
self.active_turn_show_reasoning_output = self.info.show_reasoning_output;
self.reset_streams();
self.status = "running".into();
self.running = true;
self.info
.herdr
.report_state(HerdrState::Working, None, self.info.session_id.as_deref())
.await;
self.loading_spinner.start();
self.resize_inline_viewport_if_needed(terminal)?;
terminal.draw(|frame| self.draw(frame))?;
self.active_tool_call = false;
self.pending_tool_call = None;
let interrupt_requested = Arc::new(AtomicBool::new(false));
let tool_call_active = Arc::new(AtomicBool::new(false));
let steering_prompts = Arc::new(Mutex::new(VecDeque::new()));
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
let result = {
let callback_interrupt_requested = Arc::clone(&interrupt_requested);
let callback_tool_call_active = Arc::clone(&tool_call_active);
let run_steering_prompts = Arc::clone(&steering_prompts);
let (question_tx, mut question_rx) = mpsc::unbounded_channel::<QuestionAnswerRequest>();
let mut content = Vec::with_capacity(1 + images.len());
if !prompt.is_empty() {
content.push(ContentBlock::Text(prompt));
}
content.extend(images.into_iter().map(ContentBlock::Image));
let question_request_tx = question_tx.clone();
let mut ask_questionnaire =
move |request: QuestionnaireRequest| -> crate::agent::QuestionnaireFuture {
let question_request_tx = question_request_tx.clone();
let (reply_tx, reply_rx) = oneshot::channel();
Box::pin(async move {
question_request_tx
.send(QuestionAnswerRequest {
request,
response: QuestionnaireResponseChannel::new(reply_tx),
})
.map_err(|_| {
crate::agent::AgentError::Questionnaire(
"questionnaire UI is unavailable".into(),
)
})?;
match reply_rx.await {
Ok(QuestionnaireReply::Answer(response)) => Ok(response),
Ok(QuestionnaireReply::Cancelled(
QuestionnaireCancelReason::UserCancelled,
)) => Err(crate::agent::AgentError::Questionnaire(
"questionnaire answer was cancelled".into(),
)),
Ok(QuestionnaireReply::Cancelled(
QuestionnaireCancelReason::UiUnavailable,
))
| Err(_) => Err(crate::agent::AgentError::Questionnaire(
"questionnaire UI is unavailable".into(),
)),
}
})
};
let questionnaire_handler = self
.info
.questionnaire_enabled
.then_some(&mut ask_questionnaire as crate::agent::QuestionnaireHandler<'_>);
let mut run_future = Box::pin(
agent.run_with_content_and_events_questionnaire_and_steering(
content,
move |event| {
match &event {
AgentEvent::ToolStarted { .. } => {
callback_tool_call_active.store(true, Ordering::SeqCst)
}
AgentEvent::ToolFinished { .. } => {
callback_tool_call_active.store(false, Ordering::SeqCst)
}
AgentEvent::StepStarted(_)
| AgentEvent::OutputDelta(_)
| AgentEvent::ReasoningDelta(_)
| AgentEvent::ContextUsage(_)
| AgentEvent::Usage(_)
| AgentEvent::ToolUpdated { .. }
| AgentEvent::QuestionnaireStarted(_)
| AgentEvent::QuestionnaireFinished(_) => {}
}
let _ = event_tx.send(event);
if callback_interrupt_requested.load(Ordering::SeqCst) {
return Err(crate::model::ModelError::Interrupted);
}
Ok(())
},
questionnaire_handler,
move || Ok(run_steering_prompts.lock().unwrap().pop_front()),
),
);
loop {
tokio::select! {
result = &mut run_future => {
let mut result = result;
while let Ok(event) = event_rx.try_recv() {
if let Err(err) = self.handle_queued_agent_event(event, terminal) {
result = Err(crate::agent::AgentError::Provider(err));
break;
}
}
terminal.draw(|frame| self.draw(frame))?;
break result;
}
Some(request) = question_rx.recv() => {
self.open_questionnaire(request, terminal)?;
self.resize_inline_viewport_if_needed(terminal)?;
terminal.draw(|frame| self.draw(frame))?;
}
Some(event) = event_rx.recv() => {
if let Err(err) = self.handle_queued_agent_event(event, terminal) {
break Err(crate::agent::AgentError::Provider(err));
}
match self.handle_running_terminal_events(
terminal,
&interrupt_requested,
&tool_call_active,
) {
Ok(StreamControl::Interrupt) => {
break Err(crate::agent::AgentError::Provider(crate::model::ModelError::Interrupted));
}
Ok(StreamControl::Continue | StreamControl::Resize) => {}
Err(err) => break Err(crate::agent::AgentError::Provider(err)),
}
self.drain_steering_prompts_to(&steering_prompts);
self.resize_inline_viewport_if_needed(terminal)?;
terminal.draw(|frame| self.draw(frame))?;
}
_ = tokio::time::sleep_until(self.stream_sleep_deadline()) => {
self.drain_stream_preview(terminal)?;
match self.handle_running_terminal_events(
terminal,
&interrupt_requested,
&tool_call_active,
) {
Ok(StreamControl::Interrupt) => {
break Err(crate::agent::AgentError::Provider(crate::model::ModelError::Interrupted));
}
Ok(StreamControl::Continue | StreamControl::Resize) => {}
Err(err) => break Err(crate::agent::AgentError::Provider(err)),
}
self.drain_steering_prompts_to(&steering_prompts);
self.resize_inline_viewport_if_needed(terminal)?;
terminal.draw(|frame| self.draw(frame))?;
}
}
}
};
while let Ok(event) = event_rx.try_recv() {
self.handle_agent_event(event, terminal)?;
}
self.active_tool_call = false;
self.pending_tool_call = None;
tool_call_active.store(false, Ordering::SeqCst);
match result {
Ok(answer) => {
self.running = false;
self.loading_spinner.stop();
self.finish_streams(terminal)?;
self.insert_final_answer_suffix(terminal, &answer)?;
self.reset_streams();
self.current_turn_start = None;
self.status = if self.queued_prompts.is_empty() {
"ready".into()
} else {
format!(
"running next queued message ({})",
self.queued_prompts.len()
)
};
}
Err(crate::agent::AgentError::Provider(crate::model::ModelError::Interrupted)) => {
self.running = false;
self.loading_spinner.stop();
self.finish_streams(terminal)?;
self.insert_entry(terminal, &Entry::Notice("model interrupted".into()))?;
self.reset_streams();
self.current_turn_start = None;
self.status = "interrupted".into();
}
Err(err) => {
self.reset_streams();
self.current_turn_start = None;
self.running = false;
self.loading_spinner.stop();
self.insert_entry(terminal, &Entry::Error(err.to_string()))?;
self.status = "error".into();
}
}
self.report_resting_herdr_state().await;
terminal.draw(|frame| self.draw(frame))?;
Ok(())
}
async fn report_resting_herdr_state(&self) {
let state = if self.info.auth_unavailable.is_some() {
HerdrState::Blocked
} else {
HerdrState::Idle
};
self.info
.herdr
.report_state(
state,
self.info.auth_unavailable.as_deref(),
self.info.session_id.as_deref(),
)
.await;
}
fn drain_steering_prompts_to(&mut self, target: &Arc<Mutex<VecDeque<String>>>) {
if self.steering_prompts.is_empty() {
return;
}
let mut target = target.lock().unwrap();
target.extend(self.steering_prompts.drain(..));
}
fn paste_clipboard_image<B: Backend>(
&mut self,
terminal: &mut Terminal<B>,
) -> Result<(), B::Error> {
if self.running {
self.notify_status(
terminal,
"image paste is unavailable while a model turn is running",
)?;
return Ok(());
}
if !matches!(self.composer, ComposerMode::Input) {
self.notify_status(terminal, "image paste is only available in the message box")?;
return Ok(());
}
match read_clipboard_image() {
Ok(image) => {
let summary = image_summary(&image);
self.pending_images.push(image);
self.notify_status(
terminal,
format!("attached image {} ({summary})", self.pending_images.len()),
)?;
}
Err(err) => {
self.notify_status(terminal, format!("image paste failed: {err}"))?;
}
}
Ok(())
}
fn insert_running_paste(&mut self, text: &str) {
match &mut self.composer {
ComposerMode::Input => self.insert_pasted_input_text(text),
ComposerMode::SecretInput(secret) => secret.insert_text(text),
ComposerMode::ConfigNumberInput(input) => input.insert_text(text),
ComposerMode::ConfigTextInput(input) => input.insert_text(text),
ComposerMode::Questionnaire(questionnaire) => {
questionnaire.insert_text(text);
}
ComposerMode::Picker(_) | ComposerMode::OAuthPending(_) => {}
}
}
fn handle_key_during_turn(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
if self.handle_questionnaire_key(key, terminal)? {
return Ok(());
}
if self.handle_running_config_number_key(key, terminal)? {
return Ok(());
}
if self.handle_running_config_text_key(key, terminal)? {
return Ok(());
}
if self.handle_running_picker_key(key, terminal)? {
return Ok(());
}
if self.handle_running_command_palette_key(key, terminal)? {
return Ok(());
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => {
if self.ctrl_c_streak == 0 {
self.input.clear();
self.paste_segments.clear();
self.pending_images.clear();
self.input_cursor = 0;
self.clamp_command_selection();
self.notify_status(terminal, "input cleared; press esc to interrupt model")?;
self.ctrl_c_streak = 1;
} else {
self.should_quit = true;
}
}
(KeyModifiers::CONTROL, KeyCode::Char('v'))
| (KeyModifiers::ALT, KeyCode::Char('v')) => {
self.paste_clipboard_image(terminal)?;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(KeyModifiers::CONTROL, KeyCode::Char('o')) => {
self.toggle_latest_tool_output(terminal)?;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(KeyModifiers::CONTROL, KeyCode::Char('r')) => {
self.notify_status(
terminal,
"reset is unavailable while a model turn is running",
)?;
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Backspace) => {
self.delete_word_before_cursor();
self.ctrl_c_streak = 0;
}
(_, KeyCode::Backspace) => {
self.backspace_input();
self.ctrl_c_streak = 0;
}
(_, KeyCode::Delete) => {
self.delete_input();
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Left) => {
self.input_cursor = previous_word_boundary(&self.input, self.input_cursor);
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Right) => {
self.input_cursor = next_word_boundary(&self.input, self.input_cursor);
self.ctrl_c_streak = 0;
}
(_, KeyCode::Left) => {
self.input_cursor = self.input_cursor.saturating_sub(1);
self.ctrl_c_streak = 0;
}
(_, KeyCode::Right) => {
self.input_cursor = (self.input_cursor + 1).min(self.input_char_len());
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Up) => {
if self.recall_last_queued_prompt() {
self.notify_status(
terminal,
format!(
"editing queued message; {} queued message(s) remain",
self.queued_prompts.len()
),
)?;
}
self.ctrl_c_streak = 0;
}
(_, KeyCode::Up) => {
let width = terminal.size()?.width as usize;
self.recall_input_history_or_move_cursor(HistoryDirection::Previous, width);
self.ctrl_c_streak = 0;
}
(_, KeyCode::Down) => {
let width = terminal.size()?.width as usize;
self.recall_input_history_or_move_cursor(HistoryDirection::Next, width);
self.ctrl_c_streak = 0;
}
(_, KeyCode::Home) => {
self.reset_input_history_navigation();
self.input_cursor = 0;
self.ctrl_c_streak = 0;
}
(_, KeyCode::End) => {
self.reset_input_history_navigation();
self.input_cursor = self.input_char_len();
self.ctrl_c_streak = 0;
}
(KeyModifiers::ALT, KeyCode::Enter) => {
self.queue_prompt_after_turn(terminal)?;
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(modifiers, KeyCode::Enter) if modifiers.contains(KeyModifiers::SHIFT) => {
self.insert_input_char('\n');
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
(_, KeyCode::Enter) => {
if self
.paste_burst
.should_insert_newline_for_enter(Instant::now())
{
self.insert_input_char('\n');
} else {
self.submit_during_turn(terminal)?;
}
self.ctrl_c_streak = 0;
}
(modifiers, KeyCode::Char(ch))
if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.insert_input_char(ch);
self.paste_burst.record_plain_char(Instant::now());
self.ctrl_c_streak = 0;
}
_ => {
self.paste_burst.clear();
self.ctrl_c_streak = 0;
}
}
self.clamp_command_selection();
Ok(())
}
fn submit_during_turn(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let prompt = self.expanded_input().trim().to_string();
if prompt.is_empty() {
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
return Ok(());
}
match commands::parse_command(&self.input) {
Ok(Some(invocation)) => {
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
self.execute_command_during_turn(invocation, terminal)?;
}
Ok(None) => {
self.queue_steering_prompt(prompt, terminal)?;
}
Err(commands::CommandParseError::Unknown(name)) => {
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
self.insert_entry(
terminal,
&Entry::Error(format!(
"unknown or unavailable command '/{name}' while a model turn is running"
)),
)?;
self.status = "command unavailable while running".into();
}
}
Ok(())
}
fn queue_steering_prompt(
&mut self,
prompt: String,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
self.reset_input_history_navigation();
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
self.steering_prompts.push_back(prompt);
self.insert_entry(
terminal,
&Entry::Notice(format!(
"queued steer {} for after the current output or tool call",
self.steering_prompts.len()
)),
)?;
self.status = format!("queued {} steer(s)", self.steering_prompts.len());
Ok(())
}
fn queue_prompt_after_turn(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let prompt = self.expanded_input().trim().to_string();
let display_prompt = self.input.clone();
let paste_segments = self.paste_segments.clone();
if prompt.is_empty() {
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
return Ok(());
}
self.queue_prompt(prompt, display_prompt, paste_segments, terminal)
}
fn queue_prompt(
&mut self,
prompt: String,
display_prompt: String,
paste_segments: Vec<PasteSegment>,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
self.reset_input_history_navigation();
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.clamp_command_selection();
self.queued_prompts.push_back(QueuedPrompt {
prompt,
display_prompt,
paste_segments,
});
self.insert_entry(
terminal,
&Entry::Notice(format!(
"queued message {} for after the current turn",
self.queued_prompts.len()
)),
)?;
self.status = format!("queued {} message(s)", self.queued_prompts.len());
Ok(())
}
fn execute_command_during_turn(
&mut self,
invocation: CommandInvocation,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
match invocation.id {
CommandId::Exit => self.execute_exit_command(terminal),
CommandId::Config => self.execute_config_command(terminal),
CommandId::Skills => self.execute_skills_command(terminal),
CommandId::TitleModel => self.execute_title_model_command(invocation, terminal),
CommandId::New
| CommandId::Model
| CommandId::RefreshModelList
| CommandId::Login
| CommandId::Logout
| CommandId::Resume => {
self.insert_entry(
terminal,
&Entry::Notice(format!(
"/{} is unavailable while a model turn is running",
invocation.name
)),
)?;
self.status = "command unavailable while running".into();
Ok(())
}
}
}
fn handle_running_command_palette_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<bool> {
if !self.command_palette_visible() {
return Ok(false);
}
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Up) => {
let matches = self.command_matches();
if !matches.is_empty() {
self.command_selection = if self.command_selection == 0 {
matches.len() - 1
} else {
self.command_selection - 1
};
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Down) => {
let matches = self.command_matches();
if !matches.is_empty() {
self.command_selection = (self.command_selection + 1) % matches.len();
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Tab) => {
if let Some(choice) = self.selected_command() {
let (input, cursor) = self.complete_command_choice(&choice);
self.input = input;
self.input_cursor = cursor;
self.command_palette_dismissed = false;
self.clamp_command_selection();
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Enter) => {
if let Some(choice) = self.selected_command() {
let (input, cursor) = self.complete_command_choice(&choice);
self.input = input;
self.input_cursor = cursor;
self.clamp_command_selection();
}
self.submit_during_turn(terminal)?;
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Esc) => {
self.command_palette_dismissed = true;
self.command_selection = 0;
Ok(true)
}
_ => Ok(false),
}
}
fn handle_running_picker_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<bool> {
if !matches!(self.composer, ComposerMode::Picker(_)) {
return Ok(false);
}
match (key.modifiers, key.code) {
(KeyModifiers::NONE, KeyCode::Up) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.select_previous();
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Down) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.select_next();
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Tab) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.complete_filter();
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Backspace) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.pop_filter_char();
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Char(' ')) if self.picker_space_confirms_selection() => {
self.submit_picker_selection_during_turn(terminal)?;
Ok(true)
}
(KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(ch)) => {
if let ComposerMode::Picker(picker) = &mut self.composer {
picker.push_filter_char(ch);
}
Ok(true)
}
(KeyModifiers::NONE, KeyCode::Enter) => {
self.submit_picker_selection_during_turn(terminal)?;
Ok(true)
}
(_, KeyCode::Esc) => {
self.handle_picker_escape( true)?;
Ok(true)
}
_ => Ok(true),
}
}
fn submit_picker_selection_during_turn(
&mut self,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
let Some((action, value)) = self.active_picker_selection() else {
self.composer = ComposerMode::Input;
self.status = "running".into();
return Ok(());
};
if !matches!(action, PickerAction::Config) {
self.composer = ComposerMode::Input;
}
match action {
PickerAction::InsertSkillCommand => {
self.input = format!("/skill:{value}");
self.input_cursor = self.input_char_len();
self.command_palette_dismissed = true;
self.status = "skill command inserted".into();
}
PickerAction::Config => self.submit_config_selection_during_turn(&value, terminal)?,
PickerAction::SelectTitleModel => {
self.refresh_available_auths();
let (provider, _model, auth) = self.title_model_selection();
match catalog::resolve_model_selection_for_auths(
&value,
&provider,
&auth,
&self.available_auths,
) {
Ok(selection) => self.select_title_model(selection, terminal)?,
Err(err) => {
self.insert_entry(terminal, &Entry::Error(err.to_string()))?;
self.status = "title model switch failed".into();
}
}
}
PickerAction::SelectModel
| PickerAction::LoginProvider
| PickerAction::LogoutProvider
| PickerAction::ResumeSession => {
self.insert_entry(
terminal,
&Entry::Notice(
"that picker action is unavailable while a model turn is running".into(),
),
)?;
self.status = "picker action unavailable while running".into();
}
}
Ok(())
}
fn submit_config_selection_during_turn(
&mut self,
value: &str,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
match value {
config_picker::MAX_OUTPUT_BYTES_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigNumberInput(ConfigNumberInput::new(
ConfigNumberKey::MaxOutputBytes,
config.max_output_bytes,
));
self.status = "edit max output bytes".into();
}
config_picker::MAX_TOOL_OUTPUT_LINES_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigNumberInput(ConfigNumberInput::new(
ConfigNumberKey::MaxToolOutputLines,
config.max_tool_output_lines,
));
self.status = "edit max tool output lines".into();
}
config_picker::REASONING_VALUE => {
self.insert_entry(
terminal,
&Entry::Notice(
"reasoning changes are unavailable while a model turn is running".into(),
),
)?;
self.status = "config action unavailable while running".into();
}
config_picker::SHOW_REASONING_OUTPUT_VALUE => {
self.toggle_reasoning_output(terminal)?;
}
config_picker::CHECK_FOR_UPDATES_VALUE => {
self.toggle_check_for_updates(terminal)?;
}
config_picker::WEB_SEARCH_VALUE => {
self.composer =
ComposerMode::Picker(config_picker::web_search_config_picker(&self.info));
self.status = "web search config".into();
}
config_picker::WEB_SEARCH_BACK_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::Picker(config_picker::config_picker(
&self.info,
config.max_output_bytes,
config.max_tool_output_lines,
));
self.status = "config".into();
}
config_picker::WEB_SEARCH_PROVIDER_VALUE => self.cycle_web_search_provider(terminal)?,
config_picker::WEB_SEARCH_OPENAI_KEY_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigTextInput(ConfigTextInput::new(
ConfigTextKey::OpenAiSearch,
config.web_search_openai_api_key,
));
self.status = "edit OpenAI web search API key".into();
}
config_picker::WEB_SEARCH_EXA_KEY_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigTextInput(ConfigTextInput::new(
ConfigTextKey::Exa,
config.web_search_exa_api_key,
));
self.status = "edit Exa API key".into();
}
config_picker::WEB_SEARCH_BRAVE_KEY_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigTextInput(ConfigTextInput::new(
ConfigTextKey::Brave,
config.web_search_brave_api_key,
));
self.status = "edit Brave Search API key".into();
}
_ => {}
}
Ok(())
}
fn handle_running_config_number_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<bool> {
if !matches!(self.composer, ComposerMode::ConfigNumberInput(_)) {
return Ok(false);
}
self.handle_config_number_key(key, terminal)
}
fn handle_running_config_text_key(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<bool> {
if !matches!(self.composer, ComposerMode::ConfigTextInput(_)) {
return Ok(false);
}
self.handle_config_text_key(key, terminal)
}
fn reset_streams(&mut self) {
self.assistant_stream.reset();
self.assistant_stream_in_code_block = false;
self.reasoning_stream.reset();
self.current_stream_kind = None;
self.stream_preview_deadline = None;
self.live_stream_preview = None;
}
fn loading_active(&self) -> bool {
self.running || !self.assistant_stream.is_empty() || !self.reasoning_stream.is_empty()
}
fn handle_queued_agent_event(
&mut self,
event: AgentEvent,
terminal: &mut DefaultTerminal,
) -> Result<(), crate::model::ModelError> {
self.handle_agent_event(event, terminal)?;
Ok(())
}
fn stream_sleep_deadline(&self) -> tokio::time::Instant {
let spinner_deadline = Instant::now() + LoadingSpinner::FRAME_INTERVAL;
let deadline = self
.stream_preview_deadline
.map_or(spinner_deadline, |stream_deadline| {
stream_deadline.min(spinner_deadline)
});
tokio::time::Instant::from_std(deadline)
}
fn handle_running_terminal_events(
&mut self,
terminal: &mut DefaultTerminal,
interrupt_requested: &AtomicBool,
tool_call_active: &AtomicBool,
) -> Result<StreamControl, crate::model::ModelError> {
let mut control = StreamControl::Continue;
while event::poll(Duration::from_millis(0))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if key.code == KeyCode::Esc && !self.running_escape_has_overlay_target() {
return Ok(
self.request_running_interrupt(interrupt_requested, tool_call_active)
);
}
self.handle_key_during_turn(key, terminal).map_err(|err| {
crate::model::ModelError::InvalidResponse(err.to_string())
})?;
if self.should_quit {
return Ok(
self.request_running_interrupt(interrupt_requested, tool_call_active)
);
}
}
Event::Paste(text) => {
let text = normalize_paste(&text);
self.insert_running_paste(&text);
self.paste_burst.clear();
}
Event::Resize(_, _) => {
self.reflow_history(terminal)?;
self.drain_streams(terminal)?;
control = StreamControl::Resize;
}
_ => {}
}
}
Ok(control)
}
fn running_escape_has_overlay_target(&self) -> bool {
self.command_palette_visible() || !matches!(self.composer, ComposerMode::Input)
}
fn request_running_interrupt(
&mut self,
interrupt_requested: &AtomicBool,
tool_call_active: &AtomicBool,
) -> StreamControl {
interrupt_requested.store(true, Ordering::SeqCst);
if tool_call_active.load(Ordering::SeqCst) {
self.status = "interrupt requested; waiting for tool result".into();
StreamControl::Continue
} else {
StreamControl::Interrupt
}
}
fn handle_agent_event(
&mut self,
event: AgentEvent,
terminal: &mut DefaultTerminal,
) -> std::io::Result<bool> {
match event {
AgentEvent::OutputDelta(text) => {
let switched = self.switch_stream_kind(terminal, StreamKind::Assistant)?;
self.assistant_stream.push_delta(&text);
let drained = self.drain_stream(terminal, StreamKind::Assistant)?;
self.update_stream_preview_deadline(StreamKind::Assistant);
Ok(switched || drained)
}
AgentEvent::ReasoningDelta(text) => {
if !self.active_turn_show_reasoning_output {
return Ok(false);
}
let switched = self.switch_stream_kind(terminal, StreamKind::Reasoning)?;
self.reasoning_stream.push_delta(&text);
let drained = self.drain_stream(terminal, StreamKind::Reasoning)?;
self.update_stream_preview_deadline(StreamKind::Reasoning);
Ok(switched || drained)
}
other => {
if matches!(
other,
AgentEvent::StepStarted(_)
| AgentEvent::ToolStarted { .. }
| AgentEvent::ToolFinished { .. }
) {
self.finish_streams(terminal)?;
}
if let Some(entry) = self.record_agent_event(other) {
self.insert_entry(terminal, &entry)?;
}
self.drain_streams(terminal)?;
Ok(true)
}
}
}
fn switch_stream_kind(
&mut self,
terminal: &mut DefaultTerminal,
kind: StreamKind,
) -> std::io::Result<bool> {
let inserted = if self
.current_stream_kind
.is_some_and(|current| current != kind)
{
self.finish_current_stream(terminal)?
} else {
false
};
self.current_stream_kind = Some(kind);
self.update_stream_preview_deadline(kind);
Ok(inserted)
}
fn drain_streams(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<bool> {
let reasoning_drained = self.drain_stream(terminal, StreamKind::Reasoning)?;
let assistant_drained = self.drain_stream(terminal, StreamKind::Assistant)?;
Ok(reasoning_drained || assistant_drained)
}
fn drain_stream(
&mut self,
terminal: &mut DefaultTerminal,
kind: StreamKind,
) -> std::io::Result<bool> {
let width = terminal.size()?.width as usize;
let inner_width = padded_content_width(width);
let fragment = match kind {
StreamKind::Assistant => self
.assistant_stream
.drain_renderable_markdown(inner_width, self.assistant_stream_in_code_block),
StreamKind::Reasoning => self.reasoning_stream.drain_renderable(inner_width),
};
if let Some(fragment) = fragment {
self.live_stream_preview = None;
self.insert_stream_fragment(terminal, fragment, kind)?;
Ok(true)
} else {
Ok(false)
}
}
fn finish_streams(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<bool> {
let reasoning_finished = self.finish_stream(terminal, StreamKind::Reasoning)?;
let assistant_finished = self.finish_stream(terminal, StreamKind::Assistant)?;
self.current_stream_kind = None;
self.stream_preview_deadline = None;
self.live_stream_preview = None;
Ok(reasoning_finished || assistant_finished)
}
fn finish_current_stream(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<bool> {
if let Some(kind) = self.current_stream_kind {
self.finish_stream(terminal, kind)
} else {
Ok(false)
}
}
fn finish_stream(
&mut self,
terminal: &mut DefaultTerminal,
kind: StreamKind,
) -> std::io::Result<bool> {
let fragment = match kind {
StreamKind::Assistant => self.assistant_stream.finish(),
StreamKind::Reasoning => self.reasoning_stream.finish(),
};
self.update_stream_preview_deadline(kind);
if let Some(fragment) = fragment {
self.live_stream_preview = None;
self.insert_stream_fragment(terminal, fragment, kind)?;
Ok(true)
} else {
Ok(false)
}
}
fn drain_stream_preview(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<bool> {
if self
.stream_preview_deadline
.is_none_or(|deadline| Instant::now() < deadline)
{
return Ok(false);
}
let Some(kind) = self.current_stream_kind else {
self.stream_preview_deadline = None;
return Ok(false);
};
let width = terminal.size()?.width as usize;
let inner_width = padded_content_width(width);
let preview = match kind {
StreamKind::Assistant => self
.assistant_stream
.drain_preview_markdown(inner_width, self.assistant_stream_in_code_block),
StreamKind::Reasoning => self.reasoning_stream.drain_preview(),
};
self.update_stream_preview_deadline(kind);
if let Some(preview) = preview {
self.live_stream_preview = Some(LiveStreamPreview {
kind,
text: preview.render_text().to_string(),
include_leading_blank: preview.include_leading_blank(),
});
Ok(true)
} else {
Ok(false)
}
}
fn update_stream_preview_deadline(&mut self, kind: StreamKind) {
let pending_chars = match kind {
StreamKind::Assistant => self.assistant_stream.pending_text().chars().count(),
StreamKind::Reasoning => self.reasoning_stream.pending_text().chars().count(),
};
self.stream_preview_deadline = if pending_chars >= STREAM_PREVIEW_MIN_CHARS {
Some(Instant::now() + STREAM_PREVIEW_DELAY)
} else {
None
};
}
fn insert_final_answer_suffix(
&mut self,
terminal: &mut DefaultTerminal,
answer: &str,
) -> std::io::Result<()> {
match final_answer_delta(self.assistant_stream.emitted_text(), answer) {
FinalAnswerDelta::None => {}
FinalAnswerDelta::Append(suffix) => {
self.assistant_stream.push_delta(suffix);
if let Some(fragment) = self.assistant_stream.finish() {
self.insert_stream_fragment(terminal, fragment, StreamKind::Assistant)?;
}
}
FinalAnswerDelta::Mismatch => {
self.replace_current_turn_assistant_transcript(answer);
}
}
Ok(())
}
fn render_stream_preview_lines(
&self,
preview: &LiveStreamPreview,
width: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if preview.include_leading_blank {
lines.push(Line::raw(""));
}
let mut text_lines = Vec::new();
if matches!(preview.kind, StreamKind::Assistant) {
let mut in_code_block = self.assistant_stream_in_code_block;
push_wrapped_markdown(
&mut text_lines,
&preview.text,
padded_content_width(width),
&mut in_code_block,
);
} else {
push_wrapped_text(
&mut text_lines,
&preview.text,
padded_content_width(width),
preview.kind.style(),
LineFill::Natural,
);
}
lines.extend(text_lines.into_iter().map(pad_display_line));
lines
}
fn insert_stream_fragment(
&mut self,
terminal: &mut DefaultTerminal,
fragment: StreamFragment,
kind: StreamKind,
) -> std::io::Result<()> {
let render_text = fragment.render_text();
if !render_text.is_empty() {
let width = terminal.size()?.width as usize;
let mut lines = Vec::new();
if fragment.include_leading_blank() {
lines.push(Line::raw(""));
}
let mut text_lines = Vec::new();
if matches!(kind, StreamKind::Assistant) {
push_wrapped_markdown(
&mut text_lines,
render_text,
padded_content_width(width),
&mut self.assistant_stream_in_code_block,
);
} else {
push_wrapped_text(
&mut text_lines,
render_text,
padded_content_width(width),
kind.style(),
LineFill::Natural,
);
}
lines.extend(text_lines.into_iter().map(pad_display_line));
insert_history_lines(terminal, lines)?;
self.last_inserted_was_tool = false;
}
let text = fragment.into_text();
self.push_transcript_entry(kind.entry(text));
Ok(())
}
fn replace_current_turn_assistant_transcript(&mut self, answer: &str) {
let start = self.current_turn_start.unwrap_or(0);
let assistant_indices = self
.transcript
.iter()
.enumerate()
.skip(start)
.filter_map(|(index, entry)| matches!(entry, Entry::Assistant(_)).then_some(index))
.collect::<Vec<_>>();
let Some((first, stale)) = assistant_indices.split_first() else {
self.push_transcript_entry(Entry::Assistant(answer.to_string()));
return;
};
if let Entry::Assistant(text) = &mut self.transcript[*first] {
*text = answer.to_string();
}
for index in stale.iter().rev() {
self.transcript.remove(*index);
}
}
async fn execute_command(
&mut self,
invocation: CommandInvocation,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
match invocation.id {
CommandId::Exit => self.execute_exit_command(terminal),
CommandId::New => self.execute_new_command(terminal, agent),
CommandId::Model => {
self.execute_model_command(invocation, terminal, agent)
.await
}
CommandId::TitleModel => self.execute_title_model_command(invocation, terminal),
CommandId::RefreshModelList => {
self.execute_refresh_model_list_command(invocation, terminal)
.await
}
CommandId::Login => {
self.execute_login_command(invocation, terminal, agent)
.await
}
CommandId::Logout => {
self.execute_logout_command(invocation, terminal, agent)
.await
}
CommandId::Resume => {
self.execute_resume_command(invocation, terminal, agent)
.await
}
CommandId::Config => self.execute_config_command(terminal),
CommandId::Skills => self.execute_skills_command(terminal),
}
}
fn execute_exit_command(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
self.insert_entry(terminal, &Entry::Notice("exiting rho".into()))?;
self.should_quit = true;
self.status = "exiting".into();
Ok(())
}
fn execute_new_command(
&mut self,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
agent.reset();
agent.set_session_id(None);
agent.clear_history_sink();
self.info.session_id = None;
self.composer = ComposerMode::Input;
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.command_palette_dismissed = false;
self.clamp_command_selection();
self.queued_prompts.clear();
self.steering_prompts.clear();
self.reset_streams();
self.running = false;
self.active_tool_call = false;
self.cumulative_usage = None;
self.latest_usage = None;
self.current_context = None;
self.pending_session_title = None;
self.current_turn_start = None;
self.transcript.clear();
self.last_inserted_was_tool = false;
self.reflow_history(terminal)?;
self.status = "new session".into();
Ok(())
}
async fn execute_refresh_model_list_command(
&mut self,
invocation: CommandInvocation,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
let providers = if invocation.args.trim().is_empty() {
self.refresh_available_auths();
registry::providers()
.iter()
.filter(|provider| provider.model_refresh.is_some())
.filter(|provider| {
self.available_auths
.iter()
.any(|auth| auth == provider.auth)
})
.map(|provider| provider.name.to_string())
.collect()
} else {
vec![invocation.args.trim().to_string()]
};
if providers.is_empty() {
self.insert_entry(
terminal,
&Entry::Notice(
"no refreshable providers are configured. run /login for a provider with model list support."
.into(),
),
)?;
self.status = "model refresh skipped".into();
return Ok(());
}
self.status = "refreshing model list".into();
terminal.draw(|frame| self.draw(frame))?;
for provider in providers {
match refresh_provider_models_with_store(&provider, self.credential_store.as_ref())
.await
{
Ok(refresh) => {
self.insert_entry(
terminal,
&Entry::Notice(format!(
"refreshed {} model list: {} models",
refresh.provider,
refresh.models.len()
)),
)?;
}
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!("failed to refresh {provider} model list: {err}")),
)?;
}
}
}
self.status = "model list refresh complete".into();
Ok(())
}
async fn execute_model_command(
&mut self,
invocation: CommandInvocation,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
let model = invocation.args.trim();
if model.is_empty() {
self.open_model_picker(terminal, agent).await?;
return Ok(());
}
self.refresh_available_auths();
match catalog::resolve_model_selection_for_auths(
model,
&self.info.provider,
&self.info.auth,
&self.available_auths,
) {
Ok(selection) => self.select_model(selection, terminal, agent),
Err(err) => {
self.insert_entry(terminal, &Entry::Error(err.to_string()))?;
self.status = "model switch failed".into();
Ok(())
}
}
}
async fn open_model_picker(
&mut self,
terminal: &mut DefaultTerminal,
_agent: &mut Agent,
) -> anyhow::Result<()> {
self.status = "loading models".into();
terminal.draw(|frame| self.draw(frame))?;
self.refresh_available_auths();
let picker = model_picker::model_picker(&self.info, &self.available_auths);
if picker.items.is_empty() {
self.insert_entry(
terminal,
&Entry::Notice(
"no cached API models. run /refresh-model-list after signing in.".into(),
),
)?;
self.status = "ready".into();
return Ok(());
}
self.composer = ComposerMode::Picker(picker);
self.status = "select model".into();
Ok(())
}
fn execute_title_model_command(
&mut self,
invocation: CommandInvocation,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
let model = invocation.args.trim();
if model.is_empty() {
return self.open_title_model_picker(terminal);
}
self.refresh_available_auths();
let (provider, _model, auth) = self.title_model_selection();
match catalog::resolve_model_selection_for_auths(
model,
&provider,
&auth,
&self.available_auths,
) {
Ok(selection) => self.select_title_model(selection, terminal),
Err(err) => {
self.insert_entry(terminal, &Entry::Error(err.to_string()))?;
self.status = "title model switch failed".into();
Ok(())
}
}
}
fn open_title_model_picker(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
self.status = "loading title models".into();
terminal.draw(|frame| self.draw(frame))?;
self.refresh_available_auths();
let (provider, model, _auth) = self.title_model_selection();
let picker = model_picker::title_model_picker(&provider, &model, &self.available_auths);
if picker.items.is_empty() {
self.insert_entry(
terminal,
&Entry::Notice(
"no cached API models. run /refresh-model-list after signing in.".into(),
),
)?;
self.status = "ready".into();
return Ok(());
}
self.composer = ComposerMode::Picker(picker);
self.status = "select title model".into();
Ok(())
}
async fn submit_picker_selection(
&mut self,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
let Some((action, value)) = self.active_picker_selection() else {
self.composer = ComposerMode::Input;
self.status = "ready".into();
return Ok(());
};
if !matches!(action, PickerAction::Config) {
self.composer = ComposerMode::Input;
}
match action {
PickerAction::SelectModel => {
self.refresh_available_auths();
match catalog::resolve_model_selection_for_auths(
&value,
&self.info.provider,
&self.info.auth,
&self.available_auths,
) {
Ok(selection) => self.select_model(selection, terminal, agent),
Err(err) => {
self.insert_entry(terminal, &Entry::Error(err.to_string()))?;
self.status = "model switch failed".into();
Ok(())
}
}
}
PickerAction::SelectTitleModel => {
self.refresh_available_auths();
let (provider, _model, auth) = self.title_model_selection();
match catalog::resolve_model_selection_for_auths(
&value,
&provider,
&auth,
&self.available_auths,
) {
Ok(selection) => self.select_title_model(selection, terminal),
Err(err) => {
self.insert_entry(terminal, &Entry::Error(err.to_string()))?;
self.status = "title model switch failed".into();
Ok(())
}
}
}
PickerAction::LoginProvider => {
self.start_login_for_provider(&value, terminal, agent).await
}
PickerAction::LogoutProvider => self.logout_provider(&value, terminal, agent).await,
PickerAction::InsertSkillCommand => {
self.input = format!("/skill:{value}");
self.input_cursor = self.input_char_len();
self.command_palette_dismissed = true;
self.status = "skill command inserted".into();
Ok(())
}
PickerAction::ResumeSession => {
self.submit_resume_selection(&value, terminal, agent).await
}
PickerAction::Config => self.submit_config_selection(&value, terminal, agent),
}
}
fn submit_config_selection(
&mut self,
value: &str,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
match value {
config_picker::REASONING_VALUE => self.cycle_reasoning(terminal, agent),
config_picker::SHOW_REASONING_OUTPUT_VALUE => self.toggle_reasoning_output(terminal),
config_picker::CHECK_FOR_UPDATES_VALUE => self.toggle_check_for_updates(terminal),
config_picker::MAX_OUTPUT_BYTES_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigNumberInput(ConfigNumberInput::new(
ConfigNumberKey::MaxOutputBytes,
config.max_output_bytes,
));
self.status = "edit max output bytes".into();
Ok(())
}
config_picker::MAX_TOOL_OUTPUT_LINES_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigNumberInput(ConfigNumberInput::new(
ConfigNumberKey::MaxToolOutputLines,
config.max_tool_output_lines,
));
self.status = "edit max tool output lines".into();
Ok(())
}
config_picker::WEB_SEARCH_VALUE => {
self.composer =
ComposerMode::Picker(config_picker::web_search_config_picker(&self.info));
self.status = "web search config".into();
Ok(())
}
config_picker::WEB_SEARCH_BACK_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::Picker(config_picker::config_picker(
&self.info,
config.max_output_bytes,
config.max_tool_output_lines,
));
self.status = "config".into();
Ok(())
}
config_picker::WEB_SEARCH_PROVIDER_VALUE => self.cycle_web_search_provider(terminal),
config_picker::WEB_SEARCH_OPENAI_KEY_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigTextInput(ConfigTextInput::new(
ConfigTextKey::OpenAiSearch,
config.web_search_openai_api_key,
));
self.status = "edit OpenAI web search API key".into();
Ok(())
}
config_picker::WEB_SEARCH_EXA_KEY_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigTextInput(ConfigTextInput::new(
ConfigTextKey::Exa,
config.web_search_exa_api_key,
));
self.status = "edit Exa API key".into();
Ok(())
}
config_picker::WEB_SEARCH_BRAVE_KEY_VALUE => {
let config = Config::load(self.info.config_path.clone())?;
self.composer = ComposerMode::ConfigTextInput(ConfigTextInput::new(
ConfigTextKey::Brave,
config.web_search_brave_api_key,
));
self.status = "edit Brave Search API key".into();
Ok(())
}
_ => Ok(()),
}
}
fn refresh_main_config_picker(&mut self, selected_value: &str) -> anyhow::Result<()> {
let filter = match &self.composer {
ComposerMode::Picker(picker) => picker.filter.clone(),
_ => String::new(),
};
self.open_main_config_picker(selected_value, filter)
}
fn open_main_config_picker_selected(&mut self, selected_value: &str) -> anyhow::Result<()> {
self.open_main_config_picker(selected_value, String::new())
}
fn open_main_config_picker(
&mut self,
selected_value: &str,
filter: String,
) -> anyhow::Result<()> {
let config = Config::load(self.info.config_path.clone())?;
let mut picker = config_picker::config_picker(
&self.info,
config.max_output_bytes,
config.max_tool_output_lines,
);
Self::restore_picker_position(&mut picker, selected_value, filter);
self.composer = ComposerMode::Picker(picker);
self.status = "config".into();
Ok(())
}
fn refresh_web_search_config_picker(&mut self, selected_value: &str) {
let filter = match &self.composer {
ComposerMode::Picker(picker) => picker.filter.clone(),
_ => String::new(),
};
let mut picker = config_picker::web_search_config_picker(&self.info);
Self::restore_picker_position(&mut picker, selected_value, filter);
self.composer = ComposerMode::Picker(picker);
}
fn handle_picker_escape(&mut self, running: bool) -> anyhow::Result<()> {
if self.web_search_config_picker_is_open() {
self.open_main_config_picker_selected(config_picker::WEB_SEARCH_VALUE)
} else {
self.composer = ComposerMode::Input;
self.status = if running { "running" } else { "ready" }.into();
Ok(())
}
}
fn web_search_config_picker_is_open(&self) -> bool {
matches!(
&self.composer,
ComposerMode::Picker(picker)
if picker
.items
.iter()
.any(|item| item.value == config_picker::WEB_SEARCH_BACK_VALUE)
)
}
fn picker_space_confirms_selection(&self) -> bool {
matches!(
&self.composer,
ComposerMode::Picker(picker) if picker.action.space_confirms_selection()
)
}
fn restore_picker_position(picker: &mut UiPicker, selected_value: &str, filter: String) {
picker.filter = filter;
if let Some(index) = picker
.items
.iter()
.position(|item| item.value == selected_value)
{
picker.selected = index;
if picker.selected_item().is_some() {
return;
}
}
picker.filter.clear();
if let Some(index) = picker
.items
.iter()
.position(|item| item.value == selected_value)
{
picker.selected = index;
} else {
picker.select_first_match();
}
}
fn cycle_reasoning(
&mut self,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
let reasoning = self.info.reasoning.next();
if !agent.set_provider_reasoning(reasoning) {
let provider = match build_provider(&self.info.provider, &self.info.model, reasoning) {
Ok(provider) => provider,
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!("could not update reasoning to {reasoning}: {err}")),
)?;
self.status = "reasoning change failed".into();
return Ok(());
}
};
agent.replace_provider(provider);
}
self.info.reasoning = reasoning;
let save_result = Config::load(self.info.config_path.clone()).and_then(|mut config| {
config.reasoning = reasoning;
config.save(self.info.config_path.clone())
});
if matches!(
&self.composer,
ComposerMode::Picker(picker) if picker.action == PickerAction::Config
) {
let config = Config::load(self.info.config_path.clone()).unwrap_or_default();
self.info.show_reasoning_output = config.show_reasoning_output;
self.refresh_main_config_picker(config_picker::REASONING_VALUE)?;
}
match save_result {
Ok(()) => {
self.status = format!("reasoning: {reasoning}");
}
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!(
"reasoning set to {reasoning} for this session, but saving config failed: {err}"
)),
)?;
self.status = "config save failed".into();
}
}
Ok(())
}
fn toggle_check_for_updates(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let save_result = Config::load(self.info.config_path.clone()).and_then(|mut config| {
config.check_for_updates = !config.check_for_updates;
config.save(self.info.config_path.clone())?;
Ok(config.check_for_updates)
});
match save_result {
Ok(check_for_updates) => {
if !check_for_updates {
self.info.update_notice = None;
}
self.status = if check_for_updates {
"check for updates: on".into()
} else {
"check for updates: off".into()
};
}
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!("could not save update check setting: {err}")),
)?;
self.status = "config save failed".into();
}
}
if matches!(
&self.composer,
ComposerMode::Picker(picker) if picker.action == PickerAction::Config
) {
self.refresh_main_config_picker(config_picker::CHECK_FOR_UPDATES_VALUE)?;
}
Ok(())
}
fn toggle_reasoning_output(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let show_reasoning_output = !self.info.show_reasoning_output;
let save_result = Config::load(self.info.config_path.clone()).and_then(|mut config| {
config.show_reasoning_output = show_reasoning_output;
config.save(self.info.config_path.clone())
});
match save_result {
Ok(()) => {
self.info.show_reasoning_output = show_reasoning_output;
self.status = if show_reasoning_output {
"reasoning output: shown".into()
} else {
"reasoning output: hidden".into()
};
}
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!("could not save reasoning output setting: {err}")),
)?;
self.status = "config save failed".into();
}
}
if matches!(
&self.composer,
ComposerMode::Picker(picker) if picker.action == PickerAction::Config
) {
let config = Config::load(self.info.config_path.clone()).unwrap_or_default();
self.info.show_reasoning_output = config.show_reasoning_output;
self.refresh_main_config_picker(config_picker::SHOW_REASONING_OUTPUT_VALUE)?;
}
Ok(())
}
fn cycle_web_search_provider(&mut self, _terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let mut config = Config::load(self.info.config_path.clone())?;
config.web_search_provider = match config.web_search_provider.as_str() {
"auto" => "openai",
"openai" => "exa",
"exa" => "brave",
"brave" => "disabled",
"disabled" => "auto",
_ => "auto",
}
.into();
let provider = config.web_search_provider.clone();
config.save(self.info.config_path.clone())?;
self.refresh_web_search_config_picker(config_picker::WEB_SEARCH_PROVIDER_VALUE);
self.status = format!("web search: {provider}");
Ok(())
}
fn active_picker_selection(&self) -> Option<(PickerAction, String)> {
let ComposerMode::Picker(picker) = &self.composer else {
return None;
};
picker
.selected_item()
.map(|item| (picker.action, item.value.clone()))
}
fn select_model(
&mut self,
selection: ModelSelection,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
let provider = selection.provider;
let model = selection.model;
let auth = selection.auth;
let provider_model = format!("{provider}/{model}");
let new_provider = match build_provider(&provider, &model, self.info.reasoning) {
Ok(provider) => provider,
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!("could not switch to {provider_model}: {err}")),
)?;
self.status = "model switch failed".into();
return Ok(());
}
};
agent.replace_provider(new_provider);
self.info.provider = provider.clone();
self.info.model = model.clone();
self.info.auth = auth.clone();
self.info.auth_unavailable = None;
self.using_unavailable_provider = false;
self.start_model_metadata_fetch(agent);
match Config::load(self.info.config_path.clone()).and_then(|mut config| {
config.provider = provider.clone();
config.model = model.clone();
config.auth = auth.clone();
config.save(self.info.config_path.clone())
}) {
Ok(()) => {
self.insert_entry(
terminal,
&Entry::Notice(format!(
"model switched to {provider_model} and saved to config"
)),
)?;
self.status = format!("model: {provider_model}");
}
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!(
"model switched to {provider_model} for this session, but saving config failed: {err}"
)),
)?;
self.status = "config save failed".into();
}
}
Ok(())
}
fn select_title_model(
&mut self,
selection: ModelSelection,
terminal: &mut DefaultTerminal,
) -> anyhow::Result<()> {
let provider = selection.provider;
let model = selection.model;
let auth = selection.auth;
let provider_model = format!("{provider}/{model}");
self.info.title_provider = Some(provider.clone());
self.info.title_model = Some(model.clone());
self.info.title_auth = Some(auth.clone());
match Config::load(self.info.config_path.clone()).and_then(|mut config| {
config.title_provider = Some(provider.clone());
config.title_model = Some(model.clone());
config.title_auth = Some(auth.clone());
config.save(self.info.config_path.clone())
}) {
Ok(()) => {
self.insert_entry(
terminal,
&Entry::Notice(format!(
"session title model switched to {provider_model} and saved to config"
)),
)?;
self.status = format!("title model: {provider_model}");
}
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!(
"session title model switched to {provider_model} for this session, but saving config failed: {err}"
)),
)?;
self.status = "config save failed".into();
}
}
Ok(())
}
fn refresh_available_auths(&mut self) {
self.available_auths = available_auth_modes(self.credential_store.as_ref());
}
fn save_current_config(&self) -> anyhow::Result<()> {
Config::load(self.info.config_path.clone()).and_then(|mut config| {
config.provider = self.info.provider.clone();
config.model = self.info.model.clone();
config.auth = self.info.auth.clone();
config.save(self.info.config_path.clone())
})
}
async fn execute_resume_command(
&mut self,
invocation: CommandInvocation,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
let session_id = invocation.args.trim();
if !session_id.is_empty() {
return self
.submit_resume_selection(session_id, terminal, agent)
.await;
}
self.open_resume_picker(terminal)
}
fn open_resume_picker(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
match Session::list(&self.info.cwd) {
Ok(sessions) if sessions.is_empty() => {
self.insert_entry(
terminal,
&Entry::Notice("no saved sessions for this workspace".into()),
)?;
self.status = "no sessions".into();
}
Ok(sessions) => {
let picker =
session_picker::session_picker(sessions, self.info.session_id.as_deref());
if picker.items.is_empty() {
self.insert_entry(
terminal,
&Entry::Notice("no other saved sessions for this workspace".into()),
)?;
self.status = "no sessions".into();
return Ok(());
}
self.composer = ComposerMode::Picker(picker);
self.status = "select session".into();
}
Err(err) => {
self.insert_entry(
terminal,
&Entry::Error(format!("could not list sessions: {err}")),
)?;
self.status = "resume failed".into();
}
}
Ok(())
}
async fn submit_resume_selection(
&mut self,
session_id: &str,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
match self.resume_session_by_id(session_id, terminal, agent) {
Ok(()) => {
self.info
.herdr
.report_session(self.info.session_id.as_deref())
.await;
Ok(())
}
Err(err) => {
self.composer = ComposerMode::Input;
self.insert_entry(
terminal,
&Entry::Error(format!("could not resume session: {err}")),
)?;
self.status = "resume failed".into();
Ok(())
}
}
}
fn resume_session_by_id(
&mut self,
session_id: &str,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<()> {
let (session, history) = Session::open_by_id(&self.info.cwd, session_id)?;
let full_id = session.id().to_string();
let short_id = short_session_id(&full_id);
agent.replace_history(history);
agent.set_session_id(Some(full_id.clone()));
agent.set_history_sink(SessionHistorySink::new(session));
self.info.session_id = Some(full_id);
self.composer = ComposerMode::Input;
self.input.clear();
self.paste_segments.clear();
self.input_cursor = 0;
self.command_palette_dismissed = false;
self.clamp_command_selection();
self.reset_streams();
self.running = false;
self.cumulative_usage = None;
self.latest_usage = None;
self.current_context = None;
let entries = transcript_entries_from_messages(agent.messages());
let width = terminal.size()?.width as usize;
let (_omitted, visible_entries) = recovered_history_tail(
&entries,
width,
RECOVERED_HISTORY_LINE_LIMIT,
self.info.max_tool_output_lines,
);
self.transcript = visible_entries;
self.last_inserted_was_tool = self.transcript.last().is_some_and(is_tool_entry);
self.reflow_history(terminal)?;
self.insert_entry(
terminal,
&Entry::Notice(format!("resumed session {short_id}")),
)?;
self.status = format!("resumed {short_id}");
Ok(())
}
fn execute_config_command(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let config = Config::load(self.info.config_path.clone())?;
self.info.max_tool_output_lines = config.max_tool_output_lines.max(1);
self.info.show_reasoning_output = config.show_reasoning_output;
self.composer = ComposerMode::Picker(config_picker::config_picker(
&self.info,
config.max_output_bytes,
self.info.max_tool_output_lines,
));
self.status = "config".into();
terminal.draw(|frame| self.draw(frame))?;
Ok(())
}
fn execute_skills_command(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let picker = skill_picker::skill_picker(crate::skills::discover(&self.info.cwd));
if picker.items.is_empty() {
self.insert_entry(terminal, &Entry::Notice("no skills loaded".into()))?;
self.status = "skills".into();
return Ok(());
}
self.composer = ComposerMode::Picker(picker);
self.status = "select skill".into();
Ok(())
}
fn execute_skill_command(
&mut self,
name: &str,
terminal: &mut DefaultTerminal,
agent: &mut Agent,
) -> anyhow::Result<bool> {
let Some(name) = name.strip_prefix("skill:") else {
return Ok(false);
};
let Some(skill) = crate::skills::discover(&self.info.cwd)
.into_iter()
.find(|skill| skill.name == name)
else {
return Ok(false);
};
self.ensure_session(agent)?;
agent.load_skill(&skill)?;
self.insert_entry(
terminal,
&Entry::Notice(format!(
"loaded skill {} from {}",
skill.name,
skill.path.display()
)),
)?;
self.status = format!("loaded skill {}", skill.name);
Ok(true)
}
fn toggle_latest_tool_output(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
if let Some(pending) = self.pending_tool_call.as_mut() {
if tool_display_line_count(&pending.display_lines) <= self.info.max_tool_output_lines {
self.status = "no truncated tool output".into();
return Ok(());
}
pending.expanded = !pending.expanded;
self.status = if pending.expanded {
"tool output expanded".into()
} else {
"tool output collapsed".into()
};
return Ok(());
}
let Some(index) = self
.transcript
.iter()
.rposition(|entry| expandable_tool_entry(entry, self.info.max_tool_output_lines))
else {
self.status = "no truncated tool output".into();
return Ok(());
};
let expand =
!matches!(self.transcript.get(index), Some(Entry::Tool(tool)) if tool.expanded);
for entry in &mut self.transcript {
if let Entry::Tool(tool) = entry {
tool.expanded = false;
}
}
if let Some(Entry::Tool(tool)) = self.transcript.get_mut(index) {
tool.expanded = expand;
}
self.status = if expand {
"tool output expanded".into()
} else {
"tool output collapsed".into()
};
self.reflow_history(terminal)
}
fn record_agent_event(&mut self, event: AgentEvent) -> Option<Entry> {
match event {
AgentEvent::StepStarted(step) => {
self.reset_streams();
self.running = true;
self.active_tool_call = false;
self.pending_tool_call = None;
self.loading_spinner.start_if_needed();
self.status = format!("running step {step}");
None
}
AgentEvent::ToolStarted { display_lines, .. } => {
self.active_tool_call = true;
self.pending_tool_call = Some(ToolEntry {
state: ToolEntryState::Running,
display_lines,
expanded: false,
});
None
}
AgentEvent::ToolUpdated { display_lines } => {
let expanded = self
.pending_tool_call
.as_ref()
.is_some_and(|pending| pending.expanded);
self.pending_tool_call = Some(ToolEntry {
state: ToolEntryState::Running,
display_lines,
expanded,
});
None
}
AgentEvent::OutputDelta(_) | AgentEvent::ReasoningDelta(_) => None,
AgentEvent::ContextUsage(usage) => {
self.current_context = Some(usage);
None
}
AgentEvent::Usage(usage) => {
let usage = usage_with_estimated_cost(usage, self.model_metadata.as_ref());
self.latest_usage = Some(usage.clone());
merge_usage(&mut self.cumulative_usage, usage);
None
}
AgentEvent::ToolFinished {
ok,
display_style,
display_lines,
..
} => {
self.active_tool_call = false;
let expanded = self
.pending_tool_call
.as_ref()
.is_some_and(|pending| pending.expanded);
self.pending_tool_call = None;
Some(Entry::Tool(ToolEntry {
state: ToolEntryState::Finished { ok, display_style },
display_lines,
expanded,
}))
}
AgentEvent::QuestionnaireStarted(_) => None,
AgentEvent::QuestionnaireFinished(_) => None,
}
}
fn draw(&self, frame: &mut Frame<'_>) {
let area = frame.area();
let width = area.width as usize;
let active = self.active_frame_at_for_height(width, area.height as usize, Instant::now());
frame.render_widget(Paragraph::new(active.lines).style(Style::default()), area);
frame.set_cursor_position(Position {
x: area.x.saturating_add(active.cursor.x),
y: area.y.saturating_add(active.cursor.y),
});
}
#[cfg(test)]
fn active_lines(&self, width: usize) -> Vec<Line<'static>> {
self.active_lines_at_for_height(width, INLINE_VIEWPORT_HEIGHT as usize, Instant::now())
}
#[cfg(test)]
fn active_lines_for_height(&self, width: usize, viewport_height: usize) -> Vec<Line<'static>> {
self.active_lines_at_for_height(width, viewport_height, Instant::now())
}
#[cfg(test)]
fn active_lines_at_for_height(
&self,
width: usize,
viewport_height: usize,
now: Instant,
) -> Vec<Line<'static>> {
self.active_frame_at_for_height(width, viewport_height, now)
.lines
}
fn active_frame_at_for_height(
&self,
width: usize,
viewport_height: usize,
now: Instant,
) -> ActiveFrame {
let divider_style = match &self.composer {
ComposerMode::Input => Theme::reasoning_input_border(self.info.reasoning),
ComposerMode::Picker(_) | ComposerMode::Questionnaire(_) => Theme::input_prompt(),
ComposerMode::SecretInput(_)
| ComposerMode::ConfigNumberInput(_)
| ComposerMode::ConfigTextInput(_)
| ComposerMode::OAuthPending(_) => Theme::dim(),
};
let divider = Line::styled("─".repeat(width.max(1)), divider_style);
let composer_lines = self.composer_lines(width);
let statusline_lines = self.statusline_lines(width);
let command_lines = self.command_suggestion_lines(width);
let bottom_height = 1 + statusline_lines.len() + command_lines.len();
let available_above_bottom = viewport_height.saturating_sub(bottom_height);
let show_top_divider = available_above_bottom > 1 && !composer_lines.is_empty();
let composer_budget = available_above_bottom.saturating_sub(usize::from(show_top_divider));
let visible_composer_len = composer_lines.len().min(composer_budget);
let full_cursor = self.composer_cursor_position(width);
let cursor_line = (full_cursor.y as usize).min(composer_lines.len().saturating_sub(1));
let composer_start =
visible_composer_start(cursor_line, composer_lines.len(), visible_composer_len);
let composer_end = composer_start.saturating_add(visible_composer_len);
let available_content = available_above_bottom
.saturating_sub(usize::from(show_top_divider) + visible_composer_len);
let mut content = Vec::new();
if let Some(pending) = &self.pending_tool_call {
let pending_tool_output_lines = self
.info
.max_tool_output_lines
.min(available_content.saturating_sub(3).max(1));
content.extend(entry_lines(
&Entry::Tool(pending.clone()),
width,
pending_tool_output_lines,
));
}
if let Some(preview) = &self.live_stream_preview {
content.extend(self.render_stream_preview_lines(preview, width));
}
let content_skip = content.len().saturating_sub(available_content);
let visible_content_len = content.len().saturating_sub(content_skip);
let mut lines = Vec::new();
if self.loading_active() {
lines.push(self.loading_spinner.line(now));
if visible_content_len > 0 || show_top_divider || visible_composer_len > 0 {
lines.push(Line::raw(""));
}
}
lines.extend(content.into_iter().skip(content_skip));
if show_top_divider {
lines.push(divider.clone());
}
lines.extend(composer_lines[composer_start..composer_end].iter().cloned());
lines.push(divider);
lines.extend(statusline_lines);
lines.extend(command_lines);
let composer_y = visible_content_len
+ usize::from(self.loading_active())
+ usize::from(
self.loading_active()
&& (visible_content_len > 0 || show_top_divider || visible_composer_len > 0),
)
+ usize::from(show_top_divider);
let cursor_y = if visible_composer_len == 0 {
0
} else {
composer_y + cursor_line.saturating_sub(composer_start)
};
let trimmed_from_top = lines.len().saturating_sub(viewport_height);
let lines = if trimmed_from_top == 0 {
lines
} else {
lines.into_iter().skip(trimmed_from_top).collect()
};
let max_cursor_x = width.max(1).saturating_sub(1) as u16;
let max_cursor_y = viewport_height.max(1).saturating_sub(1) as u16;
ActiveFrame {
lines,
cursor: Position {
x: full_cursor.x.min(max_cursor_x),
y: cursor_y
.saturating_sub(trimmed_from_top)
.min(max_cursor_y as usize) as u16,
},
}
}
fn composer_lines(&self, width: usize) -> Vec<Line<'static>> {
match &self.composer {
ComposerMode::Input => {
input_lines_with_images(&self.input, &self.pending_images, width)
}
ComposerMode::Picker(picker) => picker_lines(picker, width),
ComposerMode::SecretInput(secret) => secret_input_lines(secret, width),
ComposerMode::ConfigNumberInput(input) => config_number_input_lines(input, width),
ComposerMode::ConfigTextInput(input) => config_text_input_lines(input, width),
ComposerMode::OAuthPending(target) => oauth_pending_lines(target, width),
ComposerMode::Questionnaire(questionnaire) => questionnaire_lines(questionnaire, width),
}
}
fn desired_inline_viewport_height(&self, width: usize, terminal_height: u16) -> u16 {
let terminal_height = terminal_height.max(1);
let base_height = INLINE_VIEWPORT_HEIGHT as usize;
let base_live_height = self
.active_frame_at_for_height(width, base_height, Instant::now())
.lines
.len();
let overlay_height = self.composer_lines(width).len()
+ self.statusline_lines(width).len()
+ self.command_suggestion_lines(width).len()
+ 2;
let height = base_live_height.max(overlay_height) as u16;
height.max(1).min(terminal_height)
}
fn resize_inline_viewport_if_needed(
&mut self,
terminal: &mut DefaultTerminal,
) -> std::io::Result<()> {
let size = terminal.size()?;
let desired_height = self.desired_inline_viewport_height(size.width as usize, size.height);
if self
.inline_viewport_width
.is_some_and(|width| width != size.width)
{
self.inline_viewport_height = desired_height;
return self.reflow_history(terminal);
}
if desired_height == self.inline_viewport_height {
self.inline_viewport_width = Some(size.width);
return Ok(());
}
*terminal = Terminal::with_options(
CrosstermBackend::new(std::io::stdout()),
TerminalOptions {
viewport: Viewport::Inline(desired_height),
},
)?;
self.inline_viewport_height = desired_height;
self.inline_viewport_width = Some(size.width);
self.reflow_history(terminal)
}
fn statusline_lines(&self, width: usize) -> Vec<Line<'static>> {
statusline_lines(
&StatusLineState::from_tui(
&self.info,
self.cumulative_usage.clone(),
self.latest_usage.clone(),
self.current_context.clone(),
self.model_metadata.clone(),
self.pending_model_metadata.is_some(),
),
width,
)
}
fn composer_cursor_position(&self, width: usize) -> Position {
match &self.composer {
ComposerMode::Input => {
let mut position = input_cursor_position(&self.input, self.input_cursor, width);
position.y = position.y.saturating_add(self.pending_images.len() as u16);
position
}
ComposerMode::SecretInput(secret) => Position {
x: secret.cursor.min(width.max(1)) as u16,
y: 1,
},
ComposerMode::ConfigNumberInput(input) => Position {
x: input.cursor.min(width.max(1)) as u16,
y: 1,
},
ComposerMode::ConfigTextInput(input) => Position {
x: input.cursor.min(width.max(1)) as u16,
y: 1,
},
ComposerMode::Questionnaire(questionnaire) => {
questionnaire_cursor_position(questionnaire, width)
}
ComposerMode::OAuthPending(_) => Position { x: 0, y: 0 },
ComposerMode::Picker(picker) => Position {
x: display_width(&picker.filter)
.saturating_add(2)
.min(width.saturating_sub(1)) as u16,
y: 0,
},
}
}
fn command_suggestion_lines(&self, width: usize) -> Vec<Line<'static>> {
if !self.command_palette_visible() {
return Vec::new();
}
let matches = self.command_matches();
let selected_index = self.command_selection.min(matches.len().saturating_sub(1));
let start = selected_index
.saturating_add(1)
.saturating_sub(MAX_COMMAND_SUGGESTIONS);
matches
.into_iter()
.enumerate()
.skip(start)
.take(MAX_COMMAND_SUGGESTIONS)
.map(|(index, command)| {
let selected = index == selected_index;
let marker = if selected { ">" } else { " " };
let usage_width = 16usize.min(width.saturating_sub(5).max(1));
let description_width = width.saturating_sub(usage_width + 3).max(1);
let usage = truncate_one_line(&command.usage, usage_width);
let description = truncate_one_line(&command.description, description_width);
let usage_padding = " ".repeat(usage_width.saturating_sub(display_width(&usage)));
let text = format!("{marker} {usage}{usage_padding} {description}");
let style = if selected {
Theme::brand()
} else {
Theme::dim()
};
styled_line(text, width.max(1), style, LineFill::Natural)
})
.collect()
}
fn insert_session_intro(&self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
let width = terminal.size()?.width as usize;
insert_history_lines(terminal, session_header_lines(&self.info, width))?;
Ok(())
}
fn insert_recovered_history(
&mut self,
terminal: &mut DefaultTerminal,
agent: &Agent,
) -> std::io::Result<()> {
let entries = transcript_entries_from_messages(agent.messages());
if entries.is_empty() {
return Ok(());
}
let width = terminal.size()?.width as usize;
let (omitted, visible_entries) = recovered_history_tail(
&entries,
width,
RECOVERED_HISTORY_LINE_LIMIT,
self.info.max_tool_output_lines,
);
let mut lines = Vec::new();
if omitted > 0 {
lines.extend(entry_lines(
&Entry::Notice(format!(
"resumed session; showing last {} messages, omitted {omitted} earlier messages",
visible_entries.len()
)),
width,
self.info.max_tool_output_lines,
));
}
lines.extend(transcript_lines(
&visible_entries,
width,
self.info.max_tool_output_lines,
));
insert_history_lines(terminal, lines)?;
self.transcript = visible_entries;
self.last_inserted_was_tool = self.transcript.last().is_some_and(is_tool_entry);
Ok(())
}
fn exit_lines(&self, width: usize) -> Vec<Line<'static>> {
let mut lines = session_header_lines(&self.info, width);
let mut previous_was_tool = false;
for entry in &self.transcript {
if previous_was_tool && is_tool_entry(entry) {
lines.push(Line::raw(""));
}
lines.extend(entry_lines(entry, width, self.info.max_tool_output_lines));
previous_was_tool = is_tool_entry(entry);
}
let divider = Line::styled(
"─".repeat(width.max(1)),
Theme::reasoning_input_border(self.info.reasoning),
);
lines.push(divider.clone());
if matches!(self.composer, ComposerMode::SecretInput(_)) {
lines.push(Line::raw("[secret input omitted]"));
} else if matches!(self.composer, ComposerMode::OAuthPending(_)) {
lines.push(Line::raw("[oauth login pending]"));
} else if matches!(self.composer, ComposerMode::Questionnaire(_)) {
lines.push(Line::raw("[questionnaire answer pending]"));
} else {
lines.extend(input_lines_with_images(
&self.input,
&self.pending_images,
width,
));
}
lines.push(divider);
lines
}
fn insert_entry<B: Backend>(
&mut self,
terminal: &mut Terminal<B>,
entry: &Entry,
) -> Result<(), B::Error> {
let width = terminal.size()?.width as usize;
if self.last_inserted_was_tool && is_tool_entry(entry) {
insert_history_lines(terminal, vec![Line::raw("")])?;
}
insert_history_lines(
terminal,
entry_lines(entry, width, self.info.max_tool_output_lines),
)?;
self.record_inserted_entry(entry.clone());
Ok(())
}
fn notify_status<B: Backend>(
&mut self,
terminal: &mut Terminal<B>,
status: impl Into<String>,
) -> Result<(), B::Error> {
let status = status.into();
self.status = status.clone();
if self.last_status_notice.as_deref() == Some(status.as_str()) {
return Ok(());
}
self.insert_entry(terminal, &Entry::Notice(status))
}
fn record_inserted_entry(&mut self, entry: Entry) {
self.last_status_notice = match &entry {
Entry::Notice(text) => Some(text.clone()),
_ => None,
};
self.last_inserted_was_tool = is_tool_entry(&entry);
self.push_transcript_entry(entry);
}
fn reflow_history(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
clear_terminal_for_history_reflow(terminal)?;
let result = self.replay_history(terminal);
if result.is_ok() {
let size = terminal.size()?;
self.inline_viewport_width = Some(size.width);
self.inline_viewport_height =
self.desired_inline_viewport_height(size.width as usize, size.height);
}
result
}
fn replay_history(&mut self, terminal: &mut DefaultTerminal) -> std::io::Result<()> {
let width = terminal.size()?.width as usize;
let mut lines = session_header_lines(&self.info, width);
let mut previous_was_tool = false;
for entry in &self.transcript {
if previous_was_tool && is_tool_entry(entry) {
lines.push(Line::raw(""));
}
lines.extend(entry_lines(entry, width, self.info.max_tool_output_lines));
previous_was_tool = is_tool_entry(entry);
}
insert_history_lines(terminal, lines)?;
self.last_status_notice = self.transcript.iter().rev().find_map(|entry| match entry {
Entry::Notice(text) => Some(text.clone()),
_ => None,
});
self.last_inserted_was_tool = previous_was_tool;
Ok(())
}
fn push_transcript_entry(&mut self, entry: Entry) {
match entry {
Entry::Assistant(text) => match self.transcript.last_mut() {
Some(Entry::Assistant(previous)) => previous.push_str(&text),
_ => self.transcript.push(Entry::Assistant(text)),
},
Entry::Reasoning(text) => match self.transcript.last_mut() {
Some(Entry::Reasoning(previous)) => previous.push_str(&text),
_ => self.transcript.push(Entry::Reasoning(text)),
},
other => {
self.last_status_notice = match &other {
Entry::Notice(text) => Some(text.clone()),
_ => None,
};
self.transcript.push(other);
}
}
}
}
fn visible_composer_start(cursor_line: usize, line_count: usize, visible_count: usize) -> usize {
if visible_count == 0 || visible_count >= line_count {
return 0;
}
cursor_line
.saturating_add(1)
.saturating_sub(visible_count)
.min(line_count.saturating_sub(visible_count))
}
fn recovered_history_tail(
entries: &[Entry],
width: usize,
line_limit: usize,
max_tool_output_lines: usize,
) -> (usize, Vec<Entry>) {
let mut selected_start = entries.len();
let mut line_count = 0usize;
let mut next_is_tool = false;
for (index, entry) in entries.iter().enumerate().rev() {
let spacing = is_tool_entry(entry) && next_is_tool;
let entry_line_count =
entry_lines(entry, width, max_tool_output_lines).len() + usize::from(spacing);
if selected_start < entries.len() && line_count + entry_line_count > line_limit {
break;
}
selected_start = index;
line_count += entry_line_count;
next_is_tool = is_tool_entry(entry);
}
(selected_start, entries[selected_start..].to_vec())
}
fn transcript_lines(
entries: &[Entry],
width: usize,
max_tool_output_lines: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut previous_was_tool = false;
for entry in entries {
if previous_was_tool && is_tool_entry(entry) {
lines.push(Line::raw(""));
}
lines.extend(entry_lines(entry, width, max_tool_output_lines));
previous_was_tool = is_tool_entry(entry);
}
lines
}
fn transcript_entries_from_messages(messages: &[Message]) -> Vec<Entry> {
let mut entries = Vec::new();
let mut pending_tool_names = VecDeque::new();
for message in messages {
match message {
Message::System(_) => {}
Message::User(blocks) => {
let text = render_message_blocks(blocks);
if !text.is_empty() {
entries.push(Entry::User(text));
}
}
Message::Assistant(blocks) => {
let text = text_blocks(blocks);
if !text.is_empty() {
entries.push(Entry::Assistant(text));
}
pending_tool_names.extend(blocks.iter().filter_map(|block| match block {
ContentBlock::ToolCall(call) => Some(call.name.clone()),
ContentBlock::Text(_) | ContentBlock::Image(_) => None,
}));
}
Message::ToolResult(result) => {
let name = pending_tool_names
.pop_front()
.unwrap_or_else(|| "tool".into());
let display_style = ToolDisplayStyle::for_tool_name(&name);
let mut display_lines = vec![name];
if !result.content.trim().is_empty() {
display_lines.push(result.content.clone());
}
entries.push(Entry::Tool(ToolEntry {
state: ToolEntryState::Finished {
ok: result.ok,
display_style,
},
display_lines,
expanded: false,
}));
}
}
}
entries
}
fn tool_display_line_count(display_lines: &[String]) -> usize {
display_lines
.iter()
.map(|line| line.lines().count().max(1))
.sum()
}
fn text_blocks(blocks: &[ContentBlock]) -> String {
blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(text.as_str()),
ContentBlock::Image(_) | ContentBlock::ToolCall(_) => None,
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_message_blocks(blocks: &[ContentBlock]) -> String {
blocks
.iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(text.clone()),
ContentBlock::Image(image) => Some(format!("[image: {}]", image_summary(image))),
ContentBlock::ToolCall(_) => None,
})
.collect::<Vec<_>>()
.join("\n")
}
async fn generate_session_title(
provider_name: String,
model: String,
first_user_message: String,
) -> anyhow::Result<String> {
let provider = build_provider(&provider_name, &model, ReasoningLevel::Low)?;
let response = tokio::time::timeout(
Duration::from_secs(20),
provider.send_turn(ModelRequest {
messages: vec![
Message::System(
"Generate a concise title for this chat session. Return only the title, no quotes, no punctuation at the end. Use 3 to 7 words."
.into(),
),
Message::user_text(format!("First user message:\n\n{first_user_message}")),
],
tools: Vec::new(),
prompt_cache_key: None,
}),
)
.await
.map_err(|_| anyhow::anyhow!("title generation timed out"))??;
let ModelResponse::Assistant(blocks) = response;
let title = blocks
.into_iter()
.filter_map(|block| match block {
ContentBlock::Text(text) => Some(text),
ContentBlock::Image(_) | ContentBlock::ToolCall(_) => None,
})
.collect::<Vec<_>>()
.join(" ");
sanitize_session_title(&title)
.ok_or_else(|| anyhow::anyhow!("title model returned an empty title"))
}
fn sanitize_session_title(title: &str) -> Option<String> {
let title = title
.lines()
.find(|line| !line.trim().is_empty())?
.trim()
.trim_matches(|ch| matches!(ch, '"' | '\'' | '`' | '*' | '#'))
.trim()
.trim_end_matches(['.', ':', ';'])
.trim();
if title.is_empty() {
return None;
}
let mut title = title.split_whitespace().collect::<Vec<_>>().join(" ");
if title.chars().count() > 80 {
title = title.chars().take(79).collect();
title.push('…');
}
Some(title)
}
fn secret_input_lines(secret: &SecretInput, width: usize) -> Vec<Line<'static>> {
let masked = "•".repeat(secret.value.chars().count());
vec![
styled_line(
truncate_one_line(
&format!(
"enter API key for {} enter save, esc cancel",
secret.target.provider
),
width,
),
width,
Theme::dim(),
LineFill::Natural,
),
styled_line(
truncate_one_line(&masked, width),
width,
Theme::text(),
LineFill::Natural,
),
]
}
fn usage_with_estimated_cost(
mut usage: ModelUsage,
metadata: Option<&ModelMetadata>,
) -> ModelUsage {
if usage.cost_usd_micros.is_none() {
usage.cost_usd_micros = estimated_usage_cost(&usage, metadata);
}
usage
}
fn estimated_usage_cost(usage: &ModelUsage, metadata: Option<&ModelMetadata>) -> Option<u64> {
let metadata = metadata?;
let input = usage.input_tokens.unwrap_or_default();
let cache_read = usage.cache_read_tokens.unwrap_or_default();
let total_input = usage.total_input_tokens().unwrap_or_default();
let cost = metadata.cost_for_input_tokens(total_input)?;
let mut micros = 0u128;
micros += cost_component(input, cost.input_micros_per_m);
micros += cost_component(
usage.output_tokens.unwrap_or_default(),
cost.output_micros_per_m,
);
micros += cost_component(cache_read, cost.cache_read_micros_per_m);
micros += cost_component(
usage.cache_write_tokens.unwrap_or_default(),
cost.cache_write_micros_per_m,
);
(micros > 0).then_some(micros.min(u64::MAX as u128) as u64)
}
fn cost_component(tokens: u64, micros_per_million: Option<u64>) -> u128 {
tokens as u128 * micros_per_million.unwrap_or_default() as u128 / 1_000_000
}
fn merge_usage(total: &mut Option<ModelUsage>, usage: ModelUsage) {
let Some(total) = total.as_mut() else {
*total = Some(usage);
return;
};
total.input_tokens = add_optional(total.input_tokens, usage.input_tokens);
total.output_tokens = add_optional(total.output_tokens, usage.output_tokens);
total.cache_read_tokens = add_optional(total.cache_read_tokens, usage.cache_read_tokens);
total.cache_write_tokens = add_optional(total.cache_write_tokens, usage.cache_write_tokens);
total.total_tokens = usage.total_tokens.or_else(|| usage_total_tokens(&usage));
total.cost_usd_micros = add_optional(total.cost_usd_micros, usage.cost_usd_micros);
total.context_window = usage.context_window.or(total.context_window);
}
fn usage_total_tokens(usage: &ModelUsage) -> Option<u64> {
let total = usage
.total_input_tokens()
.unwrap_or_default()
.saturating_add(usage.output_tokens.unwrap_or_default());
(total > 0).then_some(total)
}
fn add_optional(left: Option<u64>, right: Option<u64>) -> Option<u64> {
match (left, right) {
(Some(left), Some(right)) => Some(left.saturating_add(right)),
(Some(value), None) | (None, Some(value)) => Some(value),
(None, None) => None,
}
}
fn config_number_input_lines(input: &ConfigNumberInput, width: usize) -> Vec<Line<'static>> {
let label = input.key.label();
vec![
styled_line(
truncate_one_line(&format!("edit {label} enter save, esc cancel"), width),
width,
Theme::dim(),
LineFill::Natural,
),
styled_line(
truncate_one_line(&input.value, width),
width,
Theme::text(),
LineFill::Natural,
),
]
}
fn config_text_input_lines(input: &ConfigTextInput, width: usize) -> Vec<Line<'static>> {
let masked = "•".repeat(input.value.chars().count());
vec![
styled_line(
truncate_one_line(
&format!("edit {} enter save, esc cancel", input.key.label()),
width,
),
width,
Theme::dim(),
LineFill::Natural,
),
styled_line(
truncate_one_line(&masked, width),
width,
Theme::text(),
LineFill::Natural,
),
]
}
fn oauth_pending_lines(target: &LoginTarget, width: usize) -> Vec<Line<'static>> {
vec![styled_line(
truncate_one_line(
&format!("waiting for {} OAuth login esc cancel", target.provider),
width,
),
width,
Theme::dim(),
LineFill::Natural,
)]
}
fn questionnaire_lines(questionnaire: &QuestionnaireComposer, width: usize) -> Vec<Line<'static>> {
questionnaire_frame(questionnaire, width).0
}
fn questionnaire_frame(
questionnaire: &QuestionnaireComposer,
width: usize,
) -> (Vec<Line<'static>>, Position) {
let mut lines = Vec::new();
if let Some(title) = &questionnaire.request.title {
push_wrapped_text(
&mut lines,
title,
width,
Theme::input_prompt(),
LineFill::Natural,
);
} else {
push_wrapped_text(
&mut lines,
&format!(
"answer {} question(s)",
questionnaire.request.questions.len()
),
width,
Theme::input_prompt(),
LineFill::Natural,
);
}
if let Some(reason) = &questionnaire.request.reason {
push_wrapped_text(
&mut lines,
&format!("reason: {reason}"),
width,
Theme::dim(),
LineFill::Natural,
);
}
lines.push(styled_line(
truncate_one_line(
"enter submit · up/down choose · space toggle · tab next · type only for other",
width,
),
width,
Theme::dim(),
LineFill::Natural,
));
let mut cursor = Position { x: 0, y: 0 };
for (index, (question, field)) in questionnaire
.request
.questions
.iter()
.zip(questionnaire.fields.iter())
.enumerate()
{
let active = questionnaire.active_index == index;
let before = lines.len();
questionnaire_push_question_lines(&mut lines, question, field, index, active, width);
if active {
cursor = questionnaire_question_cursor(question, field, before, width);
}
}
(lines, cursor)
}
fn questionnaire_push_question_lines(
lines: &mut Vec<Line<'static>>,
question: &QuestionnaireQuestion,
field: &QuestionnaireFieldState,
index: usize,
active: bool,
width: usize,
) {
let marker = if active { ">" } else { " " };
let required = if question.required { "" } else { " optional" };
push_wrapped_text(
lines,
&format!("{marker} {}. {}{required}", index + 1, question.question),
width,
if active {
Theme::input_prompt()
} else {
Theme::dim()
},
LineFill::Natural,
);
if let Some(help) = &question.help {
push_wrapped_text(
lines,
&format!(" help: {help}"),
width,
Theme::dim(),
LineFill::Natural,
);
}
let answer_hint = questionnaire_answer_hint(question);
if !answer_hint.is_empty() {
push_wrapped_text(
lines,
&format!(" {answer_hint}"),
width,
Theme::dim(),
LineFill::Natural,
);
}
match question.kind {
QuestionnaireQuestionKind::Text => {
push_prefixed_input_lines(lines, " ", &field.other_value, width, Theme::text());
}
QuestionnaireQuestionKind::Choice
| QuestionnaireQuestionKind::MultiSelect
| QuestionnaireQuestionKind::Confirm => {
for choice_index in 0..choice_count(question) {
let highlighted = active && field.choice_cursor == choice_index;
let line_marker = if highlighted { " >" } else { " " };
let selection_marker =
questionnaire_selection_marker(question, field, choice_index);
let label = questionnaire_choice_label(question, choice_index);
push_wrapped_text(
lines,
&format!("{line_marker} {selection_marker} {label}"),
width,
if highlighted {
Theme::input_prompt()
} else {
Theme::text()
},
LineFill::Natural,
);
if question.allow_other
&& choice_index == question.choices.len()
&& questionnaire_other_selected(field)
{
push_prefixed_input_lines(
lines,
" other: ",
&field.other_value,
width,
Theme::text(),
);
}
}
}
}
}
fn questionnaire_answer_hint(question: &QuestionnaireQuestion) -> String {
let mut hints = Vec::new();
match question.kind {
QuestionnaireQuestionKind::Text => hints.push("free text only when needed".into()),
QuestionnaireQuestionKind::Choice => hints.push("select one".into()),
QuestionnaireQuestionKind::MultiSelect => hints.push("select one or more".into()),
QuestionnaireQuestionKind::Confirm => hints.push("select yes or no".into()),
}
if !question.choices.is_empty() && question.allow_other {
hints.push("other available".into());
}
if let Some(default) = &question.default {
hints.push(format!(
"default: {}",
questionnaire_default_display(default)
));
}
hints.join(" · ")
}
fn questionnaire_selection_marker(
question: &QuestionnaireQuestion,
field: &QuestionnaireFieldState,
choice_index: usize,
) -> &'static str {
match (&question.kind, &field.selection) {
(QuestionnaireQuestionKind::MultiSelect, FieldSelection::Multi { selected, other }) => {
if choice_index < question.choices.len() {
if selected.contains(&choice_index) {
"[x]"
} else {
"[ ]"
}
} else if *other {
"[x]"
} else {
"[ ]"
}
}
(_, FieldSelection::Single(index)) if *index == choice_index => "(x)",
(_, FieldSelection::Other)
if question.allow_other && choice_index == question.choices.len() =>
{
"(x)"
}
(_, FieldSelection::None) => "( )",
_ => "( )",
}
}
fn questionnaire_choice_label(question: &QuestionnaireQuestion, choice_index: usize) -> String {
match question.kind {
QuestionnaireQuestionKind::Confirm => {
if choice_index == 0 {
"yes".into()
} else {
"no".into()
}
}
QuestionnaireQuestionKind::Choice | QuestionnaireQuestionKind::MultiSelect => question
.choices
.get(choice_index)
.cloned()
.unwrap_or_else(|| "Other".into()),
QuestionnaireQuestionKind::Text => String::new(),
}
}
fn questionnaire_other_selected(field: &QuestionnaireFieldState) -> bool {
match &field.selection {
FieldSelection::Other => true,
FieldSelection::Multi { other, .. } => *other,
FieldSelection::Text | FieldSelection::None | FieldSelection::Single(_) => false,
}
}
fn push_prefixed_input_lines(
lines: &mut Vec<Line<'static>>,
prefix: &str,
value: &str,
width: usize,
style: Style,
) {
let prefix_width = display_width(prefix);
let input_width = width.saturating_sub(prefix_width).max(1);
let continuation = " ".repeat(prefix_width);
for (index, line) in input_visual_lines(value, input_width)
.into_iter()
.enumerate()
{
let prefix = if index == 0 { prefix } else { &continuation };
lines.push(styled_line(
format!("{prefix}{line}"),
width,
style,
LineFill::Natural,
));
}
}
fn questionnaire_cursor_position(questionnaire: &QuestionnaireComposer, width: usize) -> Position {
questionnaire_frame(questionnaire, width).1
}
fn questionnaire_question_cursor(
question: &QuestionnaireQuestion,
field: &QuestionnaireFieldState,
start_y: usize,
width: usize,
) -> Position {
let prefix_height = questionnaire_question_prefix_line_count(question, width);
match question.kind {
QuestionnaireQuestionKind::Text => prefixed_input_cursor(
&field.other_value,
field.other_cursor,
" ",
start_y + prefix_height,
width,
),
QuestionnaireQuestionKind::Choice
| QuestionnaireQuestionKind::MultiSelect
| QuestionnaireQuestionKind::Confirm => {
let mut y = start_y + prefix_height;
for choice_index in 0..choice_count(question) {
if field.choice_cursor == choice_index {
if question.allow_other
&& choice_index == question.choices.len()
&& field.text_entry_active(question)
{
let option_lines = wrapped_line_count(
format!(
" > {} {}",
questionnaire_selection_marker(question, field, choice_index),
questionnaire_choice_label(question, choice_index)
),
width,
);
return prefixed_input_cursor(
&field.other_value,
field.other_cursor,
" other: ",
y + option_lines,
width,
);
}
return Position { x: 1, y: y as u16 };
}
y += wrapped_line_count(
format!(
" {} {}",
questionnaire_selection_marker(question, field, choice_index),
questionnaire_choice_label(question, choice_index)
),
width,
);
if question.allow_other
&& choice_index == question.choices.len()
&& questionnaire_other_selected(field)
{
y += input_visual_lines(
&field.other_value,
width.saturating_sub(display_width(" other: ")).max(1),
)
.len();
}
}
Position { x: 1, y: y as u16 }
}
}
}
fn prefixed_input_cursor(
value: &str,
cursor: usize,
prefix: &str,
start_y: usize,
width: usize,
) -> Position {
let prefix_width = display_width(prefix);
let mut position =
input_cursor_position(value, cursor, width.saturating_sub(prefix_width).max(1));
position.x = position.x.saturating_add(prefix_width as u16);
position.y = position.y.saturating_add(start_y as u16);
position
}
fn questionnaire_question_prefix_line_count(
question: &QuestionnaireQuestion,
width: usize,
) -> usize {
let required = if question.required { "" } else { " optional" };
let mut count = wrapped_line_count(format!("> 1. {}{required}", question.question), width);
if let Some(help) = &question.help {
count += wrapped_line_count(format!(" help: {help}"), width);
}
let answer_hint = questionnaire_answer_hint(question);
if !answer_hint.is_empty() {
count += wrapped_line_count(format!(" {answer_hint}"), width);
}
count
}
fn wrapped_line_count(text: String, width: usize) -> usize {
let mut lines = Vec::new();
push_wrapped_text_with(
&mut lines,
&text,
width,
Theme::text(),
LineFill::Natural,
wrap_line_at_whitespace,
);
lines.len()
}
fn questionnaire_answers(
questionnaire: &QuestionnaireComposer,
) -> Result<Vec<QuestionnaireAnswer>, String> {
questionnaire
.request
.questions
.iter()
.zip(questionnaire.fields.iter())
.enumerate()
.map(|(index, (question, field))| {
let answer = normalize_questionnaire_answer(question, field)
.map_err(|error| format!("question {}: {error}", index + 1))?;
Ok(QuestionnaireAnswer {
id: question.id.clone(),
answer,
})
})
.collect()
}
fn normalize_questionnaire_answer(
question: &QuestionnaireQuestion,
field: &QuestionnaireFieldState,
) -> Result<serde_json::Value, String> {
match question.kind {
QuestionnaireQuestionKind::Text => {
normalize_text_answer(question, &field.other_value).map(serde_json::Value::String)
}
QuestionnaireQuestionKind::Choice => match &field.selection {
FieldSelection::Single(index) => question
.choices
.get(*index)
.cloned()
.map(serde_json::Value::String)
.ok_or_else(|| "answer is not selected".into()),
FieldSelection::Other => {
normalize_text_answer(question, &field.other_value).map(serde_json::Value::String)
}
FieldSelection::None if !question.required => Ok(serde_json::Value::Null),
FieldSelection::Text | FieldSelection::None | FieldSelection::Multi { .. } => {
Err("answer is not selected".into())
}
},
QuestionnaireQuestionKind::MultiSelect => match &field.selection {
FieldSelection::Multi { selected, other } => {
let mut answers = selected
.iter()
.filter_map(|index| question.choices.get(*index).cloned())
.collect::<Vec<_>>();
if *other {
let other_answer = normalize_text_answer(question, &field.other_value)?;
if !other_answer.is_empty() {
answers.push(other_answer);
}
}
if answers.is_empty() && question.required {
return Err("select at least one answer".into());
}
Ok(serde_json::Value::Array(
answers.into_iter().map(serde_json::Value::String).collect(),
))
}
FieldSelection::Text
| FieldSelection::None
| FieldSelection::Single(_)
| FieldSelection::Other => Err("answer is not selected".into()),
},
QuestionnaireQuestionKind::Confirm => match field.selection {
FieldSelection::Single(0) => Ok(serde_json::json!("yes")),
FieldSelection::Single(1) => Ok(serde_json::json!("no")),
FieldSelection::None if !question.required => Ok(serde_json::Value::Null),
FieldSelection::Text
| FieldSelection::None
| FieldSelection::Single(_)
| FieldSelection::Multi { .. }
| FieldSelection::Other => Err("answer is not selected".into()),
},
}
}
fn normalize_text_answer(question: &QuestionnaireQuestion, value: &str) -> Result<String, String> {
let answer = value.trim().to_string();
if answer.is_empty() && question.required {
Err("answer cannot be empty".into())
} else {
Ok(answer)
}
}
fn submitted_questionnaire_entry(
request: &QuestionnaireRequest,
response: &QuestionnaireResponse,
) -> String {
if response.answers.len() == 1 {
return questionnaire_answer_display(&response.answers[0].answer);
}
let mut lines = Vec::new();
if let Some(title) = &request.title {
lines.push(title.clone());
} else {
lines.push("questionnaire answers".into());
}
for answer in &response.answers {
let label = request
.questions
.iter()
.find(|question| question.id == answer.id)
.map(|question| question.question.as_str())
.unwrap_or(answer.id.as_str());
lines.push(format!(
"{label}: {}",
questionnaire_answer_display(&answer.answer)
));
}
lines.join("\n")
}
fn questionnaire_answer_display(answer: &serde_json::Value) -> String {
match answer {
serde_json::Value::Array(values) => values
.iter()
.map(questionnaire_answer_display)
.collect::<Vec<_>>()
.join(", "),
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Bool(value) => value.to_string(),
serde_json::Value::Number(value) => value.to_string(),
serde_json::Value::Null => String::new(),
serde_json::Value::Object(_) => answer.to_string(),
}
}
fn questionnaire_notice_text(request: &QuestionnaireRequest) -> String {
match (&request.title, request.questions.as_slice()) {
(Some(title), _) => format!("agent asks: {title}"),
(None, [question]) => format!("agent asks: {}", question.question),
(None, questions) => format!("agent asks {} questions", questions.len()),
}
}
fn padded_content_width(width: usize) -> usize {
width.saturating_sub(2).max(1)
}
fn pad_display_line(line: Line<'static>) -> Line<'static> {
let edge_style = line
.spans
.first()
.map(|span| span.style)
.unwrap_or_default();
let mut spans = Vec::with_capacity(line.spans.len() + 2);
spans.push(Span::styled(" ", edge_style));
spans.extend(line.spans);
spans.push(Span::styled(" ", edge_style));
Line::from(spans)
}
fn print_exit_lines(lines: &[Line<'_>]) -> std::io::Result<()> {
let mut stdout = std::io::stdout();
stdout.write_all(b"\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[H")?;
for line in lines {
write_styled_line(&mut stdout, line)?;
stdout.write_all(b"\n")?;
}
stdout.flush()
}
fn write_styled_line(stdout: &mut impl Write, line: &Line<'_>) -> std::io::Result<()> {
for span in &line.spans {
write_style(stdout, span.style)?;
stdout.write_all(span.content.as_bytes())?;
stdout.write_all(b"\x1b[0m")?;
}
Ok(())
}
fn write_style(stdout: &mut impl Write, style: Style) -> std::io::Result<()> {
if style.add_modifier.contains(Modifier::BOLD) {
stdout.write_all(b"\x1b[1m")?;
}
if style.add_modifier.contains(Modifier::DIM) {
stdout.write_all(b"\x1b[2m")?;
}
if style.add_modifier.contains(Modifier::ITALIC) {
stdout.write_all(b"\x1b[3m")?;
}
if let Some(color) = style.fg {
write_color(stdout, color, true)?;
}
if let Some(color) = style.bg {
write_color(stdout, color, false)?;
}
Ok(())
}
fn write_color(stdout: &mut impl Write, color: Color, foreground: bool) -> std::io::Result<()> {
let code = match (foreground, color) {
(_, Color::Reset) => {
if foreground {
39
} else {
49
}
}
(true, Color::Black) => 30,
(true, Color::Red) => 31,
(true, Color::Green) => 32,
(true, Color::Yellow) => 33,
(true, Color::Blue) => 34,
(true, Color::Magenta) => 35,
(true, Color::Cyan) => 36,
(true, Color::Gray) => 37,
(true, Color::DarkGray) => 90,
(true, Color::LightRed) => 91,
(true, Color::LightGreen) => 92,
(true, Color::LightYellow) => 93,
(true, Color::LightBlue) => 94,
(true, Color::LightMagenta) => 95,
(true, Color::LightCyan) => 96,
(true, Color::White) => 97,
(false, Color::Black) => 40,
(false, Color::Red) => 41,
(false, Color::Green) => 42,
(false, Color::Yellow) => 43,
(false, Color::Blue) => 44,
(false, Color::Magenta) => 45,
(false, Color::Cyan) => 46,
(false, Color::Gray) => 47,
(false, Color::DarkGray) => 100,
(false, Color::LightRed) => 101,
(false, Color::LightGreen) => 102,
(false, Color::LightYellow) => 103,
(false, Color::LightBlue) => 104,
(false, Color::LightMagenta) => 105,
(false, Color::LightCyan) => 106,
(false, Color::White) => 107,
(true, Color::Indexed(index)) => {
write!(stdout, "\x1b[38;5;{index}m")?;
return Ok(());
}
(false, Color::Indexed(index)) => {
write!(stdout, "\x1b[48;5;{index}m")?;
return Ok(());
}
(true, Color::Rgb(red, green, blue)) => {
write!(stdout, "\x1b[38;2;{red};{green};{blue}m")?;
return Ok(());
}
(false, Color::Rgb(red, green, blue)) => {
write!(stdout, "\x1b[48;2;{red};{green};{blue}m")?;
return Ok(());
}
};
write!(stdout, "\x1b[{code}m")
}
fn clear_terminal_for_history_reflow(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
let size = terminal.size()?;
let mut stdout = std::io::stdout();
stdout.write_all(b"\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[H")?;
stdout.flush()?;
terminal.set_cursor_position(Position { x: 0, y: 0 })?;
terminal.resize(Rect::new(0, 0, size.width, size.height))?;
terminal.clear()
}
fn insert_history_lines<B: Backend>(
terminal: &mut Terminal<B>,
lines: Vec<Line<'static>>,
) -> Result<(), B::Error> {
let height = lines.len().max(1) as u16;
terminal.insert_before(height, |buf| {
Paragraph::new(lines).render(buf.area, buf);
})?;
Ok(())
}
fn previous_word_boundary(input: &str, cursor: usize) -> usize {
let chars: Vec<char> = input.chars().collect();
let mut index = cursor.min(chars.len());
while index > 0 && chars[index - 1].is_whitespace() {
index -= 1;
}
while index > 0 && !chars[index - 1].is_whitespace() {
index -= 1;
}
index
}
fn next_word_boundary(input: &str, cursor: usize) -> usize {
let chars: Vec<char> = input.chars().collect();
let mut index = cursor.min(chars.len());
while index < chars.len() && chars[index].is_whitespace() {
index += 1;
}
while index < chars.len() && !chars[index].is_whitespace() {
index += 1;
}
index
}
fn enable_bracketed_paste() -> std::io::Result<()> {
execute!(std::io::stdout(), EnableBracketedPaste)
}
fn disable_bracketed_paste() -> std::io::Result<()> {
execute!(std::io::stdout(), DisableBracketedPaste)
}
fn enable_keyboard_enhancements() -> std::io::Result<()> {
execute!(
std::io::stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)
}
fn disable_keyboard_enhancements() -> std::io::Result<()> {
execute!(std::io::stdout(), PopKeyboardEnhancementFlags)
}
fn enable_modified_keys() -> std::io::Result<()> {
let mut stdout = std::io::stdout();
stdout.write_all(b"\x1b[>4;2m")?;
stdout.flush()
}
fn disable_modified_keys() -> std::io::Result<()> {
let mut stdout = std::io::stdout();
stdout.write_all(b"\x1b[>4;0m")?;
stdout.flush()
}
fn normalize_paste(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn paste_marker_for(text: &str) -> Option<String> {
let line_count = text.split('\n').count();
let char_count = text.chars().count();
if line_count >= PASTE_COLLAPSE_MIN_LINES {
Some(format!("[ pasted: {line_count} lines ]"))
} else if char_count > PASTE_COLLAPSE_MIN_CHARS {
Some(format!("[ pasted: {char_count} chars ]"))
} else {
None
}
}
fn expand_paste_segments(input: &str, segments: &[PasteSegment]) -> String {
if segments.is_empty() {
return input.to_string();
}
let mut result = String::new();
let mut cursor = 0;
for segment in segments {
if cursor > segment.start || segment.end() > input.chars().count() {
continue;
}
result.extend(input.chars().skip(cursor).take(segment.start - cursor));
result.push_str(&segment.content);
cursor = segment.end();
}
result.extend(input.chars().skip(cursor));
result
}
fn input_lines_with_images(
input: &str,
images: &[ImageContent],
width: usize,
) -> Vec<Line<'static>> {
let mut lines = images
.iter()
.enumerate()
.map(|(index, image)| {
styled_line(
format!("[image {}: {}]", index + 1, image_summary(image)),
width.max(1),
Theme::dim(),
LineFill::Natural,
)
})
.collect::<Vec<_>>();
lines.extend(input_visual_lines(input, width).into_iter().map(Line::raw));
lines
}
fn render_user_entry(prompt: &str, images: &[ImageContent]) -> String {
let mut parts = Vec::new();
if !prompt.is_empty() {
parts.push(prompt.to_string());
}
parts.extend(
images
.iter()
.enumerate()
.map(|(index, image)| format!("[image {}: {}]", index + 1, image_summary(image))),
);
parts.join("\n")
}
fn short_session_id(id: &str) -> String {
id.chars().take(8).collect()
}
fn slash_command_args(input: &str) -> &str {
let token_end = input
.char_indices()
.find_map(|(index, ch)| ch.is_whitespace().then_some(index))
.unwrap_or(input.len());
input[token_end..].trim_start()
}
fn complete_slash_command(input: &str, cursor: usize, name: &str) -> (String, usize) {
let token_end = input
.char_indices()
.find_map(|(index, ch)| ch.is_whitespace().then_some(index))
.unwrap_or(input.len());
let token_len = input[..token_end].chars().count();
let args = slash_command_args(input);
let completed = if args.is_empty() {
format!("/{name}")
} else {
format!("/{name} {args}")
};
let completed_token_len = name.chars().count() + 1;
let new_cursor = if cursor <= token_len {
completed_token_len
} else {
completed
.chars()
.count()
.min(completed_token_len.saturating_add(cursor.saturating_sub(token_len)))
};
(completed, new_cursor)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum StreamControl {
Continue,
Interrupt,
Resize,
}
#[derive(Clone, Copy, Debug)]
enum HistoryDirection {
Previous,
Next,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credentials::{save_anthropic_api_key, save_openai_api_key, MemoryCredentialStore};
use ratatui::{backend::TestBackend, Terminal, TerminalOptions, Viewport};
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn buffer_row_text(buffer: &ratatui::buffer::Buffer, y: u16) -> String {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect()
}
fn test_tool_entry(ok: bool, display_lines: &[&str]) -> Entry {
Entry::Tool(ToolEntry {
state: ToolEntryState::Finished {
ok,
display_style: ToolDisplayStyle::file_or_command(),
},
display_lines: display_lines.iter().map(|line| (*line).into()).collect(),
expanded: false,
})
}
fn test_app() -> App {
let store = Arc::new(MemoryCredentialStore::default());
save_openai_api_key(store.as_ref(), "sk-test").unwrap();
App::new_with_credentials(
TuiInfo {
cwd: PathBuf::from("/tmp/project"),
provider: "openai".into(),
model: "gpt-5.5".into(),
reasoning: ReasoningLevel::Low,
show_reasoning_output: true,
auth: "api-key".into(),
title_provider: None,
title_model: None,
title_auth: None,
questionnaire_enabled: true,
session_id: None,
open_resume_picker: false,
config_path: None,
auth_unavailable: None,
update_notice: None,
herdr: HerdrReporter::default(),
max_tool_output_lines: 10,
},
store,
)
}
#[test]
fn sanitizes_generated_session_title() {
assert_eq!(
sanitize_session_title("\"Implement resume picker.\""),
Some("Implement resume picker".into())
);
assert_eq!(sanitize_session_title("\n\n"), None);
}
#[test]
fn title_model_defaults_to_main_model() {
let app = test_app();
assert_eq!(
app.title_model_selection(),
("openai".into(), "gpt-5.5".into(), "api-key".into())
);
}
#[test]
fn estimated_usage_cost_uses_normalized_input_and_cache_read() {
let metadata = ModelMetadata {
cost_default: Some(crate::model::models_dev::ModelCost {
input_micros_per_m: Some(1_000_000),
output_micros_per_m: Some(2_000_000),
cache_read_micros_per_m: Some(100_000),
cache_write_micros_per_m: Some(500_000),
}),
..ModelMetadata::default()
};
let usage = ModelUsage {
input_tokens: Some(300_000),
cache_read_tokens: Some(700_000),
output_tokens: Some(100_000),
cache_write_tokens: Some(10_000),
..ModelUsage::default()
};
assert_eq!(estimated_usage_cost(&usage, Some(&metadata)), Some(575_000));
}
#[test]
fn context_usage_event_is_tracked_separately_from_cumulative_usage() {
let mut app = test_app();
app.cumulative_usage = Some(ModelUsage {
input_tokens: Some(1_000),
output_tokens: Some(500),
..ModelUsage::default()
});
assert!(app
.record_agent_event(AgentEvent::ContextUsage(ContextUsage::estimated(
250,
Some(10_000),
)))
.is_none());
assert_eq!(
app.current_context,
Some(ContextUsage::estimated(250, Some(10_000)))
);
assert_eq!(
app.cumulative_usage
.as_ref()
.and_then(|usage| usage.input_tokens),
Some(1_000)
);
}
#[test]
fn usage_event_tracks_latest_usage_separately_from_cumulative_totals() {
let mut app = test_app();
assert!(app
.record_agent_event(AgentEvent::Usage(ModelUsage {
input_tokens: Some(100),
output_tokens: Some(20),
cache_read_tokens: Some(400),
..ModelUsage::default()
}))
.is_none());
assert!(app
.record_agent_event(AgentEvent::Usage(ModelUsage {
input_tokens: Some(300),
output_tokens: Some(40),
cache_read_tokens: Some(700),
..ModelUsage::default()
}))
.is_none());
assert_eq!(
app.latest_usage,
Some(ModelUsage {
input_tokens: Some(300),
output_tokens: Some(40),
cache_read_tokens: Some(700),
..ModelUsage::default()
})
);
assert_eq!(
app.cumulative_usage,
Some(ModelUsage {
input_tokens: Some(400),
output_tokens: Some(60),
cache_read_tokens: Some(1_100),
total_tokens: Some(1_040),
..ModelUsage::default()
})
);
}
#[test]
fn transcript_entries_render_without_prefix_labels() {
let entries = [
Entry::User("hello?".into()),
Entry::Assistant("hi".into()),
test_tool_entry(true, &["read_file", "read src/main.rs"]),
Entry::Notice("note".into()),
Entry::Error("bad".into()),
];
let rendered = entries
.iter()
.flat_map(|entry| entry_lines(entry, 40, 10))
.map(|line| line_text(&line))
.collect::<Vec<_>>()
.join("\n");
for label in ["you>", "rho>", "reasoning>", "tool:", "notice>", "error>"] {
assert!(
!rendered.contains(label),
"rendered label {label}: {rendered}"
);
}
}
#[test]
fn recovered_history_tail_limits_initial_redraw() {
let entries = (0..10)
.map(|index| Entry::User(format!("message {index}")))
.collect::<Vec<_>>();
let (omitted, visible) = recovered_history_tail(&entries, 80, 9, 10);
assert_eq!(omitted, 7);
assert!(matches!(visible.as_slice(), [
Entry::User(a),
Entry::User(b),
Entry::User(c),
] if a == "message 7" && b == "message 8" && c == "message 9"));
}
#[test]
fn questionnaire_cancel_sends_user_cancelled_reply() {
let mut app = test_app();
let (reply_tx, mut reply_rx) = tokio::sync::oneshot::channel();
app.composer = ComposerMode::Questionnaire(QuestionnaireComposer::new(
QuestionnaireRequest {
title: None,
reason: None,
questions: vec![QuestionnaireQuestion {
id: "file".into(),
question: "Which file?".into(),
help: None,
default: None,
kind: QuestionnaireQuestionKind::Text,
required: true,
choices: Vec::new(),
allow_other: false,
}],
},
QuestionnaireResponseChannel::new(reply_tx),
));
app.input = "draft".into();
app.input_cursor = app.input_char_len();
app.cancel_questionnaire_answer();
assert!(matches!(app.composer, ComposerMode::Input));
assert_eq!(app.input, "");
assert_eq!(app.input_cursor, 0);
assert_eq!(app.status, "answer cancelled");
assert!(matches!(
reply_rx.try_recv(),
Ok(QuestionnaireReply::Cancelled(
QuestionnaireCancelReason::UserCancelled
))
));
}
#[test]
fn questionnaire_submit_sends_selection_answers() {
let mut app = test_app();
let (reply_tx, mut reply_rx) = tokio::sync::oneshot::channel();
app.composer = ComposerMode::Questionnaire(QuestionnaireComposer::new(
QuestionnaireRequest {
title: Some("PR details".into()),
reason: Some("Need missing preferences".into()),
questions: vec![
QuestionnaireQuestion {
id: "branch".into(),
question: "Which branch?".into(),
help: None,
default: Some(serde_json::json!("main")),
kind: QuestionnaireQuestionKind::Choice,
required: true,
choices: vec!["main".into(), "develop".into()],
allow_other: true,
},
QuestionnaireQuestion {
id: "test_suites".into(),
question: "Which test suites should I run?".into(),
help: None,
default: Some(serde_json::json!(["unit"])),
kind: QuestionnaireQuestionKind::MultiSelect,
required: true,
choices: vec!["unit".into(), "e2e".into(), "lint".into()],
allow_other: false,
},
QuestionnaireQuestion {
id: "apply".into(),
question: "Apply changes?".into(),
help: None,
default: Some(serde_json::json!("yes")),
kind: QuestionnaireQuestionKind::Confirm,
required: true,
choices: Vec::new(),
allow_other: false,
},
],
},
QuestionnaireResponseChannel::new(reply_tx),
));
if let ComposerMode::Questionnaire(questionnaire) = &mut app.composer {
questionnaire.fields[0].selection = FieldSelection::Other;
questionnaire.fields[0].choice_cursor = 2;
questionnaire.fields[0].other_value = "release".into();
questionnaire.fields[0].other_cursor = "release".chars().count();
questionnaire.fields[1].selection = FieldSelection::Multi {
selected: vec![0, 1],
other: false,
};
questionnaire.fields[2].selection = FieldSelection::Single(1);
}
let display = app.prepare_questionnaire_answer().unwrap().unwrap();
assert!(display.contains("Which branch?: release"), "{display}");
assert!(
display.contains("Which test suites should I run?: unit, e2e"),
"{display}"
);
assert!(display.contains("Apply changes?: no"), "{display}");
assert!(matches!(app.composer, ComposerMode::Input));
assert_eq!(app.status, "answers submitted");
assert!(matches!(
reply_rx.try_recv(),
Ok(QuestionnaireReply::Answer(QuestionnaireResponse { answers }))
if answers == vec![
QuestionnaireAnswer { id: "branch".into(), answer: serde_json::json!("release") },
QuestionnaireAnswer { id: "test_suites".into(), answer: serde_json::json!(["unit", "e2e"]) },
QuestionnaireAnswer { id: "apply".into(), answer: serde_json::json!("no") },
]
));
}
#[test]
fn required_confirm_without_default_requires_explicit_choice() {
let question = QuestionnaireQuestion {
id: "apply".into(),
question: "Apply changes?".into(),
help: None,
default: None,
kind: QuestionnaireQuestionKind::Confirm,
required: true,
choices: Vec::new(),
allow_other: false,
};
let field = QuestionnaireFieldState::new(&question);
assert_eq!(field.selection, FieldSelection::None);
assert_eq!(
normalize_questionnaire_answer(&question, &field),
Err("answer is not selected".into())
);
let mut field = field;
field.toggle_highlighted(&question);
assert_eq!(
normalize_questionnaire_answer(&question, &field),
Ok(serde_json::json!("yes"))
);
}
#[test]
fn multi_select_default_preserves_commas() {
let question = QuestionnaireQuestion {
id: "targets".into(),
question: "Targets?".into(),
help: None,
default: Some(serde_json::json!(["New York, NY", "Los Angeles, CA"])),
kind: QuestionnaireQuestionKind::MultiSelect,
required: true,
choices: vec!["New York, NY".into(), "Boston, MA".into()],
allow_other: true,
};
let field = QuestionnaireFieldState::new(&question);
assert_eq!(
field.selection,
FieldSelection::Multi {
selected: vec![0],
other: true
}
);
assert_eq!(field.other_value, "Los Angeles, CA");
assert_eq!(
normalize_questionnaire_answer(&question, &field),
Ok(serde_json::json!(["New York, NY", "Los Angeles, CA"]))
);
}
#[test]
fn questionnaire_cursor_counts_whitespace_wrapped_question_lines() {
let question = QuestionnaireQuestion {
id: "style".into(),
question: "hello wide world".into(),
help: None,
default: None,
kind: QuestionnaireQuestionKind::Choice,
required: true,
choices: vec!["brief".into(), "detailed".into()],
allow_other: false,
};
let field = QuestionnaireFieldState::new(&question);
let expected_cursor = questionnaire_question_cursor(&question, &field, 3, 14);
let rendered = questionnaire_frame(
&QuestionnaireComposer {
request: QuestionnaireRequest {
title: None,
reason: None,
questions: vec![question.clone()],
},
response: QuestionnaireResponseChannel::new(tokio::sync::oneshot::channel().0),
fields: vec![field],
active_index: 0,
},
14,
);
assert_eq!(expected_cursor, rendered.1);
assert_eq!(expected_cursor.y, 6);
}
#[test]
fn pasted_multiline_input_collapses_to_marker_and_expands() {
let mut app = test_app();
app.insert_pasted_input_text("alpha\nbeta\ngamma");
assert_eq!(app.input, "[ pasted: 3 lines ]");
assert_eq!(app.input_cursor, app.input.chars().count());
assert_eq!(app.expanded_input(), "alpha\nbeta\ngamma");
}
#[test]
fn pasted_single_line_input_stays_literal_until_large() {
let mut app = test_app();
app.insert_pasted_input_text("hello world");
assert_eq!(app.input, "hello world");
assert!(app.paste_segments.is_empty());
assert_eq!(app.expanded_input(), "hello world");
}
#[test]
fn paste_segments_shift_after_edits_before_marker() {
let mut app = test_app();
app.insert_pasted_input_text("alpha\nbeta");
app.input_cursor = 0;
app.insert_input_text("prefix ");
assert_eq!(app.input, "prefix [ pasted: 2 lines ]");
assert_eq!(app.expanded_input(), "prefix alpha\nbeta");
}
#[test]
fn queued_pasted_prompt_keeps_marker_when_recalled_for_editing() {
let mut app = test_app();
app.insert_pasted_input_text("alpha\nbeta");
let queued = QueuedPrompt {
prompt: app.expanded_input(),
display_prompt: app.input.clone(),
paste_segments: app.paste_segments.clone(),
};
app.input.clear();
app.paste_segments.clear();
app.queued_prompts.push_back(queued);
assert!(app.recall_last_queued_prompt());
assert_eq!(app.input, "[ pasted: 2 lines ]");
assert_eq!(app.expanded_input(), "alpha\nbeta");
}
#[test]
fn queued_pasted_prompt_preserves_leading_space_segment_offsets() {
let mut app = test_app();
app.insert_input_text(" ");
app.insert_pasted_input_text("alpha\nbeta");
let queued = QueuedPrompt {
prompt: app.expanded_input().trim().to_string(),
display_prompt: app.input.clone(),
paste_segments: app.paste_segments.clone(),
};
app.input.clear();
app.paste_segments.clear();
app.queued_prompts.push_back(queued);
assert!(app.recall_last_queued_prompt());
assert_eq!(app.input, " [ pasted: 2 lines ]");
assert_eq!(app.expanded_input().trim(), "alpha\nbeta");
}
#[test]
fn slash_command_args_can_keep_collapsed_display_separate_from_expanded_prompt() {
let mut app = test_app();
app.insert_input_text("/skill:test ");
app.insert_pasted_input_text("alpha\nbeta");
let expanded_input = app.expanded_input();
assert_eq!(slash_command_args(&expanded_input).trim(), "alpha\nbeta");
assert_eq!(slash_command_args(&app.input).trim(), "[ pasted: 2 lines ]");
}
#[test]
fn paste_segment_is_removed_when_marker_is_edited() {
let mut app = test_app();
app.insert_pasted_input_text("alpha\nbeta");
app.input_cursor = 1;
app.delete_input();
assert_eq!(app.input, "[pasted: 2 lines ]");
assert!(app.paste_segments.is_empty());
assert_eq!(app.expanded_input(), "[pasted: 2 lines ]");
}
#[test]
fn normalize_paste_converts_carriage_returns() {
assert_eq!(normalize_paste("a\r\nb\rc"), "a\nb\nc");
}
#[test]
fn recovered_session_messages_become_transcript_entries() {
let entries = transcript_entries_from_messages(&[
Message::System("system".into()),
Message::User(vec![
ContentBlock::Text("hello".into()),
ContentBlock::Image(ImageContent {
data: "aW1n".into(),
mime_type: "image/png".into(),
}),
]),
Message::Assistant(vec![ContentBlock::Text("hi".into())]),
Message::Assistant(vec![ContentBlock::ToolCall(crate::tool::ToolCall {
id: "call_1".into(),
name: "read_file".into(),
arguments: serde_json::json!({"path": "src/main.rs"}),
})]),
Message::ToolResult(crate::tool::ToolResult {
id: "call_1".into(),
ok: false,
content: "missing file".into(),
}),
]);
assert!(
matches!(entries[0], Entry::User(ref text) if text == "hello\n[image: image/png 3 B]")
);
assert!(matches!(entries[1], Entry::Assistant(ref text) if text == "hi"));
assert!(matches!(
entries[2],
Entry::Tool(ToolEntry {
state: ToolEntryState::Finished {
ok: false,
display_style: ToolDisplayStyle::FileOrCommand,
},
ref display_lines,
..
}) if display_lines == &vec!["read_file".to_string(), "missing file".to_string()]
));
let lines = entry_lines(&entries[2], 40, 10);
assert_eq!(lines[1].spans[0].style.fg, Some(Color::White));
assert_eq!(lines[1].spans[0].style.bg, Some(Color::Red));
assert!(!lines[1].spans[0].style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn bash_tool_block_shows_command() {
let lines = entry_lines(
&test_tool_entry(true, &["bash", "cargo test", "ignored output"]),
40,
10,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(rendered.contains("bash"));
assert!(rendered.contains("cargo test"));
assert!(!rendered.contains("tool:"));
}
#[test]
fn read_file_tool_block_shows_file_name_only() {
let lines = entry_lines(
&test_tool_entry(true, &["read_file", "src/main.rs"]),
40,
10,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(rendered.contains("read_file"));
assert!(rendered.contains("src/main.rs"));
}
#[test]
fn skill_tool_block_shows_single_magenta_status_line() {
let lines = entry_lines(
&Entry::Tool(ToolEntry {
state: ToolEntryState::Finished {
ok: true,
display_style: ToolDisplayStyle::skill(),
},
display_lines: vec!["skill caveman".into()],
expanded: false,
}),
40,
10,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert_eq!(lines[1].spans[0].style.fg, Some(Color::White));
assert_eq!(lines[1].spans[0].style.bg, Some(Color::Magenta));
assert!(!lines[1].spans[0].style.add_modifier.contains(Modifier::DIM));
assert!(rendered.contains("skill caveman"));
assert_eq!(rendered.matches("skill").count(), 1);
}
#[test]
fn skill_tool_block_uses_subtle_red_failure_background() {
let lines = entry_lines(
&Entry::Tool(ToolEntry {
state: ToolEntryState::Finished {
ok: false,
display_style: ToolDisplayStyle::skill(),
},
display_lines: vec!["unknown skill".into()],
expanded: false,
}),
40,
10,
);
assert_eq!(lines[1].spans[0].style.fg, Some(Color::White));
assert_eq!(lines[1].spans[0].style.bg, Some(Color::Red));
assert!(!lines[1].spans[0].style.add_modifier.contains(Modifier::DIM));
}
#[test]
fn read_file_tool_block_shows_line_range_label() {
let lines = entry_lines(
&test_tool_entry(true, &["read_file", "src/file.rs:10-24"]),
40,
10,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(rendered.contains("read_file"));
assert!(rendered.contains("src/file.rs:10-24"));
}
#[test]
fn tool_block_truncates_multiline_output_with_expand_prompt() {
let lines = entry_lines(
&test_tool_entry(true, &["bash", "line 1\nline 2\nline 3"]),
40,
2,
);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(rendered.contains("bash"));
assert!(rendered.contains("line 1"));
assert!(!rendered.contains("line 2"));
assert!(rendered.contains("... 2 more lines, ctrl+o to expand"));
}
#[test]
fn expanded_tool_block_shows_full_multiline_output() {
let mut entry = test_tool_entry(true, &["bash", "line 1\nline 2\nline 3"]);
let Entry::Tool(tool) = &mut entry else {
panic!("expected tool entry");
};
tool.expanded = true;
let lines = entry_lines(&entry, 40, 2);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(rendered.contains("line 1"));
assert!(rendered.contains("line 2"));
assert!(rendered.contains("line 3"));
assert!(rendered.contains("ctrl+o to collapse"));
}
#[test]
fn untruncated_tool_block_does_not_show_expand_prompt() {
let lines = entry_lines(&test_tool_entry(true, &["bash", "line 1"]), 40, 2);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(!rendered.contains("ctrl+o"));
}
#[test]
fn toggling_latest_truncated_tool_collapses_previous_tool() {
let mut app = test_app();
app.info.max_tool_output_lines = 1;
app.transcript = vec![
test_tool_entry(true, &["first", "a\nb"]),
test_tool_entry(true, &["second", "c\nd"]),
];
if let Entry::Tool(tool) = &mut app.transcript[0] {
tool.expanded = true;
}
let index = app
.transcript
.iter()
.rposition(|entry| expandable_tool_entry(entry, app.info.max_tool_output_lines))
.unwrap();
for entry in &mut app.transcript {
if let Entry::Tool(tool) = entry {
tool.expanded = false;
}
}
if let Entry::Tool(tool) = &mut app.transcript[index] {
tool.expanded = true;
}
assert!(matches!(
app.transcript[0],
Entry::Tool(ToolEntry {
expanded: false,
..
})
));
assert!(matches!(
app.transcript[1],
Entry::Tool(ToolEntry { expanded: true, .. })
));
}
#[test]
fn final_answer_delta_handles_unstreamed_suffix_and_mismatch() {
assert_eq!(
final_answer_delta("", "final"),
FinalAnswerDelta::Append("final")
);
assert_eq!(
final_answer_delta("hello", "hello world"),
FinalAnswerDelta::Append(" world")
);
assert_eq!(final_answer_delta("hello", "hello"), FinalAnswerDelta::None);
assert_eq!(
final_answer_delta("hello", "goodbye"),
FinalAnswerDelta::Mismatch
);
}
#[test]
fn final_answer_mismatch_replaces_transcript_without_duplicating_entry() {
let mut app = test_app();
app.push_transcript_entry(Entry::Assistant("streamed".into()));
app.replace_current_turn_assistant_transcript("final");
assert!(matches!(
app.transcript.as_slice(),
[Entry::Assistant(text)] if text == "final"
));
}
#[test]
fn final_answer_mismatch_replaces_transcript_with_empty_answer() {
let mut app = test_app();
app.push_transcript_entry(Entry::Assistant("streamed".into()));
app.replace_current_turn_assistant_transcript("");
assert!(matches!(
app.transcript.as_slice(),
[Entry::Assistant(text)] if text.is_empty()
));
}
#[test]
fn final_answer_mismatch_replaces_interleaved_current_turn_assistant_fragments() {
let mut app = test_app();
app.push_transcript_entry(Entry::User("prompt".into()));
app.current_turn_start = Some(app.transcript.len());
app.push_transcript_entry(Entry::Assistant("hel".into()));
app.push_transcript_entry(Entry::Reasoning("thinking".into()));
app.push_transcript_entry(Entry::Assistant("lo".into()));
app.replace_current_turn_assistant_transcript("goodbye");
assert!(matches!(
app.transcript.as_slice(),
[Entry::User(_), Entry::Assistant(text), Entry::Reasoning(_)] if text == "goodbye"
));
}
#[test]
fn step_started_clears_stream_state() {
let mut app = test_app();
app.assistant_stream.push_delta("current");
app.reasoning_stream.push_delta("reasoning");
assert!(app.record_agent_event(AgentEvent::StepStarted(2)).is_none());
assert!(app.assistant_stream.is_empty());
assert!(app.reasoning_stream.is_empty());
assert!(app.running);
assert_eq!(app.status, "running step 2");
}
#[test]
fn active_lines_do_not_render_pending_stream_text() {
let mut app = test_app();
app.running = true;
app.assistant_stream.push_delta("hello");
app.reasoning_stream.push_delta("thinking");
let lines = app.active_lines(40);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(line_text(&lines[0]).contains("working"), "{rendered}");
assert_eq!(line_text(&lines[1]), "", "{rendered}");
assert!(!rendered.contains("hello"), "{rendered}");
assert!(!rendered.contains("thinking"), "{rendered}");
}
#[test]
fn input_divider_style_tracks_reasoning_level() {
let mut app = test_app();
app.input = "hello".into();
app.info.reasoning = ReasoningLevel::Off;
let off_lines = app.active_lines(20);
let off_style = off_lines[0].style;
app.info.reasoning = ReasoningLevel::High;
let high_lines = app.active_lines(20);
let high_style = high_lines[0].style;
assert_eq!(line_text(&high_lines[0]), "────────────────────");
assert_eq!(line_text(&high_lines[1]), "hello");
assert_eq!(line_text(&high_lines[2]), "────────────────────");
assert_eq!(
off_style,
Theme::reasoning_input_border(ReasoningLevel::Off)
);
assert_eq!(
high_style,
Theme::reasoning_input_border(ReasoningLevel::High)
);
assert_eq!(high_lines[2].style, high_style);
assert_ne!(off_style, high_style);
}
#[test]
fn active_lines_for_height_uses_actual_viewport_height() {
let mut app = test_app();
app.running = true;
let small_lines = app.active_lines_for_height(40, 4);
let default_lines = app.active_lines_for_height(40, INLINE_VIEWPORT_HEIGHT as usize);
assert_eq!(line_text(&small_lines[0]), "");
assert_eq!(line_text(&small_lines[1]), "─".repeat(40));
assert!(line_text(&default_lines[0]).contains("working"));
}
#[test]
fn loading_spinner_advances_frames() {
let started_at = Instant::now();
let spinner = LoadingSpinner {
started_at: Some(started_at),
};
assert_eq!(spinner.frame_at(started_at), "⠋");
assert_eq!(
spinner.frame_at(started_at + LoadingSpinner::FRAME_INTERVAL),
"⠙"
);
}
#[test]
fn loading_spinner_line_separates_frame_from_text() {
let started_at = Instant::now();
let spinner = LoadingSpinner {
started_at: Some(started_at),
};
assert_eq!(line_text(&spinner.line(started_at)), "⠋ working");
}
#[test]
fn active_lines_separate_spinner_from_content_with_blank_line() {
let mut app = test_app();
app.running = true;
app.pending_tool_call = Some(ToolEntry {
state: ToolEntryState::Running,
display_lines: vec!["bash".into(), "cargo test".into()],
expanded: false,
});
let lines =
app.active_lines_at_for_height(40, INLINE_VIEWPORT_HEIGHT as usize, Instant::now());
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(line_text(&lines[0]).contains("working"), "{rendered}");
assert_eq!(line_text(&lines[1]), "", "{rendered}");
assert!(rendered.contains("bash"), "{rendered}");
}
#[test]
fn active_lines_hide_spinner_when_idle() {
let app = test_app();
let rendered = app
.active_lines_at_for_height(40, INLINE_VIEWPORT_HEIGHT as usize, Instant::now())
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(!rendered.contains("working"), "{rendered}");
}
#[test]
fn desired_inline_viewport_height_shrinks_to_live_lines() {
let app = test_app();
assert!(app.desired_inline_viewport_height(60, 24) < INLINE_VIEWPORT_HEIGHT);
}
#[test]
fn draw_anchors_last_live_line_to_viewport_bottom() {
let app = test_app();
let height = app.desired_inline_viewport_height(60, 24);
let backend = TestBackend::new(60, height);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
.unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let bottom = buffer_row_text(terminal.backend().buffer(), height.saturating_sub(1));
assert!(bottom.contains("low"), "{bottom:?}");
assert!(!bottom.contains("ready"), "{bottom:?}");
}
#[test]
fn long_input_keeps_statusline_and_cursor_visible() {
let mut app = test_app();
app.input = (0..30)
.map(|index| format!("line {index:02}"))
.collect::<Vec<_>>()
.join("\n");
app.input_cursor = app.input_char_len();
let height = 8;
let backend = TestBackend::new(40, height);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
.unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let rows = (0..height)
.map(|row| buffer_row_text(terminal.backend().buffer(), row))
.collect::<Vec<_>>();
let bottom = rows.last().unwrap();
let cursor = terminal.backend().cursor_position();
assert!(rows.iter().any(|row| row.contains("line 29")), "{rows:#?}");
assert!(bottom.contains("low"), "{bottom:?}");
assert!(!bottom.contains("ready"), "{bottom:?}");
assert!(cursor.y < height, "{cursor:?}");
assert!(
rows[cursor.y as usize].contains("line 29"),
"{rows:#?} {cursor:?}"
);
}
#[test]
fn command_palette_anchors_last_suggestion_to_viewport_bottom() {
let mut app = test_app();
app.input = "/m".into();
app.input_cursor = 2;
app.clamp_command_selection();
let height = app.desired_inline_viewport_height(60, 24);
let backend = TestBackend::new(60, height);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
.unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let bottom = buffer_row_text(terminal.backend().buffer(), height.saturating_sub(1));
assert!(
bottom.contains("/model") || bottom.contains("/"),
"{bottom:?}"
);
assert!(!bottom.trim().is_empty(), "{bottom:?}");
}
#[test]
fn long_picker_filter_does_not_clip_bottom_status() {
let mut app = test_app();
let mut picker = UiPicker::new(
"models",
"enter select",
vec![PickerItem {
label: "gpt-5.5".into(),
detail: None,
preview: None,
badge: None,
value: "gpt-5.5".into(),
}],
PickerAction::SelectModel,
);
picker.filter = "x".repeat(120);
app.composer = ComposerMode::Picker(picker);
let height = app.desired_inline_viewport_height(40, 24);
let backend = TestBackend::new(40, height);
let mut terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
.unwrap();
terminal.draw(|frame| app.draw(frame)).unwrap();
let bottom = buffer_row_text(terminal.backend().buffer(), height.saturating_sub(1));
assert!(bottom.contains("low"), "{bottom:?}");
assert!(!bottom.contains("ready"), "{bottom:?}");
}
#[test]
fn command_palette_visibility_tracks_leading_command_token() {
let mut app = test_app();
app.input = "/".into();
app.input_cursor = 1;
app.clamp_command_selection();
assert!(app.command_palette_visible());
app.input = "/mo".into();
app.input_cursor = 3;
app.clamp_command_selection();
assert!(app.command_palette_visible());
app.input = "/model arg".into();
app.input_cursor = app.input_char_len();
app.clamp_command_selection();
assert!(!app.command_palette_visible());
app.input = "hello /model".into();
app.input_cursor = app.input_char_len();
app.clamp_command_selection();
assert!(!app.command_palette_visible());
}
#[test]
fn command_palette_rendering_shows_selected_match() {
let mut app = test_app();
app.input = "/m".into();
app.input_cursor = 2;
app.clamp_command_selection();
let rendered = app
.active_lines(60)
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("> /model [model]"), "{rendered}");
assert!(rendered.contains("show or switch model"), "{rendered}");
}
#[test]
fn command_palette_renders_under_message_box() {
let mut app = test_app();
app.input = "/m".into();
app.input_cursor = 2;
app.clamp_command_selection();
let lines = app
.active_lines(60)
.iter()
.map(line_text)
.collect::<Vec<_>>();
let input_index = lines
.iter()
.position(|line| line.trim_end() == "/m")
.unwrap();
let suggestion_index = lines
.iter()
.position(|line| line.contains("> /model [model]"))
.unwrap();
assert!(suggestion_index > input_index, "{lines:#?}");
}
#[test]
fn picker_renders_in_place_of_message_box() {
let mut app = test_app();
app.input = "draft prompt".into();
app.input_cursor = app.input_char_len();
app.composer = ComposerMode::Picker(UiPicker::new(
"select model",
"enter confirm",
vec![
PickerItem {
label: "model-a".into(),
detail: None,
preview: None,
badge: Some(PickerBadge {
text: "(selected)".into(),
tone: PickerBadgeTone::Selected,
}),
value: "model-a".into(),
},
PickerItem {
label: "model-b".into(),
detail: None,
preview: None,
badge: None,
value: "model-b".into(),
},
],
PickerAction::SelectModel,
));
let rendered = app
.active_lines(60)
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("select model"), "{rendered}");
assert!(rendered.contains("→ model-a"), "{rendered}");
assert!(!rendered.contains("draft prompt"), "{rendered}");
}
#[test]
fn secret_input_masks_api_key() {
let mut app = test_app();
let target = catalog::login_target_for_provider("openai").unwrap();
let mut secret = SecretInput::new(target);
secret.insert_text("sk-secret-value");
app.composer = ComposerMode::SecretInput(secret);
let rendered = app
.active_lines(60)
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("enter API key for openai"), "{rendered}");
assert!(rendered.contains("••••"), "{rendered}");
assert!(!rendered.contains("sk-secret-value"), "{rendered}");
}
#[test]
fn login_provider_picker_uses_provider_names_only() {
let mut app = test_app();
app.open_provider_picker("login", PickerAction::LoginProvider);
let rendered = app
.active_lines(80)
.iter()
.map(line_text)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("openai"), "{rendered}");
assert!(rendered.contains("openai-codex"), "{rendered}");
assert!(rendered.contains("anthropic"), "{rendered}");
assert!(!rendered.contains("api-key"), "{rendered}");
assert!(!rendered.contains("> codex"), "{rendered}");
}
#[test]
fn model_picker_uses_all_available_auths() {
let store = Arc::new(MemoryCredentialStore::default());
save_openai_api_key(store.as_ref(), "sk-test").unwrap();
save_codex_tokens(
store.as_ref(),
&crate::credentials::CodexTokens {
access_token: "access".into(),
refresh_token: Some("refresh".into()),
id_token: None,
account_id: None,
},
)
.unwrap();
save_anthropic_api_key(store.as_ref(), "sk-ant-test").unwrap();
let mut app = App::new_with_credentials(
TuiInfo {
cwd: PathBuf::from("/tmp/project"),
provider: "openai".into(),
model: "gpt-5.5".into(),
reasoning: ReasoningLevel::Low,
show_reasoning_output: true,
auth: "api-key".into(),
title_provider: None,
title_model: None,
title_auth: None,
questionnaire_enabled: true,
session_id: None,
open_resume_picker: false,
config_path: None,
auth_unavailable: None,
update_notice: None,
herdr: HerdrReporter::default(),
max_tool_output_lines: 10,
},
store,
);
app.refresh_available_auths();
let models = catalog::available_models_for_auths(&app.available_auths);
assert!(app.available_auths.iter().any(|auth| auth == "api-key"));
assert!(app
.available_auths
.iter()
.any(|auth| auth == "anthropic-api-key"));
assert!(models.iter().any(|model| model.provider == "openai-codex"));
}
#[test]
fn picker_filters_by_regex_and_autocompletes() {
let mut picker = UiPicker::new(
"select model",
"enter confirm",
vec![
PickerItem {
label: "openai/gpt-5.5".into(),
detail: None,
preview: None,
badge: None,
value: "openai/gpt-5.5".into(),
},
PickerItem {
label: "openai-codex/gpt-5.4-mini".into(),
detail: None,
preview: None,
badge: None,
value: "openai-codex/gpt-5.4-mini".into(),
},
],
PickerAction::SelectModel,
);
for ch in "codex.*mini".chars() {
picker.push_filter_char(ch);
}
assert_eq!(picker.matching_indices(), vec![1]);
assert_eq!(
picker.selected_item().unwrap().value,
"openai-codex/gpt-5.4-mini"
);
picker.complete_filter();
assert_eq!(picker.filter, regex::escape("openai-codex/gpt-5.4-mini"));
}
#[test]
fn picker_lines_render_name_detail_table_with_truncated_detail() {
let picker = UiPicker::new(
"loaded skills",
"enter inserts command",
vec![PickerItem {
label: "test-skill".into(),
detail: Some("this detail is much too long for the available width".into()),
preview: None,
badge: None,
value: "test-skill".into(),
}],
PickerAction::InsertSkillCommand,
);
let lines = picker_lines(&picker, 36);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(!rendered.contains("| detail"), "{rendered}");
assert!(rendered.contains("→ test-skill"), "{rendered}");
assert!(
rendered.contains("this detail is much too long"),
"{rendered}"
);
assert!(rendered.contains("loaded skills"), "{rendered}");
}
#[test]
fn picker_lines_use_single_column_without_details() {
let picker = UiPicker::new(
"select model",
"enter confirm",
vec![PickerItem {
label: "openai-codex/gpt-5.3-codex-max".into(),
detail: None,
preview: None,
badge: Some(PickerBadge {
text: "(selected)".into(),
tone: PickerBadgeTone::Selected,
}),
value: "openai-codex/gpt-5.3-codex-max".into(),
}],
PickerAction::SelectModel,
);
let lines = picker_lines(&picker, 60);
let rendered = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(!rendered.contains("| detail"), "{rendered}");
assert!(
rendered.contains("→ openai-codex/gpt-5.3-codex-max"),
"{rendered}"
);
assert!(rendered.contains("(selected)"), "{rendered}");
assert_eq!(lines[2].spans[2].style.fg, Some(Color::Yellow));
}
#[test]
fn picker_selection_wraps() {
let mut picker = UiPicker::new(
"select model",
"enter confirm",
vec![
PickerItem {
label: "model-a".into(),
detail: None,
preview: None,
badge: None,
value: "model-a".into(),
},
PickerItem {
label: "model-b".into(),
detail: None,
preview: None,
badge: None,
value: "model-b".into(),
},
],
PickerAction::SelectModel,
);
picker.select_previous();
assert_eq!(picker.selected_item().unwrap().value, "model-b");
picker.select_next();
assert_eq!(picker.selected_item().unwrap().value, "model-a");
}
#[test]
fn web_search_config_restore_keeps_api_key_row_selected() {
let config_dir = tempfile::tempdir().unwrap();
let mut app = test_app();
app.info.config_path = Some(config_dir.path().join("config.toml"));
let mut picker = config_picker::web_search_config_picker(&app.info);
App::restore_picker_position(
&mut picker,
config_picker::WEB_SEARCH_EXA_KEY_VALUE,
String::new(),
);
assert_eq!(
picker.selected_item().unwrap().value,
config_picker::WEB_SEARCH_EXA_KEY_VALUE
);
}
#[test]
fn esc_from_nested_web_search_config_returns_to_main_config() {
let config_dir = tempfile::tempdir().unwrap();
let mut app = test_app();
app.info.config_path = Some(config_dir.path().join("config.toml"));
app.composer = ComposerMode::Picker(config_picker::web_search_config_picker(&app.info));
app.handle_picker_escape( false).unwrap();
let ComposerMode::Picker(picker) = &app.composer else {
panic!("expected picker after nested config escape");
};
assert_eq!(
picker.selected_item().unwrap().value,
config_picker::WEB_SEARCH_VALUE
);
assert!(!app.web_search_config_picker_is_open());
assert_eq!(app.status, "config");
}
#[test]
fn esc_from_main_config_still_closes_picker() {
let config_dir = tempfile::tempdir().unwrap();
let mut app = test_app();
app.info.config_path = Some(config_dir.path().join("config.toml"));
app.composer = ComposerMode::Picker(config_picker::config_picker(&app.info, 12000, 10));
app.handle_picker_escape( false).unwrap();
assert!(matches!(app.composer, ComposerMode::Input));
assert_eq!(app.status, "ready");
}
#[test]
fn input_history_recalls_previous_messages_and_restores_draft() {
let mut app = test_app();
app.push_input_history("first message");
app.push_input_history("second message");
app.input = "draft".into();
app.input_cursor = app.input_char_len();
app.recall_input_history_or_move_cursor(HistoryDirection::Previous, 80);
assert_eq!(app.input, "second message");
assert_eq!(app.input_cursor, "second message".chars().count());
app.recall_input_history_or_move_cursor(HistoryDirection::Previous, 80);
assert_eq!(app.input, "first message");
app.recall_input_history_or_move_cursor(HistoryDirection::Next, 80);
assert_eq!(app.input, "second message");
app.recall_input_history_or_move_cursor(HistoryDirection::Next, 80);
assert_eq!(app.input, "draft");
assert_eq!(app.input_history_cursor, None);
}
#[test]
fn input_history_clears_paste_segments_and_restores_draft_segments() {
let mut app = test_app();
app.push_input_history("previous message long enough for marker");
app.insert_pasted_input_text("alpha\nbeta");
app.recall_input_history_or_move_cursor(HistoryDirection::Previous, 80);
assert_eq!(app.input, "previous message long enough for marker");
assert!(app.paste_segments.is_empty());
assert_eq!(
app.expanded_input(),
"previous message long enough for marker"
);
app.recall_input_history_or_move_cursor(HistoryDirection::Next, 80);
assert_eq!(app.input, "[ pasted: 2 lines ]");
assert_eq!(app.expanded_input(), "alpha\nbeta");
}
#[test]
fn editing_input_exits_history_navigation() {
let mut app = test_app();
app.push_input_history("previous");
app.recall_input_history_or_move_cursor(HistoryDirection::Previous, 80);
app.insert_input_char('!');
assert_eq!(app.input, "previous!");
assert_eq!(app.input_history_cursor, None);
assert_eq!(app.input_history_draft, None);
}
#[test]
fn alt_up_recalls_last_queued_message_for_editing() {
let mut app = test_app();
app.queued_prompts.push_back("first queued".into());
app.queued_prompts.push_back("second queued".into());
assert!(app.recall_last_queued_prompt());
assert_eq!(app.input, "second queued");
assert_eq!(app.input_cursor, "second queued".chars().count());
assert_eq!(app.queued_prompts, VecDeque::from(["first queued".into()]));
}
#[test]
fn alt_up_removed_queued_messages_do_not_enter_prompt_history() {
let mut app = test_app();
app.queued_prompts.push_back("first queued".into());
app.queued_prompts.push_back("second queued".into());
assert!(app.recall_last_queued_prompt());
app.input.clear();
app.input_cursor = 0;
assert!(app.recall_last_queued_prompt());
assert_eq!(app.input, "first queued");
assert!(app.queued_prompts.is_empty());
assert!(!app.recall_input_history(HistoryDirection::Previous));
}
#[test]
fn command_selection_clamps_to_available_matches() {
let mut app = test_app();
app.input = "/".into();
app.input_cursor = 1;
app.clamp_command_selection();
app.command_selection = 99;
app.clamp_command_selection();
assert_eq!(app.command_selection, app.command_matches().len() - 1);
app.input = "/mo".into();
app.input_cursor = 3;
app.clamp_command_selection();
assert_eq!(app.command_selection, 0);
}
#[test]
fn command_suggestions_truncate_long_descriptions() {
let project = tempfile::tempdir().unwrap();
let skill_dir = project
.path()
.join(".agents/skills/zz-deterministic-truncation-skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: zz-deterministic-truncation-skill\ndescription: this description is intentionally long enough to require truncation in a narrow command suggestion row\n---\nbody\n",
)
.unwrap();
let mut app = test_app();
app.info.cwd = project.path().to_path_buf();
app.input = "/zz".into();
app.input_cursor = 3;
app.clamp_command_selection();
let lines = app.command_suggestion_lines(40);
assert!(lines.iter().any(|line| line_text(line).contains('…')));
assert!(lines
.iter()
.all(|line| line_text(line).chars().count() <= 40));
}
#[test]
fn slash_command_args_preserves_text_after_skill_command() {
assert_eq!(
slash_command_args("/skill:rust-review check this diff"),
"check this diff"
);
}
#[test]
fn complete_slash_command_inserts_prefixed_skill_command() {
let (input, cursor) = complete_slash_command("/cav", 4, "skill:caveman");
assert_eq!(input, "/skill:caveman");
assert_eq!(cursor, 14);
}
#[test]
fn paste_burst_treats_enter_as_newline() {
let start = Instant::now();
let mut burst = PasteBurst::default();
burst.record_plain_char(start);
burst.record_plain_char(start + Duration::from_millis(1));
assert!(burst.should_insert_newline_for_enter(start + Duration::from_millis(2)));
}
#[test]
fn status_notice_suppresses_consecutive_duplicates() {
let mut app = test_app();
let mut terminal = Terminal::new(TestBackend::new(60, 10)).unwrap();
app.notify_status(&mut terminal, "input cleared; press ctrl-c again to quit")
.unwrap();
app.notify_status(&mut terminal, "input cleared; press ctrl-c again to quit")
.unwrap();
assert_eq!(
app.transcript
.iter()
.filter(|entry| matches!(entry, Entry::Notice(text) if text == "input cleared; press ctrl-c again to quit"))
.count(),
1
);
}
#[test]
fn paste_burst_expires_before_enter_submit() {
let start = Instant::now();
let mut burst = PasteBurst::default();
burst.record_plain_char(start);
burst.record_plain_char(start + Duration::from_millis(1));
assert!(!burst.should_insert_newline_for_enter(
start + PASTE_ENTER_SUPPRESSION + Duration::from_millis(2)
));
}
#[test]
fn image_paste_is_unavailable_while_running() {
let mut app = test_app();
app.running = true;
let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap();
app.paste_clipboard_image(&mut terminal).unwrap();
assert!(app.pending_images.is_empty());
assert_eq!(
app.status,
"image paste is unavailable while a model turn is running"
);
}
#[test]
fn paste_normalization_converts_crlf_and_cr() {
assert_eq!(normalize_paste("a\r\nb\rc"), "a\nb\nc");
}
}