diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 29f09b3d20..53280f5609 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -2425,7 +2425,7 @@ Parse error: permissions.toml at permissions.toml could not be parsed: expected ); assert_eq!( app.transcript_spacing, - crate::tui::app::TranscriptSpacing::Comfortable + crate::tui::app::TranscriptSpacing::Compact ); // Evidence preserved: thinking is not hidden by the preset. assert!(app.show_thinking, "calm preset must not hide thinking"); diff --git a/crates/tui/src/core/engine/lsp_hooks.rs b/crates/tui/src/core/engine/lsp_hooks.rs index 593c72f6bd..58b1c5e936 100644 --- a/crates/tui/src/core/engine/lsp_hooks.rs +++ b/crates/tui/src/core/engine/lsp_hooks.rs @@ -53,6 +53,8 @@ impl Engine { return; } let paths = edited_paths_for_tool(tool_name, tool_input); + let mut found = 0usize; + let mut files = 0usize; for path in paths { let absolute = if path.is_absolute() { path.clone() @@ -64,9 +66,21 @@ impl Engine { // batch by sequence. let seq = self.turn_counter; if let Some(block) = self.lsp_manager.diagnostics_for(&absolute, seq).await { + found = found.saturating_add(block.items.len()); + files = files.saturating_add(1); self.pending_lsp_blocks.push(block); } } + if found > 0 { + let _ = self + .tx_event + .send(Event::LspRepairUpdate { + diagnostics_found: found, + files, + injected: false, + }) + .await; + } } /// Drain `pending_lsp_blocks` into a single synthetic user message so the @@ -79,6 +93,8 @@ impl Engine { return; } let blocks = std::mem::take(&mut self.pending_lsp_blocks); + let found: usize = blocks.iter().map(|b| b.items.len()).sum(); + let files = blocks.len(); let rendered = crate::lsp::render_blocks(&blocks); if rendered.is_empty() { return; @@ -88,5 +104,13 @@ impl Engine { crate::core::ops::UserInputProvenance::Runtime, )) .await; + let _ = self + .tx_event + .send(Event::LspRepairUpdate { + diagnostics_found: found, + files, + injected: true, + }) + .await; } } diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 56c03c31c2..e65cdceda6 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -260,6 +260,14 @@ pub enum Event { blocked_write: bool, }, + /// Observable LSP repair-loop update for the Turn Inspector (#4107). + /// Carries only summary counts/state — never raw prompt internals. + LspRepairUpdate { + diagnostics_found: usize, + files: usize, + injected: bool, + }, + // === Prefix-Cache Stability Events === /// The prefix (system prompt + tool specs) changed between turns, /// which invalidates DeepSeek's KV prefix cache. Carries diagnostics diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 5fafd3499c..319c89e8a6 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -373,24 +373,25 @@ impl Default for Settings { // making long-session continuity the default runtime behavior. auto_compact: false, auto_compact_threshold_percent: 80.0, - calm_mode: false, + // #4095: default presentation is compact/calm; verbose detail is opt-in. + calm_mode: true, tool_collapse_mode: "compact".to_string(), - low_motion: false, - fancy_animations: true, + low_motion: true, + fancy_animations: false, bracketed_paste: true, paste_burst_detection: true, mention_menu_limit: 128, mention_walk_depth: 10, mention_menu_behavior: "fuzzy".to_string(), show_thinking: true, - show_tool_details: true, + show_tool_details: false, locale: "auto".to_string(), theme: "system".to_string(), background_color: None, composer_density: "comfortable".to_string(), composer_border: true, composer_vim_mode: "normal".to_string(), - transcript_spacing: "comfortable".to_string(), + transcript_spacing: "compact".to_string(), default_mode: "agent".to_string(), sidebar_width_percent: 28, sidebar_focus: "pinned".to_string(), @@ -421,7 +422,7 @@ impl Default for Settings { pub const CALM_PRESET_FIELDS: &[(&str, &str)] = &[ ("calm_mode", "true"), ("tool_collapse", "calm"), - ("transcript_spacing", "comfortable"), + ("transcript_spacing", "compact"), ("low_motion", "true"), ("fancy_animations", "false"), ("show_tool_details", "false"), @@ -1522,11 +1523,22 @@ fn env_truthy(name: &str) -> bool { mod tests { use super::*; + /// Explicit animated baseline for env-force tests (#4095 flipped defaults to calm). + fn animated_settings() -> Settings { + let mut s = Settings::default(); + s.calm_mode = false; + s.low_motion = false; + s.fancy_animations = true; + s.show_tool_details = true; + s.transcript_spacing = "comfortable".to_string(); + s + } + #[test] fn apply_preset_calm_sets_bundle_and_preserves_evidence() { let mut settings = Settings::default(); - // Defaults are the debug-visible posture. - assert!(!settings.calm_mode); + // Defaults are already the calm/compact posture (#4095). + assert!(settings.calm_mode); assert!(settings.show_thinking); let changed = settings.apply_preset("CALM").expect("calm preset applies"); @@ -1540,7 +1552,7 @@ mod tests { assert!(settings.calm_mode); assert_eq!(settings.tool_collapse_mode, "calm"); - assert_eq!(settings.transcript_spacing, "comfortable"); + assert_eq!(settings.transcript_spacing, "compact"); assert!(settings.low_motion); assert!(!settings.fancy_animations); assert!(!settings.show_tool_details); @@ -1552,6 +1564,19 @@ mod tests { ); } + #[test] + fn default_settings_are_compact_presentation() { + let settings = Settings::default(); + assert!(settings.calm_mode); + assert!(!settings.show_tool_details); + assert!(settings.low_motion); + assert!(!settings.fancy_animations); + assert_eq!(settings.transcript_spacing, "compact"); + assert_eq!(settings.tool_collapse_mode, "compact"); + // Thinking stays visible — compact is not "hide evidence". + assert!(settings.show_thinking); + } + #[test] fn apply_preset_rejects_unknown_name() { let mut settings = Settings::default(); @@ -1595,7 +1620,10 @@ mod tests { #[test] fn default_settings_show_footer_water_strip() { let settings = Settings::default(); - assert!(settings.fancy_animations); + assert!( + !settings.fancy_animations, + "default presentation is calm (#4095)" + ); } #[test] @@ -1901,7 +1929,7 @@ mod tests { unsafe { std::env::set_var("NO_ANIMATIONS", "1"); } - let mut settings = Settings::default(); + let mut settings = animated_settings(); assert!(!settings.low_motion, "default is animated"); assert!(settings.fancy_animations, "default shows the water strip"); settings.apply_env_overrides(); @@ -1980,7 +2008,7 @@ mod tests { unsafe { std::env::set_var("NO_ANIMATIONS", truthy); } - let mut s = Settings::default(); + let mut s = animated_settings(); s.apply_env_overrides(); assert!(s.low_motion, "{truthy:?} should be truthy"); } @@ -1989,7 +2017,7 @@ mod tests { unsafe { std::env::set_var("NO_ANIMATIONS", falsy); } - let mut s = Settings::default(); + let mut s = animated_settings(); s.apply_env_overrides(); assert!(!s.low_motion, "{falsy:?} should be falsy"); } @@ -2038,7 +2066,7 @@ mod tests { /// Serialise tests that mutate `TERM_PROGRAM` through this guard. /// Uses the process-wide test env lock so this serializes not just /// with itself but with every other env-mutating test in the suite - /// — otherwise a concurrent test that calls `Settings::default()` + /// — otherwise a concurrent test that calls `animated_settings()` /// can read whatever value our two `set_var`s have raced into the /// env at that instant. fn term_program_test_guard() -> std::sync::MutexGuard<'static, ()> { @@ -2053,7 +2081,7 @@ mod tests { unsafe { std::env::set_var("TERM_PROGRAM", "vscode"); } - let mut settings = Settings::default(); + let mut settings = animated_settings(); assert!(!settings.low_motion, "default is animated"); settings.apply_env_overrides(); assert!( @@ -2081,7 +2109,7 @@ mod tests { unsafe { std::env::set_var("TERM_PROGRAM", "Ghostty"); } - let mut settings = Settings::default(); + let mut settings = animated_settings(); assert!(!settings.low_motion, "default is animated"); settings.apply_env_overrides(); assert!( @@ -2163,7 +2191,7 @@ mod tests { unsafe { std::env::set_var("TERM_PROGRAM", program); } - let mut s = Settings::default(); + let mut s = animated_settings(); s.apply_env_overrides(); assert!( !s.low_motion, @@ -2219,7 +2247,7 @@ mod tests { std::env::remove_var("TERMINATOR_UUID"); std::env::set_var(var, val); } - let mut settings = Settings::default(); + let mut settings = animated_settings(); assert!(!settings.low_motion, "default is animated"); settings.apply_env_overrides(); assert!( @@ -2257,7 +2285,7 @@ mod tests { unsafe { std::env::set_var("TERM_PROGRAM", "Termius"); } - let mut settings = Settings::default(); + let mut settings = animated_settings(); assert!(!settings.low_motion, "default is animated"); settings.apply_env_overrides(); assert!( @@ -2345,7 +2373,7 @@ mod tests { } } - let mut settings = Settings::default(); + let mut settings = animated_settings(); assert!(!settings.low_motion, "default is animated"); assert!(settings.fancy_animations, "default shows the water strip"); assert_eq!(settings.synchronized_output, "auto"); @@ -2451,7 +2479,7 @@ mod tests { } std::env::set_var(var, val); } - let mut settings = Settings::default(); + let mut settings = animated_settings(); assert!(!settings.low_motion, "default is animated"); assert!(settings.fancy_animations, "default shows the water strip"); settings.apply_env_overrides(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index d60e4567e6..f3b0b01281 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -381,6 +381,30 @@ pub struct AgentProgressMeta { pub spawn_depth: u32, } +/// Per-turn LSP repair-loop summary for the Turn Inspector (#4107). +/// Observable state only — no raw diagnostic text or prompt internals. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LspRepairState { + pub diagnostics_found: usize, + pub files_touched: usize, + pub injected: bool, + pub repair_attempted: bool, + /// "resolved" | "still_failing" | "unknown" | "unavailable" + pub latest: &'static str, +} + +impl Default for LspRepairState { + fn default() -> Self { + Self { + diagnostics_found: 0, + files_touched: 0, + injected: false, + repair_attempted: false, + latest: "unavailable", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ComposerDensity { Compact, @@ -2200,6 +2224,8 @@ pub struct App { /// Whether LSP diagnostics are currently enabled. Mirrors the config file /// `[lsp].enabled` setting. Toggled at runtime via `/lsp on|off`. pub lsp_enabled: bool, + /// Current-turn LSP repair-loop summary for Ctrl-O Turn Inspector (#4107). + pub lsp_repair: LspRepairState, /// Derived title for the current session shown in the composer border. /// Updated when `EngineEvent::SessionUpdated` fires or a saved session is loaded. pub session_title: Option, @@ -2944,6 +2970,7 @@ impl App { collapsed_cell_map: Vec::new(), edit_in_progress: false, lsp_enabled: config.lsp.as_ref().and_then(|l| l.enabled).unwrap_or(true), + lsp_repair: LspRepairState::default(), composer_arrows_scroll: config .tui .as_ref() diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index e8ae983715..be4754ea01 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -1319,19 +1319,16 @@ impl GenericToolCell { } // Sub-agent launch already gets a dedicated `DelegateCard` - // that owns the live action tree, status, and final summary. The - // generic tool block for the same call duplicates that signal at - // 3-4 lines per spawn — N parallel spawns multiply the noise. In - // live mode, render one compact summary line and let the - // DelegateCard be the source of truth. Transcript mode keeps the - // full block for spawns so session replay remains complete, but - // inspection calls (peek/status/wait) stay compact there too — a - // full projection dump per check is exactly the dogfood-A5 noise - // and carries no replay value (#4112). - if self.name == "agent" - && (matches!(mode, RenderMode::Live) || agent_activity::is_agent_inspection(self)) - { - return agent_activity::render_agent_compact(self, low_motion); + // that owns the live action tree, status, and final summary (#4133). + // Spawns therefore render nothing here in either mode — one visible + // artifact per delegated unit. Inspection/join calls (peek/status/ + // wait) stay as a single compact line (#4112 dogfood A5). + if self.name == "agent" { + if agent_activity::is_agent_inspection(self) { + return agent_activity::render_agent_compact(self, low_motion); + } + // Spawn / start / run: suppress the generic tool card entirely. + return Vec::new(); } // A call to a tool that doesn't exist carries exactly one useful diff --git a/crates/tui/src/tui/history/tests.rs b/crates/tui/src/tui/history/tests.rs index e910cc5c07..471efec6c8 100644 --- a/crates/tui/src/tui/history/tests.rs +++ b/crates/tui/src/tui/history/tests.rs @@ -347,7 +347,8 @@ fn extract_agent_id_returns_none_for_empty_id() { } #[test] -fn agent_renders_single_compact_line_in_live_mode() { +fn agent_spawn_suppresses_generic_card_in_live_mode() { + // #4133: spawn cards yield entirely to DelegateCard — no generic tool row. let cell = GenericToolCell { name: "agent".to_string(), status: ToolStatus::Running, @@ -362,19 +363,38 @@ fn agent_renders_single_compact_line_in_live_mode() { is_diff: false, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); - // One header line, no details/args/output expansion. + assert!( + lines.is_empty(), + "spawn generic tool card must be suppressed: {lines:?}" + ); +} + +#[test] +fn agent_inspection_renders_single_compact_line_in_live_mode() { + let cell = GenericToolCell { + name: "agent".to_string(), + status: ToolStatus::Running, + input_summary: Some("action: peek agent_id: agent-abc12".to_string()), + output: Some( + r#"{"agent_id": "agent-abc12", "nickname": "Beluga", "model": "deepseek-v4-flash"}"# + .to_string(), + ), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + }; + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); assert_eq!(lines.len(), 1, "expected exactly 1 line, got {lines:?}"); let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); - // Header carries the agent id and the running status. assert!( rendered.contains("agent-abc12"), "expected agent id in header: {rendered:?}" ); assert!( - rendered.contains("running"), - "expected status in header: {rendered:?}" + rendered.contains("checking"), + "expected inspection status in header: {rendered:?}" ); - // No verbose `args:` / `name:` rows. assert!( !rendered.contains("args"), "args should be hidden: {rendered:?}" @@ -382,30 +402,32 @@ fn agent_renders_single_compact_line_in_live_mode() { } #[test] -fn agent_pending_render_uses_fallback_token() { +fn agent_pending_inspection_uses_fallback_token() { + // Pending inspection (no agent_id yet) still renders a compact check line. let cell = GenericToolCell { name: "agent".to_string(), status: ToolStatus::Running, - input_summary: Some("prompt: do thing".to_string()), + input_summary: Some("action: peek prompt: do thing".to_string()), output: None, prompts: None, spillover_path: None, output_summary: None, is_diff: false, }; - let rendered: String = cell.lines_with_mode(80, true, super::RenderMode::Live)[0] - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect(); - assert!(rendered.contains("do-thing"), "{rendered:?}"); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + assert_eq!(lines.len(), 1, "inspection must stay compact: {lines:?}"); + let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); + assert!( + rendered.contains("checking") || rendered.contains("subagent"), + "{rendered:?}" + ); assert!(!rendered.contains('\u{2026}'), "{rendered:?}"); } #[test] -fn agent_transcript_mode_keeps_full_block() { - // Transcript mode is for replay/debug — preserve the full block - // so session export still carries the args/output verbatim. +fn agent_spawn_suppresses_generic_card_in_transcript_mode() { + // #4133: spawn cards are suppressed in both Live and Transcript; DelegateCard + // is the sole visible spawn artifact. let cell = GenericToolCell { name: "agent".to_string(), status: ToolStatus::Success, @@ -417,9 +439,10 @@ fn agent_transcript_mode_keeps_full_block() { is_diff: false, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Transcript); - // Transcript mode emits header + name kv + (no args, output present) - // + output rows. At minimum more than the live one-liner. - assert!(lines.len() > 1, "expected verbose transcript render"); + assert!( + lines.is_empty(), + "spawn generic tool card must be suppressed in transcript: {lines:?}" + ); } #[test] @@ -442,23 +465,21 @@ fn other_tools_are_unaffected_by_agent_compact_path() { #[test] fn agent_compact_header_omits_unknown_child_fallback() { - // #4148: an agent whose identity can't be resolved must not leak the raw - // internal "unknown child" token into the default transcript. + // #4148: an inspection whose identity can't be resolved must not leak the + // raw internal "unknown child" token into the default transcript. let cell = GenericToolCell { name: "agent".to_string(), status: ToolStatus::Running, - input_summary: Some("agent_type: delegate".to_string()), + input_summary: Some("action: peek agent_type: delegate".to_string()), output: None, prompts: None, spillover_path: None, output_summary: None, is_diff: false, }; - let rendered: String = cell.lines_with_mode(80, true, super::RenderMode::Live)[0] - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect(); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + assert_eq!(lines.len(), 1, "inspection must stay compact: {lines:?}"); + let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!( !rendered.contains("unknown child"), "raw fallback token must not leak: {rendered:?}" @@ -472,28 +493,24 @@ fn agent_compact_header_omits_unknown_child_fallback() { #[test] fn agent_compact_header_does_not_duplicate_delegate_verb() { // #4148: when the resolved identity collapses to the "delegate" verb, the - // compact header must not render a redundant "delegate · delegate". + // compact inspection header must not render a redundant "delegate · delegate". let cell = GenericToolCell { name: "agent".to_string(), status: ToolStatus::Running, - input_summary: Some("role: delegate".to_string()), + input_summary: Some("action: peek role: delegate".to_string()), output: None, prompts: None, spillover_path: None, output_summary: None, is_diff: false, }; - let rendered: String = cell.lines_with_mode(80, true, super::RenderMode::Live)[0] - .spans - .iter() - .map(|s| s.content.as_ref()) - .collect(); + let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + assert_eq!(lines.len(), 1, "inspection must stay compact: {lines:?}"); + let rendered: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect(); assert!( !rendered.contains("delegate delegate"), "no adjacent duplicate: {rendered:?}" ); - // The verb sits before the status; the redundant summary tail is dropped, - // so "delegate" appears exactly once. assert_eq!( rendered.matches("delegate").count(), 1, @@ -1338,10 +1355,12 @@ fn exec_cell_header_includes_compact_command_summary() { #[test] fn generic_tool_cell_picks_family_from_tool_name() { + // Use an inspection call so the compact Delegate header still renders; + // spawn cards are suppressed entirely (#4133). let cell = GenericToolCell { name: "agent".to_string(), status: ToolStatus::Running, - input_summary: Some("foo".to_string()), + input_summary: Some("action: peek foo".to_string()), output: None, prompts: None, spillover_path: None, @@ -1349,6 +1368,7 @@ fn generic_tool_cell_picks_family_from_tool_name() { is_diff: false, }; let lines = cell.lines_with_mode(80, true, super::RenderMode::Live); + assert_eq!(lines.len(), 1, "inspection must stay compact: {lines:?}"); let header_visible: String = lines[0] .spans .iter() @@ -2857,15 +2877,17 @@ fn agent_inspection_stays_compact_in_transcript_mode() { } #[test] -fn agent_spawn_keeps_full_block_in_transcript_mode() { +fn agent_spawn_suppresses_generic_card_in_favor_of_delegate_card() { let cell = agent_cell( Some("prompt: map the repo"), ToolStatus::Success, Some(r#"{"agent_id":"agent_scout_1","status":"running"}"#), ); - let lines = cell.lines_with_mode(120, true, super::RenderMode::Transcript); - assert!( - lines.len() > 1, - "spawns keep the full block for session replay: {lines:?}" - ); + for mode in [super::RenderMode::Live, super::RenderMode::Transcript] { + let lines = cell.lines_with_mode(120, true, mode); + assert!( + lines.is_empty(), + "spawn generic tool card must yield to DelegateCard (#4133): {mode:?} {lines:?}" + ); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 09ca08bf20..730ff88a9a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2500,6 +2500,7 @@ async fn run_event_loop( app.is_loading = true; app.offline_mode = false; app.turn_error_posted = false; + app.lsp_repair = crate::tui::app::LspRepairState::default(); app.prompt_suggestion = None; app.prompt_suggestion_gen .fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -3000,6 +3001,35 @@ async fn run_event_loop( } } } + EngineEvent::LspRepairUpdate { + diagnostics_found, + files, + injected, + } => { + let repair = &mut app.lsp_repair; + repair.diagnostics_found = + repair.diagnostics_found.saturating_add(diagnostics_found); + repair.files_touched = repair.files_touched.saturating_add(files); + if injected { + // Injection itself is not a repair attempt — the model + // has only been shown the diagnostics so far (#4107). + repair.injected = true; + if repair.latest == "unavailable" || repair.latest.is_empty() { + repair.latest = "unknown"; + } + } else if repair.injected { + // Diagnostics after a prior injection imply the model + // edited again (a repair attempt). Zero findings = resolved. + repair.repair_attempted = true; + repair.latest = if diagnostics_found == 0 { + "resolved" + } else { + "still_failing" + }; + } else { + repair.latest = "unknown"; + } + } EngineEvent::PauseEvents { ack } => { if !event_broker.is_paused() { pause_terminal( diff --git a/crates/tui/src/tui/ui/activity_detail.rs b/crates/tui/src/tui/ui/activity_detail.rs index 4b07d01b7b..6e266dcf7c 100644 --- a/crates/tui/src/tui/ui/activity_detail.rs +++ b/crates/tui/src/tui/ui/activity_detail.rs @@ -1430,17 +1430,48 @@ fn turn_files_changed(app: &App, start: usize, end: usize) -> Vec { lines } -/// Section 5 — diagnostics / LSP repair loop. +/// Section 5 — diagnostics / LSP repair loop (#4107). /// -/// Structured per-turn LSP repair state is not surfaced to the TUI yet -/// (issue #4106), so this degrades to the coarse enable flag rather than a -/// blank section. +/// Shows the observable repair loop when LSP produced diagnostics this turn. +/// Stays quiet when LSP is disabled or no diagnostics were found. fn turn_diagnostics_lines(app: &App) -> Vec { - if app.lsp_enabled { - vec!["LSP enabled — no repair-loop details surfaced yet (see #4106)".to_string()] - } else { - vec!["LSP disabled".to_string()] + if !app.lsp_enabled { + return Vec::new(); + } + let repair = &app.lsp_repair; + if repair.diagnostics_found == 0 && !repair.injected && !repair.repair_attempted { + return Vec::new(); } + let mut lines = Vec::new(); + if repair.diagnostics_found > 0 { + lines.push(format!( + "Found {} diagnostic{} across {} file{}", + repair.diagnostics_found, + if repair.diagnostics_found == 1 { + "" + } else { + "s" + }, + repair.files_touched.max(1), + if repair.files_touched == 1 { "" } else { "s" }, + )); + } + lines.push(if repair.injected { + "Injected into the next model request".to_string() + } else { + "Queued — not yet injected".to_string() + }); + if repair.repair_attempted { + lines.push("Model attempted a repair after injection".to_string()); + } + let latest = match repair.latest { + "resolved" => "Latest: resolved", + "still_failing" => "Latest: still failing", + "unavailable" => "Latest: unavailable", + _ => "Latest: unknown", + }; + lines.push(latest.to_string()); + lines } /// Section 6 — tests / verifier results. @@ -1589,3 +1620,66 @@ fn turn_result_lines(app: &App, start: usize, end: usize) -> Vec { lines } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::tui::app::{App, LspRepairState, TuiOptions}; + use std::path::PathBuf; + + fn test_app() -> App { + let options = TuiOptions { + model: "deepseek-v4-flash".to_string(), + workspace: PathBuf::from("."), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: true, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + App::new(options, &Config::default()) + } + + #[test] + fn turn_diagnostics_lines_quiet_when_no_activity() { + let mut app = test_app(); + app.lsp_enabled = true; + assert!(turn_diagnostics_lines(&app).is_empty()); + app.lsp_enabled = false; + assert!(turn_diagnostics_lines(&app).is_empty()); + } + + #[test] + fn turn_diagnostics_lines_summarize_repair_loop() { + let mut app = test_app(); + app.lsp_enabled = true; + app.lsp_repair = LspRepairState { + diagnostics_found: 2, + files_touched: 1, + injected: true, + repair_attempted: true, + latest: "still_failing", + }; + let joined = turn_diagnostics_lines(&app).join("\n"); + assert!(joined.contains("Found 2 diagnostics"), "{joined}"); + assert!( + joined.contains("Injected into the next model request"), + "{joined}" + ); + assert!(joined.contains("Model attempted a repair"), "{joined}"); + assert!(joined.contains("still failing"), "{joined}"); + } +}