1pub 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
85const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(MIN_TICK_RATE_MS);
87const MAX_AGENT_EVENTS_PER_RENDER: usize = 256;
89const TERMINAL_WRITE_BUFFER_CAPACITY: usize = 64 * 1024;
92
93enum AcpEventWrite {
94 Continue,
95 Finished,
96 Cancelled,
97 Failed(String),
98}
99
100struct 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
163pub 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
178pub 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
643fn 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
1025fn 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
1126fn redact_secret(text: &str) -> String {
1128 text.replace("sk-", "sk-[REDACTED]")
1129}
1130
1131fn 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
1195fn 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
1312fn 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
1325fn 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
1341fn 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
1401fn 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
1494fn 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
1513fn 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
1551fn 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 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 ®istry_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(®istry_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(®istry_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(®istry_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(®istry_path, "1.2.0");
2247 let mut update_output = Vec::new();
2248 run_acp_update(&cli, "codex", Some(®istry_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}