mod agent_lifecycle;
mod commands;
mod context;
mod input;
mod onboarding;
#[cfg(test)]
pub use agent_lifecycle::{handle_agent_event, remember_input};
use commands::{handle_command, handle_running_command};
pub use context::CONTEXT_INSPECTION_MAX_ITEMS;
pub use context::start_auto_compaction;
#[cfg(test)]
use input::accept_model_suggestion;
pub use commands::command_suggestions_for_app;
pub use input::{FilePickerSource, Mode, PickerItem, PickerState, PromptAccessory};
pub use onboarding::setup_model_options;
pub use onboarding::{ChatGptOAuthDriver, ChatGptOAuthMethod, ChatGptOAuthRecovery, FirstRunRecovery, RecoveryStage};
use input::{
handle_key, handle_mouse, offline_model_picker_items, open_model_picker, open_reasoning_effort_picker,
open_skill_picker, submit_user_turn,
};
use onboarding::{
PendingSetupReasoningEffort, advance_after_setup_model_config, handle_first_run_key, poll_chatgpt_oauth_on_tick,
provider_authenticated, provider_for_model, selected_provider_missing,
};
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use serde::{Deserialize, Serialize};
use thndrs_agent::CancelToken;
use thndrs_agent::ProviderRequestAccounting;
pub use thndrs_agent::ToolStatus;
use thndrs_agent::context::{self as agent_context, CompactionConfig, CompactionPolicy};
use thndrs_agent::context::{
CompactionSummaryCandidate, ContextItemKind, ContextVisibility, HarnessCandidate, InstructionCandidate,
PinnedCandidate, SelectionInput, SkillCandidate, TranscriptCandidate, UserTurnCandidate,
};
use crate::acp::config::provider_label;
use crate::acp::permissions::{PendingPermission, PermissionDecision};
use crate::cli::commands::auth::CredentialScope;
use crate::cli::commands::setup::SetupProviderArg;
use crate::cli::git::{self, GitStatusSummary};
use crate::cli::input::history::{INPUT_HISTORY_LIMIT, InputHistoryStore};
use crate::cli::{Cli, MIN_TICK_RATE_MS, ReasoningEffort, Theme, WebSearchMode};
use crate::input::PromptInput;
use crate::providers::{codex, opencode, umans};
use crate::thndrs_core::auth;
use crate::tools::shell::ProcessRegistry;
use crate::{config, fuzzy, internals, prompt, session, skills, tools, utils};
const QUIT_CONFIRM_TIMEOUT_MS: u64 = 3_000;
const STOPPING_GRACE_MS: u64 = 250;
pub const VISIBLE_ROWS: usize = 8;
const LARGE_PICKER_LIMIT: usize = 200;
const MODEL_PICKER_LIMIT: usize = 50;
#[derive(Clone, Debug, Eq, PartialEq, Default)]
pub enum RunState {
#[default]
Idle,
Working,
Stopping,
Error(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
pub enum PromptState {
#[default]
Editable,
Submitted,
Streaming,
RunningTool,
Stopped,
Errored,
}
#[derive(Clone, Debug, Default)]
pub struct TurnTtftState {
pending_since: Option<Instant>,
last_completed: Option<Duration>,
}
impl TurnTtftState {
pub fn start_turn(&mut self) {
self.pending_since = Some(Instant::now());
}
pub fn stop_on_semantic_output(&mut self) {
if let Some(started_at) = self.pending_since.take() {
self.last_completed = Some(started_at.elapsed());
}
}
pub fn clear_pending(&mut self) {
self.pending_since = None;
}
pub fn is_pending(&self) -> bool {
self.pending_since.is_some()
}
pub fn last_completed(&self) -> Option<Duration> {
self.last_completed
}
#[cfg(test)]
pub fn set_pending_for_test(&mut self) {
self.pending_since = Some(Instant::now());
}
#[cfg(test)]
pub fn set_last_completed_for_test(&mut self, duration: Duration) {
self.pending_since = None;
self.last_completed = Some(duration);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
pub enum QueueTarget {
Steering,
#[default]
FollowUp,
}
impl QueueTarget {
pub fn toggle(self) -> Self {
match self {
QueueTarget::Steering => QueueTarget::FollowUp,
QueueTarget::FollowUp => QueueTarget::Steering,
}
}
pub fn label(self) -> &'static str {
match self {
QueueTarget::Steering => "steering",
QueueTarget::FollowUp => "follow-up",
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DetailPane {
pub entry_index: usize,
pub scroll: usize,
pub open: bool,
}
impl DetailPane {
pub fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_sub(1);
}
pub fn scroll_down(&mut self, total: usize) {
if total > 0 {
self.scroll = self.scroll.saturating_add(1);
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum Entry {
User { text: String },
Agent { text: String, streaming: bool },
Reasoning { text: String, streaming: bool },
Tool {
name: String,
arguments: String,
status: ToolStatus,
output: Vec<String>,
},
Status { text: String },
Error { text: String },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AgentEvent {
Started,
Status(String),
Usage {
input_tokens: u64,
output_tokens: u64,
},
RequestAccounting(Box<ProviderRequestAccounting>),
AssistantDelta(String),
ReasoningDelta(String),
ToolStarted {
id: String,
name: String,
arguments: String,
},
ToolFinished {
id: String,
output: Vec<String>,
status: ToolStatus,
write_result: Option<tools::WriteResult>,
shell_result: Option<Box<tools::shell::ProcessResult>>,
},
ModelMetadataLoaded(Vec<(String, String)>),
Retrying {
attempt: u32,
max_attempts: u32,
delay_ms: u64,
error: String,
},
PermissionRequest(PendingPermission),
PermissionResolved {
tool_call_id: String,
outcome: String,
},
AcpSession(session::AcpSessionMetadata),
Finished,
Failed(String),
Cancelled,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Msg {
Key(crossterm::event::KeyEvent),
Mouse(crossterm::event::MouseEvent),
Tick,
Clear,
Quit,
Agent(AgentEvent),
GitStatusChanged(Option<GitStatusSummary>),
}
#[derive(Debug)]
pub struct App {
pub cli: Cli,
pub session_id: String,
pub mode: Mode,
pub run_state: RunState,
pub input: PromptInput,
pub input_history: Vec<String>,
input_history_store: InputHistoryStore,
pub history_cursor: Option<usize>,
pub history_draft: String,
pub transcript: Vec<Entry>,
pub cwd: PathBuf,
pub git_status: Option<GitStatusSummary>,
pub model: String,
pub model_picker_items: Vec<PickerItem>,
pub user_label: String,
pub websearch: WebSearchMode,
pub theme: Theme,
pub verbose: bool,
pub session_tokens_in: u64,
pub session_tokens_out: u64,
pub ttft: TurnTtftState,
pub context_sources: Vec<crate::context::ContextSource>,
pub context_diagnostics: Vec<crate::context::InstructionDiagnostic>,
pub context_ledger: Option<agent_context::ContextLedger>,
pub last_request_accounting: Option<ProviderRequestAccounting>,
pub skills: Vec<skills::SkillMetadata>,
pub skill_diagnostics: Vec<skills::SkillDiagnostic>,
pub ui_tick: u64,
pub ctrl_d_pending: Option<u64>,
pub stopping_deadline: Option<u64>,
pub session_writer: Option<session::SessionWriter>,
pub tool_artifacts: HashMap<String, String>,
pub turn_count: u64,
pub process_registry: ProcessRegistry,
pub last_input: Option<String>,
pending_manual_compaction: Option<context::PendingManualCompaction>,
pending_compaction_review: Option<context::PendingCompactionReview>,
pub last_compaction_review: Option<session::CompactionReviewResult>,
context_pins: Vec<PinnedCandidate>,
context_dropped_ids: Vec<String>,
compaction_summaries: Vec<CompactionSummaryCandidate>,
pub queue_target: QueueTarget,
pending_setup_reasoning_effort: Option<PendingSetupReasoningEffort>,
pub picker: Option<PickerState>,
pub prompt_accessory: PromptAccessory,
pub first_run_recovery: Option<FirstRunRecovery>,
pub chatgpt_oauth_driver: ChatGptOAuthDriver,
pub chatgpt_browser_login: Option<auth::ChatGptCodexBrowserLogin>,
pub queued_steering: Vec<String>,
pub queued_followups: Vec<String>,
pub kill_ring: Vec<String>,
pub detail_pane: DetailPane,
pub pending_permission: Option<PendingPermission>,
pub config_diagnostics: Vec<String>,
pub mcp_config_files: Vec<session::SessionConfigFile>,
pub mcp_config_diagnostics: Vec<String>,
pub quit: bool,
}
impl From<&Cli> for App {
fn from(value: &Cli) -> Self {
let workspace_root = crate::context::discover_workspace_root(&value.cwd);
let mut cli_snapshot = value.clone();
cli_snapshot.cwd = workspace_root.clone();
cli_snapshot.tick_rate_ms = cli_snapshot.tick_rate_ms.max(MIN_TICK_RATE_MS);
let context_inventory = crate::context::discover_instructions(&workspace_root);
let context_sources = context_inventory.sources;
let context_diagnostics = context_inventory.diagnostics;
let skill_inventory = skills::discover(&workspace_root, &value.skill_dirs);
let transcript = Vec::new();
let sessions_dir = value
.session_dir
.clone()
.unwrap_or_else(|| session::sessions_dir(&workspace_root));
let session_id = session::generate_session_id();
let input_history_store = InputHistoryStore::for_workspace(&workspace_root);
let input_history = input_history_store.load_recent().ok().flatten().unwrap_or_default();
let (mcp_config_files, mcp_config_diagnostics) = agent_lifecycle::load_mcp_config_audit(&workspace_root);
let config_meta = {
let files: Vec<session::SessionConfigFile> = value
.config_layers
.iter()
.filter_map(|layer| {
let path = layer.display_path.as_ref()?;
Some(session::SessionConfigFile {
path: path.clone(),
source: layer.source.as_str().to_string(),
sha256: layer.hash.clone().unwrap_or_default(),
})
})
.collect();
let origins: std::collections::BTreeMap<String, String> = value
.config_origins
.iter()
.map(|(key, origin)| (key.clone(), format!("{}:{}", origin.source.as_str(), origin.detail)))
.collect();
let diagnostics = value.config_diagnostics.clone();
let session_dir = Some(sessions_dir.display().to_string());
Some(session::SessionConfigMeta {
session_dir,
files,
origins,
diagnostics,
mcp_files: mcp_config_files.clone(),
mcp_diagnostics: mcp_config_diagnostics.clone(),
})
};
let mut session_writer = session::SessionWriter::create(
&sessions_dir,
&session_id,
&workspace_root.display().to_string(),
"scratch",
provider_label(&value.model),
&value.model,
value.websearch.label(),
env!("CARGO_PKG_VERSION"),
config_meta,
)
.ok();
if let Some(ref mut writer) = session_writer.as_mut()
&& !context_sources.is_empty()
{
let _ = writer.append_context(&context_sources);
}
let mut app = App {
cli: cli_snapshot,
session_id,
mode: Mode::default(),
run_state: RunState::default(),
input: PromptInput::new(),
input_history,
input_history_store,
history_cursor: None,
history_draft: String::new(),
transcript,
git_status: git::collect(&workspace_root),
cwd: workspace_root,
model: value.model.clone(),
model_picker_items: offline_model_picker_items(),
user_label: default_user_label(),
websearch: value.websearch,
theme: value.theme,
verbose: value.verbose,
session_tokens_in: 0,
session_tokens_out: 0,
ttft: TurnTtftState::default(),
context_sources,
context_diagnostics,
context_ledger: None,
last_request_accounting: None,
skills: skill_inventory.skills,
skill_diagnostics: skill_inventory.diagnostics,
ui_tick: 0,
ctrl_d_pending: None,
stopping_deadline: None,
session_writer,
tool_artifacts: HashMap::new(),
turn_count: 0,
process_registry: ProcessRegistry::new(),
last_input: None,
pending_manual_compaction: None,
pending_compaction_review: None,
last_compaction_review: None,
context_pins: Vec::new(),
context_dropped_ids: Vec::new(),
compaction_summaries: Vec::new(),
queue_target: QueueTarget::default(),
pending_setup_reasoning_effort: None,
picker: None,
prompt_accessory: PromptAccessory::None,
first_run_recovery: None,
chatgpt_oauth_driver: ChatGptOAuthDriver::default(),
chatgpt_browser_login: None,
queued_steering: Vec::new(),
queued_followups: Vec::new(),
kill_ring: Vec::new(),
detail_pane: DetailPane::default(),
pending_permission: None,
config_diagnostics: value.config_diagnostics.clone(),
mcp_config_files,
mcp_config_diagnostics,
quit: false,
};
app.first_run_recovery = selected_provider_missing(&app);
app
}
}
impl App {
pub fn from_cli(cli: &Cli) -> Self {
cli.into()
}
pub fn compaction_in_flight(&self) -> bool {
self.pending_manual_compaction.is_some()
}
pub fn artifact_store(&self) -> crate::artifacts::ArtifactStore {
crate::artifacts::ArtifactStore::new(self.session_directory().join("artifacts"))
}
pub fn token_accounting_status(&self) -> String {
let Some(accounting) = &self.last_request_accounting else {
return format!(
"tokens\nsession totals: in {} out {}\nrequest accounting: unavailable",
self.session_tokens_in, self.session_tokens_out
);
};
let estimate = accounting
.estimated_input_tokens
.value
.map_or_else(|| "unknown".to_string(), |value| value.to_string());
let estimate_source = match &accounting.estimated_input_tokens.provenance {
thndrs_agent::MeasurementProvenance::Estimated { version, .. } => format!("estimated/{version}"),
_ => "unknown".to_string(),
};
let Some(usage) = accounting.provider_usage.as_ref() else {
return format!(
"tokens\nrequest: {} attempt {}\nlocal: {} bytes, {} tokens ({})\nprovider: unknown\nshadow receipts: {}",
accounting.request_id,
accounting.attempt,
accounting.serialized_bytes.value,
estimate,
estimate_source,
accounting.shadow_receipts.len()
);
};
let inclusive = usage
.inclusive_input_tokens
.value
.map_or_else(|| "unknown".to_string(), |value| value.to_string());
let estimate_error = match (
accounting.estimated_input_tokens.value,
usage.inclusive_input_tokens.value,
) {
(Some(estimated), Some(provider)) => (provider as i128 - estimated as i128).to_string(),
_ => "unknown".to_string(),
};
format!(
"tokens\nrequest: {} attempt {}\nlocal: {} bytes, {} tokens ({})\nprovider {} reported: {} input / {} output\ncache: {} read / {} create\nnormalized input: {} ({})\nestimate error: {} tokens\nshadow receipts: {}",
accounting.request_id,
accounting.attempt,
accounting.serialized_bytes.value,
estimate,
estimate_source,
usage.provider,
display_token(usage.components.input_tokens),
display_token(usage.components.output_tokens),
display_token(usage.components.cache_read_input_tokens),
display_token(usage.components.cache_creation_input_tokens),
inclusive,
usage.rule.label(),
estimate_error,
accounting.shadow_receipts.len()
)
}
pub fn self_knowledge_snapshot(&self) -> internals::SelfKnowledgeSnapshot {
let tools = tools::tool_definitions();
let provider = internals::ProviderSnapshot::new(provider_label(&self.model), &self.model, self.websearch);
let runtime = internals::RuntimeSnapshot::new(
provider,
self.cwd.display().to_string(),
internals::RENDERER_MODE,
tools.iter().map(|tool| tool.name.to_string()).collect(),
);
let references = internals::ReferenceSnapshot::from_skills(&self.skills);
let prompt_context = internals::PromptContextSnapshot::new(
prompt::default_fragments()
.into_iter()
.map(|fragment| fragment.name.to_string())
.collect(),
&self.context_sources,
);
let inventory = internals::KnowledgeInventorySnapshot::new(references, prompt_context);
let mut diagnostics: Vec<String> = self
.skill_diagnostics
.iter()
.map(skills::SkillDiagnostic::summary)
.collect();
diagnostics.extend(self.config_diagnostics.iter().cloned());
diagnostics.extend(self.mcp_config_diagnostics.iter().cloned());
internals::SelfKnowledgeSnapshot::new(
internals::AppIdentitySnapshot::default(),
runtime,
inventory,
diagnostics,
)
}
pub fn status_label(&self) -> &'static str {
match self.run_state {
RunState::Working => match self.transcript.last() {
Some(Entry::Reasoning { streaming: true, .. }) => "thinking",
Some(Entry::Agent { streaming: true, .. }) => "working",
Some(Entry::Tool { status: ToolStatus::Running, .. }) => "running tool",
Some(Entry::Tool { status: ToolStatus::Cancelled, .. }) => "cancelled tool",
Some(Entry::User { .. }) | None => "sending",
_ => "working",
},
RunState::Stopping => "stopping",
RunState::Error(_) => "failed",
RunState::Idle => match self.transcript.last() {
Some(Entry::Status { text }) if text == "cancelled" => "cancelled",
_ => match self.last_non_status_entry() {
Some(Entry::Error { .. }) => "failed",
Some(Entry::Tool { status: ToolStatus::Failed, .. }) => "failed",
Some(Entry::Tool { status: ToolStatus::Cancelled, .. }) => "cancelled",
Some(Entry::Agent { streaming: false, .. }) | Some(Entry::Tool { status: ToolStatus::Ok, .. }) => {
"done"
}
_ => "idle",
},
},
}
}
pub fn prompt_state(&self) -> PromptState {
match self.run_state {
RunState::Working => match self.transcript.last() {
Some(Entry::Reasoning { streaming: true, .. }) => PromptState::Streaming,
Some(Entry::Agent { streaming: true, .. }) => PromptState::Streaming,
Some(Entry::Tool { status: ToolStatus::Running, .. }) => PromptState::RunningTool,
_ => PromptState::Submitted,
},
RunState::Stopping => PromptState::Stopped,
RunState::Error(_) => PromptState::Errored,
RunState::Idle => match self.transcript.last() {
Some(Entry::Status { text }) if text == "cancelled" => PromptState::Stopped,
_ => match self.last_non_status_entry() {
Some(Entry::Error { .. }) => PromptState::Errored,
_ => PromptState::Editable,
},
},
}
}
fn last_non_status_entry(&self) -> Option<&Entry> {
self.transcript
.iter()
.rev()
.find(|entry| !matches!(entry, Entry::Status { .. }))
.or_else(|| self.transcript.last())
}
fn refresh_git_status(&mut self) {
self.git_status = git::collect(&self.cwd);
}
fn session_directory(&self) -> PathBuf {
self.cli
.session_dir
.clone()
.unwrap_or_else(|| session::sessions_dir(&self.cwd))
}
pub fn effective_compaction_policy(&self) -> CompactionPolicy {
let config = self
.cli
.config_layers
.iter()
.map(|layer| &layer.config.context.compaction)
.rev()
.find(|config| **config != CompactionConfig::default())
.cloned()
.unwrap_or_default();
CompactionPolicy::from_config(&config)
}
}
fn display_token(value: Option<u64>) -> String {
value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
}
pub fn update(app: &mut App, msg: &Msg) -> Option<Msg> {
match msg {
Msg::Key(key) => handle_key(app, *key),
Msg::Mouse(mouse) => handle_mouse(app, *mouse),
Msg::Quit => {
let results = app.process_registry.shutdown();
agent_lifecycle::record_background_results(app, results);
app.quit = true;
None
}
Msg::Tick => {
app.ui_tick = app.ui_tick.wrapping_add(1);
if let Some(deadline) = app.ctrl_d_pending
&& agent_lifecycle::now_or_after_deadline(app.ui_tick, deadline)
{
app.ctrl_d_pending = None;
}
agent_lifecycle::drain_background_processes(app);
agent_lifecycle::finish_stopping_if_due(app);
poll_chatgpt_oauth_on_tick(app);
None
}
Msg::Clear => {
app.transcript.clear();
app.detail_pane = DetailPane::default();
None
}
Msg::Agent(event) => agent_lifecycle::handle_agent_event(app, event.clone()),
Msg::GitStatusChanged(status) => {
app.git_status = status.clone();
None
}
}
}
fn default_user_label() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.map(|name| format!("User ({name})"))
.unwrap_or_else(|_| String::from("You"))
}
fn is_verbose_status(text: &str) -> bool {
text.starts_with("provider:") || text.starts_with("logs ") || text.starts_with("tool budget:")
}
fn quit_confirm_timeout_ticks(app: &App) -> u64 {
let tick_ms = app.cli.tick_rate_ms.max(1);
QUIT_CONFIRM_TIMEOUT_MS / tick_ms + u64::from(!QUIT_CONFIRM_TIMEOUT_MS.is_multiple_of(tick_ms))
}