Skip to main content

thndrs_lib/
lib.rs

1//! `thndrs` library entrypoint with terminal setup, draw loop, event polling,
2//! and cleanup.
3//!
4//! The `thndrs` bin just calls [`run`].
5
6pub mod cli;
7pub mod server;
8
9#[path = "core/agent.rs"]
10pub mod agent;
11#[path = "core/session/mod.rs"]
12pub mod session;
13
14#[path = "core/mod.rs"]
15mod thndrs_core;
16
17pub use cli::{app, input, renderer};
18
19pub use prelude::*;
20pub use thndrs_core::{
21    acp, artifacts, config, context, fuzzy, harness, internals, mcp, prelude, prompt, providers, search, skills, tools,
22    utils,
23};
24
25#[cfg(test)]
26pub mod test_env {
27    use std::cell::Cell;
28    use std::sync::{Mutex, MutexGuard};
29
30    static ENV_LOCK: Mutex<()> = Mutex::new(());
31    thread_local! {
32        static LOCK_DEPTH: Cell<usize> = const { Cell::new(0) };
33    }
34
35    pub struct Guard {
36        _lock: Option<MutexGuard<'static, ()>>,
37    }
38
39    impl Drop for Guard {
40        fn drop(&mut self) {
41            LOCK_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
42        }
43    }
44
45    pub fn lock() -> Guard {
46        let nested = LOCK_DEPTH.with(|depth| {
47            let nested = depth.get() > 0;
48            depth.set(depth.get() + 1);
49            nested
50        });
51        let lock = (!nested).then(|| ENV_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()));
52        Guard { _lock: lock }
53    }
54}
55
56use std::io;
57use std::path::{Path, PathBuf};
58use std::sync::Arc;
59use std::sync::mpsc;
60use std::thread;
61use std::time::{Duration, Instant};
62
63use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
64use ratatui::Terminal;
65use ratatui::backend::CrosstermBackend;
66use ratatui::layout::Rect;
67
68use acp::config::provider_label;
69use app::{App, Msg, RunState, start_auto_compaction, update};
70use cli::{
71    Cli, Command, MIN_TICK_RATE_MS, commands,
72    commands::debug::DebugCommand,
73    commands::mcp::McpCommand,
74    commands::session::{SessionCommand, SessionDataFormat},
75    commands::skills::SkillsCommand,
76};
77use mcp::manager::McpManager;
78use prompt::PromptBundle;
79use renderer::alternate::{AlternateScreenSession, AlternateViewport, render_logical_frame};
80use utils::datetime;
81
82use thndrs_agent::CancelToken;
83use thndrs_agent::context as agent_context;
84
85/// Smallest interval at which the TUI applies periodic agent-driven updates.
86const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(MIN_TICK_RATE_MS);
87/// Largest event burst applied before yielding back to the render/event loop.
88const MAX_AGENT_EVENTS_PER_RENDER: usize = 256;
89/// Buffer one complete terminal transaction so CRLF scrollback commits cannot
90/// be line-buffered and shown before their replacement frame.
91const TERMINAL_WRITE_BUFFER_CAPACITY: usize = 64 * 1024;
92
93enum AcpEventWrite {
94    Continue,
95    Finished,
96    Cancelled,
97    Failed(String),
98}
99
100/// State carried by the main loop for a single agent run.
101struct AgentSlot {
102    receiver: mpsc::Receiver<app::AgentEvent>,
103    cancel: CancelToken,
104    steering: mpsc::Sender<String>,
105}
106
107struct GitStatusWatcher {
108    receiver: mpsc::Receiver<Option<renderer::git::GitStatusSummary>>,
109    _initialized: mpsc::Receiver<()>,
110    stop: mpsc::Sender<()>,
111}
112
113impl GitStatusWatcher {
114    fn spawn(cwd: PathBuf) -> Self {
115        Self::spawn_with_interval(cwd, Duration::from_millis(1000))
116    }
117
118    fn spawn_with_interval(cwd: PathBuf, interval: Duration) -> Self {
119        let (status_tx, status_rx) = mpsc::channel();
120        let (initialized_tx, initialized_rx) = mpsc::channel();
121        let (stop_tx, stop_rx) = mpsc::channel();
122        thread::spawn(move || {
123            let mut last = renderer::git::collect(&cwd);
124            let _ = initialized_tx.send(());
125            loop {
126                match stop_rx.recv_timeout(interval) {
127                    Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
128                    Err(mpsc::RecvTimeoutError::Timeout) => {}
129                }
130
131                let next = renderer::git::collect(&cwd);
132                if next != last {
133                    last = next.clone();
134                    if status_tx.send(next).is_err() {
135                        break;
136                    }
137                }
138            }
139        });
140        Self { receiver: status_rx, _initialized: initialized_rx, stop: stop_tx }
141    }
142
143    #[cfg(test)]
144    fn wait_until_initialized(&self) {
145        self._initialized
146            .recv()
147            .expect("git status watcher should report initialization");
148    }
149}
150
151impl Drop for GitStatusWatcher {
152    fn drop(&mut self) {
153        let _ = self.stop.send(());
154    }
155}
156
157#[derive(Clone, Debug)]
158struct Observability {
159    session_log_path: PathBuf,
160    daily_log_path: PathBuf,
161}
162
163/// Run the TUI to completion using the given CLI configuration.
164///
165/// Sets up the terminal, drives the draw loop, polls events on a tick, and
166/// restores the terminal on exit.
167pub fn run(cli: &Cli) -> io::Result<()> {
168    if let Some(command) = &cli.command {
169        return run_command(cli, command);
170    }
171    if cli.print_prompt {
172        return run_print_prompt(cli);
173    }
174    let tick = Duration::from_millis(cli.tick_rate_ms);
175    run_inline(tick, cli)
176}
177
178/// Render the `--print-prompt` debug view as a string.
179///
180/// Produces a human-readable dump of the assembled prompt bundle: system prompt,
181/// tool catalog, lowered Umans messages, and environment metadata. Secrets
182/// (`sk-` prefixed values) are redacted. The date is replaced with `[date]` so
183/// the output is stable for snapshot testing.
184pub fn render_print_prompt(bundle: &PromptBundle) -> String {
185    let system_prompt = prompt::render_system_prompt(bundle);
186    let messages = prompt::lower_to_umans_messages(bundle);
187    let tool_catalog = prompt::render_tool_catalog(bundle);
188    let mut out = String::new();
189
190    out.push_str(&format!("=== System Prompt ===\n{system_prompt}\n\n"));
191    out.push_str(&format!("=== Tool Catalog ({} tools) ===\n", bundle.tool_catalog.len()));
192    out.push_str(&serde_json::to_string_pretty(&tools::sorted_json_value(&tool_catalog)).unwrap_or_default());
193    out.push_str(&format!(
194        "\n\n=== Lowered Provider Messages ({} messages) ===\n",
195        messages.len()
196    ));
197
198    for (i, msg) in messages.iter().enumerate() {
199        let redacted = redact_secret(&msg.as_text());
200        let truncated = if redacted.len() > 200 { format!("{}...", &redacted[..200]) } else { redacted };
201        out.push_str(&format!("[{i}] {}: {truncated}\n", msg.role));
202    }
203
204    out.push_str("\n=== Environment ===\n");
205    out.push_str(&format!("  cwd: {}\n", bundle.environment.cwd));
206    out.push_str(&format!("  model: {}\n", bundle.environment.model));
207    out.push_str(&format!("  search: {}\n", bundle.environment.search_mode.label()));
208    out.push_str("  date: [date]\n");
209    out.push_str(&format!("  context_sources: {}\n", bundle.project_context.len()));
210    out.push_str(&format!("  skills: {}\n", bundle.available_skills.len()));
211
212    out
213}
214
215fn run_command(cli: &Cli, command: &Command) -> io::Result<()> {
216    match command {
217        Command::Setup(command) => run_setup_command(cli, command),
218        Command::Login(command) => run_login_command(cli, command),
219        Command::Logout(command) => run_logout_command(cli, command),
220        Command::Auth { command } => run_auth_command(cli, command),
221        Command::Doctor(command) => run_doctor_command(cli, command),
222        Command::Config { command } => run_config_command(cli, command),
223        Command::Acp { command } => run_acp_command(cli, command),
224        Command::Mcp { command } => run_mcp_command(cli, command),
225        Command::Skills { command } => run_skills_command(cli, command),
226        Command::Session { command } => run_session_command(cli, command),
227        Command::Debug { command } => run_debug_command(cli, command),
228    }
229}
230
231fn run_setup_command(_cli: &Cli, _command: &commands::setup::SetupCommand) -> io::Result<()> {
232    cli::commands::setup::run(_cli, _command)
233}
234
235fn run_login_command(_cli: &Cli, _command: &commands::auth::LoginCommand) -> io::Result<()> {
236    cli::commands::auth::run_login(_cli, _command)
237}
238
239fn run_logout_command(_cli: &Cli, _command: &commands::auth::LogoutCommand) -> io::Result<()> {
240    cli::commands::auth::run_logout(_cli, _command)
241}
242
243fn run_auth_command(_cli: &Cli, _command: &commands::auth::AuthCommand) -> io::Result<()> {
244    cli::commands::auth::run_auth(_cli, _command)
245}
246
247fn run_doctor_command(_cli: &Cli, _command: &commands::doctor::DoctorCommand) -> io::Result<()> {
248    cli::commands::doctor::run(_cli, _command)
249}
250
251fn run_config_command(_cli: &Cli, _command: &commands::config::ConfigCommand) -> io::Result<()> {
252    cli::commands::config::run(_cli, _command)
253}
254
255fn load_mcp_manager_for_workspace(workspace: &Path) -> io::Result<Arc<McpManager>> {
256    let env_vars: Vec<(String, String)> = std::env::vars().collect();
257    let effective = mcp::config::load_effective_mcp(workspace, &env_vars).map_err(io::Error::other)?;
258    let mut manager = McpManager::from_config(&effective.config);
259    manager.extend_diagnostics(effective.diagnostics);
260    Ok(Arc::new(manager))
261}
262
263fn load_effective_mcp_for_workspace(workspace: &Path) -> io::Result<mcp::config::EffectiveMcpConfig> {
264    let env_vars: Vec<(String, String)> = std::env::vars().collect();
265    mcp::config::load_effective_mcp(workspace, &env_vars).map_err(io::Error::other)
266}
267
268fn run_mcp_command(cli: &Cli, command: &McpCommand) -> io::Result<()> {
269    let stdout = io::stdout();
270    let mut lock = stdout.lock();
271    match command {
272        McpCommand::List => run_mcp_list(cli, &mut lock),
273        McpCommand::Test { name } => run_mcp_test(cli, name, &mut lock),
274        McpCommand::Tools { name } => run_mcp_tools(cli, name, &mut lock),
275        McpCommand::Call { server, tool, json } => run_mcp_call(cli, server, tool, json, &mut lock),
276    }
277}
278
279fn run_skills_command(cli: &Cli, command: &SkillsCommand) -> io::Result<()> {
280    cli::commands::skills::run(cli, command)
281}
282
283fn run_session_command(cli: &Cli, command: &SessionCommand) -> io::Result<()> {
284    let workspace = crate::context::discover_workspace_root(&cli.cwd);
285    let dir = cli
286        .session_dir
287        .clone()
288        .unwrap_or_else(|| session::sessions_dir(&workspace));
289    let stdout = io::stdout();
290    let mut lock = stdout.lock();
291    match command {
292        SessionCommand::List => run_session_list(&dir, &mut lock),
293        SessionCommand::Latest => run_session_latest(&dir, &mut lock),
294        SessionCommand::Titles => run_session_titles(&dir, &mut lock),
295        SessionCommand::Show { session_id } => run_session_show(&dir, session_id, &mut lock),
296        SessionCommand::Resume { session_id } => run_session_resume(&dir, session_id, &mut lock),
297        SessionCommand::Inspect { session_id, format } => run_session_inspect(&dir, session_id, *format, &mut lock),
298        SessionCommand::Export { session_id, format } => run_session_export(&dir, session_id, *format, &mut lock),
299    }
300}
301
302fn run_session_list<W: io::Write>(dir: &Path, writer: &mut W) -> io::Result<()> {
303    let files = session::list_session_files(dir);
304    if files.is_empty() {
305        writeln!(writer, "no sessions found")?;
306        return Ok(());
307    }
308    let summaries = session::list_session_summaries(dir);
309
310    for (file, summary) in files.into_iter().zip(summaries) {
311        let id = file.file_stem().and_then(|stem| stem.to_str()).unwrap_or("session");
312        writeln!(
313            writer,
314            "{id}\t{}\t{}\t{}",
315            summary.title,
316            summary.model,
317            summary.sidebar_label().replace('\n', " ")
318        )?;
319    }
320    Ok(())
321}
322
323fn run_session_latest<W: io::Write>(dir: &Path, writer: &mut W) -> io::Result<()> {
324    let Some(file) = session::latest_session_file(dir) else {
325        writeln!(writer, "no sessions found")?;
326        return Ok(());
327    };
328    let summary = session::SessionReader::read_summary(&file);
329    let id = file.file_stem().and_then(|stem| stem.to_str()).unwrap_or("session");
330    writeln!(writer, "id: {id}")?;
331    writeln!(writer, "path: {}", file.display())?;
332    writeln!(writer, "title: {}", summary.title)?;
333    writeln!(writer, "model: {}", summary.model)?;
334    writeln!(
335        writer,
336        "tokens: in {} out {}",
337        summary.input_tokens, summary.output_tokens
338    )?;
339    Ok(())
340}
341
342fn run_session_titles<W: io::Write>(dir: &Path, writer: &mut W) -> io::Result<()> {
343    let titles = session::list_session_titles(dir);
344    if titles.is_empty() {
345        writeln!(writer, "no sessions found")?;
346        return Ok(());
347    }
348
349    for title in titles {
350        writeln!(writer, "{title}")?;
351    }
352    Ok(())
353}
354
355fn run_session_show<W: io::Write>(dir: &Path, session_id: &str, writer: &mut W) -> io::Result<()> {
356    let path = session::resolve_session_file(dir, session_id).map_err(io::Error::other)?;
357
358    let _reader = session::SessionReader;
359    let transcript = session::SessionReader::read_transcript(&path);
360    if transcript.is_empty() {
361        writeln!(writer, "session `{session_id}` has no replayable transcript")?;
362        return Ok(());
363    }
364
365    for entry in transcript {
366        match entry {
367            app::Entry::User { text } => writeln!(writer, "user: {text}")?,
368            app::Entry::Agent { text, .. } => writeln!(writer, "assistant: {text}")?,
369            app::Entry::Reasoning { text, .. } => writeln!(writer, "reasoning: {text}")?,
370            app::Entry::Tool { name, status, output, .. } => {
371                writeln!(writer, "tool {name} {:?}: {}", status, output.join("\n"))?;
372            }
373            app::Entry::Status { text } => writeln!(writer, "status: {text}")?,
374            app::Entry::Error { text } => writeln!(writer, "error: {text}")?,
375        }
376    }
377    Ok(())
378}
379
380fn run_session_resume<W: io::Write>(dir: &Path, session_id: &str, writer: &mut W) -> io::Result<()> {
381    let path = session::resolve_session_file(dir, session_id).map_err(io::Error::other)?;
382    let id = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or(session_id);
383    let session_writer = session::SessionWriter::resume(&path, id)?;
384    writeln!(writer, "resumed: {id}")?;
385    writeln!(writer, "path: {}", session_writer.path().display())?;
386    Ok(())
387}
388
389fn run_session_inspect<W: io::Write>(
390    dir: &Path, session_id: &str, format: SessionDataFormat, writer: &mut W,
391) -> io::Result<()> {
392    if format != SessionDataFormat::Json {
393        return Err(io::Error::new(
394            io::ErrorKind::InvalidInput,
395            "inspect only supports --format json",
396        ));
397    }
398    let path = session::resolve_session_file(dir, session_id).map_err(io::Error::other)?;
399    let id = path.file_stem().and_then(|stem| stem.to_str()).unwrap_or(session_id);
400    let summary = session::SessionReader::read_summary(&path);
401    let records = session::SessionReader::read_redacted_records(&path);
402    let projection = serde_json::json!({
403        "schema_version": session::SCHEMA_VERSION,
404        "session": {
405            "id": id,
406            "path": path,
407            "title": summary.title,
408            "model": summary.model,
409            "usage": { "input_tokens": summary.input_tokens, "output_tokens": summary.output_tokens },
410        },
411        "records": records,
412    });
413    serde_json::to_writer_pretty(&mut *writer, &projection).map_err(io::Error::other)?;
414    writeln!(writer)?;
415    Ok(())
416}
417
418fn run_session_export<W: io::Write>(
419    dir: &Path, session_id: &str, format: SessionDataFormat, writer: &mut W,
420) -> io::Result<()> {
421    if format != SessionDataFormat::Jsonl {
422        return Err(io::Error::new(
423            io::ErrorKind::InvalidInput,
424            "export only supports --format jsonl",
425        ));
426    }
427    let path = session::resolve_session_file(dir, session_id).map_err(io::Error::other)?;
428    for record in session::SessionReader::read_redacted_records(&path) {
429        serde_json::to_writer(&mut *writer, &record).map_err(io::Error::other)?;
430        writeln!(writer)?;
431    }
432    Ok(())
433}
434
435fn run_debug_command(cli: &Cli, command: &DebugCommand) -> io::Result<()> {
436    let workspace = crate::context::discover_workspace_root(&cli.cwd);
437    let stdout = io::stdout();
438    let mut lock = stdout.lock();
439    match command {
440        DebugCommand::Tail { lines } => run_debug_tail(&workspace, *lines, &mut lock),
441        DebugCommand::SessionLog { session_id, lines } => {
442            run_debug_session_log(&workspace, session_id, *lines, &mut lock)
443        }
444    }
445}
446
447fn run_debug_tail<W: io::Write>(workspace: &Path, lines: usize, writer: &mut W) -> io::Result<()> {
448    let daily_dir = workspace.join(".thndrs").join("logs").join("daily");
449    let Some(path) = newest_log_file(&daily_dir) else {
450        return Err(io::Error::new(io::ErrorKind::NotFound, "no daily debug log found"));
451    };
452    write_log_tail(&path, lines, writer)
453}
454
455fn run_debug_session_log<W: io::Write>(
456    workspace: &Path, session_id: &str, lines: usize, writer: &mut W,
457) -> io::Result<()> {
458    let sessions = session::sessions_dir(workspace);
459    let session_path = session::resolve_session_file(&sessions, session_id).map_err(io::Error::other)?;
460    let id = session_path
461        .file_stem()
462        .and_then(|stem| stem.to_str())
463        .unwrap_or(session_id);
464    let log_path = workspace
465        .join(".thndrs")
466        .join("logs")
467        .join("sessions")
468        .join(format!("thndrs-{id}.log"));
469    write_log_tail(&log_path, lines, writer)
470}
471
472fn write_log_tail<W: io::Write>(path: &Path, lines: usize, writer: &mut W) -> io::Result<()> {
473    let tail = session::read_redacted_log_tail(path, lines.min(2_000));
474    if tail.is_empty() {
475        return Err(io::Error::new(
476            io::ErrorKind::NotFound,
477            format!("debug log `{}` is empty or missing", path.display()),
478        ));
479    }
480    for line in tail {
481        writeln!(writer, "{line}")?;
482    }
483    Ok(())
484}
485
486fn newest_log_file(dir: &Path) -> Option<PathBuf> {
487    let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
488        .ok()?
489        .filter_map(Result::ok)
490        .map(|entry| entry.path())
491        .filter(|path| path.extension().is_some_and(|extension| extension == "log"))
492        .collect();
493    files.sort_by(|left, right| {
494        let left_time = std::fs::metadata(left).and_then(|metadata| metadata.modified()).ok();
495        let right_time = std::fs::metadata(right).and_then(|metadata| metadata.modified()).ok();
496        right_time.cmp(&left_time).then_with(|| right.cmp(left))
497    });
498    files.into_iter().next()
499}
500
501fn run_mcp_list<W: io::Write>(cli: &Cli, writer: &mut W) -> io::Result<()> {
502    let workspace = crate::context::discover_workspace_root(&cli.cwd);
503    let effective = load_effective_mcp_for_workspace(&workspace)?;
504    if effective.config.servers.is_empty() {
505        writeln!(writer, "no MCP servers configured")?;
506        return Ok(());
507    }
508
509    for (name, server) in &effective.config.servers {
510        let status = if server.enabled { "enabled" } else { "disabled" };
511        writeln!(writer, "{name}\t{status}\t{:?}", server.transport)?;
512    }
513    for diagnostic in effective.diagnostics {
514        writeln!(writer, "diagnostic: {diagnostic}")?;
515    }
516    Ok(())
517}
518
519fn run_mcp_test<W: io::Write>(cli: &Cli, name: &str, writer: &mut W) -> io::Result<()> {
520    let workspace = crate::context::discover_workspace_root(&cli.cwd);
521    let effective = load_effective_mcp_for_workspace(&workspace)?;
522    let server = configured_mcp_server(&effective.config, name)?;
523    let client = mcp::manager::McpClient::connect(name.to_string(), &server).map_err(io::Error::other)?;
524    writeln!(writer, "{name}\tready\t{} tools", client.tool_definitions().len())?;
525    for diagnostic in client.diagnostics() {
526        writeln!(writer, "diagnostic: {diagnostic}")?;
527    }
528    Ok(())
529}
530
531fn run_mcp_tools<W: io::Write>(cli: &Cli, name: &str, writer: &mut W) -> io::Result<()> {
532    let workspace = crate::context::discover_workspace_root(&cli.cwd);
533    let effective = load_effective_mcp_for_workspace(&workspace)?;
534    let server = configured_mcp_server(&effective.config, name)?;
535    let client = mcp::manager::McpClient::connect(name.to_string(), &server).map_err(io::Error::other)?;
536    for tool in client.tool_definitions() {
537        writeln!(writer, "{}\t{}", tool.name, tool.description)?;
538    }
539    Ok(())
540}
541
542fn run_mcp_call<W: io::Write>(
543    cli: &Cli, server_name: &str, tool_name: &str, json: &str, writer: &mut W,
544) -> io::Result<()> {
545    let workspace = crate::context::discover_workspace_root(&cli.cwd);
546    let effective = load_effective_mcp_for_workspace(&workspace)?;
547    let server = configured_mcp_server(&effective.config, server_name)?;
548    let client = mcp::manager::McpClient::connect(server_name.to_string(), &server).map_err(io::Error::other)?;
549    let namespaced = mcp::adapter::namespaced_tool_name(server_name, tool_name);
550    let request = tools::ToolUseRequest::new(namespaced, json.to_string(), "cli".to_string());
551    let output = client.call_tool(&request);
552    match output.status {
553        app::ToolStatus::Failed => writeln!(
554            writer,
555            "failed: {}",
556            output.error.unwrap_or_else(|| "MCP tool failed".to_string())
557        ),
558        _ => {
559            for line in output.display_lines() {
560                writeln!(writer, "{line}")?;
561            }
562            Ok(())
563        }
564    }
565}
566
567fn configured_mcp_server(config: &mcp::config::McpConfig, name: &str) -> io::Result<mcp::config::McpServerConfig> {
568    let server = config.servers.get(name).cloned().ok_or_else(|| {
569        io::Error::new(
570            io::ErrorKind::NotFound,
571            format!("MCP server `{name}` is not configured"),
572        )
573    })?;
574    if !server.enabled {
575        return Err(io::Error::new(
576            io::ErrorKind::InvalidInput,
577            format!("MCP server `{name}` is disabled"),
578        ));
579    }
580    Ok(server)
581}
582
583fn run_acp_command(cli: &Cli, command: &commands::acp::AcpCommand) -> io::Result<()> {
584    use commands::acp::AcpCommand;
585    match command {
586        AcpCommand::Serve => run_acp_server(cli),
587        AcpCommand::List => {
588            print!("{}", render_acp_list(cli));
589            Ok(())
590        }
591        AcpCommand::Inspect { name } => {
592            print!("{}", render_acp_inspect(cli, name)?);
593            Ok(())
594        }
595        AcpCommand::Smoke { name, prompt } => {
596            let stdout = io::stdout();
597            let mut lock = stdout.lock();
598            run_acp_smoke(cli, name, prompt, &mut lock)
599        }
600        AcpCommand::Logout { name } => {
601            let stdout = io::stdout();
602            let mut lock = stdout.lock();
603            run_acp_logout(cli, name, &mut lock)
604        }
605        AcpCommand::ListSessions { name } => {
606            let stdout = io::stdout();
607            let mut lock = stdout.lock();
608            run_acp_list_sessions(cli, name, &mut lock)
609        }
610        AcpCommand::LoadSession { name, session_id } => {
611            let stdout = io::stdout();
612            let mut lock = stdout.lock();
613            run_acp_load_session(cli, name, session_id, &mut lock)
614        }
615        AcpCommand::ResumeSession { name, session_id } => {
616            let stdout = io::stdout();
617            let mut lock = stdout.lock();
618            run_acp_resume_session(cli, name, session_id, &mut lock)
619        }
620        AcpCommand::CloseSession { name, session_id } => {
621            let stdout = io::stdout();
622            let mut lock = stdout.lock();
623            run_acp_close_session(cli, name, session_id, &mut lock)
624        }
625        AcpCommand::Registry { file } => {
626            let stdout = io::stdout();
627            let mut lock = stdout.lock();
628            run_acp_registry(file.as_deref(), &mut lock)
629        }
630        AcpCommand::Install { agent_id, name, file, yes } => {
631            let stdout = io::stdout();
632            let mut lock = stdout.lock();
633            run_acp_install(cli, agent_id, name.clone(), file.as_deref(), *yes, &mut lock)
634        }
635        AcpCommand::Update { name, file, yes } => {
636            let stdout = io::stdout();
637            let mut lock = stdout.lock();
638            run_acp_update(cli, name, file.as_deref(), *yes, &mut lock)
639        }
640    }
641}
642
643/// Run the local ACP agent server through the primary `thndrs` executable.
644///
645/// The server shares the normal configuration pipeline with the CLI/TUI while
646/// keeping its protocol transport isolated: stdout remains ACP JSON-RPC and
647/// diagnostics are emitted only on stderr.
648fn run_acp_server(cli: &Cli) -> io::Result<()> {
649    let server_config = server::ServerConfig::new(
650        config::resolve_cli_path(&cli.cwd),
651        cli.model.clone(),
652        cli.websearch.label().to_string(),
653        cli.session_dir.clone(),
654    )
655    .with_search_url(cli.websearch_url.clone())
656    .with_reasoning(cli.reasoning_effort, cli.reasoning_summary);
657    let _ = tracing_subscriber::fmt()
658        .with_writer(io::stderr)
659        .with_ansi(false)
660        .try_init();
661    let runtime = tokio::runtime::Builder::new_multi_thread()
662        .enable_all()
663        .build()
664        .map_err(|error| io::Error::other(format!("failed to start ACP runtime: {error}")))?;
665
666    runtime
667        .block_on(server::run_stdio(server_config))
668        .map_err(|error| io::Error::other(format!("ACP server failed: {error}")))
669}
670
671fn render_acp_list(cli: &Cli) -> String {
672    let mut out = String::new();
673    if cli.acp_agents.is_empty() {
674        out.push_str("no ACP agents configured\n");
675        return out;
676    }
677
678    for (name, agent) in &cli.acp_agents {
679        let status = if agent.enabled { "enabled" } else { "disabled" };
680        out.push_str(&format!(
681            "{name}\t{status}\t{}\n",
682            acp::config::redacted_command_display(agent)
683        ));
684    }
685    out
686}
687
688fn render_acp_inspect(cli: &Cli, name: &str) -> io::Result<String> {
689    let agent = cli
690        .acp_agents
691        .get(name)
692        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("ACP agent `{name}` is not configured")))?;
693    let source = cli
694        .config_origins
695        .get("acp_agents")
696        .map(|origin| format!("{}:{}", origin.source.as_str(), origin.detail))
697        .unwrap_or_else(|| "default:default".to_string());
698    let env_keys = if agent.env.is_empty() {
699        String::from("none")
700    } else {
701        agent.env.keys().cloned().collect::<Vec<_>>().join(", ")
702    };
703    let args = if agent.args.is_empty() { String::from("none") } else { agent.args.join(" ") };
704
705    Ok(format!(
706        "name: {name}\nstatus: {}\ncommand: {}\nargs: {args}\nenv_keys: {env_keys}\ntimeout_secs: {}\nsource: {source}\n",
707        if agent.enabled { "enabled" } else { "disabled" },
708        acp::config::redacted_command_display(agent),
709        agent.timeout_secs,
710    ))
711}
712
713fn run_acp_smoke<W: io::Write>(cli: &Cli, name: &str, prompt: &str, writer: &mut W) -> io::Result<()> {
714    let agent = cli
715        .acp_agents
716        .get(name)
717        .cloned()
718        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("ACP agent `{name}` is not configured")))?;
719    if !agent.enabled {
720        return Err(io::Error::new(
721            io::ErrorKind::InvalidInput,
722            format!("ACP agent `{name}` is disabled"),
723        ));
724    }
725
726    let workspace_root = crate::context::discover_workspace_root(&cli.cwd);
727    let mut handle = acp::runner::RunHandle::new(
728        workspace_root.clone(),
729        name.to_string(),
730        Some(agent),
731        prompt.to_string(),
732    );
733    if let Ok(effective_mcp) = load_effective_mcp_for_workspace(&workspace_root) {
734        handle = handle
735            .with_mcp_config(effective_mcp.config)
736            .with_mcp_diagnostics(effective_mcp.diagnostics);
737    }
738    let rx = handle.spawn();
739    for event in rx {
740        match write_acp_event(writer, event)? {
741            AcpEventWrite::Continue => {}
742            AcpEventWrite::Finished => {
743                writeln!(writer, "\nfinished")?;
744                return Ok(());
745            }
746            AcpEventWrite::Cancelled => {
747                writeln!(writer, "\ncancelled")?;
748                return Ok(());
749            }
750            AcpEventWrite::Failed(message) => {
751                writeln!(writer, "\nfailed: {message}")?;
752                return Err(io::Error::other(message));
753            }
754        }
755    }
756    Err(io::Error::new(
757        io::ErrorKind::UnexpectedEof,
758        "ACP smoke stream ended before a terminal event",
759    ))
760}
761
762fn run_acp_logout<W: io::Write>(cli: &Cli, name: &str, writer: &mut W) -> io::Result<()> {
763    let agent = cli
764        .acp_agents
765        .get(name)
766        .cloned()
767        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("ACP agent `{name}` is not configured")))?;
768    for line in acp::runner::logout(name, agent).map_err(io::Error::other)? {
769        writeln!(writer, "{line}")?;
770    }
771    Ok(())
772}
773
774fn run_acp_list_sessions<W: io::Write>(cli: &Cli, name: &str, writer: &mut W) -> io::Result<()> {
775    let agent = configured_acp_agent(cli, name)?;
776    let workspace_root = crate::context::discover_workspace_root(&cli.cwd);
777    let sessions = acp::runner::list_sessions(name, agent, workspace_root).map_err(io::Error::other)?;
778    if sessions.is_empty() {
779        writeln!(writer, "no ACP sessions found")?;
780        return Ok(());
781    }
782    for session in sessions {
783        let title = session.title.unwrap_or_else(|| "-".to_string());
784        let updated_at = session.updated_at.unwrap_or_else(|| "-".to_string());
785        writeln!(
786            writer,
787            "{}\t{}\t{}\t{}",
788            session.session_id,
789            session.cwd.display(),
790            title,
791            updated_at
792        )?;
793        if !session.additional_directories.is_empty() {
794            let dirs = session
795                .additional_directories
796                .iter()
797                .map(|path| path.display().to_string())
798                .collect::<Vec<_>>()
799                .join(", ");
800            writeln!(writer, "  additional_directories: {dirs}")?;
801        }
802    }
803    Ok(())
804}
805
806fn run_acp_load_session<W: io::Write>(cli: &Cli, name: &str, session_id: &str, writer: &mut W) -> io::Result<()> {
807    let agent = configured_acp_agent(cli, name)?;
808    let workspace_root = crate::context::discover_workspace_root(&cli.cwd);
809    let events =
810        acp::runner::load_session(name, agent, workspace_root, session_id.to_string()).map_err(io::Error::other)?;
811    for event in events {
812        match write_acp_event(writer, event)? {
813            AcpEventWrite::Continue | AcpEventWrite::Finished | AcpEventWrite::Cancelled | AcpEventWrite::Failed(_) => {
814            }
815        }
816    }
817    writeln!(writer, "\nloaded: {name} {session_id}")?;
818    Ok(())
819}
820
821fn run_acp_resume_session<W: io::Write>(cli: &Cli, name: &str, session_id: &str, writer: &mut W) -> io::Result<()> {
822    let agent = configured_acp_agent(cli, name)?;
823    let workspace_root = crate::context::discover_workspace_root(&cli.cwd);
824    let metadata =
825        acp::runner::resume_session(name, agent, workspace_root, session_id.to_string()).map_err(io::Error::other)?;
826    writeln!(
827        writer,
828        "acp_session: {} {}",
829        metadata.agent_name, metadata.acp_session_id
830    )?;
831    writeln!(writer, "resumed: {name} {session_id}")?;
832    Ok(())
833}
834
835fn run_acp_close_session<W: io::Write>(cli: &Cli, name: &str, session_id: &str, writer: &mut W) -> io::Result<()> {
836    let agent = configured_acp_agent(cli, name)?;
837    for line in acp::runner::close_session(name, agent, session_id.to_string()).map_err(io::Error::other)? {
838        writeln!(writer, "{line}")?;
839    }
840    Ok(())
841}
842
843fn run_acp_registry<W: io::Write>(file: Option<&Path>, writer: &mut W) -> io::Result<()> {
844    let registry = match file {
845        Some(path) => acp::registry::read_file(path),
846        None => acp::registry::fetch_official(),
847    }
848    .map_err(io::Error::other)?;
849    write!(writer, "{registry}")
850}
851
852fn run_acp_install<W: io::Write>(
853    cli: &Cli, agent_id: &str, name: Option<String>, file: Option<&Path>, yes: bool, writer: &mut W,
854) -> io::Result<()> {
855    let workspace = crate::context::discover_workspace_root(&cli.cwd);
856    let request = acp::registry::InstallRequest {
857        agent_id: agent_id.to_string(),
858        name,
859        source: registry_source(file),
860        confirmed: yes,
861        timestamp: datetime::now_iso8601(),
862    };
863    let outcome = acp::registry::install(&workspace, &request).map_err(io::Error::other)?;
864    writeln!(
865        writer,
866        "installed: {} {} {}",
867        outcome.name, outcome.agent_id, outcome.agent_version
868    )?;
869    writeln!(writer, "model: {}", outcome.model)?;
870    writeln!(writer, "config: {}", outcome.config_path.display())?;
871    writeln!(writer, "metadata: {}", outcome.metadata_path.display())
872}
873
874fn run_acp_update<W: io::Write>(
875    cli: &Cli, name: &str, file: Option<&Path>, yes: bool, writer: &mut W,
876) -> io::Result<()> {
877    let workspace = crate::context::discover_workspace_root(&cli.cwd);
878    let request = acp::registry::UpdateRequest {
879        name: name.to_string(),
880        source: registry_source(file),
881        confirmed: yes,
882        timestamp: datetime::now_iso8601(),
883    };
884    let outcome = acp::registry::update(&workspace, &request).map_err(io::Error::other)?;
885    writeln!(
886        writer,
887        "updated: {} {} {}",
888        outcome.name, outcome.agent_id, outcome.agent_version
889    )?;
890    writeln!(writer, "model: {}", outcome.model)?;
891    writeln!(writer, "config: {}", outcome.config_path.display())?;
892    writeln!(writer, "metadata: {}", outcome.metadata_path.display())
893}
894
895fn registry_source(file: Option<&Path>) -> acp::registry::RegistrySource {
896    match file {
897        Some(path) => acp::registry::RegistrySource::File(path.to_path_buf()),
898        None => acp::registry::RegistrySource::Official,
899    }
900}
901
902fn write_acp_event<W: io::Write>(writer: &mut W, event: app::AgentEvent) -> io::Result<AcpEventWrite> {
903    match event {
904        app::AgentEvent::Started => writeln!(writer, "started")?,
905        app::AgentEvent::Status(text) => writeln!(writer, "status: {text}")?,
906        app::AgentEvent::AssistantDelta(text) => write!(writer, "{text}")?,
907        app::AgentEvent::ReasoningDelta(text) => writeln!(writer, "reasoning: {text}")?,
908        app::AgentEvent::Usage { input_tokens, output_tokens } => {
909            writeln!(writer, "usage: input={input_tokens} output={output_tokens}")?
910        }
911        app::AgentEvent::RequestAccounting(accounting) => {
912            let input = accounting
913                .provider_usage
914                .as_ref()
915                .and_then(|usage| usage.components.input_tokens);
916            let output = accounting
917                .provider_usage
918                .as_ref()
919                .and_then(|usage| usage.components.output_tokens);
920            writeln!(
921                writer,
922                "request: {} bytes input={input:?} output={output:?}",
923                accounting.serialized_bytes.value
924            )?;
925        }
926        app::AgentEvent::ToolStarted { id, name, arguments } => {
927            writeln!(writer, "tool_started: {name}#{id} {arguments}")?
928        }
929        app::AgentEvent::ToolFinished { id, status, output, .. } => {
930            writeln!(writer, "tool_finished: {id} {status:?} {}", output.join("\n"))?
931        }
932        app::AgentEvent::PermissionRequest(permission) => {
933            writeln!(writer, "permission: {} ({})", permission.title, permission.tool_call_id)?;
934            let _ = permission.cancel();
935        }
936        app::AgentEvent::PermissionResolved { tool_call_id, outcome } => {
937            writeln!(writer, "permission_resolved: {tool_call_id} {outcome}")?
938        }
939        app::AgentEvent::AcpSession(metadata) => writeln!(
940            writer,
941            "acp_session: {} {}",
942            metadata.agent_name, metadata.acp_session_id
943        )?,
944        app::AgentEvent::ModelMetadataLoaded(_) | app::AgentEvent::Retrying { .. } => {}
945        app::AgentEvent::Finished => return Ok(AcpEventWrite::Finished),
946        app::AgentEvent::Cancelled => return Ok(AcpEventWrite::Cancelled),
947        app::AgentEvent::Failed(message) => return Ok(AcpEventWrite::Failed(message)),
948    }
949    Ok(AcpEventWrite::Continue)
950}
951
952fn configured_acp_agent(cli: &Cli, name: &str) -> io::Result<config::AcpAgentConfig> {
953    let agent = cli
954        .acp_agents
955        .get(name)
956        .cloned()
957        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("ACP agent `{name}` is not configured")))?;
958    if !agent.enabled {
959        return Err(io::Error::new(
960            io::ErrorKind::InvalidInput,
961            format!("ACP agent `{name}` is disabled"),
962        ));
963    }
964    Ok(agent)
965}
966
967fn init_tracing(workspace_root: &Path, session_id: &str) -> Option<Observability> {
968    let session_log_dir = workspace_root.join(".thndrs").join("logs").join("sessions");
969    let daily_log_dir = workspace_root.join(".thndrs").join("logs").join("daily");
970    let session_log_path = session_log_dir.join(format!("thndrs-{session_id}.log"));
971    let daily_log_path = daily_log_dir.join(format!("{}.log", datetime::rounded_date()));
972    std::fs::create_dir_all(&session_log_dir).ok()?;
973    std::fs::create_dir_all(&daily_log_dir).ok()?;
974    let file = std::fs::OpenOptions::new()
975        .create(true)
976        .append(true)
977        .open(&session_log_path)
978        .ok()?;
979
980    if tracing_subscriber::fmt()
981        .with_writer(std::sync::Mutex::new(file))
982        .with_ansi(false)
983        .with_target(true)
984        .with_thread_ids(true)
985        .try_init()
986        .is_ok()
987    {
988        Some(Observability { session_log_path, daily_log_path })
989    } else {
990        None
991    }
992}
993
994fn daily_detail_value(value: &str) -> String {
995    value.chars().filter(|c| *c != '\n' && *c != '\r').take(300).collect()
996}
997
998fn append_daily_log(observability: &Option<Observability>, session_id: &str, event: &str, details: &str) {
999    let Some(obs) = observability else {
1000        return;
1001    };
1002
1003    if let Some(parent) = obs.daily_log_path.parent() {
1004        let _ = std::fs::create_dir_all(parent);
1005    }
1006
1007    use std::io::Write;
1008    let Ok(mut file) = std::fs::OpenOptions::new()
1009        .create(true)
1010        .append(true)
1011        .open(&obs.daily_log_path)
1012    else {
1013        return;
1014    };
1015    let _ = writeln!(
1016        file,
1017        "{} session={} event={} {}",
1018        datetime::now_iso8601(),
1019        session_id,
1020        event,
1021        details
1022    );
1023}
1024
1025/// Print the assembled prompt bundle with secrets redacted, without calling
1026/// the provider. This is the `--print-prompt` debug path.
1027fn run_print_prompt(cli: &Cli) -> io::Result<()> {
1028    let workspace_root = crate::context::discover_workspace_root(&cli.cwd);
1029    let context_sources = crate::context::discover_instructions(&workspace_root).sources;
1030    let skill_inventory = skills::discover(&workspace_root, &cli.skill_dirs);
1031    let mcp_manager = load_mcp_manager_for_workspace(&workspace_root).ok();
1032    let tool_catalog = tools::runtime_tool_definitions(mcp_manager.as_deref());
1033    let user_turn = String::from("(no user prompt — print-prompt debug mode)");
1034    let provider = acp::config::provider_label(&cli.model);
1035    let (limits, _) = agent_context::ModelContextLimits::resolve(provider, &cli.model, None, None);
1036    let selection = agent_context::SelectionInput {
1037        harness: prompt::default_fragments()
1038            .into_iter()
1039            .map(|fragment| agent_context::HarnessCandidate::new(fragment.name, fragment.content.len()))
1040            .collect(),
1041        user_turn: Some(agent_context::UserTurnCandidate::new(
1042            "print-prompt",
1043            1,
1044            user_turn.len(),
1045        )),
1046        instructions: context_sources
1047            .iter()
1048            .map(|source| agent_context::InstructionCandidate {
1049                path: source.path.clone(),
1050                scope: source.scope.clone(),
1051                content_hash: source.content_hash,
1052                byte_count: source.byte_count,
1053                content: Some(source.content.clone()),
1054                truncated: source.truncated,
1055                applicable: true,
1056            })
1057            .collect(),
1058        ..Default::default()
1059    };
1060    let ledger = agent_context::select_context(&selection, limits);
1061
1062    let bundle = PromptBundle::new_with_skills(
1063        &workspace_root,
1064        &cli.model,
1065        cli.websearch,
1066        &context_sources,
1067        &skill_inventory.skills,
1068        &[],
1069        &user_turn,
1070    )
1071    .with_tool_catalog(tool_catalog)
1072    .with_context_ledger(ledger);
1073
1074    let mut output = render_print_prompt(&bundle);
1075    output.push_str(&render_print_prompt_config(cli, &workspace_root));
1076    print!("{output}");
1077    Ok(())
1078}
1079
1080fn render_print_prompt_config(cli: &Cli, workspace_root: &Path) -> String {
1081    let session_dir = cli
1082        .session_dir
1083        .clone()
1084        .unwrap_or_else(|| session::sessions_dir(workspace_root));
1085    let mut out = String::new();
1086
1087    out.push_str("\n\n=== Effective Config ===\n");
1088    out.push_str(&format!("  provider: {}\n", provider_label(&cli.model)));
1089    out.push_str(&format!("  model: {}\n", cli.model));
1090    out.push_str(&format!("  search: {}\n", cli.websearch.label()));
1091    out.push_str(&format!("  workspace: {}\n", workspace_root.display()));
1092    out.push_str(&format!("  session_dir: {}\n", session_dir.display()));
1093
1094    out.push_str("  files:\n");
1095    if cli.config_layers.is_empty() {
1096        out.push_str("    none\n");
1097    } else {
1098        for layer in &cli.config_layers {
1099            let path = layer.display_path.as_deref().unwrap_or("<unknown>");
1100            let hash = layer.hash.as_deref().unwrap_or("");
1101            out.push_str(&format!("    {} {} {}\n", layer.source.as_str(), path, hash));
1102        }
1103    }
1104
1105    out.push_str("  origins:\n");
1106    if cli.config_origins.is_empty() {
1107        out.push_str("    none\n");
1108    } else {
1109        for (key, origin) in &cli.config_origins {
1110            out.push_str(&format!("    {key}: {}:{}\n", origin.source.as_str(), origin.detail));
1111        }
1112    }
1113
1114    out.push_str("  diagnostics:\n");
1115    if cli.config_diagnostics.is_empty() {
1116        out.push_str("    none");
1117    } else {
1118        for diagnostic in &cli.config_diagnostics {
1119            out.push_str(&format!("    {}\n", redact_secret(diagnostic)));
1120        }
1121    }
1122
1123    out
1124}
1125
1126/// Redact secret-like values from prompt content for debug display.
1127fn redact_secret(text: &str) -> String {
1128    text.replace("sk-", "sk-[REDACTED]")
1129}
1130
1131/// Interactive alternate-screen mode with one application-owned viewport.
1132fn run_inline(tick: Duration, cli: &Cli) -> io::Result<()> {
1133    let mouse_enabled = cli.mouse && !cli.no_mouse;
1134    let _terminal_session = AlternateScreenSession::enter(mouse_enabled)?;
1135    let stdout = io::BufWriter::with_capacity(TERMINAL_WRITE_BUFFER_CAPACITY, io::stdout());
1136    let terminal = Terminal::new(CrosstermBackend::new(stdout))?;
1137    let mut surface = RatatuiSurface::new(terminal);
1138    interactive_loop(&mut surface, tick, cli)
1139}
1140
1141trait InteractiveSurface {
1142    fn draw(&mut self, app: &mut App) -> io::Result<()>;
1143    fn resize(&mut self, width: u16, height: u16) -> io::Result<()>;
1144    fn clear(&mut self) -> io::Result<()>;
1145    fn handle_navigation(&mut self, app: &App, event: &Event) -> bool;
1146}
1147
1148struct RatatuiSurface<W: io::Write> {
1149    terminal: Terminal<CrosstermBackend<W>>,
1150    viewport: AlternateViewport,
1151}
1152
1153impl<W: io::Write> RatatuiSurface<W> {
1154    fn new(terminal: Terminal<CrosstermBackend<W>>) -> Self {
1155        Self { terminal, viewport: AlternateViewport::default() }
1156    }
1157}
1158
1159impl<W: io::Write> InteractiveSurface for RatatuiSurface<W> {
1160    fn draw(&mut self, app: &mut App) -> io::Result<()> {
1161        let projection_started = Instant::now();
1162        renderer::style::set_theme(app.theme);
1163        let area = self.terminal.size()?;
1164        let logical = self
1165            .viewport
1166            .build_frame(app, area.width as usize, area.height as usize);
1167        let projection_elapsed = projection_started.elapsed();
1168        let draw_started = Instant::now();
1169        self.terminal.draw(|frame| render_logical_frame(frame, &logical))?;
1170        tracing::trace!(
1171            projection_us = projection_elapsed.as_micros(),
1172            draw_us = draw_started.elapsed().as_micros(),
1173            width = area.width,
1174            height = area.height,
1175            "ratatui frame timing"
1176        );
1177        Ok(())
1178    }
1179
1180    fn resize(&mut self, width: u16, height: u16) -> io::Result<()> {
1181        self.terminal.resize(Rect::new(0, 0, width, height))?;
1182        Ok(())
1183    }
1184
1185    fn clear(&mut self) -> io::Result<()> {
1186        self.viewport.reset();
1187        self.terminal.clear()
1188    }
1189
1190    fn handle_navigation(&mut self, app: &App, event: &Event) -> bool {
1191        self.viewport.handle_navigation(app, event)
1192    }
1193}
1194
1195/// Renderer-neutral interactive coordinator for application, agent, terminal,
1196/// and render events.
1197fn interactive_loop<S: InteractiveSurface>(surface: &mut S, tick: Duration, cli: &Cli) -> io::Result<()> {
1198    let tick = tick.max(MIN_RENDER_INTERVAL);
1199    let mut app = App::from_cli(cli);
1200    let workspace_root = crate::context::discover_workspace_root(&cli.cwd);
1201    let observability = init_tracing(&workspace_root, &app.session_id);
1202    if cli.verbose
1203        && let Some(obs) = &observability
1204    {
1205        app.transcript
1206            .push(app::Entry::Status { text: format!("logs  {}", obs.session_log_path.display()) });
1207    }
1208    tracing::info!(
1209        session = %app.session_id,
1210        cwd = %workspace_root.display(),
1211        model = %cli.model,
1212        websearch = %cli.websearch.label(),
1213        "starting thndrs (ratatui renderer)"
1214    );
1215    append_daily_log(
1216        &observability,
1217        &app.session_id,
1218        "session_start",
1219        &format!(
1220            "cwd={} model={} websearch={}",
1221            workspace_root.display(),
1222            cli.model,
1223            cli.websearch.label()
1224        ),
1225    );
1226
1227    let mut agent: Option<AgentSlot> = None;
1228    let git_watcher = GitStatusWatcher::spawn(workspace_root);
1229    surface.draw(&mut app)?;
1230    let mut render_dirty = false;
1231
1232    loop {
1233        let deadline = Instant::now() + tick;
1234        while Instant::now() < deadline {
1235            render_dirty |= drain_agent_events(&mut app, &mut agent, surface, &observability)?;
1236            render_dirty |= drain_git_status_watcher(&mut app, &git_watcher, surface)?;
1237            manage_agent_lifecycle(&app, &mut agent);
1238            maybe_spawn_agent(&mut app, &mut agent);
1239            flush_steering(&mut app, &agent);
1240            if render_dirty {
1241                surface.draw(&mut app)?;
1242                render_dirty = false;
1243            }
1244
1245            if app.quit {
1246                tracing::info!("quitting thndrs");
1247                append_daily_log(&observability, &app.session_id, "session_end", "reason=quit");
1248                return Ok(());
1249            }
1250
1251            let remaining = deadline.saturating_duration_since(Instant::now());
1252            if !event::poll(remaining)? {
1253                break;
1254            }
1255            let terminal_event = event::read()?;
1256            if surface.handle_navigation(&app, &terminal_event) {
1257                surface.draw(&mut app)?;
1258                continue;
1259            }
1260            match terminal_event {
1261                Event::Key(key) if key.kind == KeyEventKind::Release => {}
1262                Event::Key(key) => {
1263                    handle_key(&mut app, key, &mut agent, surface)?;
1264                    surface.draw(&mut app)?;
1265                }
1266                Event::Mouse(mouse) => {
1267                    handle_msg(&mut app, Msg::Mouse(mouse), surface)?;
1268                    surface.draw(&mut app)?;
1269                }
1270                Event::Resize(width, height) => {
1271                    surface.resize(width, height)?;
1272                    surface.draw(&mut app)?;
1273                }
1274                Event::Paste(text) => {
1275                    for ch in text.replace("\r\n", "\n").replace('\r', "\n").chars() {
1276                        handle_msg(
1277                            &mut app,
1278                            Msg::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
1279                            surface,
1280                        )?;
1281                    }
1282                    surface.draw(&mut app)?;
1283                }
1284                _ => {}
1285            }
1286
1287            maybe_spawn_agent(&mut app, &mut agent);
1288            flush_steering(&mut app, &agent);
1289
1290            if app.quit {
1291                tracing::info!("quitting thndrs");
1292                append_daily_log(&observability, &app.session_id, "session_end", "reason=quit");
1293                return Ok(());
1294            }
1295        }
1296        handle_msg(&mut app, Msg::Tick, surface)?;
1297        render_dirty |=
1298            app.run_state != RunState::Idle || app.first_run_recovery.is_some() || app.ctrl_d_pending.is_some();
1299        render_dirty |= drain_git_status_watcher(&mut app, &git_watcher, surface)?;
1300        if render_dirty {
1301            surface.draw(&mut app)?;
1302            render_dirty = false;
1303        }
1304        if app.quit {
1305            tracing::info!("quitting thndrs");
1306            append_daily_log(&observability, &app.session_id, "session_end", "reason=quit");
1307            return Ok(());
1308        }
1309    }
1310}
1311
1312/// Process a key through the shared application update path.
1313fn handle_key<S: InteractiveSurface>(
1314    app: &mut App, key: KeyEvent, agent: &mut Option<AgentSlot>, surface: &mut S,
1315) -> io::Result<()> {
1316    if key.code == crossterm::event::KeyCode::Esc
1317        && app.run_state == RunState::Working
1318        && let Some(slot) = agent
1319    {
1320        slot.cancel.cancel();
1321    }
1322    handle_msg(app, Msg::Key(key), surface)
1323}
1324
1325/// Process a message and all pure follow-ups through one application path.
1326fn handle_msg<S: InteractiveSurface>(app: &mut App, msg: Msg, surface: &mut S) -> io::Result<()> {
1327    let mut next = Some(msg);
1328    while let Some(m) = next {
1329        let is_clear = matches!(m, Msg::Clear);
1330        next = update(app, &m);
1331        if is_clear {
1332            surface.clear()?;
1333        }
1334        if app.quit {
1335            return Ok(());
1336        }
1337    }
1338    Ok(())
1339}
1340
1341/// Drain a bounded burst of agent events through the shared update path.
1342fn drain_agent_events<S: InteractiveSurface>(
1343    app: &mut App, agent: &mut Option<AgentSlot>, surface: &mut S, observability: &Option<Observability>,
1344) -> io::Result<bool> {
1345    let Some(slot) = agent else {
1346        return Ok(false);
1347    };
1348    let mut changed = false;
1349
1350    for _ in 0..MAX_AGENT_EVENTS_PER_RENDER {
1351        match slot.receiver.try_recv() {
1352            Ok(event) => {
1353                match &event {
1354                    app::AgentEvent::Failed(msg) => {
1355                        tracing::error!(error = %msg, "agent failed");
1356                        append_daily_log(
1357                            observability,
1358                            &app.session_id,
1359                            "agent_failed",
1360                            &format!("error={}", daily_detail_value(msg)),
1361                        );
1362                    }
1363                    app::AgentEvent::Cancelled => {
1364                        tracing::warn!("agent cancelled");
1365                        append_daily_log(observability, &app.session_id, "agent_cancelled", "");
1366                    }
1367                    app::AgentEvent::Finished => {
1368                        tracing::info!("agent finished");
1369                        append_daily_log(observability, &app.session_id, "agent_finished", "");
1370                    }
1371                    _ => {}
1372                }
1373                handle_msg(app, Msg::Agent(event), surface)?;
1374                changed = true;
1375            }
1376            Err(mpsc::TryRecvError::Empty) => break,
1377            Err(mpsc::TryRecvError::Disconnected) => {
1378                *agent = None;
1379                if app.run_state == RunState::Stopping {
1380                    handle_msg(app, Msg::Agent(app::AgentEvent::Cancelled), surface)?;
1381                    changed = true;
1382                }
1383                break;
1384            }
1385        }
1386    }
1387    Ok(changed)
1388}
1389
1390fn drain_git_status_watcher<S: InteractiveSurface>(
1391    app: &mut App, watcher: &GitStatusWatcher, surface: &mut S,
1392) -> io::Result<bool> {
1393    let mut changed = false;
1394    while let Ok(status) = watcher.receiver.try_recv() {
1395        handle_msg(app, Msg::GitStatusChanged(status), surface)?;
1396        changed = true;
1397    }
1398    Ok(changed)
1399}
1400
1401/// Spawn the unified agent stream if the app is in [`RunState::Working`] state
1402/// and no agent slot exists yet.
1403///
1404/// The run chooses a provider from the selected model id. The
1405/// [`agent::CancelToken`] is retained so `Escape` can signal cooperative
1406/// cancellation.
1407fn maybe_spawn_agent(app: &mut App, agent: &mut Option<AgentSlot>) {
1408    if app.run_state != RunState::Working {
1409        return;
1410    }
1411    if agent.is_some() {
1412        return;
1413    }
1414
1415    let prompt = active_provider_prompt(app);
1416    let cli = app.cli.clone();
1417    let workspace_root = crate::context::discover_workspace_root(&cli.cwd);
1418    let mut config = tools::AgentRunConfig::new(workspace_root, cli.model.clone(), cli.websearch)
1419        .with_search_url(cli.websearch_url.clone())
1420        .with_reasoning(cli.reasoning_effort, cli.reasoning_summary)
1421        .with_process_registry(app.process_registry.clone());
1422    if let Some(acp_name) = acp::config::parse_model_id(&cli.model) {
1423        tracing::info!(
1424            cwd = %config.root.display(),
1425            model = %config.model,
1426            acp_agent = %acp_name,
1427            "spawning ACP agent run"
1428        );
1429        let (steering_tx, _steering_rx) = mpsc::channel();
1430        let mut handle = acp::runner::RunHandle::new(
1431            config.root,
1432            acp_name.to_string(),
1433            cli.acp_agents.get(acp_name).cloned(),
1434            prompt,
1435        );
1436        if let Ok(effective_mcp) = load_effective_mcp_for_workspace(&handle.root) {
1437            handle = handle
1438                .with_mcp_config(effective_mcp.config)
1439                .with_mcp_diagnostics(effective_mcp.diagnostics);
1440        }
1441        let cancel = handle.cancel.clone();
1442        let receiver = handle.spawn();
1443        *agent = Some(AgentSlot { receiver, cancel, steering: steering_tx });
1444        return;
1445    }
1446    let mcp_manager = load_mcp_manager_for_workspace(&config.root).ok();
1447    if let Some(manager) = mcp_manager.clone() {
1448        config = config.with_mcp_manager(manager);
1449    }
1450    tracing::info!(
1451        cwd = %config.root.display(),
1452        model = %config.model,
1453        requested_websearch = %cli.websearch.label(),
1454        search_backend = %config.search_mode.label(),
1455        "spawning agent run"
1456    );
1457
1458    let tool_catalog = tools::runtime_tool_definitions(mcp_manager.as_deref());
1459    let ledger = app.refresh_context_ledger(Some(&prompt));
1460    let turn_id = format!("turn_{}", app.turn_count);
1461    config = config.with_request_context(turn_id, &ledger);
1462    let bundle = PromptBundle::new_with_skills(
1463        &config.root,
1464        &config.model,
1465        config.search_mode,
1466        &app.context_sources,
1467        &app.skills,
1468        &app.transcript,
1469        &prompt,
1470    )
1471    .with_tool_catalog(tool_catalog)
1472    .with_context_ledger(ledger);
1473
1474    if !app.compaction_in_flight() && preflight_requires_auto_compaction(app, &bundle) {
1475        start_auto_compaction(app, prompt);
1476        return;
1477    }
1478
1479    if let Some(ref mut writer) = app.session_writer {
1480        let turn_id = format!("turn_{}", app.turn_count);
1481        if let Some(ledger) = &bundle.context_ledger {
1482            let _ = writer.append_context_ledger(&turn_id, ledger);
1483        }
1484        let metadata = session::PromptMetadata::from_bundle(&bundle);
1485        let _ = writer.append_prompt_metadata(&turn_id, &metadata);
1486    }
1487    let messages = prompt::lower_to_umans_messages(&bundle);
1488    let expects_write = agent::prompt_expects_workspace_write(&prompt);
1489    let (steering_tx, steering_rx) = mpsc::channel();
1490    let turn = harness::HarnessTurn::provider_with_steering(config, messages, expects_write, steering_rx).start();
1491    *agent = Some(AgentSlot { receiver: turn.events, cancel: turn.cancel, steering: steering_tx });
1492}
1493
1494/// Return the prompt for the turn that is about to start.
1495///
1496/// Normal submitted prompts are present in the transcript, but internal turns
1497/// such as compaction must remain invisible there. `last_input` carries the
1498/// exact active provider prompt for both paths; the transcript fallback keeps
1499/// manually assembled application state usable in tests and adapters.
1500fn active_provider_prompt(app: &App) -> String {
1501    app.last_input.clone().unwrap_or_else(|| {
1502        app.transcript
1503            .iter()
1504            .rev()
1505            .find_map(|entry| match entry {
1506                app::Entry::User { text } => Some(text.clone()),
1507                _ => None,
1508            })
1509            .unwrap_or_default()
1510    })
1511}
1512
1513/// Whether the upcoming provider request is oversized and needs auto-compaction.
1514///
1515/// Resolves model limits conservatively (static provider metadata or fallback;
1516/// live metadata loads inside the agent thread), estimates the full prompt
1517/// token cost from the lowered provider messages (which include the rendered
1518/// system prompt as the first message), and runs the pure
1519/// [`agent_context::preflight_auto_compaction`] decision.
1520///
1521/// Returns `false` when auto mode is disabled or the estimate fits the policy.
1522fn preflight_requires_auto_compaction(app: &App, bundle: &PromptBundle) -> bool {
1523    let policy = app.effective_compaction_policy();
1524    if !matches!(policy.mode, agent_context::CompactionMode::Auto) {
1525        return false;
1526    }
1527    let provider = acp::config::provider_label(&app.model);
1528    let (limits, _) = agent_context::ModelContextLimits::resolve(provider, &app.model, None, None);
1529    let messages = prompt::lower_to_umans_messages(bundle);
1530    let bytes = messages.iter().map(|message| message.as_text().len()).sum::<usize>();
1531    let estimate = agent_context::estimate_tokens(bytes) as u64;
1532    matches!(
1533        agent_context::preflight_auto_compaction(policy, &limits, estimate),
1534        agent_context::AutoCompactionDecision::Compact
1535    )
1536}
1537
1538fn flush_steering(app: &mut App, agent: &Option<AgentSlot>) {
1539    let Some(slot) = agent else {
1540        return;
1541    };
1542    let mut unsent = Vec::new();
1543    for message in app.queued_steering.drain(..) {
1544        if slot.steering.send(message.clone()).is_err() {
1545            unsent.push(message);
1546        }
1547    }
1548    app.queued_steering = unsent;
1549}
1550
1551/// Keep the active agent slot aligned with the app lifecycle.
1552///
1553/// `Stopping` is a pending terminal state: keep the receiver alive so the app
1554/// can observe `Cancelled`/`Finished`/`Failed` and transition back to idle.
1555fn manage_agent_lifecycle(app: &App, agent: &mut Option<AgentSlot>) {
1556    match app.run_state {
1557        RunState::Working => {}
1558        RunState::Stopping => {
1559            if let Some(slot) = agent {
1560                slot.cancel.cancel();
1561            }
1562        }
1563        RunState::Idle | RunState::Error(_) => {
1564            if let Some(slot) = agent.take() {
1565                tracing::info!("cancelling dropped agent slot");
1566                slot.cancel.cancel();
1567            }
1568        }
1569    }
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574    use super::*;
1575    use crate::context::ContextSource;
1576    use prompt::PromptBundle;
1577    use std::path::{Path, PathBuf};
1578    use std::process::Command;
1579
1580    fn snapshot_bundle() -> PromptBundle {
1581        let source = ContextSource {
1582            path: PathBuf::from("/repo/AGENTS.md"),
1583            scope: ".".to_string(),
1584            content: "# Project\n\nBuild with cargo. Run tests with cargo test.\n".to_string(),
1585            content_hash: 12345,
1586            truncated: false,
1587            byte_count: 50,
1588        };
1589        PromptBundle {
1590            fragments: prompt::default_fragments(),
1591            environment: prompt::EnvironmentMetadata {
1592                cwd: "/repo".to_string(),
1593                model: "umans-coder".to_string(),
1594                search_mode: WebSearchMode::DuckDuckGo,
1595                date: "2026-06-29".to_string(),
1596            },
1597            project_context: vec![source],
1598            tool_catalog: tools::tool_definitions(),
1599            available_skills: Vec::new(),
1600            transcript_tail: Vec::new(),
1601            user_turn: "explain this repo".to_string(),
1602            history_reuse: prompt::HistoryReuse::Unavailable,
1603            prev_context_hash: None,
1604            context_ledger: None,
1605        }
1606    }
1607
1608    fn git(cwd: &Path, args: &[&str]) {
1609        let output = Command::new("git")
1610            .args(args)
1611            .current_dir(cwd)
1612            .output()
1613            .unwrap_or_else(|err| panic!("git {args:?} failed to start: {err}"));
1614        assert!(
1615            output.status.success(),
1616            "git {args:?} failed: {}",
1617            String::from_utf8_lossy(&output.stderr)
1618        );
1619    }
1620
1621    // FIXME: should be a include_str!
1622    fn fake_agent_fixture() -> PathBuf {
1623        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1624            .join("tests")
1625            .join("fixtures")
1626            .join("fake_acp_agent.py")
1627    }
1628
1629    #[derive(Default)]
1630    struct TestSurface {
1631        clears: usize,
1632        size: (u16, u16),
1633    }
1634
1635    impl InteractiveSurface for TestSurface {
1636        fn draw(&mut self, _app: &mut App) -> io::Result<()> {
1637            Ok(())
1638        }
1639
1640        fn resize(&mut self, width: u16, height: u16) -> io::Result<()> {
1641            self.size = (width, height);
1642            Ok(())
1643        }
1644
1645        fn clear(&mut self) -> io::Result<()> {
1646            self.clears += 1;
1647            Ok(())
1648        }
1649
1650        fn handle_navigation(&mut self, _app: &App, _event: &Event) -> bool {
1651            false
1652        }
1653    }
1654
1655    fn acp_fixture_cli(cwd: &Path, script: &str) -> Cli {
1656        let mut agents = config::AcpAgentsConfig::new();
1657        agents.insert(
1658            "local".to_string(),
1659            config::AcpAgentConfig {
1660                command: "python3".to_string(),
1661                args: vec![fake_agent_fixture().display().to_string(), script.to_string()],
1662                timeout_secs: 2,
1663                ..config::AcpAgentConfig::default()
1664            },
1665        );
1666        Cli { cwd: cwd.to_path_buf(), acp_agents: agents, ..Cli::default() }
1667    }
1668
1669    fn write_registry_fixture(path: &Path, version: &str) {
1670        std::fs::write(
1671            path,
1672            format!(
1673                r#"{{
1674                    "version": "1.0.0",
1675                    "agents": [{{
1676                        "id": "codex-acp",
1677                        "name": "Codex",
1678                        "version": "{version}",
1679                        "description": "ACP adapter",
1680                        "distribution": {{
1681                            "npx": {{
1682                                "package": "@agentclientprotocol/codex-acp@{version}",
1683                                "args": ["--acp"]
1684                            }}
1685                        }}
1686                    }}]
1687                }}"#
1688            ),
1689        )
1690        .expect("write registry");
1691    }
1692
1693    fn write_fake_mcp_server(dir: &Path) -> PathBuf {
1694        let path = dir.join("fake_mcp_cli.py");
1695        std::fs::write(
1696            &path,
1697            r#"#!/usr/bin/env python3
1698import json
1699import sys
1700
1701for line in sys.stdin:
1702    msg = json.loads(line)
1703    method = msg.get("method")
1704    if method == "initialize":
1705        print(json.dumps({
1706            "jsonrpc": "2.0",
1707            "id": msg["id"],
1708            "result": {
1709                "protocolVersion": "2025-06-18",
1710                "capabilities": {"tools": {}},
1711                "serverInfo": {"name": "fake", "version": "0.1.0"}
1712            }
1713        }), flush=True)
1714    elif method == "notifications/initialized":
1715        continue
1716    elif method == "tools/list":
1717        print(json.dumps({
1718            "jsonrpc": "2.0",
1719            "id": msg["id"],
1720            "result": {
1721                "tools": [{
1722                    "name": "echo",
1723                    "description": "Echo text",
1724                    "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}
1725                }]
1726            }
1727        }), flush=True)
1728    elif method == "tools/call":
1729        args = msg.get("params", {}).get("arguments", {})
1730        print(json.dumps({
1731            "jsonrpc": "2.0",
1732            "id": msg["id"],
1733            "result": {"content": [{"type": "text", "text": args.get("text", "")}], "isError": False}
1734        }), flush=True)
1735"#,
1736        )
1737        .expect("write fake mcp server");
1738        path
1739    }
1740
1741    #[test]
1742    fn render_print_prompt_snapshot() {
1743        let bundle = snapshot_bundle();
1744        let output = render_print_prompt(&bundle);
1745        insta::assert_snapshot!(output);
1746    }
1747
1748    #[test]
1749    fn render_print_prompt_redacts_secrets() {
1750        let mut bundle = snapshot_bundle();
1751        bundle.user_turn = "my key is sk-test123".to_string();
1752        let output = render_print_prompt(&bundle);
1753        assert!(
1754            !output.contains("sk-test123"),
1755            "secrets should be redacted in print-prompt output"
1756        );
1757        assert!(output.contains("sk-[REDACTED]"), "redacted marker should appear");
1758    }
1759
1760    #[test]
1761    fn render_print_prompt_includes_all_sections() {
1762        let bundle = snapshot_bundle();
1763        let output = render_print_prompt(&bundle);
1764        assert!(
1765            output.contains("=== System Prompt ==="),
1766            "should have system prompt section"
1767        );
1768        assert!(output.contains("=== Tool Catalog"), "should have tool catalog section");
1769        assert!(
1770            output.contains("=== Lowered Provider Messages"),
1771            "should have messages section"
1772        );
1773        assert!(
1774            output.contains("=== Environment ==="),
1775            "should have environment section"
1776        );
1777    }
1778
1779    #[test]
1780    fn render_print_prompt_config_includes_effective_config_metadata() {
1781        let mut origins = std::collections::BTreeMap::new();
1782        origins.insert(
1783            "model".to_string(),
1784            config::ConfigOrigin { source: config::ConfigSource::CliFlag, detail: "--model".to_string() },
1785        );
1786        let cli = Cli {
1787            model: "umans-glm-5.2".to_string(),
1788            websearch: WebSearchMode::Searxng,
1789            websearch_url: Some("http://127.0.0.1:8080".to_string()),
1790            session_dir: Some(PathBuf::from("/repo/custom-sessions")),
1791            config_layers: vec![config::LoadedConfigLayer {
1792                source: config::ConfigSource::ProjectFile,
1793                config: config::Config::default(),
1794                path: None,
1795                display_path: Some(".thndrs/config.toml".to_string()),
1796                hash: Some("abc123".to_string()),
1797            }],
1798            config_origins: origins,
1799            config_diagnostics: vec!["diagnostic with sk-secret".to_string()],
1800            ..Cli::default()
1801        };
1802
1803        let output = render_print_prompt_config(&cli, Path::new("/repo"));
1804
1805        assert!(output.contains("=== Effective Config ==="));
1806        assert!(output.contains("provider: umans"));
1807        assert!(output.contains("model: umans-glm-5.2"));
1808        assert!(output.contains("search: searxng"));
1809        assert!(output.contains("workspace: /repo"));
1810        assert!(output.contains("session_dir: /repo/custom-sessions"));
1811        assert!(output.contains("project .thndrs/config.toml abc123"));
1812        assert!(output.contains("model: cli:--model"));
1813        assert!(output.contains("sk-[REDACTED]secret"));
1814
1815        insta::assert_snapshot!(output, @r###"
1816
1817
1818=== Effective Config ===
1819  provider: umans
1820  model: umans-glm-5.2
1821  search: searxng
1822  workspace: /repo
1823  session_dir: /repo/custom-sessions
1824  files:
1825    project .thndrs/config.toml abc123
1826  origins:
1827    model: cli:--model
1828  diagnostics:
1829    diagnostic with sk-[REDACTED]secret
1830"###);
1831    }
1832
1833    #[test]
1834    fn render_print_prompt_date_is_redacted() {
1835        let bundle = snapshot_bundle();
1836        let output = render_print_prompt(&bundle);
1837        let env_section = output.split("=== Environment ===").nth(1).unwrap_or("");
1838        assert!(
1839            env_section.contains("date: [date]"),
1840            "date in env section should be redacted to [date] for snapshot stability"
1841        );
1842    }
1843
1844    #[test]
1845    fn redact_secret_replaces_sk_prefix() {
1846        let result = redact_secret("token: sk-abc123 rest");
1847        assert_eq!(result, "token: sk-[REDACTED]abc123 rest");
1848    }
1849
1850    #[test]
1851    fn acp_list_renders_enabled_and_disabled_agents() {
1852        let mut agents = config::AcpAgentsConfig::new();
1853        agents.insert(
1854            "disabled".to_string(),
1855            config::AcpAgentConfig {
1856                command: "agent-off".to_string(),
1857                enabled: false,
1858                ..config::AcpAgentConfig::default()
1859            },
1860        );
1861        agents.insert(
1862            "local".to_string(),
1863            config::AcpAgentConfig {
1864                command: "agent".to_string(),
1865                args: vec!["--acp".to_string()],
1866                ..config::AcpAgentConfig::default()
1867            },
1868        );
1869        let cli = Cli { acp_agents: agents, ..Cli::default() };
1870
1871        let output = render_acp_list(&cli);
1872
1873        assert!(output.contains("disabled\tdisabled\tagent-off"));
1874        assert!(output.contains("local\tenabled\tagent --acp"));
1875    }
1876
1877    #[test]
1878    fn acp_inspect_renders_redacted_config_details() {
1879        let mut agents = config::AcpAgentsConfig::new();
1880        agents.insert(
1881            "local".to_string(),
1882            config::AcpAgentConfig {
1883                command: "agent".to_string(),
1884                args: vec!["--acp".to_string()],
1885                env: std::collections::BTreeMap::from([
1886                    ("TOKEN".to_string(), "secret-value".to_string()),
1887                    ("PLAIN".to_string(), "visible-value".to_string()),
1888                ]),
1889                timeout_secs: 12,
1890                ..config::AcpAgentConfig::default()
1891            },
1892        );
1893        let mut origins = std::collections::BTreeMap::new();
1894        origins.insert(
1895            "acp_agents".to_string(),
1896            config::ConfigOrigin {
1897                source: config::ConfigSource::ProjectFile,
1898                detail: ".thndrs/config.toml".to_string(),
1899            },
1900        );
1901        let cli = Cli { acp_agents: agents, config_origins: origins, ..Cli::default() };
1902
1903        let output = render_acp_inspect(&cli, "local").expect("inspect");
1904
1905        assert!(output.contains("name: local"));
1906        assert!(output.contains("status: enabled"));
1907        assert!(output.contains("command: agent --acp"));
1908        assert!(output.contains("args: --acp"));
1909        assert!(output.contains("env_keys: PLAIN, TOKEN"));
1910        assert!(output.contains("timeout_secs: 12"));
1911        assert!(output.contains("source: project:.thndrs/config.toml"));
1912        assert!(!output.contains("secret-value"));
1913        assert!(!output.contains("visible-value"));
1914    }
1915
1916    #[test]
1917    fn acp_smoke_runs_fake_agent_and_prints_events() {
1918        let temp = tempfile::tempdir().expect("temp dir");
1919        let mut agents = config::AcpAgentsConfig::new();
1920        agents.insert(
1921            "local".to_string(),
1922            config::AcpAgentConfig {
1923                command: "python3".to_string(),
1924                args: vec![fake_agent_fixture().display().to_string(), "lifecycle".to_string()],
1925                timeout_secs: 2,
1926                ..config::AcpAgentConfig::default()
1927            },
1928        );
1929        let cli = Cli { cwd: temp.path().to_path_buf(), acp_agents: agents, ..Cli::default() };
1930        let mut output = Vec::new();
1931
1932        run_acp_smoke(&cli, "local", "ping", &mut output).expect("smoke run");
1933        let output = String::from_utf8(output).expect("utf8");
1934
1935        assert!(output.contains("started"));
1936        assert!(output.contains("status: acp: connected to fake-acp-agent 0.0.0"));
1937        assert!(output.contains("acp_session: local fake-session-1"));
1938        assert!(output.contains("pong from fake ACP agent"));
1939        assert!(output.contains("finished"));
1940    }
1941
1942    #[test]
1943    fn acp_logout_runs_fake_agent_and_prints_result() {
1944        let mut agents = config::AcpAgentsConfig::new();
1945        agents.insert(
1946            "local".to_string(),
1947            config::AcpAgentConfig {
1948                command: "python3".to_string(),
1949                args: vec![fake_agent_fixture().display().to_string(), "auth-success".to_string()],
1950                timeout_secs: 2,
1951                ..config::AcpAgentConfig::default()
1952            },
1953        );
1954        let cli = Cli { acp_agents: agents, ..Cli::default() };
1955        let mut output = Vec::new();
1956
1957        run_acp_logout(&cli, "local", &mut output).expect("logout run");
1958        let output = String::from_utf8(output).expect("utf8");
1959
1960        assert!(output.contains("acp: logged out `local`"));
1961    }
1962
1963    #[test]
1964    fn acp_list_sessions_runs_fake_agent_and_prints_sessions() {
1965        let temp = tempfile::tempdir().expect("temp dir");
1966        let cli = acp_fixture_cli(temp.path(), "sessions");
1967        let mut output = Vec::new();
1968
1969        run_acp_list_sessions(&cli, "local", &mut output).expect("list sessions");
1970        let output = String::from_utf8(output).expect("utf8");
1971        assert!(output.contains("external-session-1"));
1972        assert!(output.contains("Fixture Session"));
1973        assert!(output.contains("2026-07-04T00:00:00Z"));
1974    }
1975
1976    #[test]
1977    fn acp_load_session_runs_fake_agent_and_prints_replay() {
1978        let temp = tempfile::tempdir().expect("temp dir");
1979        let cli = acp_fixture_cli(temp.path(), "sessions");
1980        let mut output = Vec::new();
1981
1982        run_acp_load_session(&cli, "local", "external-session-1", &mut output).expect("load session");
1983        let output = String::from_utf8(output).expect("utf8");
1984        assert!(output.contains("replayed external-session-1"));
1985        assert!(output.contains("loaded: local external-session-1"));
1986    }
1987
1988    #[test]
1989    fn acp_resume_and_close_session_run_fake_agent() {
1990        let temp = tempfile::tempdir().expect("temp dir");
1991        let cli = acp_fixture_cli(temp.path(), "sessions");
1992        let mut resume_output = Vec::new();
1993        let mut close_output = Vec::new();
1994
1995        run_acp_resume_session(&cli, "local", "external-session-1", &mut resume_output).expect("resume session");
1996        run_acp_close_session(&cli, "local", "external-session-1", &mut close_output).expect("close session");
1997        let resume_output = String::from_utf8(resume_output).expect("utf8");
1998        let close_output = String::from_utf8(close_output).expect("utf8");
1999        assert!(resume_output.contains("acp_session: local external-session-1"));
2000        assert!(resume_output.contains("resumed: local external-session-1"));
2001        assert!(close_output.contains("acp: closed `local` session external-session-1"));
2002    }
2003
2004    #[test]
2005    fn session_list_and_latest_print_local_session_summaries() {
2006        let temp = tempfile::tempdir().expect("temp dir");
2007        let session_dir = temp.path().join("sessions");
2008        let mut writer = session::SessionWriter::create(
2009            &session_dir,
2010            "session-new",
2011            "/repo",
2012            "Latest Work",
2013            "umans",
2014            "umans-coder",
2015            "none",
2016            "0.1.0",
2017            None,
2018        )
2019        .expect("create session");
2020        writer.append_usage(7, 11).expect("append usage");
2021
2022        let mut list_output = Vec::new();
2023        let mut latest_output = Vec::new();
2024
2025        run_session_list(&session_dir, &mut list_output).expect("list sessions");
2026        run_session_latest(&session_dir, &mut latest_output).expect("latest session");
2027        let list_output = String::from_utf8(list_output).expect("utf8");
2028        let latest_output = String::from_utf8(latest_output).expect("utf8");
2029
2030        assert!(list_output.contains("session-new"));
2031        assert!(list_output.contains("Latest Work"));
2032        assert!(list_output.contains("umans-coder"));
2033        assert!(list_output.contains("in 7 out 11"));
2034        assert!(latest_output.contains("id: session-new"));
2035        assert!(latest_output.contains("title: Latest Work"));
2036        assert!(latest_output.contains("tokens: in 7 out 11"));
2037    }
2038
2039    #[test]
2040    fn session_titles_prints_titles_newest_first() {
2041        let temp = tempfile::tempdir().expect("temp dir");
2042        let session_dir = temp.path().join("sessions");
2043        session::SessionWriter::create(
2044            &session_dir,
2045            "session-old",
2046            "/repo",
2047            "Old",
2048            "umans",
2049            "umans-coder",
2050            "none",
2051            "0.1.0",
2052            None,
2053        )
2054        .expect("create old session");
2055        std::thread::sleep(Duration::from_millis(5));
2056        session::SessionWriter::create(
2057            &session_dir,
2058            "session-new",
2059            "/repo",
2060            "New",
2061            "umans",
2062            "umans-coder",
2063            "none",
2064            "0.1.0",
2065            None,
2066        )
2067        .expect("create new session");
2068
2069        let mut output = Vec::new();
2070
2071        run_session_titles(&session_dir, &mut output).expect("titles");
2072        let output = String::from_utf8(output).expect("utf8");
2073
2074        assert_eq!(output.lines().collect::<Vec<_>>(), vec!["New", "Old"]);
2075    }
2076
2077    #[test]
2078    fn session_show_prints_replayable_transcript() {
2079        let temp = tempfile::tempdir().expect("temp dir");
2080        let session_dir = temp.path().join("sessions");
2081        let mut writer = session::SessionWriter::create(
2082            &session_dir,
2083            "session-show",
2084            "/repo",
2085            "Show",
2086            "umans",
2087            "umans-coder",
2088            "none",
2089            "0.1.0",
2090            None,
2091        )
2092        .expect("create session");
2093        writer
2094            .append_entry(&app::Entry::User { text: "hello".to_string() }, "turn_1")
2095            .expect("append user");
2096        writer
2097            .append_entry(
2098                &app::Entry::Agent { text: "hi".to_string(), streaming: false },
2099                "turn_1",
2100            )
2101            .expect("append assistant");
2102        let mut output = Vec::new();
2103
2104        run_session_show(&session_dir, "session-show", &mut output).expect("show session");
2105        let output = String::from_utf8(output).expect("utf8");
2106
2107        assert!(output.contains("user: hello"));
2108        assert!(output.contains("assistant: hi"));
2109    }
2110
2111    #[test]
2112    fn session_inspect_and_export_are_redacted_and_sequence_ordered() {
2113        let temp = tempfile::tempdir().expect("temp dir");
2114        let session_dir = temp.path().join("sessions");
2115        let mut writer = session::SessionWriter::create(
2116            &session_dir,
2117            "session-inspect",
2118            "/repo",
2119            "Inspect",
2120            "umans",
2121            "umans-coder",
2122            "none",
2123            "0.1.0",
2124            None,
2125        )
2126        .expect("create session");
2127        writer
2128            .append_entry(
2129                &app::Entry::User { text: "api_key=sk-secretvalue123".to_string() },
2130                "turn_1",
2131            )
2132            .expect("append user");
2133        writer.append_usage(2, 3).expect("append usage");
2134        drop(writer);
2135
2136        let mut inspect = Vec::new();
2137        let mut export = Vec::new();
2138        run_session_inspect(&session_dir, "session-ins", SessionDataFormat::Json, &mut inspect).expect("inspect");
2139        run_session_export(&session_dir, "session-inspect", SessionDataFormat::Jsonl, &mut export).expect("export");
2140        let inspect = String::from_utf8(inspect).expect("utf8");
2141        let export = String::from_utf8(export).expect("utf8");
2142
2143        assert!(inspect.contains("\"input_tokens\": 2"));
2144        assert!(inspect.contains("api_key=[REDACTED]"));
2145        assert!(!inspect.contains("sk-secretvalue123"));
2146        let lines = export.lines().collect::<Vec<_>>();
2147        assert_eq!(lines.len(), 3);
2148        assert!(lines[0].contains("session_meta"));
2149        assert!(lines[1].contains("user"));
2150        assert!(lines[2].contains("usage"));
2151    }
2152
2153    #[test]
2154    fn debug_session_log_reads_a_bounded_redacted_tail() {
2155        let temp = tempfile::tempdir().expect("temp dir");
2156        let sessions_dir = session::sessions_dir(temp.path());
2157        let writer = session::SessionWriter::create(
2158            &sessions_dir,
2159            "session-log",
2160            "/repo",
2161            "Log",
2162            "umans",
2163            "umans-coder",
2164            "none",
2165            "0.1.0",
2166            None,
2167        )
2168        .expect("create session");
2169        drop(writer);
2170        let log_dir = temp.path().join(".thndrs").join("logs").join("sessions");
2171        std::fs::create_dir_all(&log_dir).expect("create log dir");
2172        std::fs::write(
2173            log_dir.join("thndrs-session-log.log"),
2174            "first\napi_key=sk-secretvalue123\nlast\n",
2175        )
2176        .expect("write log");
2177
2178        let mut output = Vec::new();
2179        run_debug_session_log(temp.path(), "session-l", 2, &mut output).expect("read log");
2180        let output = String::from_utf8(output).expect("utf8");
2181
2182        assert!(!output.contains("first"));
2183        assert!(output.contains("api_key=[REDACTED]"));
2184        assert!(!output.contains("sk-secretvalue123"));
2185        assert!(output.contains("last"));
2186    }
2187
2188    #[test]
2189    fn acp_registry_reads_file_and_prints_review_gate() {
2190        let temp = tempfile::tempdir().expect("temp dir");
2191        let registry_path = temp.path().join("registry.json");
2192        std::fs::write(
2193            &registry_path,
2194            r#"{
2195                "version": "1.0.0",
2196                "agents": [{
2197                    "id": "codex-acp",
2198                    "name": "Codex",
2199                    "version": "1.1.0",
2200                    "description": "ACP adapter for OpenAI's coding assistant",
2201                    "repository": "https://github.com/agentclientprotocol/codex-acp",
2202                    "distribution": {
2203                        "npx": {
2204                            "package": "@agentclientprotocol/codex-acp@1.1.0",
2205                            "env": {"OPENAI_API_KEY": "sk-secret"}
2206                        }
2207                    }
2208                }]
2209            }"#,
2210        )
2211        .expect("write registry");
2212        let mut output = Vec::new();
2213
2214        run_acp_registry(Some(&registry_path), &mut output).expect("registry");
2215        let output = String::from_utf8(output).expect("utf8");
2216
2217        assert!(output.contains("ACP registry v1.0.0"));
2218        assert!(output.contains("codex-acp\tCodex\t1.1.0\tnpx:@agentclientprotocol/codex-acp@1.1.0"));
2219        assert!(output.contains("install/update: use `thndrs acp install"));
2220        assert!(!output.contains("OPENAI_API_KEY"));
2221        assert!(!output.contains("sk-secret"));
2222    }
2223
2224    #[test]
2225    fn acp_install_and_update_registry_agent() {
2226        let temp = tempfile::tempdir().expect("temp dir");
2227        let registry_path = temp.path().join("registry.json");
2228        write_registry_fixture(&registry_path, "1.1.0");
2229        let cli = Cli { cwd: temp.path().to_path_buf(), ..Cli::default() };
2230        let mut install_output = Vec::new();
2231
2232        run_acp_install(
2233            &cli,
2234            "codex-acp",
2235            Some("codex".to_string()),
2236            Some(&registry_path),
2237            true,
2238            &mut install_output,
2239        )
2240        .expect("install");
2241        let install_output = String::from_utf8(install_output).expect("utf8");
2242
2243        assert!(install_output.contains("installed: codex codex-acp 1.1.0"));
2244        assert!(install_output.contains("model: acp:codex"));
2245
2246        write_registry_fixture(&registry_path, "1.2.0");
2247        let mut update_output = Vec::new();
2248        run_acp_update(&cli, "codex", Some(&registry_path), true, &mut update_output).expect("update");
2249        let update_output = String::from_utf8(update_output).expect("utf8");
2250
2251        assert!(update_output.contains("updated: codex codex-acp 1.2.0"));
2252        let config = std::fs::read_to_string(temp.path().join(".thndrs/config.toml")).expect("config");
2253        assert!(config.contains("@agentclientprotocol/codex-acp@1.2.0"));
2254    }
2255
2256    #[test]
2257    fn mcp_list_tools_and_call_use_fake_server() {
2258        let tmp = tempfile::tempdir().expect("tempdir");
2259        let workspace = tmp.path().join("workspace");
2260        std::fs::create_dir_all(workspace.join(".thndrs")).expect("create config dir");
2261        let script = write_fake_mcp_server(tmp.path());
2262        std::fs::write(
2263            workspace.join(".thndrs").join("mcp.toml"),
2264            format!(
2265                r#"
2266                [servers.docs]
2267                command = "python3"
2268                args = [{:?}]
2269                timeout_secs = 5
2270                "#,
2271                script.display().to_string()
2272            ),
2273        )
2274        .expect("write mcp config");
2275        let cli = Cli { cwd: workspace, ..Cli::default() };
2276
2277        let mut list_output = Vec::new();
2278        run_mcp_list(&cli, &mut list_output).expect("list mcp");
2279        let list = String::from_utf8(list_output).expect("utf8");
2280        assert!(list.contains("docs"));
2281        assert!(list.contains("enabled"));
2282
2283        let mut tools_output = Vec::new();
2284        run_mcp_tools(&cli, "docs", &mut tools_output).expect("tools mcp");
2285        let tools = String::from_utf8(tools_output).expect("utf8");
2286        assert!(tools.contains("mcp__docs__echo"));
2287
2288        let mut call_output = Vec::new();
2289        run_mcp_call(&cli, "docs", "echo", r#"{"text":"hello"}"#, &mut call_output).expect("call mcp");
2290        let call = String::from_utf8(call_output).expect("utf8");
2291        assert!(call.contains("hello"));
2292    }
2293
2294    #[test]
2295    fn clear_resets_application_and_render_surface() {
2296        let cli = Cli::default();
2297        let mut app = App::from_cli(&cli);
2298        app.session_writer = None;
2299        app.transcript.push(app::Entry::User { text: "hello".to_string() });
2300
2301        let mut surface = TestSurface::default();
2302        handle_msg(&mut app, Msg::Clear, &mut surface).expect("clear");
2303        assert!(app.transcript.is_empty());
2304        assert_eq!(surface.clears, 1);
2305    }
2306
2307    #[test]
2308    fn flush_steering_sends_queued_messages_to_active_agent() {
2309        let cli = Cli::default();
2310        let mut app = App::from_cli(&cli);
2311        app.session_writer = None;
2312        app.run_state = RunState::Working;
2313        app.queued_steering.push("use the failing test first".to_string());
2314        let (event_tx, event_rx) = mpsc::channel();
2315        drop(event_tx);
2316        let (steering_tx, steering_rx) = mpsc::channel();
2317        let slot = AgentSlot { receiver: event_rx, cancel: CancelToken::new(), steering: steering_tx };
2318
2319        flush_steering(&mut app, &Some(slot));
2320
2321        assert!(
2322            app.queued_steering.is_empty(),
2323            "sent steering should leave the app queue"
2324        );
2325        assert_eq!(
2326            steering_rx.try_recv().expect("active run should receive steering"),
2327            "use the failing test first"
2328        );
2329    }
2330
2331    #[test]
2332    fn manage_agent_lifecycle_keeps_stopping_slot_until_terminal_event() {
2333        let cli = Cli::default();
2334        let mut app = App::from_cli(&cli);
2335        app.session_writer = None;
2336        app.run_state = RunState::Stopping;
2337        let (_event_tx, event_rx) = mpsc::channel();
2338        let (steering_tx, _steering_rx) = mpsc::channel();
2339        let cancel = CancelToken::new();
2340        let mut agent = Some(AgentSlot { receiver: event_rx, cancel: cancel.clone(), steering: steering_tx });
2341
2342        manage_agent_lifecycle(&app, &mut agent);
2343
2344        assert!(agent.is_some(), "stopping should keep the receiver for terminal events");
2345        assert!(
2346            cancel.is_cancelled(),
2347            "stopping should still signal cooperative cancellation"
2348        );
2349    }
2350
2351    #[test]
2352    fn disconnected_stopping_agent_returns_app_to_idle() {
2353        let cli = Cli::default();
2354        let mut app = App::from_cli(&cli);
2355        app.session_writer = None;
2356        app.run_state = RunState::Stopping;
2357        let (event_tx, event_rx) = mpsc::channel();
2358        drop(event_tx);
2359        let (steering_tx, _steering_rx) = mpsc::channel();
2360        let mut agent = Some(AgentSlot { receiver: event_rx, cancel: CancelToken::new(), steering: steering_tx });
2361        let mut surface = TestSurface::default();
2362        drain_agent_events(&mut app, &mut agent, &mut surface, &None).expect("drain events");
2363
2364        assert!(agent.is_none(), "disconnected slot should be cleared");
2365        assert_eq!(app.run_state, RunState::Idle);
2366    }
2367
2368    #[test]
2369    fn drain_agent_events_limits_each_render_batch() {
2370        let cli = Cli::default();
2371        let mut app = App::from_cli(&cli);
2372        app.session_writer = None;
2373        let (event_tx, event_rx) = mpsc::channel();
2374        for index in 0..=MAX_AGENT_EVENTS_PER_RENDER {
2375            event_tx
2376                .send(app::AgentEvent::Status(format!("event {index}")))
2377                .expect("queue event");
2378        }
2379        let (steering_tx, _steering_rx) = mpsc::channel();
2380        let mut agent = Some(AgentSlot { receiver: event_rx, cancel: CancelToken::new(), steering: steering_tx });
2381        let mut surface = TestSurface::default();
2382        let changed = drain_agent_events(&mut app, &mut agent, &mut surface, &None).expect("drain events");
2383
2384        assert!(changed);
2385        assert!(matches!(
2386            agent.as_ref().expect("agent remains active").receiver.try_recv(),
2387            Ok(app::AgentEvent::Status(text)) if text == format!("event {MAX_AGENT_EVENTS_PER_RENDER}")
2388        ));
2389    }
2390
2391    #[test]
2392    fn maybe_spawn_agent_uses_current_app_model_after_picker_switch() {
2393        let cli = Cli::default();
2394        let mut app = App::from_cli(&cli);
2395        app.session_writer = None;
2396        app.model = "fake-agent".to_string();
2397        app.cli.model = app.model.clone();
2398        app.run_state = RunState::Working;
2399        app.transcript
2400            .push(app::Entry::User { text: "hello with switched model".to_string() });
2401
2402        let mut agent = None;
2403        maybe_spawn_agent(&mut app, &mut agent);
2404        let slot = agent.as_ref().expect("agent spawned");
2405
2406        let first_model_event = loop {
2407            let event = slot
2408                .receiver
2409                .recv_timeout(std::time::Duration::from_secs(2))
2410                .expect("agent event");
2411            if !matches!(event, app::AgentEvent::Started) {
2412                break event;
2413            }
2414        };
2415        slot.cancel.cancel();
2416
2417        assert!(
2418            matches!(first_model_event, app::AgentEvent::ReasoningDelta(_)),
2419            "switched fake model should run fake provider, got {first_model_event:?}"
2420        );
2421    }
2422
2423    #[test]
2424    fn active_provider_prompt_prefers_an_internal_turn_over_visible_history() {
2425        let cli = Cli::default();
2426        let mut app = App::from_cli(&cli);
2427        app.session_writer = None;
2428        app.transcript
2429            .push(app::Entry::User { text: "visible user turn".to_string() });
2430        app.last_input = Some("Summarize the active context.".to_string());
2431
2432        assert_eq!(active_provider_prompt(&app), "Summarize the active context.");
2433    }
2434
2435    #[test]
2436    fn maybe_spawn_agent_auto_compacts_oversized_turn_instead_of_spawning() {
2437        let workspace = tempfile::tempdir().expect("create authenticated workspace");
2438        crate::thndrs_core::auth::set_credential(
2439            &crate::thndrs_core::auth::project_credentials_path(workspace.path()),
2440            crate::thndrs_core::auth::UMANS_API_KEY_ENV,
2441            "test-umans-key",
2442        )
2443        .expect("seed test credential");
2444        let mut config = config::Config::default();
2445        config.context.compaction.mode = agent_context::CompactionMode::Auto;
2446        let cli = Cli {
2447            cwd: workspace.path().to_path_buf(),
2448            model: "fake-agent".to_string(),
2449            config_layers: vec![config::LoadedConfigLayer {
2450                source: config::ConfigSource::ProjectFile,
2451                config,
2452                path: None,
2453                display_path: None,
2454                hash: None,
2455            }],
2456            ..Cli::default()
2457        };
2458        let mut app = App::from_cli(&cli);
2459        app.session_writer = None;
2460        app.run_state = RunState::Working;
2461        let big = "x".repeat(5_000);
2462        for _ in 0..20 {
2463            app.transcript.push(app::Entry::User { text: big.clone() });
2464            app.transcript
2465                .push(app::Entry::Agent { text: big.clone(), streaming: false });
2466        }
2467        app.transcript
2468            .push(app::Entry::User { text: "final oversized turn".to_string() });
2469
2470        let mut agent = None;
2471        maybe_spawn_agent(&mut app, &mut agent);
2472
2473        assert!(
2474            agent.is_none(),
2475            "the known-oversized request must never be sent to the main provider"
2476        );
2477        assert!(
2478            app.compaction_in_flight(),
2479            "auto-compaction should be triggered instead"
2480        );
2481        assert!(
2482            app.transcript
2483                .iter()
2484                .all(|entry| !matches!(entry, app::Entry::User { text } if text.contains("Summarize"))),
2485            "the internal compaction prompt must stay out of the visible transcript"
2486        );
2487        assert!(
2488            app.last_input
2489                .as_deref()
2490                .is_some_and(|prompt| prompt.contains("Summarize")),
2491            "the internal compaction prompt should remain active for the provider"
2492        );
2493    }
2494
2495    #[test]
2496    fn maybe_spawn_agent_does_not_auto_compact_when_mode_is_manual() {
2497        let mut config = config::Config::default();
2498        config.context.compaction.mode = agent_context::CompactionMode::Manual;
2499        let cli = Cli {
2500            model: "fake-agent".to_string(),
2501            config_layers: vec![config::LoadedConfigLayer {
2502                source: config::ConfigSource::ProjectFile,
2503                config,
2504                path: None,
2505                display_path: None,
2506                hash: None,
2507            }],
2508            ..Cli::default()
2509        };
2510        let mut app = App::from_cli(&cli);
2511        app.session_writer = None;
2512        app.run_state = RunState::Working;
2513        let big = "x".repeat(5_000);
2514        for _ in 0..20 {
2515            app.transcript.push(app::Entry::User { text: big.clone() });
2516            app.transcript
2517                .push(app::Entry::Agent { text: big.clone(), streaming: false });
2518        }
2519        app.transcript
2520            .push(app::Entry::User { text: "final oversized turn".to_string() });
2521
2522        let mut agent = None;
2523        maybe_spawn_agent(&mut app, &mut agent);
2524
2525        assert!(!app.compaction_in_flight(), "manual mode must not auto-compact");
2526        assert!(agent.is_some(), "manual mode sends the request to the provider");
2527        if let Some(slot) = agent {
2528            slot.cancel.cancel();
2529        }
2530    }
2531
2532    #[test]
2533    fn maybe_spawn_agent_does_not_run_preflight_while_agent_in_flight() {
2534        let mut config = config::Config::default();
2535        config.context.compaction.mode = agent_context::CompactionMode::Auto;
2536        let cli = Cli {
2537            model: "fake-agent".to_string(),
2538            config_layers: vec![config::LoadedConfigLayer {
2539                source: config::ConfigSource::ProjectFile,
2540                config,
2541                path: None,
2542                display_path: None,
2543                hash: None,
2544            }],
2545            ..Cli::default()
2546        };
2547        let mut app = App::from_cli(&cli);
2548        app.session_writer = None;
2549        app.run_state = RunState::Working;
2550        let big = "x".repeat(5_000);
2551        for _ in 0..20 {
2552            app.transcript.push(app::Entry::User { text: big.clone() });
2553            app.transcript
2554                .push(app::Entry::Agent { text: big.clone(), streaming: false });
2555        }
2556        app.transcript
2557            .push(app::Entry::User { text: "in-flight turn".to_string() });
2558
2559        let (steering_tx, _steering_rx) = mpsc::channel();
2560        let existing = AgentSlot { receiver: mpsc::channel().1, cancel: CancelToken::new(), steering: steering_tx };
2561        let mut agent = Some(existing);
2562        maybe_spawn_agent(&mut app, &mut agent);
2563
2564        assert!(
2565            !app.compaction_in_flight(),
2566            "in-flight requests must never be interrupted for compaction"
2567        );
2568        assert!(agent.is_some(), "the existing agent slot must be preserved");
2569    }
2570
2571    #[test]
2572    fn resize_event_dimensions_drive_the_full_viewport_repaint() {
2573        let mut surface = TestSurface::default();
2574        surface.resize(100, 30).expect("resize");
2575        assert_eq!(surface.size, (100, 30));
2576    }
2577
2578    #[test]
2579    fn git_status_watcher_reports_external_change() {
2580        let dir = tempfile::tempdir().expect("temp git dir");
2581        git(dir.path(), &["init"]);
2582        git(dir.path(), &["config", "user.email", "test@example.com"]);
2583        git(dir.path(), &["config", "user.name", "Test User"]);
2584        std::fs::write(dir.path().join("tracked.txt"), "clean\n").expect("write tracked file");
2585        git(dir.path(), &["add", "tracked.txt"]);
2586        git(dir.path(), &["commit", "-m", "initial"]);
2587
2588        let watcher = GitStatusWatcher::spawn_with_interval(dir.path().to_path_buf(), Duration::from_millis(50));
2589        watcher.wait_until_initialized();
2590        std::fs::write(dir.path().join("tracked.txt"), "dirty\n").expect("modify tracked file");
2591
2592        let status = watcher
2593            .receiver
2594            .recv_timeout(Duration::from_secs(2))
2595            .expect("watcher should report dirty git status")
2596            .expect("repo status should be available");
2597        assert_eq!(status.modified, 1);
2598        assert!(
2599            status.display().ends_with("+0 ~1 -0"),
2600            "dirty summary should show one modified file: {}",
2601            status.display()
2602        );
2603    }
2604}