diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e2f1a9..e730cb41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pending-input preview rows now label delivery mode explicitly as steer pending, rejected steer, or queued follow-up, with wrapped continuation rows aligned under the label so busy-turn input state is easier to read (#2054). +- Sidebar hover details now use row-level metadata for truncated Work, Tasks, + and Agents rows. Mouse hover opens a bordered, wrapping popover with the full + underlying row text, long turn/agent ids, and current sub-agent progress + instead of repeating the already-ellipsized sidebar label (#2694, #2734). - Auto-generated project instructions now reuse the bounded Project Context Pack data instead of running an unbounded summary/tree scan when no `.codewhale/instructions.md` file exists. The fallback keeps later @@ -90,7 +94,8 @@ workspace update and completed-thread save APIs (#2640, #2639), **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), **@idling11** for the PlanArtifact direction in Plan mode (#2733) and the -dense tool-call transcript collapse direction (#2738, #2692), and +dense tool-call transcript collapse/sidebar detail direction (#2738, #2734, +#2692, #2694), and **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). Thanks also to **@NASLXTO** and **@wuxixing** for the large-workspace startup reports (#697, #1827), and to diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 136abf75..7385cdad 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1143,6 +1143,21 @@ pub struct SidebarHoverState { pub sections: Vec, } +/// Per-row metadata for sidebar detail popovers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SidebarHoverRow { + /// Absolute row position in the terminal. + pub row_y: u16, + /// Text shown in the compact sidebar row. + pub display_text: String, + /// Full untruncated text for the popover. + pub full_text: String, + /// Optional additional detail line. + pub detail: Option, + /// Whether the compact row lost information. + pub is_truncated: bool, +} + /// Per-section metadata for sidebar hover detection. #[derive(Debug, Clone)] pub struct SidebarHoverSection { @@ -1150,6 +1165,8 @@ pub struct SidebarHoverSection { pub content_area: Rect, /// Full original text for each content line rendered. pub lines: Vec, + /// Per-row metadata for rich hover popovers. + pub rows: Vec, } impl Default for SessionState { diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 26aec094..47a3e542 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -303,11 +303,9 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec= section.content_area.x @@ -323,17 +321,35 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec section.content_area.width as usize; - let desired = truncated.then(|| full.clone()); + if let Some(row) = section.rows.iter().find(|row| row.row_y == mouse.row) { + let desired = row.is_truncated.then(|| { + if let Some(detail) = row.detail.as_deref() + && !detail.trim().is_empty() + { + format!("{}\n{detail}", row.full_text) + } else { + row.full_text.clone() + } + }); if app.sidebar_hover_tooltip != desired { app.sidebar_hover_tooltip = desired; app.needs_redraw = true; } found = true; break; + } else if section.rows.is_empty() { + let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize; + if let Some(full) = section.lines.get(line_idx) { + let truncated = + text_display_width(full) > section.content_area.width as usize; + let desired = truncated.then(|| full.clone()); + if app.sidebar_hover_tooltip != desired { + app.sidebar_hover_tooltip = desired; + app.needs_redraw = true; + } + found = true; + break; + } } } } diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 20bfffa7..410b1f1f 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -24,7 +24,9 @@ use crate::tools::plan::StepStatus; use crate::tools::subagent::SubAgentStatus; use crate::tools::todo::TodoStatus; -use super::app::{App, SidebarFocus, SidebarHoverSection, SidebarHoverState, TaskPanelEntry}; +use super::app::{ + App, SidebarFocus, SidebarHoverRow, SidebarHoverSection, SidebarHoverState, TaskPanelEntry, +}; use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; use super::subagent_routing::active_fanout_counts; use super::ui_text::{concise_shell_command_label, truncate_line_to_width}; @@ -331,6 +333,155 @@ fn work_panel_lines( lines } +fn work_panel_hover_texts( + summary: &SidebarWorkSummary, + content_width: usize, + max_rows: usize, +) -> Vec { + let mut texts = Vec::with_capacity(max_rows.max(4)); + + if let Some(objective) = summary.goal_objective.as_deref() + && !objective.trim().is_empty() + && texts.len() < max_rows + { + let icon = if summary.goal_completed { "✓" } else { "◆" }; + texts.push(format!("{icon} {objective}")); + + if let Some(started) = summary.goal_started_at + && texts.len() < max_rows + { + let elapsed = crate::tui::notifications::humanize_duration(started.elapsed()); + let elapsed_str = if summary.goal_completed { + format!("completed in {elapsed}") + } else { + format!("elapsed: {elapsed}") + }; + texts.push(elapsed_str); + } + + if let Some(budget) = summary.goal_token_budget + && texts.len() < max_rows + { + let pct = if budget > 0 { + ((summary.tokens_used as f64 / budget as f64) * 100.0).min(100.0) + } else { + 0.0 + }; + let bar_width = content_width.min(20); + let filled = ((pct / 100.0) * bar_width as f64) as usize; + let bar = format!( + "[{}{}] {:.0}%", + "█".repeat(filled), + "░".repeat(bar_width.saturating_sub(filled)), + pct + ); + texts.push(format!( + "tokens: {}/{} {}", + summary.tokens_used, budget, bar + )); + } + } + + if summary.state_updating && texts.len() < max_rows { + texts.push("Work state updating...".to_string()); + } + + if !summary.checklist_items.is_empty() && texts.len() < max_rows { + let total = summary.checklist_items.len(); + let completed = summary + .checklist_items + .iter() + .filter(|item| item.status == TodoStatus::Completed) + .count(); + texts.push(format!( + "{}% complete ({completed}/{total})", + summary.checklist_completion_pct + )); + + let reserve_for_strategy = if summary.has_strategy() { 2 } else { 0 }; + let available_item_rows = max_rows + .saturating_sub(texts.len()) + .saturating_sub(reserve_for_strategy) + .min(summary.checklist_items.len()); + let max_items = + if summary.checklist_items.len() > available_item_rows && available_item_rows > 1 { + available_item_rows - 1 + } else { + available_item_rows + }; + let start = checklist_window_start(&summary.checklist_items, max_items); + let end = start + .saturating_add(max_items) + .min(summary.checklist_items.len()); + for item in summary.checklist_items[start..end].iter() { + let prefix = match item.status { + TodoStatus::Pending => "[ ]", + TodoStatus::InProgress => "[~]", + TodoStatus::Completed => "[✓]", + }; + texts.push(format!("{prefix} #{} {}", item.id, item.content)); + } + + let earlier = start; + let later = summary.checklist_items.len().saturating_sub(end); + let remaining = earlier.saturating_add(later); + if remaining > 0 && texts.len() < max_rows { + let label = match (earlier, later) { + (0, later) => format!("+{later} more checklist items"), + (earlier, 0) => format!("+{earlier} earlier checklist items"), + (earlier, later) => format!("+{earlier} earlier, +{later} later"), + }; + texts.push(label); + } + } + + if summary.has_strategy() && texts.len() < max_rows { + if summary.checklist_items.is_empty() && !summary.strategy_steps.is_empty() { + let (pending, in_progress, completed) = summary.strategy_counts(); + let total = pending + in_progress + completed; + texts.push(format!( + "Strategy metadata {}% complete ({completed}/{total})", + summary.strategy_progress_percent() + )); + } else { + texts.push("Strategy metadata".to_string()); + } + + if let Some(explanation) = summary.strategy_explanation.as_deref() + && texts.len() < max_rows + { + texts.push(explanation.to_string()); + } + + let max_steps = max_rows + .saturating_sub(texts.len()) + .min(summary.strategy_steps.len()); + for step in summary.strategy_steps.iter().take(max_steps) { + let prefix = match step.status { + StepStatus::Pending => "[ ]", + StepStatus::InProgress => "[~]", + StepStatus::Completed => "[✓]", + }; + let mut text = format!("{prefix} {}", step.text); + if !step.elapsed.is_empty() { + let _ = write!(text, " ({})", step.elapsed); + } + texts.push(text); + } + + let remaining = summary.strategy_steps.len().saturating_sub(max_steps); + if remaining > 0 && texts.len() < max_rows { + texts.push(format!("+{remaining} more strategy steps")); + } + } + + if texts.is_empty() { + texts.push("No active work".to_string()); + } + + texts +} + fn push_work_goal_lines( summary: &SidebarWorkSummary, content_width: usize, @@ -587,7 +738,7 @@ fn render_sidebar_work(f: &mut Frame, area: Rect, app: &mut App) { &app.ui_theme, ); - let full_texts: Vec = lines.iter().map(|l| spans_to_text(&l.spans)).collect(); + let full_texts = work_panel_hover_texts(&summary, content_width.max(1), usable_rows); render_sidebar_section(f, area, "Work", lines, full_texts, app); } @@ -600,7 +751,7 @@ fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &mut App) { let usable_rows = area.height.saturating_sub(3) as usize; let lines = task_panel_lines(app, content_width.max(1), usable_rows.max(1)); - let full_texts: Vec = lines.iter().map(|l| spans_to_text(&l.spans)).collect(); + let full_texts = task_panel_hover_texts(app, usable_rows.max(1)); render_sidebar_section(f, area, "Tasks", lines, full_texts, app); } @@ -738,6 +889,86 @@ fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec Vec { + let mut texts = Vec::with_capacity(max_rows.max(4)); + + if let Some(turn_id) = app.runtime_turn_id.as_ref() { + let status = app.runtime_turn_status.as_deref().unwrap_or("unknown"); + texts.push(format!("turn {turn_id} ({status})")); + } + + let active_rows = active_tool_rows(app); + if !active_rows.is_empty() && texts.len() < max_rows { + texts.push("Live tools".to_string()); + push_tool_row_hover_texts(&mut texts, &active_rows, max_rows); + } + + let background_rows = background_task_rows(app, &active_rows); + if !background_rows.is_empty() && texts.len() < max_rows { + let running = background_rows + .iter() + .filter(|task| task.status == "running") + .count(); + let done = background_rows.len().saturating_sub(running); + let label = if running == 0 { + format!("Background commands: {done} completed") + } else if done == 0 { + format!("Background commands: {running} running") + } else { + format!("Background commands: {running} running, {done} completed") + }; + texts.push(label); + + let max_items = max_rows.saturating_sub(texts.len()); + for task in background_rows.iter().take(max_items) { + let duration = task + .duration_ms + .map(format_duration_ms) + .unwrap_or_else(|| "-".to_string()); + let (label, detail) = background_task_labels(task, &duration); + texts.push(label); + if texts.len() >= max_rows { + break; + } + texts.push(format!(" {detail}")); + } + + if texts.len() < max_rows + && background_rows + .iter() + .any(|task| task.id.starts_with("shell_") && task.status == "running") + { + texts.push("Ctrl+K -> /jobs cancel-all".to_string()); + } + } + + if texts.len() < max_rows { + let recent_rows = recent_tool_rows(app, 4); + if !recent_rows.is_empty() { + texts.push("Recent tools".to_string()); + push_tool_row_hover_texts(&mut texts, &recent_rows, max_rows); + } + } + + if texts.len() + 1 < max_rows + && app.runtime_turn_id.is_some() + && app.sidebar_focus == SidebarFocus::Tasks + { + texts.push("y -> copy turn id · Y -> copy full status".to_string()); + } + + if texts.is_empty() + || (texts.len() == 1 + && app.runtime_turn_id.is_some() + && active_rows.is_empty() + && background_rows.is_empty()) + { + texts.push("No live tools or background jobs".to_string()); + } + + texts +} + fn push_sidebar_label_theme(lines: &mut Vec>, label: &str, theme: &palette::UiTheme) { lines.push(Line::from(Span::styled( label.to_string(), @@ -745,6 +976,24 @@ fn push_sidebar_label_theme(lines: &mut Vec>, label: &str, theme: ))); } +fn push_tool_row_hover_texts(texts: &mut Vec, rows: &[SidebarToolRow], max_rows: usize) { + for row in rows { + if texts.len() >= max_rows { + break; + } + let (marker, _) = tool_status_marker(row.status, &palette::UI_THEME); + let label = if let Some(duration_ms) = row.duration_ms { + format!("{marker} {} {}", row.name, format_duration_ms(duration_ms)) + } else { + format!("{marker} {}", row.name) + }; + texts.push(label); + if !row.summary.trim().is_empty() && texts.len() < max_rows { + texts.push(format!(" {}", row.summary)); + } + } +} + fn background_task_labels(task: &TaskPanelEntry, duration: &str) -> (String, String) { if let Some(command) = task.prompt_summary.strip_prefix("shell: ") { let command = concise_shell_command_label(command, 96); @@ -1520,8 +1769,9 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) { usable_rows.max(1), &app.ui_theme, ); + let full_texts = subagent_panel_hover_texts(&summary, &rows, usable_rows.max(1)); - render_sidebar_section(f, area, "Agents", lines, Vec::new(), app); + render_sidebar_section(f, area, "Agents", lines, full_texts, app); } /// Minimal projection of the data the sub-agent sidebar needs. Lifted out @@ -1747,6 +1997,84 @@ pub fn subagent_panel_lines( lines } +fn subagent_panel_hover_texts( + summary: &SidebarSubagentSummary, + rows: &[SidebarAgentRow], + max_rows: usize, +) -> Vec { + let mut texts = Vec::with_capacity(max_rows.max(4)); + + let fanout_total = summary.fanout_total.unwrap_or(0); + if summary.cached_total == 0 + && summary.progress_only_count == 0 + && fanout_total == 0 + && !summary.foreground_rlm_running + { + texts.push("No agents".to_string()); + return texts; + } + + let (live_running, total) = if let Some(total) = summary.fanout_total { + (summary.fanout_running, total) + } else { + ( + summary.cached_running + summary.progress_only_count, + summary.cached_total + summary.progress_only_count, + ) + }; + let done = total.saturating_sub(live_running); + if live_running > 0 { + texts.push(format!("{live_running} running / {total}")); + } else { + texts.push(format!("{done} done")); + } + + if !summary.role_counts.is_empty() && texts.len() < max_rows { + let mix: Vec = summary + .role_counts + .iter() + .map(|(role, count)| format!("{count} {role}")) + .collect(); + texts.push(mix.join(" · ")); + } + + for row in rows { + if texts.len() >= max_rows { + break; + } + let (marker, _) = agent_status_marker(row.status.as_str(), &palette::UI_THEME); + texts.push(format!("{marker} {} {}", row.role, row.name)); + + if row.status == "done" { + continue; + } + + if texts.len() >= max_rows { + break; + } + let mut detail_parts = Vec::new(); + detail_parts.push(row.id.clone()); + if row.steps_taken > 0 { + detail_parts.push(format!("{} step(s)", row.steps_taken)); + } + if let Some(duration) = row.duration_ms { + detail_parts.push(format_duration_ms(duration)); + } + if let Some(progress) = row.progress.as_deref() + && !progress.trim().is_empty() + { + detail_parts.push(summarize_tool_output(progress)); + } + texts.push(format!(" {}", detail_parts.join(" · "))); + } + + if summary.foreground_rlm_running && texts.len() < max_rows { + texts.push("RLM foreground work active".to_string()); + } + + texts +} + fn agent_status_marker( status: &str, theme: &palette::UiTheme, @@ -1922,9 +2250,26 @@ fn render_sidebar_section( width: area.width.saturating_sub(2 + padding.left + padding.right), height: area.height.saturating_sub(2 + padding.top + padding.bottom), }; + let display_texts: Vec = lines + .iter() + .map(|line| spans_to_text(&line.spans)) + .collect(); + let hover_texts: Vec = display_texts + .iter() + .enumerate() + .map(|(idx, display)| { + full_texts + .get(idx) + .filter(|text| !text.trim().is_empty()) + .cloned() + .unwrap_or_else(|| display.clone()) + }) + .collect(); + let rows = sidebar_hover_rows(content_area, &display_texts, &hover_texts); app.sidebar_hover.sections.push(SidebarHoverSection { content_area, - lines: full_texts, + lines: hover_texts, + rows, }); // Truncate the panel title so it always fits within the section width // even after a resize. The title occupies up to 4 chars of border chrome @@ -1964,15 +2309,42 @@ fn render_sidebar_section( f.render_widget(section, area); } +fn sidebar_hover_rows( + content_area: Rect, + display_texts: &[String], + hover_texts: &[String], +) -> Vec { + display_texts + .iter() + .zip(hover_texts.iter()) + .enumerate() + .map(|(idx, (display_text, full_text))| { + let row_y = content_area.y.saturating_add(idx as u16); + let display_width = unicode_width::UnicodeWidthStr::width(display_text.as_str()); + let full_width = unicode_width::UnicodeWidthStr::width(full_text.as_str()); + SidebarHoverRow { + row_y, + display_text: display_text.clone(), + full_text: full_text.clone(), + detail: None, + is_truncated: display_width > content_area.width as usize + || full_width > content_area.width as usize + || display_text != full_text, + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::{ ACTIVE_TOOL_COMPLETED_ROW_TTL, ACTIVE_TOOL_STALE_RUNNING_ROW_TTL, AutoSidebarPanel, - AutoSidebarState, SidebarAgentRow, SidebarHoverSection, SidebarHoverState, + AutoSidebarState, SidebarAgentRow, SidebarHoverRow, SidebarHoverSection, SidebarHoverState, SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkStrategyStep, SidebarWorkSummary, ToolRowOrder, auto_sidebar_panels, editorial_tool_rows, - normalize_activity_text, sidebar_work_summary, subagent_panel_lines, task_panel_lines, - work_panel_empty_hint, work_panel_lines, + normalize_activity_text, sidebar_hover_rows, sidebar_work_summary, + subagent_panel_hover_texts, subagent_panel_lines, task_panel_lines, work_panel_empty_hint, + work_panel_hover_texts, work_panel_lines, }; use crate::config::Config; use crate::palette; @@ -2991,6 +3363,7 @@ mod tests { let section = SidebarHoverSection { content_area: Rect::new(1, 1, 38, 8), lines: vec!["line 1".to_string(), "line 2".to_string()], + rows: vec![], }; assert_eq!(section.lines.len(), 2); assert_eq!(section.lines[0], "line 1"); @@ -3007,6 +3380,7 @@ mod tests { "second".to_string(), "third".to_string(), ], + rows: vec![], }; // Mouse within content area, first line @@ -3020,4 +3394,88 @@ mod tests { // Mouse outside content area (above) — row < content_area.y assert!((1u16) < section.content_area.y); } + + #[test] + fn work_hover_text_preserves_full_checklist_item() { + let long_item = + "Add ProviderKind::HuggingFace direct route with all auth and docs coverage"; + let summary = SidebarWorkSummary { + checklist_completion_pct: 0, + checklist_items: vec![SidebarWorkChecklistItem { + id: 7, + content: long_item.to_string(), + status: TodoStatus::InProgress, + }], + ..SidebarWorkSummary::default() + }; + + let display = lines_to_text(&work_panel_lines( + &summary, + 18, + 4, + PaletteMode::Dark, + &palette::UI_THEME, + )); + let hover = work_panel_hover_texts(&summary, 18, 4); + + assert!( + display.iter().any(|line| line.contains("...")), + "compact Work row should be ellipsized in this fixture: {display:?}" + ); + assert!( + hover.iter().any(|line| line.contains(long_item)), + "hover text should retain the full checklist item: {hover:?}" + ); + } + + #[test] + fn sidebar_hover_rows_mark_source_text_diff_as_truncated() { + use ratatui::layout::Rect; + let display = vec!["[~] agent imple…".to_string()]; + let full = vec!["[~] agent implementation-worker-for-sidebar-detail-popover".to_string()]; + let rows = sidebar_hover_rows(Rect::new(62, 5, 16, 4), &display, &full); + + let expected = SidebarHoverRow { + row_y: 5, + display_text: display[0].clone(), + full_text: full[0].clone(), + detail: None, + is_truncated: true, + }; + assert_eq!(rows, vec![expected]); + } + + #[test] + fn subagent_hover_text_preserves_full_agent_id_and_progress() { + let mut role_counts = std::collections::BTreeMap::new(); + role_counts.insert("worker".to_string(), 1); + let summary = SidebarSubagentSummary { + cached_total: 1, + cached_running: 1, + role_counts, + ..SidebarSubagentSummary::default() + }; + let long_id = "019e9142-83f6-7713-87f1-28902e74bf05"; + let long_progress = + "currently reviewing sidebar hover popover wrapping and hitbox metadata"; + let rows = vec![SidebarAgentRow { + id: long_id.to_string(), + name: "sidebar-detail-worker-with-long-name".to_string(), + role: "worker".to_string(), + status: "running".to_string(), + progress: Some(long_progress.to_string()), + steps_taken: 9, + duration_ms: Some(12_345), + }]; + + let hover = subagent_panel_hover_texts(&summary, &rows, 5); + assert!( + hover.iter().any(|line| line.contains(long_id)), + "hover text should include the full agent id: {hover:?}" + ); + assert!( + hover.iter().any(|line| line.contains(long_progress)), + "hover text should include the full progress before popover wrapping: {hover:?}" + ); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index d1f3abc9..f9ca48c4 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -106,7 +106,9 @@ use crate::tui::tool_routing::exploring_label; use crate::tui::tool_routing::{ handle_tool_call_complete, handle_tool_call_started, maybe_add_patch_preview, }; -use crate::tui::ui_text::{history_cell_to_text, line_to_plain, truncate_line_to_width}; +use crate::tui::ui_text::{ + history_cell_to_text, line_to_plain, text_display_width, truncate_line_to_width, +}; use crate::tui::user_input::UserInputView; use crate::tui::views::subagent_view_agents; use crate::tui::vim_mode; @@ -3447,6 +3449,10 @@ async fn run_event_loop( app.mention_menu_hidden = true; app.mention_menu_selected = 0; } + KeyCode::Esc if app.sidebar_hover_tooltip.is_some() => { + app.sidebar_hover_tooltip = None; + app.needs_redraw = true; + } KeyCode::Esc => { match next_escape_action(app, slash_menu_open) { EscapeAction::CloseSlashMenu => { @@ -6778,34 +6784,55 @@ fn render(f: &mut Frame, app: &mut App) { } } - // Render sidebar hover tooltip if active. + // Render sidebar hover popover if active. if let Some(ref tooltip_text) = app.sidebar_hover_tooltip && let Some((mouse_col, mouse_row)) = app.last_mouse_pos { - let text_width = (tooltip_text.len() as u16).clamp(10, 60); - let tooltip_height = 1u16; - let x = mouse_col - .saturating_add(2) - .min(size.width.saturating_sub(text_width)); - // Sit one row BELOW the cursor so the tooltip never paints over - // the row above the hovered line (which read as corruption). - let y = mouse_row - .saturating_add(1) - .min(size.height.saturating_sub(tooltip_height)); - if text_width > 0 && tooltip_height > 0 { + let max_popup_width = 72u16.min(size.width.saturating_sub(4)); + if max_popup_width >= 10 && size.height >= 3 { + let popup_width = tooltip_text + .lines() + .map(text_display_width) + .max() + .unwrap_or(0) + .saturating_add(2) + .clamp(12, max_popup_width as usize) + as u16; + let inner_width = popup_width.saturating_sub(2).max(1) as usize; + let wrapped_rows = tooltip_text.lines().fold(0u16, |rows, line| { + let width = text_display_width(line); + rows.saturating_add(((width.max(1) - 1) / inner_width + 1) as u16) + }); + let popup_content_height = wrapped_rows.clamp(1, 10); + let popup_height = popup_content_height.saturating_add(2); + let x = mouse_col + .saturating_add(2) + .min(size.width.saturating_sub(popup_width)); + // Sit one row BELOW the cursor so the tooltip never paints over + // the row above the hovered line (which read as corruption). + let y = mouse_row + .saturating_add(1) + .min(size.height.saturating_sub(popup_height)); let tooltip_area = Rect { x, y, - width: text_width, - height: tooltip_height, + width: popup_width, + height: popup_height, }; - // Neutral elevated-surface styling so the tooltip reads as a - // tooltip, not a warning highlight (was STATUS_WARNING). - let tooltip = ratatui::widgets::Paragraph::new(tooltip_text.as_str()).style( - Style::default() - .bg(palette::SURFACE_ELEVATED) - .fg(palette::TEXT_PRIMARY), - ); + // Neutral elevated-surface styling so the popover reads as a + // detail surface, not a warning highlight. + let tooltip = ratatui::widgets::Paragraph::new(tooltip_text.as_str()) + .wrap(ratatui::widgets::Wrap { trim: false }) + .block( + Block::default() + .borders(ratatui::widgets::Borders::ALL) + .border_style(Style::default().fg(palette::DEEPSEEK_BLUE)) + .style( + Style::default() + .bg(palette::SURFACE_ELEVATED) + .fg(palette::TEXT_PRIMARY), + ), + ); f.render_widget(tooltip, tooltip_area); } } diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index f9aeed2b..3d009c38 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -50,6 +50,7 @@ harvest/stewardship commits: | #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | +| #2734 sidebar detail popovers | Locally harvested as the mouse-hover slice for #2694. | Work/Tasks/Agents hover metadata now stores row hitboxes, compact display text, and full source text so truncated checklist items, task/turn ids, and sub-agent ids/progress expand into a bordered wrapping popover. The harvest fixes reviewer risks from the PR by treating row metadata as authoritative, sizing by display width instead of bytes, and keeping source text untruncated. `cargo test -p codewhale-tui --bin codewhale-tui --locked sidebar_hover -- --nocapture`, `... work_hover_text_preserves_full_checklist_item ...`, and `... subagent_hover_text_preserves_full_agent_id_and_progress ...` passed. Credit @idling11; keep #2694 open for keyboard access, richer Work/Tasks/Agents metadata, redaction expansion, and clipping/snapshot coverage. | | #2532 pending-input delivery-mode labels | Locally re-harvested for #2054. | Pending-input preview rows now label steer-pending, rejected-steer, and queued-follow-up delivery modes, and wrapped continuation rows align under the label. `cargo test -p codewhale-tui --bin codewhale-tui --locked pending_input_preview -- --nocapture` passed. Credit @cyq1017; #2054 remains open for cancel/edit-mode affordance clarity. | | #697/#1827 bounded auto-generated project context | Locally implemented from the stabilization audit. | When no project instructions exist, startup now writes `.codewhale/instructions.md` from the bounded Project Context Pack data instead of an unbounded summary/tree scan. The generated file avoids the dynamic `` marker when that setting is disabled, keeps later top-level folders visible, and omits noisy directory tails. `cargo test -p codewhale-tui --bin codewhale-tui --locked auto_generated_context_is_bounded_for_many_file_workspace -- --nocapture` and `cargo test -p codewhale-tui --bin codewhale-tui --locked project_context_pack -- --nocapture` passed. Credit reporters @NASLXTO and @wuxixing, plus earlier context-cap/startup work from @linzhiqin2003 and @merchloubna70-dot; leave #697/#1827 open pending real massive-repo/manual startup verification. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | @@ -138,6 +139,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit. | #2730 canonical codewhale settings path | Mergeable | Already harvested as `9e15805f6`; follow-up reviewer assertion added locally. Comment/close original after integration branch is public, crediting @xyuai and issue #2664. | | #2732 pausable command lifecycle | Draft/mergeable | Defer; review flagged behavior changes. | | #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | +| #2734 sidebar detail popovers | Mergeable / locally harvested | Harvested the mouse-hover popover slice with row-source fixes and tests. Comment on the original after the integration branch is public, crediting @idling11; leave #2694 open for keyboard navigation and richer structured detail acceptance criteria. | | #2736 sub-agent model inheritance | Mergeable | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2738 dense tool-call transcript collapse | Mergeable / locally harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Comment/close original after the integration branch is public, crediting @idling11 and issue #2692. |