1mod agent_lifecycle;
17mod commands;
18mod context;
19mod input;
20mod onboarding;
21#[cfg(test)]
22pub use agent_lifecycle::{handle_agent_event, remember_input};
23use commands::{handle_command, handle_running_command};
24
25pub use context::CONTEXT_INSPECTION_MAX_ITEMS;
26pub use context::start_auto_compaction;
27
28#[cfg(test)]
29use input::accept_model_suggestion;
30
31pub use commands::command_suggestions_for_app;
32pub use input::{FilePickerSource, Mode, PickerItem, PickerState, PromptAccessory};
33pub use onboarding::setup_model_options;
34pub use onboarding::{ChatGptOAuthDriver, ChatGptOAuthMethod, ChatGptOAuthRecovery, FirstRunRecovery, RecoveryStage};
35
36use input::{
37 handle_key, handle_mouse, offline_model_picker_items, open_model_picker, open_reasoning_effort_picker,
38 open_skill_picker, submit_user_turn,
39};
40use onboarding::{
41 PendingSetupReasoningEffort, advance_after_setup_model_config, handle_first_run_key, poll_chatgpt_oauth_on_tick,
42 provider_authenticated, provider_for_model, selected_provider_missing,
43};
44
45#[cfg(test)]
46mod tests;
47
48use std::collections::HashMap;
49use std::path::{Path, PathBuf};
50use std::time::{Duration, Instant};
51
52use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
53use serde::{Deserialize, Serialize};
54use thndrs_agent::CancelToken;
55use thndrs_agent::ProviderRequestAccounting;
56pub use thndrs_agent::ToolStatus;
57use thndrs_agent::context::{self as agent_context, CompactionConfig, CompactionPolicy};
58use thndrs_agent::context::{
59 CompactionSummaryCandidate, ContextItemKind, ContextVisibility, HarnessCandidate, InstructionCandidate,
60 PinnedCandidate, SelectionInput, SkillCandidate, TranscriptCandidate, UserTurnCandidate,
61};
62
63use crate::acp::config::provider_label;
64use crate::acp::permissions::{PendingPermission, PermissionDecision};
65use crate::cli::commands::auth::CredentialScope;
66use crate::cli::commands::setup::SetupProviderArg;
67use crate::cli::git::{self, GitStatusSummary};
68use crate::cli::input::history::{INPUT_HISTORY_LIMIT, InputHistoryStore};
69use crate::cli::{Cli, MIN_TICK_RATE_MS, ReasoningEffort, Theme, WebSearchMode};
70use crate::input::PromptInput;
71use crate::providers::{codex, opencode, umans};
72use crate::thndrs_core::auth;
73use crate::tools::shell::ProcessRegistry;
74use crate::{config, fuzzy, internals, prompt, session, skills, tools, utils};
75
76const QUIT_CONFIRM_TIMEOUT_MS: u64 = 3_000;
78const STOPPING_GRACE_MS: u64 = 250;
81
82pub const VISIBLE_ROWS: usize = 8;
83
84const LARGE_PICKER_LIMIT: usize = 200;
87const MODEL_PICKER_LIMIT: usize = 50;
88
89#[derive(Clone, Debug, Eq, PartialEq, Default)]
91pub enum RunState {
92 #[default]
94 Idle,
95 Working,
97 Stopping,
99 Error(String),
101}
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
108pub enum PromptState {
109 #[default]
111 Editable,
112 Submitted,
114 Streaming,
116 RunningTool,
118 Stopped,
120 Errored,
122}
123
124#[derive(Clone, Debug, Default)]
126pub struct TurnTtftState {
127 pending_since: Option<Instant>,
128 last_completed: Option<Duration>,
129}
130
131impl TurnTtftState {
132 pub fn start_turn(&mut self) {
134 self.pending_since = Some(Instant::now());
135 }
136
137 pub fn stop_on_semantic_output(&mut self) {
139 if let Some(started_at) = self.pending_since.take() {
140 self.last_completed = Some(started_at.elapsed());
141 }
142 }
143
144 pub fn clear_pending(&mut self) {
146 self.pending_since = None;
147 }
148
149 pub fn is_pending(&self) -> bool {
151 self.pending_since.is_some()
152 }
153
154 pub fn last_completed(&self) -> Option<Duration> {
156 self.last_completed
157 }
158
159 #[cfg(test)]
160 pub fn set_pending_for_test(&mut self) {
161 self.pending_since = Some(Instant::now());
162 }
163
164 #[cfg(test)]
165 pub fn set_last_completed_for_test(&mut self, duration: Duration) {
166 self.pending_since = None;
167 self.last_completed = Some(duration);
168 }
169}
170
171#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
173pub enum QueueTarget {
174 Steering,
176 #[default]
178 FollowUp,
179}
180
181impl QueueTarget {
182 pub fn toggle(self) -> Self {
183 match self {
184 QueueTarget::Steering => QueueTarget::FollowUp,
185 QueueTarget::FollowUp => QueueTarget::Steering,
186 }
187 }
188
189 pub fn label(self) -> &'static str {
190 match self {
191 QueueTarget::Steering => "steering",
192 QueueTarget::FollowUp => "follow-up",
193 }
194 }
195}
196
197#[derive(Clone, Debug, Default, Eq, PartialEq)]
204pub struct DetailPane {
205 pub entry_index: usize,
207 pub scroll: usize,
209 pub open: bool,
211}
212
213impl DetailPane {
214 pub fn scroll_up(&mut self) {
216 self.scroll = self.scroll.saturating_sub(1);
217 }
218
219 pub fn scroll_down(&mut self, total: usize) {
224 if total > 0 {
225 self.scroll = self.scroll.saturating_add(1);
226 }
227 }
228}
229
230#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
232pub enum Entry {
233 User { text: String },
235 Agent { text: String, streaming: bool },
237 Reasoning { text: String, streaming: bool },
239 Tool {
241 name: String,
242 arguments: String,
243 status: ToolStatus,
244 output: Vec<String>,
245 },
246 Status { text: String },
248 Error { text: String },
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
254pub enum AgentEvent {
255 Started,
256 Status(String),
257 Usage {
258 input_tokens: u64,
259 output_tokens: u64,
260 },
261 RequestAccounting(Box<ProviderRequestAccounting>),
263 AssistantDelta(String),
264 ReasoningDelta(String),
265 ToolStarted {
266 id: String,
267 name: String,
268 arguments: String,
269 },
270 ToolFinished {
271 id: String,
272 output: Vec<String>,
273 status: ToolStatus,
274 write_result: Option<tools::WriteResult>,
276 shell_result: Option<Box<tools::shell::ProcessResult>>,
279 },
280 ModelMetadataLoaded(Vec<(String, String)>),
281 Retrying {
282 attempt: u32,
283 max_attempts: u32,
284 delay_ms: u64,
285 error: String,
286 },
287 PermissionRequest(PendingPermission),
288 PermissionResolved {
289 tool_call_id: String,
290 outcome: String,
291 },
292 AcpSession(session::AcpSessionMetadata),
293 Finished,
294 Failed(String),
295 Cancelled,
296}
297
298#[derive(Clone, Debug, Eq, PartialEq)]
300pub enum Msg {
301 Key(crossterm::event::KeyEvent),
303 Mouse(crossterm::event::MouseEvent),
305 Tick,
307 Clear,
309 Quit,
311 Agent(AgentEvent),
313 GitStatusChanged(Option<GitStatusSummary>),
315}
316
317#[derive(Debug)]
319pub struct App {
320 pub cli: Cli,
322 pub session_id: String,
323 pub mode: Mode,
324 pub run_state: RunState,
325 pub input: PromptInput,
326 pub input_history: Vec<String>,
328 input_history_store: InputHistoryStore,
330 pub history_cursor: Option<usize>,
332 pub history_draft: String,
334 pub transcript: Vec<Entry>,
335 pub cwd: PathBuf,
336 pub git_status: Option<GitStatusSummary>,
338 pub model: String,
339 pub model_picker_items: Vec<PickerItem>,
340 pub user_label: String,
341 pub websearch: WebSearchMode,
342 pub theme: Theme,
344 pub verbose: bool,
346 pub session_tokens_in: u64,
348 pub session_tokens_out: u64,
349 pub ttft: TurnTtftState,
351 pub context_sources: Vec<crate::context::ContextSource>,
353 pub context_diagnostics: Vec<crate::context::InstructionDiagnostic>,
355 pub context_ledger: Option<agent_context::ContextLedger>,
358 pub last_request_accounting: Option<ProviderRequestAccounting>,
361 pub skills: Vec<skills::SkillMetadata>,
363 pub skill_diagnostics: Vec<skills::SkillDiagnostic>,
365 pub ui_tick: u64,
367 pub ctrl_d_pending: Option<u64>,
371 pub stopping_deadline: Option<u64>,
374 pub session_writer: Option<session::SessionWriter>,
377 pub tool_artifacts: HashMap<String, String>,
379 pub turn_count: u64,
381 pub process_registry: ProcessRegistry,
383 pub last_input: Option<String>,
388 pending_manual_compaction: Option<context::PendingManualCompaction>,
392 pending_compaction_review: Option<context::PendingCompactionReview>,
395 pub last_compaction_review: Option<session::CompactionReviewResult>,
397 context_pins: Vec<PinnedCandidate>,
399 context_dropped_ids: Vec<String>,
402 compaction_summaries: Vec<CompactionSummaryCandidate>,
404 pub queue_target: QueueTarget,
406 pending_setup_reasoning_effort: Option<PendingSetupReasoningEffort>,
408 pub picker: Option<PickerState>,
410 pub prompt_accessory: PromptAccessory,
412 pub first_run_recovery: Option<FirstRunRecovery>,
414 pub chatgpt_oauth_driver: ChatGptOAuthDriver,
416 pub chatgpt_browser_login: Option<auth::ChatGptCodexBrowserLogin>,
418 pub queued_steering: Vec<String>,
420 pub queued_followups: Vec<String>,
422 pub kill_ring: Vec<String>,
424 pub detail_pane: DetailPane,
426 pub pending_permission: Option<PendingPermission>,
428 pub config_diagnostics: Vec<String>,
431 pub mcp_config_files: Vec<session::SessionConfigFile>,
433 pub mcp_config_diagnostics: Vec<String>,
435 pub quit: bool,
437}
438
439impl From<&Cli> for App {
440 fn from(value: &Cli) -> Self {
441 let workspace_root = crate::context::discover_workspace_root(&value.cwd);
442 let mut cli_snapshot = value.clone();
443 cli_snapshot.cwd = workspace_root.clone();
444 cli_snapshot.tick_rate_ms = cli_snapshot.tick_rate_ms.max(MIN_TICK_RATE_MS);
445 let context_inventory = crate::context::discover_instructions(&workspace_root);
446 let context_sources = context_inventory.sources;
447 let context_diagnostics = context_inventory.diagnostics;
448 let skill_inventory = skills::discover(&workspace_root, &value.skill_dirs);
449 let transcript = Vec::new();
450 let sessions_dir = value
451 .session_dir
452 .clone()
453 .unwrap_or_else(|| session::sessions_dir(&workspace_root));
454 let session_id = session::generate_session_id();
455 let input_history_store = InputHistoryStore::for_workspace(&workspace_root);
456 let input_history = input_history_store.load_recent().ok().flatten().unwrap_or_default();
457 let (mcp_config_files, mcp_config_diagnostics) = agent_lifecycle::load_mcp_config_audit(&workspace_root);
458
459 let config_meta = {
460 let files: Vec<session::SessionConfigFile> = value
461 .config_layers
462 .iter()
463 .filter_map(|layer| {
464 let path = layer.display_path.as_ref()?;
465 Some(session::SessionConfigFile {
466 path: path.clone(),
467 source: layer.source.as_str().to_string(),
468 sha256: layer.hash.clone().unwrap_or_default(),
469 })
470 })
471 .collect();
472 let origins: std::collections::BTreeMap<String, String> = value
473 .config_origins
474 .iter()
475 .map(|(key, origin)| (key.clone(), format!("{}:{}", origin.source.as_str(), origin.detail)))
476 .collect();
477 let diagnostics = value.config_diagnostics.clone();
478 let session_dir = Some(sessions_dir.display().to_string());
479 Some(session::SessionConfigMeta {
480 session_dir,
481 files,
482 origins,
483 diagnostics,
484 mcp_files: mcp_config_files.clone(),
485 mcp_diagnostics: mcp_config_diagnostics.clone(),
486 })
487 };
488
489 let mut session_writer = session::SessionWriter::create(
490 &sessions_dir,
491 &session_id,
492 &workspace_root.display().to_string(),
493 "scratch",
494 provider_label(&value.model),
495 &value.model,
496 value.websearch.label(),
497 env!("CARGO_PKG_VERSION"),
498 config_meta,
499 )
500 .ok();
501
502 if let Some(ref mut writer) = session_writer.as_mut()
503 && !context_sources.is_empty()
504 {
505 let _ = writer.append_context(&context_sources);
506 }
507
508 let mut app = App {
509 cli: cli_snapshot,
510 session_id,
511 mode: Mode::default(),
512 run_state: RunState::default(),
513 input: PromptInput::new(),
514 input_history,
515 input_history_store,
516 history_cursor: None,
517 history_draft: String::new(),
518 transcript,
519 git_status: git::collect(&workspace_root),
520 cwd: workspace_root,
521 model: value.model.clone(),
522 model_picker_items: offline_model_picker_items(),
523 user_label: default_user_label(),
524 websearch: value.websearch,
525 theme: value.theme,
526 verbose: value.verbose,
527 session_tokens_in: 0,
528 session_tokens_out: 0,
529 ttft: TurnTtftState::default(),
530 context_sources,
531 context_diagnostics,
532 context_ledger: None,
533 last_request_accounting: None,
534 skills: skill_inventory.skills,
535 skill_diagnostics: skill_inventory.diagnostics,
536 ui_tick: 0,
537 ctrl_d_pending: None,
538 stopping_deadline: None,
539 session_writer,
540 tool_artifacts: HashMap::new(),
541 turn_count: 0,
542 process_registry: ProcessRegistry::new(),
543 last_input: None,
544 pending_manual_compaction: None,
545 pending_compaction_review: None,
546 last_compaction_review: None,
547 context_pins: Vec::new(),
548 context_dropped_ids: Vec::new(),
549 compaction_summaries: Vec::new(),
550 queue_target: QueueTarget::default(),
551 pending_setup_reasoning_effort: None,
552 picker: None,
553 prompt_accessory: PromptAccessory::None,
554 first_run_recovery: None,
555 chatgpt_oauth_driver: ChatGptOAuthDriver::default(),
556 chatgpt_browser_login: None,
557 queued_steering: Vec::new(),
558 queued_followups: Vec::new(),
559 kill_ring: Vec::new(),
560 detail_pane: DetailPane::default(),
561 pending_permission: None,
562 config_diagnostics: value.config_diagnostics.clone(),
563 mcp_config_files,
564 mcp_config_diagnostics,
565 quit: false,
566 };
567
568 app.first_run_recovery = selected_provider_missing(&app);
569 app
570 }
571}
572
573impl App {
574 pub fn from_cli(cli: &Cli) -> Self {
579 cli.into()
580 }
581
582 pub fn compaction_in_flight(&self) -> bool {
587 self.pending_manual_compaction.is_some()
588 }
589
590 pub fn artifact_store(&self) -> crate::artifacts::ArtifactStore {
595 crate::artifacts::ArtifactStore::new(self.session_directory().join("artifacts"))
596 }
597
598 pub fn token_accounting_status(&self) -> String {
600 let Some(accounting) = &self.last_request_accounting else {
601 return format!(
602 "tokens\nsession totals: in {} out {}\nrequest accounting: unavailable",
603 self.session_tokens_in, self.session_tokens_out
604 );
605 };
606 let estimate = accounting
607 .estimated_input_tokens
608 .value
609 .map_or_else(|| "unknown".to_string(), |value| value.to_string());
610 let estimate_source = match &accounting.estimated_input_tokens.provenance {
611 thndrs_agent::MeasurementProvenance::Estimated { version, .. } => format!("estimated/{version}"),
612 _ => "unknown".to_string(),
613 };
614 let Some(usage) = accounting.provider_usage.as_ref() else {
615 return format!(
616 "tokens\nrequest: {} attempt {}\nlocal: {} bytes, {} tokens ({})\nprovider: unknown\nshadow receipts: {}",
617 accounting.request_id,
618 accounting.attempt,
619 accounting.serialized_bytes.value,
620 estimate,
621 estimate_source,
622 accounting.shadow_receipts.len()
623 );
624 };
625 let inclusive = usage
626 .inclusive_input_tokens
627 .value
628 .map_or_else(|| "unknown".to_string(), |value| value.to_string());
629 let estimate_error = match (
630 accounting.estimated_input_tokens.value,
631 usage.inclusive_input_tokens.value,
632 ) {
633 (Some(estimated), Some(provider)) => (provider as i128 - estimated as i128).to_string(),
634 _ => "unknown".to_string(),
635 };
636 format!(
637 "tokens\nrequest: {} attempt {}\nlocal: {} bytes, {} tokens ({})\nprovider {} reported: {} input / {} output\ncache: {} read / {} create\nnormalized input: {} ({})\nestimate error: {} tokens\nshadow receipts: {}",
638 accounting.request_id,
639 accounting.attempt,
640 accounting.serialized_bytes.value,
641 estimate,
642 estimate_source,
643 usage.provider,
644 display_token(usage.components.input_tokens),
645 display_token(usage.components.output_tokens),
646 display_token(usage.components.cache_read_input_tokens),
647 display_token(usage.components.cache_creation_input_tokens),
648 inclusive,
649 usage.rule.label(),
650 estimate_error,
651 accounting.shadow_receipts.len()
652 )
653 }
654
655 pub fn self_knowledge_snapshot(&self) -> internals::SelfKnowledgeSnapshot {
657 let tools = tools::tool_definitions();
658 let provider = internals::ProviderSnapshot::new(provider_label(&self.model), &self.model, self.websearch);
659 let runtime = internals::RuntimeSnapshot::new(
660 provider,
661 self.cwd.display().to_string(),
662 internals::RENDERER_MODE,
663 tools.iter().map(|tool| tool.name.to_string()).collect(),
664 );
665 let references = internals::ReferenceSnapshot::from_skills(&self.skills);
666 let prompt_context = internals::PromptContextSnapshot::new(
667 prompt::default_fragments()
668 .into_iter()
669 .map(|fragment| fragment.name.to_string())
670 .collect(),
671 &self.context_sources,
672 );
673 let inventory = internals::KnowledgeInventorySnapshot::new(references, prompt_context);
674 let mut diagnostics: Vec<String> = self
675 .skill_diagnostics
676 .iter()
677 .map(skills::SkillDiagnostic::summary)
678 .collect();
679 diagnostics.extend(self.config_diagnostics.iter().cloned());
680 diagnostics.extend(self.mcp_config_diagnostics.iter().cloned());
681 internals::SelfKnowledgeSnapshot::new(
682 internals::AppIdentitySnapshot::default(),
683 runtime,
684 inventory,
685 diagnostics,
686 )
687 }
688
689 pub fn status_label(&self) -> &'static str {
694 match self.run_state {
695 RunState::Working => match self.transcript.last() {
696 Some(Entry::Reasoning { streaming: true, .. }) => "thinking",
697 Some(Entry::Agent { streaming: true, .. }) => "working",
698 Some(Entry::Tool { status: ToolStatus::Running, .. }) => "running tool",
699 Some(Entry::Tool { status: ToolStatus::Cancelled, .. }) => "cancelled tool",
700 Some(Entry::User { .. }) | None => "sending",
701 _ => "working",
702 },
703 RunState::Stopping => "stopping",
704 RunState::Error(_) => "failed",
705 RunState::Idle => match self.transcript.last() {
706 Some(Entry::Status { text }) if text == "cancelled" => "cancelled",
707 _ => match self.last_non_status_entry() {
708 Some(Entry::Error { .. }) => "failed",
709 Some(Entry::Tool { status: ToolStatus::Failed, .. }) => "failed",
710 Some(Entry::Tool { status: ToolStatus::Cancelled, .. }) => "cancelled",
711 Some(Entry::Agent { streaming: false, .. }) | Some(Entry::Tool { status: ToolStatus::Ok, .. }) => {
712 "done"
713 }
714 _ => "idle",
715 },
716 },
717 }
718 }
719
720 pub fn prompt_state(&self) -> PromptState {
722 match self.run_state {
723 RunState::Working => match self.transcript.last() {
724 Some(Entry::Reasoning { streaming: true, .. }) => PromptState::Streaming,
725 Some(Entry::Agent { streaming: true, .. }) => PromptState::Streaming,
726 Some(Entry::Tool { status: ToolStatus::Running, .. }) => PromptState::RunningTool,
727 _ => PromptState::Submitted,
728 },
729 RunState::Stopping => PromptState::Stopped,
730 RunState::Error(_) => PromptState::Errored,
731 RunState::Idle => match self.transcript.last() {
732 Some(Entry::Status { text }) if text == "cancelled" => PromptState::Stopped,
733 _ => match self.last_non_status_entry() {
734 Some(Entry::Error { .. }) => PromptState::Errored,
735 _ => PromptState::Editable,
736 },
737 },
738 }
739 }
740
741 fn last_non_status_entry(&self) -> Option<&Entry> {
742 self.transcript
743 .iter()
744 .rev()
745 .find(|entry| !matches!(entry, Entry::Status { .. }))
746 .or_else(|| self.transcript.last())
747 }
748
749 fn refresh_git_status(&mut self) {
750 self.git_status = git::collect(&self.cwd);
751 }
752
753 fn session_directory(&self) -> PathBuf {
754 self.cli
755 .session_dir
756 .clone()
757 .unwrap_or_else(|| session::sessions_dir(&self.cwd))
758 }
759
760 pub fn effective_compaction_policy(&self) -> CompactionPolicy {
762 let config = self
763 .cli
764 .config_layers
765 .iter()
766 .map(|layer| &layer.config.context.compaction)
767 .rev()
768 .find(|config| **config != CompactionConfig::default())
769 .cloned()
770 .unwrap_or_default();
771 CompactionPolicy::from_config(&config)
772 }
773}
774
775fn display_token(value: Option<u64>) -> String {
776 value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
777}
778
779pub fn update(app: &mut App, msg: &Msg) -> Option<Msg> {
781 match msg {
782 Msg::Key(key) => handle_key(app, *key),
783 Msg::Mouse(mouse) => handle_mouse(app, *mouse),
784 Msg::Quit => {
785 let results = app.process_registry.shutdown();
786 agent_lifecycle::record_background_results(app, results);
787 app.quit = true;
788 None
789 }
790 Msg::Tick => {
791 app.ui_tick = app.ui_tick.wrapping_add(1);
792 if let Some(deadline) = app.ctrl_d_pending
793 && agent_lifecycle::now_or_after_deadline(app.ui_tick, deadline)
794 {
795 app.ctrl_d_pending = None;
796 }
797 agent_lifecycle::drain_background_processes(app);
798 agent_lifecycle::finish_stopping_if_due(app);
799 poll_chatgpt_oauth_on_tick(app);
800 None
801 }
802 Msg::Clear => {
803 app.transcript.clear();
804 app.detail_pane = DetailPane::default();
805 None
806 }
807 Msg::Agent(event) => agent_lifecycle::handle_agent_event(app, event.clone()),
808 Msg::GitStatusChanged(status) => {
809 app.git_status = status.clone();
810 None
811 }
812 }
813}
814
815fn default_user_label() -> String {
816 std::env::var("USER")
817 .or_else(|_| std::env::var("USERNAME"))
818 .map(|name| format!("User ({name})"))
819 .unwrap_or_else(|_| String::from("You"))
820}
821
822fn is_verbose_status(text: &str) -> bool {
823 text.starts_with("provider:") || text.starts_with("logs ") || text.starts_with("tool budget:")
824}
825
826fn quit_confirm_timeout_ticks(app: &App) -> u64 {
831 let tick_ms = app.cli.tick_rate_ms.max(1);
832 QUIT_CONFIRM_TIMEOUT_MS / tick_ms + u64::from(!QUIT_CONFIRM_TIMEOUT_MS.is_multiple_of(tick_ms))
833}