feat(sidebar): show full details for truncated rows
Harvested from PR #2734 by @idling11 with reviewer fixes for row-source fidelity, row-authoritative hit testing, and display-width popover sizing. Refs #2694. Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com>
This commit is contained in:
+6
-1
@@ -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
|
||||
|
||||
@@ -1143,6 +1143,21 @@ pub struct SidebarHoverState {
|
||||
pub sections: Vec<SidebarHoverSection>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Per-row metadata for rich hover popovers.
|
||||
pub rows: Vec<SidebarHoverRow>,
|
||||
}
|
||||
|
||||
impl Default for SessionState {
|
||||
|
||||
@@ -303,11 +303,9 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
|
||||
// Update last mouse position for tooltip rendering.
|
||||
app.last_mouse_pos = Some((mouse.column, mouse.row));
|
||||
|
||||
// Check sidebar sections for hover tooltip. Only surface a tooltip
|
||||
// when the hovered line was actually truncated to fit the panel
|
||||
// width — otherwise it just paints a redundant copy of
|
||||
// already-visible text over the neighbouring row, which reads as
|
||||
// visual corruption.
|
||||
// Check sidebar sections for hover popovers. Only surface a
|
||||
// popover when the hovered row lost information in the compact
|
||||
// sidebar view.
|
||||
let mut found = false;
|
||||
for section in &app.sidebar_hover.sections {
|
||||
if mouse.column >= section.content_area.x
|
||||
@@ -323,17 +321,35 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
|
||||
.y
|
||||
.saturating_add(section.content_area.height)
|
||||
{
|
||||
let line_idx = (mouse.row.saturating_sub(section.content_area.y)) as usize;
|
||||
if let Some(full) = section.lines.get(line_idx) {
|
||||
let truncated = UnicodeWidthStr::width(full.as_str())
|
||||
> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> = 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<String> = 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<Lin
|
||||
lines
|
||||
}
|
||||
|
||||
fn task_panel_hover_texts(app: &App, max_rows: usize) -> Vec<String> {
|
||||
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<Line<'static>>, 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<Line<'static>>, label: &str, theme:
|
||||
)));
|
||||
}
|
||||
|
||||
fn push_tool_row_hover_texts(texts: &mut Vec<String>, 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<String> {
|
||||
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<String> = 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<String> = lines
|
||||
.iter()
|
||||
.map(|line| spans_to_text(&line.spans))
|
||||
.collect();
|
||||
let hover_texts: Vec<String> = 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<SidebarHoverRow> {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+49
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `<project_context_pack>` 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. |
|
||||
|
||||
Reference in New Issue
Block a user