diff --git a/Cargo.lock b/Cargo.lock index 5e7cbb4..67b3196 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,7 @@ dependencies = [ "serde_json", "sysinfo", "tempfile", + "unicode-width 0.2.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index db0a7e2..d562fb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ serde_json = "1" dirs = "6" chrono = { version = "0.4", features = ["serde"] } tempfile = "3" +unicode-width = "0.2" [target.'cfg(target_vendor = "apple")'.dependencies] proc_pidinfo = "0.1" diff --git a/src/jump/cmux.rs b/src/jump/cmux.rs index eb1cf46..d7092cd 100644 --- a/src/jump/cmux.rs +++ b/src/jump/cmux.rs @@ -2,29 +2,303 @@ //! //! Each cmux surface exports `CMUX_WORKSPACE_ID` (a UUID), inherited by the //! agent process. We read it from the process environment and focus the -//! workspace via the cmux CLI, which accepts the UUID directly as `--workspace`. +//! workspace via the cmux CLI's `workspace select` command. use super::{pid_env_var, JumpAttempt, TerminalJumper}; use std::process::Command; pub struct CmuxJumper; +#[derive(Debug, PartialEq, Eq)] +struct CmuxCommandPlan { + workspace_id: String, + terminal_id: Option, + program: String, + args: Vec, + envs: Vec<(String, String)>, +} + impl TerminalJumper for CmuxJumper { fn name(&self) -> &'static str { "cmux" } fn try_jump(&self, pid: u32) -> JumpAttempt { - let Some(workspace) = pid_env_var(pid, "CMUX_WORKSPACE_ID") else { + let Some(plan) = command_plan_from_env(|name| pid_env_var(pid, name)) else { return JumpAttempt::NotApplicable; }; - match Command::new("cmux") - .args(["select-workspace", "--workspace", &workspace]) - .output() - { + let mut command = Command::new(&plan.program); + command.args(&plan.args); + for key in cmux_env_removals(std::env::vars().map(|(key, _)| key)) { + command.env_remove(key); + } + command.envs(plan.envs.iter().map(|(key, value)| (key, value))); + + match command.output() { Ok(o) if o.status.success() => JumpAttempt::Jumped, - Ok(o) => JumpAttempt::Failed(format!("select-workspace exited {}", o.status)), + Ok(o) if command_output_has_broken_pipe(&o.stdout, &o.stderr) => { + jump_via_applescript_after_socket_failure(&plan) + } + Ok(o) => JumpAttempt::Failed(format_command_failure( + "workspace select", + &o.status.to_string(), + &o.stdout, + &o.stderr, + )), Err(e) => JumpAttempt::Failed(format!("cmux CLI not runnable ({e})")), } } } + +fn command_plan_from_env(mut env: impl FnMut(&str) -> Option) -> Option { + let workspace = non_empty(env("CMUX_WORKSPACE_ID"))?; + let terminal_id = non_empty(env("CMUX_PANEL_ID")).or_else(|| non_empty(env("CMUX_SURFACE_ID"))); + let program = non_empty(env("CMUX_BUNDLED_CLI_PATH")).unwrap_or_else(|| "cmux".to_string()); + let args = vec![ + "workspace".to_string(), + "select".to_string(), + workspace.clone(), + ]; + let envs = ["CMUX_SOCKET_PATH", "CMUX_SOCKET", "CMUX_SOCKET_PASSWORD"] + .into_iter() + .filter_map(|name| non_empty(env(name)).map(|value| (name.to_string(), value))) + .collect(); + + Some(CmuxCommandPlan { + workspace_id: workspace, + terminal_id, + program, + args, + envs, + }) +} + +fn non_empty(value: Option) -> Option { + value.filter(|v| !v.is_empty()) +} + +fn cmux_env_removals(env_keys: impl IntoIterator) -> Vec { + env_keys + .into_iter() + .filter(|key| key.starts_with("CMUX_")) + .collect() +} + +fn jump_via_applescript(plan: &CmuxCommandPlan) -> JumpAttempt { + let script = applescript_focus_script(&plan.workspace_id, plan.terminal_id.as_deref()); + match Command::new("osascript").arg("-e").arg(script).output() { + Ok(o) if o.status.success() => JumpAttempt::Jumped, + Ok(o) => JumpAttempt::Failed(format_command_failure( + "AppleScript focus", + &o.status.to_string(), + &o.stdout, + &o.stderr, + )), + Err(e) => JumpAttempt::Failed(format!("AppleScript not runnable ({e})")), + } +} + +fn jump_via_applescript_after_socket_failure(plan: &CmuxCommandPlan) -> JumpAttempt { + jump_via_applescript_after_socket_failure_with(plan, jump_via_applescript) +} + +fn jump_via_applescript_after_socket_failure_with( + plan: &CmuxCommandPlan, + jump: impl FnOnce(&CmuxCommandPlan) -> JumpAttempt, +) -> JumpAttempt { + match jump(plan) { + JumpAttempt::Failed(msg) if msg.starts_with("AppleScript not runnable") => { + JumpAttempt::Failed("socket broken; restart cmux".to_string()) + } + attempt => attempt, + } +} + +fn applescript_focus_script(workspace_id: &str, terminal_id: Option<&str>) -> String { + let workspace_id = applescript_string(workspace_id); + let focus_terminal = terminal_id.map(|id| { + format!( + "\n focus (first terminal of w whose id is {})", + applescript_string(id) + ) + }); + + format!( + "tell application \"cmux\"\n repeat with w in windows\n repeat with candidate in tabs of w\n if id of candidate is {} then\n select tab candidate{}\n activate window w\n return true\n end if\n end repeat\n end repeat\n error \"cmux workspace not found\"\nend tell", + workspace_id, + focus_terminal.unwrap_or_default() + ) +} + +fn applescript_string(value: &str) -> String { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +fn format_command_failure(action: &str, status: &str, stdout: &[u8], stderr: &[u8]) -> String { + if command_output_has_broken_pipe(stdout, stderr) { + return "socket broken; restart cmux".to_string(); + } + + let mut msg = format!("{action} exited {status}"); + if let Some(detail) = command_output_detail(stderr).or_else(|| command_output_detail(stdout)) { + msg.push_str(": "); + msg.push_str(&detail); + } + msg +} + +fn command_output_contains(output: &[u8], needle: &str) -> bool { + String::from_utf8_lossy(output).contains(needle) +} + +fn command_output_has_broken_pipe(stdout: &[u8], stderr: &[u8]) -> bool { + command_output_contains(stderr, "Broken pipe") || command_output_contains(stdout, "Broken pipe") +} + +fn command_output_detail(output: &[u8]) -> Option { + let text = String::from_utf8_lossy(output); + let detail = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("; "); + if detail.is_empty() { + None + } else { + Some(detail) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_failure_includes_stderr_detail() { + let msg = format_command_failure( + "workspace select", + "exit status: 1", + b"", + b"Error: workspace not found\n", + ); + + assert_eq!( + msg, + "workspace select exited exit status: 1: Error: workspace not found" + ); + } + + #[test] + fn command_failure_summarizes_broken_pipe_socket_failure() { + let msg = format_command_failure( + "workspace select", + "exit status: 1", + b"", + b"Error: Failed to write to socket (Broken pipe, errno 32)\n", + ); + + assert_eq!(msg, "socket broken; restart cmux"); + } + + #[test] + fn command_plan_uses_target_cmux_context() { + let env = |name: &str| match name { + "CMUX_WORKSPACE_ID" => Some("workspace-1".to_string()), + "CMUX_PANEL_ID" => Some("terminal-1".to_string()), + "CMUX_BUNDLED_CLI_PATH" => { + Some("/Applications/cmux.app/Contents/Resources/bin/cmux".to_string()) + } + "CMUX_SOCKET_PATH" => Some("/tmp/cmux.sock".to_string()), + "CMUX_SOCKET_PASSWORD" => Some("pw".to_string()), + _ => None, + }; + + let plan = command_plan_from_env(env).unwrap(); + + assert_eq!( + plan.program, + "/Applications/cmux.app/Contents/Resources/bin/cmux" + ); + assert_eq!(plan.args, ["workspace", "select", "workspace-1"]); + assert_eq!( + plan.envs, + [ + ("CMUX_SOCKET_PATH".to_string(), "/tmp/cmux.sock".to_string()), + ("CMUX_SOCKET_PASSWORD".to_string(), "pw".to_string()), + ] + ); + assert_eq!(plan.workspace_id, "workspace-1"); + assert_eq!(plan.terminal_id.as_deref(), Some("terminal-1")); + } + + #[test] + fn applescript_fallback_selects_workspace_and_terminal() { + let script = applescript_focus_script("workspace-1", Some("terminal-1")); + + assert!(script.contains("if id of candidate is \"workspace-1\" then")); + assert!(script.contains("select tab candidate")); + assert!(script.contains("focus (first terminal of w whose id is \"terminal-1\")")); + assert!(script.contains("activate window w")); + } + + #[test] + fn applescript_string_escapes_quotes_and_backslashes() { + assert_eq!(applescript_string(r#"a\b"c"#), r#""a\\b\"c""#); + } + + #[test] + fn cmux_env_removals_only_removes_cmux_keys() { + let removals = cmux_env_removals([ + "CMUX_WORKSPACE_ID".to_string(), + "PATH".to_string(), + "CMUX_SOCKET_PATH".to_string(), + "HOME".to_string(), + ]); + + assert_eq!(removals, ["CMUX_WORKSPACE_ID", "CMUX_SOCKET_PATH"]); + } + + #[test] + fn broken_pipe_attempts_applescript_fallback() { + let plan = CmuxCommandPlan { + workspace_id: "workspace-1".to_string(), + terminal_id: Some("terminal-1".to_string()), + program: "cmux".to_string(), + args: vec![], + envs: vec![], + }; + let mut called = false; + + let result = jump_via_applescript_after_socket_failure_with(&plan, |fallback_plan| { + called = true; + assert_eq!(fallback_plan.workspace_id, "workspace-1"); + assert_eq!(fallback_plan.terminal_id.as_deref(), Some("terminal-1")); + JumpAttempt::Jumped + }); + + assert_eq!(result, JumpAttempt::Jumped); + assert!(called); + } + + #[test] + fn broken_pipe_reports_socket_failure_when_applescript_is_unavailable() { + let plan = CmuxCommandPlan { + workspace_id: "workspace-1".to_string(), + terminal_id: Some("terminal-1".to_string()), + program: "cmux".to_string(), + args: vec![], + envs: vec![], + }; + + let result = jump_via_applescript_after_socket_failure_with(&plan, |_| { + JumpAttempt::Failed("AppleScript not runnable (No such file or directory)".to_string()) + }); + + assert_eq!( + result, + JumpAttempt::Failed("socket broken; restart cmux".to_string()) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 6a5d31d..b393efc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,8 +66,8 @@ pub mod ui; use app::{App, JumpOutcome}; use crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseButton, - MouseEvent, MouseEventKind, + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, + MouseButton, MouseEvent, MouseEventKind, }; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -259,76 +259,9 @@ fn run_app( let had_input = if event::poll(render_interval)? { match event::read()? { Event::Key(key) if key.kind == KeyEventKind::Press => { - if app.help_open { - // Any key dismisses help. - app.help_open = false; - } else if app.view_open { - match key.code { - KeyCode::Esc | KeyCode::Char('v') => app.view_open = false, - KeyCode::Char('T') => app.tree_view = !app.tree_view, - KeyCode::Char('l') => app.toggle_timeline(), - KeyCode::Char('f') => app.toggle_file_audit(), - KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), - KeyCode::Char('M') => app.toggle_mcp_session_suppression(), - KeyCode::Char('t') => app.cycle_theme(), - _ => {} - } - } else if app.config_open { - match key.code { - KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('c') => { - app.toggle_config() - } - KeyCode::Down | KeyCode::Char('j') => app.config_select_next(), - KeyCode::Up | KeyCode::Char('k') => app.config_select_prev(), - KeyCode::Enter | KeyCode::Char(' ') => app.config_toggle_selected(), - _ => {} - } - } else if app.filter_active { - match key.code { - KeyCode::Esc => app.clear_filter(), - KeyCode::Enter => app.filter_active = false, - KeyCode::Backspace => app.filter_pop(), - KeyCode::Down => app.select_next(), - KeyCode::Up => app.select_prev(), - KeyCode::Char(c) => app.filter_push(c), - _ => {} - } - } else { - match key.code { - KeyCode::Char('q') => app.quit(), - KeyCode::Char('r') if !demo_mode => app.tick(), - KeyCode::Down | KeyCode::Char('j') => app.select_next(), - KeyCode::Up | KeyCode::Char('k') => app.select_prev(), - KeyCode::Right | KeyCode::Tab => app.select_next_narrow_tab(), - KeyCode::Left | KeyCode::BackTab => app.select_prev_narrow_tab(), - KeyCode::Char('w') => app.set_narrow_tab(app::NarrowTab::Work), - KeyCode::Char('u') => app.set_narrow_tab(app::NarrowTab::Usage), - KeyCode::Char('s') => app.set_narrow_tab(app::NarrowTab::System), - KeyCode::Char('+') | KeyCode::Char('=') => { - app.maximize_active_narrow_section() - } - KeyCode::Char('-') => app.restore_narrow_sections(), - KeyCode::Char('x') if !demo_mode => app.kill_selected(), - KeyCode::Char('X') if !demo_mode => app.kill_orphan_ports(), - KeyCode::Char('t') => app.cycle_theme(), - KeyCode::Char('T') => app.tree_view = !app.tree_view, - KeyCode::Char('l') | KeyCode::Char('L') => app.toggle_timeline(), - KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), - KeyCode::Char('M') => app.toggle_mcp_session_suppression(), - KeyCode::Char('c') => app.toggle_config(), - KeyCode::Char('v') => app.toggle_view_menu(), - KeyCode::Char('?') => app.toggle_help(), - KeyCode::Char('/') => app.filter_active = true, - KeyCode::Esc if !app.filter_text.is_empty() => app.clear_filter(), - KeyCode::Char('f') | KeyCode::Char('F') => app.toggle_file_audit(), - KeyCode::Enter if !demo_mode => match app.jump_to_session() { - JumpOutcome::Jumped if exit_on_jump => app.quit(), - JumpOutcome::Failed(msg) => app.set_status(msg), - JumpOutcome::Jumped | JumpOutcome::NoOp => {} - }, - _ => {} - } - } + handle_key_press(&mut app, key, demo_mode, exit_on_jump, |app| { + app.jump_to_session() + }) } Event::Mouse(mouse) => { let size = terminal.size()?; @@ -361,6 +294,81 @@ fn run_app( Ok(()) } +fn handle_key_press( + app: &mut App, + key: KeyEvent, + demo_mode: bool, + exit_on_jump: bool, + jump_to_session: impl FnOnce(&mut App) -> JumpOutcome, +) { + if app.help_open { + // Any key dismisses help. + app.help_open = false; + } else if app.view_open { + match key.code { + KeyCode::Esc | KeyCode::Char('v') => app.view_open = false, + KeyCode::Char('T') => app.tree_view = !app.tree_view, + KeyCode::Char('l') => app.toggle_timeline(), + KeyCode::Char('f') => app.toggle_file_audit(), + KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), + KeyCode::Char('M') => app.toggle_mcp_session_suppression(), + KeyCode::Char('t') => app.cycle_theme(), + _ => {} + } + } else if app.config_open { + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('c') => app.toggle_config(), + KeyCode::Down | KeyCode::Char('j') => app.config_select_next(), + KeyCode::Up | KeyCode::Char('k') => app.config_select_prev(), + KeyCode::Enter | KeyCode::Char(' ') => app.config_toggle_selected(), + _ => {} + } + } else if app.filter_active { + match key.code { + KeyCode::Esc => app.clear_filter(), + KeyCode::Enter => app.filter_active = false, + KeyCode::Backspace => app.filter_pop(), + KeyCode::Down => app.select_next(), + KeyCode::Up => app.select_prev(), + KeyCode::Char(c) => app.filter_push(c), + _ => {} + } + } else { + match key.code { + KeyCode::Char('q') => app.quit(), + KeyCode::Char('r') if !demo_mode => app.tick(), + KeyCode::Down | KeyCode::Char('j') => app.select_next(), + KeyCode::Up | KeyCode::Char('k') => app.select_prev(), + KeyCode::Right | KeyCode::Tab => app.select_next_narrow_tab(), + KeyCode::Left | KeyCode::BackTab => app.select_prev_narrow_tab(), + KeyCode::Char('w') => app.set_narrow_tab(app::NarrowTab::Work), + KeyCode::Char('u') => app.set_narrow_tab(app::NarrowTab::Usage), + KeyCode::Char('s') => app.set_narrow_tab(app::NarrowTab::System), + KeyCode::Char('+') | KeyCode::Char('=') => app.maximize_active_narrow_section(), + KeyCode::Char('-') => app.restore_narrow_sections(), + KeyCode::Char('x') if !demo_mode => app.kill_selected(), + KeyCode::Char('X') if !demo_mode => app.kill_orphan_ports(), + KeyCode::Char('t') => app.cycle_theme(), + KeyCode::Char('T') => app.tree_view = !app.tree_view, + KeyCode::Char('l') | KeyCode::Char('L') => app.toggle_timeline(), + KeyCode::Char(c @ '1'..='7') => app.toggle_panel(c as u8 - b'0'), + KeyCode::Char('M') => app.toggle_mcp_session_suppression(), + KeyCode::Char('c') => app.toggle_config(), + KeyCode::Char('v') => app.toggle_view_menu(), + KeyCode::Char('?') => app.toggle_help(), + KeyCode::Char('/') => app.filter_active = true, + KeyCode::Esc if !app.filter_text.is_empty() => app.clear_filter(), + KeyCode::Char('f') | KeyCode::Char('F') => app.toggle_file_audit(), + KeyCode::Enter if !demo_mode => match jump_to_session(app) { + JumpOutcome::Jumped if exit_on_jump => app.quit(), + JumpOutcome::Failed(msg) => app.set_status(msg), + JumpOutcome::Jumped | JumpOutcome::NoOp => {} + }, + _ => {} + } + } +} + fn handle_mouse_event(app: &mut App, mouse: MouseEvent, area: Rect) { if app.help_open || app.view_open || app.config_open || app.filter_active { return; @@ -569,10 +577,39 @@ fn fmt_tok(n: u64) -> String { #[cfg(test)] mod tests { use super::*; + use crossterm::event::{KeyEvent, KeyModifiers}; + use ratatui::backend::TestBackend; #[test] fn mouse_capture_is_opt_in() { assert!(!should_enable_mouse_capture(["abtop"])); assert!(should_enable_mouse_capture(["abtop", "--mouse"])); } + + #[test] + fn enter_jump_failure_renders_footer_status() { + let mut app = App::new_with_config( + theme::Theme::default(), + &[], + config::PanelVisibility::default(), + ); + demo::populate_demo(&mut app); + + handle_key_press( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + false, + false, + |_| JumpOutcome::Failed("cmux: socket broken; restart cmux".to_string()), + ); + + let backend = TestBackend::new(120, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| ui::draw(f, &app)).unwrap(); + let text = format!("{}", terminal.backend()); + + assert!(text.contains("cmux: socket broken; restart cmux")); + assert!(!text.contains("Broken pipe")); + assert!(!text.contains("select-workspace")); + } } diff --git a/src/ui/footer.rs b/src/ui/footer.rs index b790187..7b95632 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -179,3 +179,40 @@ pub(crate) fn draw_footer(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { f.render_widget(Paragraph::new(Line::from(spans)), area); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PanelVisibility; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn footer_renders_concise_cmux_socket_failure() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + app.set_status("cmux: socket broken; restart cmux".to_string()); + + let backend = TestBackend::new(120, 1); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw_footer( + f, + &app, + Rect { + x: 0, + y: 0, + width: 120, + height: 1, + }, + &app.theme, + ) + }) + .unwrap(); + let text = format!("{}", terminal.backend()); + + assert!(text.contains("cmux: socket broken; restart cmux")); + assert!(!text.contains("Broken pipe")); + assert!(!text.contains("select-workspace")); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index dc8f248..1f77b08 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,6 +19,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use ratatui::Frame; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; // ── braille graph symbols — from btop_draw.cpp ────────────────────────────── // 5x5 lookup: [prev_val * 5 + cur_val], values 0-4 @@ -982,10 +983,19 @@ pub(crate) fn truncate_str(s: &str, max: usize) -> String { if max == 0 { return String::new(); } - if s.chars().count() <= max { + if UnicodeWidthStr::width(s) <= max { s.to_string() } else { - let truncated: String = s.chars().take(max - 1).collect(); + let mut width = 0; + let mut truncated = String::new(); + for ch in s.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width >= max { + break; + } + truncated.push(ch); + width += ch_width; + } format!("{}…", truncated) } } @@ -1011,6 +1021,11 @@ mod tests { assert_eq!(fmt_age(341_493), "3d ago"); } + #[test] + fn truncate_str_respects_terminal_display_width() { + assert_eq!(truncate_str("AB123", 6), "AB1…"); + } + #[test] fn compact_sizes_render_sessions_instead_of_too_small() { for (w, h) in [(69, 27), (80, 24)] { diff --git a/src/ui/sessions.rs b/src/ui/sessions.rs index e7a745d..1a911eb 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -5,7 +5,7 @@ use crate::theme::Theme; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Cell, Paragraph, Row, Table}; +use ratatui::widgets::{Cell, Clear, Paragraph, Row, Table}; use ratatui::Frame; use super::{btop_block_active, fmt_mem_kb, fmt_tokens, grad_at, make_gradient, truncate_str}; @@ -134,9 +134,9 @@ pub(crate) fn draw_sessions_panel_active( let marker = if selected { "►" } else { " " }; let (agent_label, agent_color) = match session.agent_cli { - "claude" => ("*CC", Color::Rgb(217, 119, 87)), // #D97757 terracotta - "codex" => (">CD", Color::Rgb(122, 157, 255)), // #7A9DFF periwinkle - "opencode" => ("#OC", Color::Rgb(74, 222, 128)), // #4ADE80 emerald + "claude" => ("*CC", Color::Rgb(217, 119, 87)), // #D97757 terracotta + "codex" => (">CD", Color::Rgb(122, 157, 255)), // #7A9DFF periwinkle + "opencode" => ("#OC", Color::Rgb(74, 222, 128)), // #4ADE80 emerald other => { let fallback: String = other.chars().take(3).collect::().to_uppercase(); ( @@ -255,8 +255,7 @@ pub(crate) fn draw_sessions_panel_active( rows.push(Row::new(cells).style(row_style).height(1)); // 2nd line: task text in Summary column - let summary_idx = - 3 + show_pid as usize + show_session_id as usize + show_config as usize; + let summary_idx = 3 + show_pid as usize + show_session_id as usize + show_config as usize; let total_cols = 6 + show_pid as usize + show_session_id as usize @@ -274,7 +273,7 @@ pub(crate) fn draw_sessions_panel_active( .map(|s| s.as_str()) .unwrap_or(""); Cell::from(Span::styled( - format!("└─ {}", task_text), + task_row_text(task_text, w.saturating_sub(24) as usize), Style::default().fg(theme.graph_text), )) } else { @@ -462,6 +461,7 @@ pub(crate) fn draw_sessions_panel_active( Vec::new() }; + f.render_widget(Clear, table_area); let table = Table::new(visible, widths_vec).header(header); f.render_widget(table, table_area); @@ -984,6 +984,10 @@ fn draw_file_audit(f: &mut Frame, session: &AgentSession, area: Rect, theme: &Th f.render_widget(Paragraph::new(lines), area); } +fn task_row_text(task_text: &str, max_width: usize) -> String { + truncate_str(&format!("└─ {task_text}"), max_width) +} + pub(crate) fn shorten_model(model: &str, is_1m: bool) -> String { // "claude-opus-4-6" → "opus4.6", "claude-sonnet-4-6" → "sonnet4.6", "claude-haiku-4-5" → "haiku4.5" let s = model.strip_prefix("claude-").unwrap_or(model); @@ -1338,4 +1342,128 @@ mod tests { "non-1M Codex context windows must not be labeled as 1M\n{text}" ); } + + #[test] + fn task_row_text_respects_terminal_display_width() { + assert_eq!(task_row_text("ABCD", 6), "└─ A…"); + } + + #[test] + fn session_table_clears_rows_when_selection_scrolls() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + app.sessions = vec![ + test_session("first111", "first"), + test_session("second22", "second"), + test_session("third333", "third"), + test_session("fourth44", "fourth"), + ]; + app.selected = 3; + + let backend = TestBackend::new(120, 14); + let mut terminal = Terminal::new(backend).unwrap(); + let area = Rect { + x: 0, + y: 0, + width: 120, + height: 14, + }; + + terminal + .draw(|f| { + let lines = (0..area.height) + .map(|_| Line::from("STALE".repeat(24))) + .collect::>(); + f.render_widget(Paragraph::new(lines), area); + }) + .unwrap(); + + terminal + .draw(|f| draw_sessions_panel(f, &app, area, &app.theme)) + .unwrap(); + + app.selected = 2; + terminal + .draw(|f| draw_sessions_panel(f, &app, area, &app.theme)) + .unwrap(); + + let text = format!("{}", terminal.backend()); + assert!( + !text.contains("STALE") && !text.contains("fourth44"), + "offscreen session row should be cleared after selection scroll\n{text}" + ); + } + + #[test] + fn session_table_repaints_selection_marker_when_moving() { + let mut app = App::new_with_config(Theme::default(), &[], PanelVisibility::default()); + app.sessions = vec![ + test_session("first111", "first"), + test_session("second22", "second"), + ]; + + let backend = TestBackend::new(120, 14); + let mut terminal = Terminal::new(backend).unwrap(); + let area = Rect { + x: 0, + y: 0, + width: 120, + height: 14, + }; + + app.selected = 0; + terminal + .draw(|f| draw_sessions_panel(f, &app, area, &app.theme)) + .unwrap(); + + app.selected = 1; + terminal + .draw(|f| draw_sessions_panel(f, &app, area, &app.theme)) + .unwrap(); + + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(1, 2)].symbol(), " "); + assert_eq!(buffer[(1, 4)].symbol(), "►"); + } + + fn test_session(session_id: &str, project_name: &str) -> AgentSession { + AgentSession { + agent_cli: "claude", + pid: 42, + session_id: session_id.into(), + cwd: format!("/tmp/{project_name}"), + project_name: project_name.into(), + started_at: 0, + status: SessionStatus::Waiting, + model: "claude-opus-4-6".into(), + effort: String::new(), + context_percent: 10.0, + total_input_tokens: 1_000, + total_output_tokens: 500, + total_cache_read: 0, + total_cache_create: 0, + turn_count: 1, + current_tasks: vec!["waiting for input".into()], + mem_mb: 0, + version: String::new(), + git_branch: String::new(), + git_added: 0, + git_modified: 0, + token_history: Vec::new(), + context_history: Vec::new(), + compaction_count: 0, + context_window: 200_000, + subagents: Vec::new(), + mem_file_count: 0, + mem_line_count: 0, + children: Vec::new(), + initial_prompt: "prompt".into(), + first_assistant_text: String::new(), + chat_messages: Vec::new(), + tool_calls: Vec::new(), + pending_since_ms: 0, + thinking_since_ms: 0, + file_accesses: Vec::new(), + config_root: "~/.claude".into(), + } + } }