feat(transcript): collapse dense tool runs

Harvested from PR #2738 by @idling11.

Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com>
This commit is contained in:
Hunter B
2026-06-03 22:00:46 -07:00
parent 55024a16d8
commit c76ec47526
10 changed files with 771 additions and 29 deletions
+6 -1
View File
@@ -60,6 +60,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tool-agent sub-agent routing now inherits the parent session model, or an
explicit tool-agent override, instead of hard-coding `deepseek-v4-flash`;
the fast lane still disables thinking through provider-aware request shaping.
- Dense successful read/search/list tool runs now collapse into a single
expandable transcript row by default, while running, failed, shell, patch,
review, diff, and other risky tool cells remain visible. The setting
`tool_collapse = "compact" | "expanded" | "calm"` controls the behavior.
### Community
@@ -70,7 +74,8 @@ prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale
settings-path migration work (#2730), **@gaord** for the runtime thread
workspace update API (#2640), **@shenjackyuanjie** for the
HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634),
**@idling11** for the PlanArtifact direction in Plan mode (#2733), and
**@idling11** for the PlanArtifact direction in Plan mode (#2733) and the
dense tool-call transcript collapse direction (#2738, #2692), and
**@h3c-hexin** for the tool-agent model inheritance and configured
`skills_dir` fixes (#2736, #2737).
+9
View File
@@ -204,6 +204,9 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult {
"max_history" | "history" => Some(app.max_input_history.to_string()),
"sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()),
"sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()),
"tool_collapse" | "tool_collapse_mode" | "collapse" => {
Some(app.tool_collapse_mode.as_setting().to_string())
}
"context_panel" | "context" | "session_panel" => {
Some(if app.context_panel { "true" } else { "false" }.to_string())
}
@@ -847,6 +850,12 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
app.mark_history_updated();
}
"tool_collapse" | "tool_collapse_mode" | "collapse" => {
app.tool_collapse_mode =
crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode);
app.expanded_tool_runs.clear();
app.mark_history_updated();
}
"default_mode" | "mode" => {
let mode = AppMode::from_setting(&settings.default_mode);
app.set_mode(mode);
+49
View File
@@ -203,6 +203,8 @@ pub struct Settings {
pub auto_compact_threshold_percent: f64,
/// Reduce status noise and collapse details more aggressively
pub calm_mode: bool,
/// Dense tool-run collapse mode: compact, expanded, or calm.
pub tool_collapse_mode: String,
/// Streaming pacing mode. `true` pins the chunker to one-character-per-
/// commit-tick (typewriter); `false` drains the upstream cadence (each
/// commit flushes everything queued, which matches V4-pro's burst pattern
@@ -330,6 +332,7 @@ impl Default for Settings {
auto_compact: false,
auto_compact_threshold_percent: 80.0,
calm_mode: false,
tool_collapse_mode: "compact".to_string(),
low_motion: false,
fancy_animations: true,
bracketed_paste: true,
@@ -401,6 +404,7 @@ impl Settings {
s.default_mode = normalize_mode(&s.default_mode).to_string();
s.composer_density = normalize_composer_density(&s.composer_density).to_string();
s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string();
s.tool_collapse_mode = normalize_tool_collapse_mode(&s.tool_collapse_mode).to_string();
s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string();
s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string();
s.synchronized_output =
@@ -565,6 +569,15 @@ impl Settings {
"calm_mode" | "calm" => {
self.calm_mode = parse_bool(value)?;
}
"tool_collapse" | "tool_collapse_mode" | "collapse" => {
let normalized = normalize_tool_collapse_mode(value);
if !matches!(normalized, "compact" | "expanded" | "calm") {
return Err(anyhow::anyhow!(
"Failed to update setting: invalid tool collapse mode '{value}'. Expected: compact, expanded, or calm."
));
}
self.tool_collapse_mode = normalized.to_string();
}
"low_motion" | "motion" => {
self.low_motion = parse_bool(value)?;
}
@@ -774,6 +787,7 @@ impl Settings {
self.auto_compact_threshold_percent
));
lines.push(format!(" calm_mode: {}", self.calm_mode));
lines.push(format!(" tool_collapse: {}", self.tool_collapse_mode));
lines.push(format!(" low_motion: {}", self.low_motion));
lines.push(format!(" fancy_animations: {}", self.fancy_animations));
lines.push(format!(" bracketed_paste: {}", self.bracketed_paste));
@@ -849,6 +863,10 @@ impl Settings {
"Auto-compact trigger threshold percent when auto_compact is on: 10-100 (default 80)",
),
("calm_mode", "Calmer UI defaults: on/off"),
(
"tool_collapse",
"Dense tool-run collapse mode: compact, expanded, calm",
),
(
"low_motion",
"Streaming pacing: on = typewriter (one char/tick), off = upstream cadence",
@@ -1178,6 +1196,15 @@ fn normalize_transcript_spacing(value: &str) -> &str {
}
}
fn normalize_tool_collapse_mode(value: &str) -> &str {
match value.trim().to_ascii_lowercase().as_str() {
"compact" | "default" | "on" | "true" => "compact",
"expanded" | "expand" | "off" | "none" | "false" => "expanded",
"calm" | "calm_mode" | "calm-mode" | "calm_only" | "calm-only" => "calm",
_ => value,
}
}
/// Normalize the `status_indicator` header chip setting. Accepts the
/// canonical names plus common aliases ("none"/"hidden" → "off",
/// "dot" → "dots"). Unknown values fall through unchanged so the parser
@@ -1542,6 +1569,28 @@ mod tests {
assert!(!settings.context_panel);
}
#[test]
fn tool_collapse_mode_is_configurable() {
let mut settings = Settings::default();
assert_eq!(settings.tool_collapse_mode, "compact");
settings
.set("tool_collapse", "expanded")
.expect("expanded mode");
assert_eq!(settings.tool_collapse_mode, "expanded");
settings.set("collapse", "calm-only").expect("calm alias");
assert_eq!(settings.tool_collapse_mode, "calm");
settings.set("collapse", "off").expect("off alias");
assert_eq!(settings.tool_collapse_mode, "expanded");
let err = settings
.set("tool_collapse", "mystery")
.expect_err("invalid collapse mode");
assert!(err.to_string().contains("invalid tool collapse mode"));
}
#[test]
fn display_localizes_header_and_config_file_label() {
let settings = Settings::default();
+148 -1
View File
@@ -327,6 +327,46 @@ impl SidebarFocus {
}
}
/// Controls how dense tool-call runs are collapsed in the transcript.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolCollapseMode {
/// Collapse qualifying tool runs by default.
Compact,
/// Never collapse tool runs automatically.
Expanded,
/// Collapse only when calm mode is active.
Calm,
}
impl ToolCollapseMode {
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"expanded" | "off" | "none" => Self::Expanded,
"calm" | "calm-mode" | "calm_only" | "calm-only" => Self::Calm,
_ => Self::Compact,
}
}
#[must_use]
pub fn as_setting(self) -> &'static str {
match self {
Self::Compact => "compact",
Self::Expanded => "expanded",
Self::Calm => "calm",
}
}
#[must_use]
pub fn is_active(self, calm_mode: bool) -> bool {
match self {
Self::Compact => true,
Self::Expanded => false,
Self::Calm => calm_mode,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusToastLevel {
Info,
@@ -1320,6 +1360,12 @@ pub struct App {
pub sidebar_width_dirty: bool,
/// Whether the session-context panel is enabled (#504).
pub context_panel: bool,
/// Minimum number of consecutive safe tool cells needed for auto-collapse.
pub tool_collapse_threshold: usize,
/// Tool runs the user explicitly expanded. Stores original history indices.
pub expanded_tool_runs: HashSet<usize>,
/// Current dense tool-run collapse behavior.
pub tool_collapse_mode: ToolCollapseMode,
/// File-tree pane state. `None` when hidden; `Some` when visible.
pub file_tree: Option<crate::tui::file_tree::FileTreeState>,
/// Whether the file-tree pane was actually rendered in the last frame.
@@ -2048,6 +2094,9 @@ impl App {
sidebar_resize_total_width: 0,
sidebar_width_dirty: false,
context_panel: settings.context_panel,
tool_collapse_threshold: 3,
expanded_tool_runs: HashSet::new(),
tool_collapse_mode: ToolCollapseMode::from_setting(&settings.tool_collapse_mode),
file_tree: None,
file_tree_visible: false,
compact_threshold,
@@ -2607,6 +2656,10 @@ impl App {
.into_iter()
.filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
.collect();
self.expanded_tool_runs = std::mem::take(&mut self.expanded_tool_runs)
.into_iter()
.filter_map(|idx| if idx >= n { Some(idx - n) } else { None })
.collect();
self.collapsed_cell_map.clear();
}
@@ -2701,6 +2754,7 @@ impl App {
self.session_context_references.clear();
self.session_artifacts.clear();
self.collapsed_cells.clear();
self.expanded_tool_runs.clear();
self.collapsed_cell_map.clear();
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
@@ -2713,6 +2767,8 @@ impl App {
self.history_revisions.pop();
self.context_references_by_cell.remove(&self.history.len());
self.rebuild_session_context_references();
self.expanded_tool_runs
.retain(|idx| *idx < self.history.len());
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
@@ -2750,11 +2806,42 @@ impl App {
}
// Drop collapsed cells that reference indices past the new tail.
self.collapsed_cells.retain(|idx| *idx < new_len);
self.expanded_tool_runs.retain(|idx| *idx < new_len);
self.collapsed_cell_map.clear();
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
#[must_use]
pub fn tool_collapse_active(&self) -> bool {
self.tool_collapse_threshold > 0 && self.tool_collapse_mode.is_active(self.calm_mode)
}
#[must_use]
pub fn tool_run_start_for_history_index(&self, index: usize) -> Option<usize> {
if !self.tool_collapse_active() || index >= self.history.len() {
return None;
}
crate::tui::history::detect_tool_runs(&self.history, self.tool_collapse_threshold)
.into_iter()
.find(|run| index >= run.start && index < run.start.saturating_add(run.count))
.map(|run| run.start)
}
pub fn toggle_tool_run_expansion_at(&mut self, index: usize) -> bool {
let Some(start) = self.tool_run_start_for_history_index(index) else {
return false;
};
if self.expanded_tool_runs.remove(&start) {
self.status_message = Some("Tool group collapsed".to_string());
} else {
self.expanded_tool_runs.insert(start);
self.status_message = Some("Tool group expanded".to_string());
}
self.mark_history_updated();
true
}
/// Bump the active-cell revision counter and request a redraw.
///
/// Use this whenever an entry inside `active_cell` is mutated. The
@@ -2787,6 +2874,14 @@ impl App {
self.virtual_cell_count()
}
#[must_use]
pub fn original_cell_index_for_rendered(&self, rendered_index: usize) -> usize {
self.collapsed_cell_map
.get(rendered_index)
.copied()
.unwrap_or(rendered_index)
}
/// Resolve a virtual cell index to either a committed history cell or an
/// active-cell entry. Used by the pager / details lookup code so it can
/// transparently address still-in-flight cells.
@@ -2842,7 +2937,7 @@ impl App {
.ordered_endpoints()
.and_then(|(start, _)| line_meta.get(start.line_index))
.and_then(TranscriptLineMeta::cell_line)
.map(|(cell_index, _)| cell_index)
.map(|(cell_index, _)| self.original_cell_index_for_rendered(cell_index))
.filter(|&idx| self.cell_has_detail_target(idx));
if selected_cell.is_some() {
return selected_cell;
@@ -2854,6 +2949,7 @@ impl App {
let Some((cell_index, _)) = meta.cell_line() else {
continue;
};
let cell_index = self.original_cell_index_for_rendered(cell_index);
if self.cell_has_detail_target(cell_index) {
return Some(cell_index);
}
@@ -4996,6 +5092,7 @@ mod tests {
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
use crate::tools::todo::TodoStatus;
use crate::tui::clipboard::PastedImage;
use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus};
fn test_options(yolo: bool) -> TuiOptions {
TuiOptions {
@@ -6155,6 +6252,56 @@ mod tests {
assert!(app.history_version > initial_version);
}
#[test]
fn expanded_tool_runs_rebase_when_history_prefix_shifts() {
let mut app = App::new(test_options(false), &Config::default());
app.expanded_tool_runs = std::collections::HashSet::from([2usize, 6usize]);
app.shift_history_maps_down(3);
assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([3]));
}
#[test]
fn expanded_tool_runs_prune_when_history_is_truncated() {
let mut app = App::new(test_options(false), &Config::default());
for idx in 0..5 {
app.add_message(HistoryCell::System {
content: format!("cell {idx}"),
});
}
app.expanded_tool_runs = std::collections::HashSet::from([1usize, 4usize]);
app.truncate_history_to(3);
assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([1]));
}
#[test]
fn tool_run_expansion_toggle_opens_and_closes_run() {
let mut app = App::new(test_options(false), &Config::default());
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
for name in ["read_file", "list_dir", "web_search"] {
app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Success,
input_summary: None,
output: Some("ok".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
}
assert!(app.toggle_tool_run_expansion_at(0));
assert!(app.expanded_tool_runs.contains(&0));
assert!(app.toggle_tool_run_expansion_at(2));
assert!(!app.expanded_tool_runs.contains(&0));
assert!(!app.toggle_tool_run_expansion_at(99));
}
#[test]
fn test_scroll_operations() {
let mut app = App::new(test_options(false), &Config::default());
+298
View File
@@ -683,6 +683,68 @@ pub enum ToolCell {
}
impl ToolCell {
/// Status for cells that have a concrete lifecycle state.
pub fn status(&self) -> Option<ToolStatus> {
match self {
ToolCell::Exec(cell) => Some(cell.status),
ToolCell::Exploring(cell) => {
let has_running = cell
.entries
.iter()
.any(|entry| entry.status == ToolStatus::Running);
let has_failed = cell
.entries
.iter()
.any(|entry| entry.status == ToolStatus::Failed);
Some(if has_running {
ToolStatus::Running
} else if has_failed {
ToolStatus::Failed
} else {
ToolStatus::Success
})
}
ToolCell::PlanUpdate(cell) => Some(cell.status),
ToolCell::PatchSummary(cell) => Some(cell.status),
ToolCell::Review(cell) => Some(cell.status),
ToolCell::Mcp(cell) => Some(cell.status),
ToolCell::WebSearch(cell) => Some(cell.status),
ToolCell::Generic(cell) => Some(cell.status),
ToolCell::DiffPreview(_) | ToolCell::ViewImage(_) => Some(ToolStatus::Success),
}
}
#[must_use]
pub fn is_success(&self) -> bool {
self.status() == Some(ToolStatus::Success)
}
#[must_use]
pub fn is_running(&self) -> bool {
self.status() == Some(ToolStatus::Running)
}
#[must_use]
pub fn is_failed(&self) -> bool {
self.status() == Some(ToolStatus::Failed)
}
/// Whether this cell should stay visible even inside a dense tool run.
#[must_use]
pub fn is_collapsible_guard(&self) -> bool {
self.is_running()
|| self.is_failed()
|| matches!(
self,
ToolCell::Exec(_)
| ToolCell::PatchSummary(_)
| ToolCell::Review(_)
| ToolCell::DiffPreview(_)
| ToolCell::PlanUpdate(_)
)
|| matches!(self, ToolCell::Generic(cell) if generic_tool_name_is_collapse_guard(&cell.name) || cell.is_diff)
}
/// Render the tool cell into lines.
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
self.lines_with_motion(width, false)
@@ -715,6 +777,104 @@ impl ToolCell {
}
}
// ── Tool-run grouping for transcript collapse (#2692) ──────────────
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolRun {
/// Original index of the first tool cell in `App::history`.
pub start: usize,
/// Number of collapsed cells in the run.
pub count: usize,
/// Dominant tool names, deduplicated and capped for summary rendering.
pub tool_families: Vec<String>,
}
/// Detect contiguous runs of successful, low-risk tool cells.
///
/// Failed, running, shell, patch, review, diff, and plan-update cells split
/// runs so important state never disappears into a summary row.
pub fn detect_tool_runs(history: &[HistoryCell], min_size: usize) -> Vec<ToolRun> {
if min_size == 0 {
return Vec::new();
}
let mut runs = Vec::new();
let mut index = 0;
while index < history.len() {
if !is_collapsible_tool_cell(&history[index]) {
index += 1;
continue;
}
let start = index;
let mut names: Vec<String> = Vec::new();
while index < history.len() && is_collapsible_tool_cell(&history[index]) {
if let HistoryCell::Tool(tool) = &history[index] {
let name = tool_display_name(tool);
if !names.iter().any(|existing| existing == name) {
names.push(name.to_string());
}
}
index += 1;
}
let count = index - start;
if count >= min_size {
names.truncate(3);
runs.push(ToolRun {
start,
count,
tool_families: names,
});
}
}
runs
}
fn is_collapsible_tool_cell(cell: &HistoryCell) -> bool {
matches!(cell, HistoryCell::Tool(tool) if tool.is_success() && !tool.is_collapsible_guard())
}
fn generic_tool_name_is_collapse_guard(name: &str) -> bool {
let normalized = name.trim().to_ascii_lowercase();
normalized.contains("patch")
|| normalized.contains("write")
|| normalized.contains("edit")
|| normalized.contains("delete")
|| normalized.contains("remove")
|| normalized.contains("commit")
|| normalized.contains("push")
|| normalized.contains("shell")
|| normalized.contains("exec")
|| normalized.contains("review")
}
fn tool_display_name(tool: &ToolCell) -> &str {
match tool {
ToolCell::Generic(cell) => cell.name.as_str(),
ToolCell::Mcp(cell) => cell.tool.as_str(),
ToolCell::WebSearch(_) => "web_search",
ToolCell::ViewImage(_) => "view_image",
ToolCell::Exploring(_) => "explore",
ToolCell::Exec(_) => "shell",
ToolCell::PlanUpdate(_) => "update_plan",
ToolCell::PatchSummary(_) => "apply_patch",
ToolCell::Review(_) => "review",
ToolCell::DiffPreview(_) => "diff",
}
}
#[must_use]
pub fn tool_run_summary(run: &ToolRun) -> String {
let tools = if run.tool_families.is_empty() {
"tools".to_string()
} else {
run.tool_families.join(", ")
};
format!("{} tools ({tools}) · all ok", run.count)
}
/// Overall status for a tool execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolStatus {
@@ -5393,4 +5553,142 @@ mod tests {
assert_eq!(label_span.content.as_ref(), "Info");
assert_eq!(label_span.style.fg, Some(palette::TEXT_DIM));
}
fn success_generic_tool(name: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Success,
input_summary: Some(format!("args for {name}")),
output: Some(format!("output for {name}")),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}))
}
fn failed_generic_tool(name: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Failed,
input_summary: None,
output: Some("failed".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}))
}
fn running_generic_tool(name: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Running,
input_summary: None,
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}))
}
fn shell_tool(command: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: command.to_string(),
status: ToolStatus::Success,
output: Some("ok".to_string()),
started_at: None,
duration_ms: None,
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
}))
}
#[test]
fn detect_tool_runs_finds_contiguous_successful_safe_tools() {
let history = vec![
HistoryCell::User {
content: "go".to_string(),
},
success_generic_tool("read_file"),
success_generic_tool("list_dir"),
success_generic_tool("web_search"),
HistoryCell::Assistant {
content: "done".to_string(),
streaming: false,
},
];
let runs = super::detect_tool_runs(&history, 3);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].start, 1);
assert_eq!(runs[0].count, 3);
assert_eq!(
runs[0].tool_families,
vec!["read_file", "list_dir", "web_search"]
);
}
#[test]
fn detect_tool_runs_honors_threshold_and_boundaries() {
let short = vec![
success_generic_tool("read_file"),
success_generic_tool("list_dir"),
];
assert!(super::detect_tool_runs(&short, 3).is_empty());
let with_assistant_boundary = vec![
success_generic_tool("read_file"),
HistoryCell::Assistant {
content: "pause".to_string(),
streaming: false,
},
success_generic_tool("list_dir"),
success_generic_tool("web_search"),
];
assert!(super::detect_tool_runs(&with_assistant_boundary, 3).is_empty());
}
#[test]
fn detect_tool_runs_keeps_failed_running_and_shell_cells_visible() {
let history = vec![
success_generic_tool("read_file"),
success_generic_tool("list_dir"),
failed_generic_tool("web_search"),
success_generic_tool("read_file"),
success_generic_tool("list_dir"),
running_generic_tool("web_search"),
success_generic_tool("read_file"),
success_generic_tool("list_dir"),
shell_tool("rm -rf target"),
success_generic_tool("read_file"),
success_generic_tool("list_dir"),
success_generic_tool("web_search"),
];
let runs = super::detect_tool_runs(&history, 3);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].start, 9);
assert_eq!(runs[0].count, 3);
}
#[test]
fn tool_run_summary_reports_compact_success_group() {
let run = super::ToolRun {
start: 4,
count: 5,
tool_families: vec!["read_file".to_string(), "list_dir".to_string()],
};
let summary = super::tool_run_summary(&run);
assert!(summary.contains("5 tools"));
assert!(summary.contains("read_file"));
assert!(summary.contains("list_dir"));
assert!(summary.contains("all ok"));
}
}
+19 -8
View File
@@ -42,6 +42,20 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) ->
}
}
fn toggle_tool_run_expand(app: &mut App, mouse: MouseEvent) -> bool {
if !app.tool_collapse_active() {
return false;
}
let Some(rendered_idx) = transcript_cell_index_from_mouse(app, mouse) else {
return false;
};
let original_idx = app.original_cell_index_for_rendered(rendered_idx);
if app.tool_run_start_for_history_index(original_idx) != Some(original_idx) {
return false;
}
app.toggle_tool_run_expansion_at(original_idx)
}
/// Handle mouse events on the sidebar resize handle (the 1-col vertical bar
/// between the chat area and the sidebar). Returns true when the event was
/// consumed so other handlers skip it.
@@ -367,6 +381,10 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return Vec::new();
}
if toggle_tool_run_expand(app, mouse) {
return Vec::new();
}
if let Some(point) = selection_point_from_mouse(app, mouse) {
app.viewport.transcript_selection.anchor = Some(point);
app.viewport.transcript_selection.head = Some(point);
@@ -620,14 +638,7 @@ pub(crate) fn build_context_menu_entries(app: &App, mouse: MouseEvent) -> Vec<Co
}
if let Some(filtered_cell_index) = transcript_cell_index_from_mouse(app, mouse) {
// Convert filtered index → original virtual index using the
// mapping built in ChatWidget::new. When no cells are collapsed
// this is an identity mapping.
let cell_index = app
.collapsed_cell_map
.get(filtered_cell_index)
.copied()
.unwrap_or(filtered_cell_index);
let cell_index = app.original_cell_index_for_rendered(filtered_cell_index);
let target = detail_target_label(app, cell_index)
.map(|label| truncate_line_to_width(label.as_str(), 28))
+16 -3
View File
@@ -3232,6 +3232,14 @@ async fn run_event_loop(
{
continue;
}
KeyCode::Enter
if key.modifiers == KeyModifiers::NONE
&& app.input.is_empty()
&& detail_target_cell_index(app)
.is_some_and(|idx| app.toggle_tool_run_expansion_at(idx)) =>
{
continue;
}
KeyCode::Char('l')
if key_shortcuts::alt_nav_modifiers(key.modifiers)
&& app.input.is_empty()
@@ -3259,6 +3267,9 @@ async fn run_event_loop(
if key.modifiers == KeyModifiers::NONE && app.input.is_empty() =>
{
if let Some(idx) = detail_target_cell_index(app) {
if app.toggle_tool_run_expansion_at(idx) {
continue;
}
let is_thinking = app
.history
.get(idx)
@@ -8286,7 +8297,7 @@ fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool
let current_cell = line_meta
.get(top)
.and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line)
.map(|(cell_index, _)| cell_index);
.map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
let mut scan_indices = Vec::new();
match direction {
@@ -8302,6 +8313,7 @@ fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool
let Some((cell_index, _)) = line_meta[idx].cell_line() else {
continue;
};
let cell_index = app.original_cell_index_for_rendered(cell_index);
if current_cell.is_some_and(|current| current == cell_index) {
continue;
}
@@ -8579,7 +8591,7 @@ fn selected_transcript_cell_index(app: &App) -> Option<usize> {
.line_meta()
.get(start.line_index)
.and_then(|meta| meta.cell_line())
.map(|(cell_index, _)| cell_index)
.map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index))
})
}
@@ -9124,7 +9136,7 @@ fn detail_target_cell_index(app: &App) -> Option<usize> {
.line_meta()
.get(start.line_index)
.and_then(|meta| meta.cell_line())
.map(|(cell_index, _)| cell_index);
.map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));
}
app.detail_cell_index_for_viewport(
@@ -9171,6 +9183,7 @@ fn activity_footer_target_cell_index(app: &App) -> Option<usize> {
let Some((cell_index, _)) = meta.cell_line() else {
continue;
};
let cell_index = app.original_cell_index_for_rendered(cell_index);
if app
.cell_at_virtual_index(cell_index)
.is_some_and(is_meaningful_activity_cell)
+9
View File
@@ -745,6 +745,13 @@ impl ConfigView {
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Display,
key: "tool_collapse".to_string(),
value: settings.tool_collapse_mode.clone(),
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Composer,
key: "composer_density".to_string(),
@@ -1244,6 +1251,7 @@ fn config_hint_for_key(key: &str) -> &'static str {
| "composer_border"
| "paste_burst_detection" => "on/off, true/false, yes/no, 1/0",
"composer_density" | "transcript_spacing" => "compact | comfortable | spacious",
"tool_collapse" => "compact | expanded | calm",
"theme" => "system | dark | light | grayscale",
"locale" => "auto | en | ja | zh-Hans | pt-BR",
"background_color" => "#RRGGBB | default",
@@ -2392,6 +2400,7 @@ mod tests {
assert!(keys.contains(&"status_indicator"));
assert!(keys.contains(&"synchronized_output"));
assert!(keys.contains(&"auto_compact"));
assert!(keys.contains(&"tool_collapse"));
assert!(keys.contains(&"composer_border"));
assert!(keys.contains(&"composer_vim_mode"));
assert!(keys.contains(&"bracketed_paste"));
+213 -14
View File
@@ -22,6 +22,7 @@ pub use footer::{
pub use header::{HeaderData, HeaderWidget, header_status_indicator_frame};
pub use renderable::Renderable;
use std::collections::HashSet;
use std::time::Duration;
use crate::localization::Locale;
@@ -30,7 +31,7 @@ use crate::tui::app::{App, AppMode, ComposerDensity, VimMode};
use crate::tui::approval::{
ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory,
};
use crate::tui::history::HistoryCell;
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus};
use crate::tui::scrolling::TranscriptLineMeta;
use crate::{
commands,
@@ -129,7 +130,25 @@ impl ChatWidget {
.map_or(&[], |active| active.entries());
let history_len = app.history.len();
let has_collapsed = !app.collapsed_cells.is_empty();
let tool_runs = if app.tool_collapse_active() {
crate::tui::history::detect_tool_runs(&app.history, app.tool_collapse_threshold)
} else {
Vec::new()
};
let collapsed_run_starts: HashSet<usize> = tool_runs
.iter()
.filter_map(|run| (!app.expanded_tool_runs.contains(&run.start)).then_some(run.start))
.collect();
let mut collapsed_tool_indices: HashSet<usize> = HashSet::new();
for run in &tool_runs {
if !collapsed_run_starts.contains(&run.start) {
continue;
}
for offset in 1..run.count {
collapsed_tool_indices.insert(run.start + offset);
}
}
let has_collapsed = !app.collapsed_cells.is_empty() || !collapsed_run_starts.is_empty();
// Fast path: no collapsed cells — use original slices directly.
if !has_collapsed {
@@ -174,6 +193,18 @@ impl ChatWidget {
if app.collapsed_cells.contains(&idx) {
continue;
}
if collapsed_tool_indices.contains(&idx) {
continue;
}
if let Some(run) = tool_runs
.iter()
.find(|run| run.start == idx && collapsed_run_starts.contains(&idx))
{
filtered_cells.push(tool_run_summary_cell(run));
filtered_revs.push(tool_run_summary_revision(run, &app.history_revisions));
filtered_to_original.push(idx);
continue;
}
filtered_cells.push(cell.clone());
filtered_revs.push(app.history_revisions[idx]);
filtered_to_original.push(idx);
@@ -277,14 +308,26 @@ impl ChatWidget {
&& let Some(send_at) = app.last_send_at
{
if send_at.elapsed() < SEND_FLASH_DURATION {
apply_send_flash(&mut lines, top, &app.history, line_meta);
apply_send_flash(
&mut lines,
top,
&app.history,
line_meta,
&app.collapsed_cell_map,
);
} else {
app.last_send_at = None;
}
}
if let Some(target_cell) = detail_target_cell {
apply_detail_target_highlight(&mut lines, top, target_cell, line_meta);
apply_detail_target_highlight(
&mut lines,
top,
target_cell,
line_meta,
&app.collapsed_cell_map,
);
}
apply_selection(&mut lines, top, app);
@@ -323,6 +366,27 @@ impl ChatWidget {
}
}
fn tool_run_summary_cell(run: &ToolRun) -> HistoryCell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "activity_group".to_string(),
status: ToolStatus::Success,
input_summary: Some(crate::tui::history::tool_run_summary(run)),
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}))
}
fn tool_run_summary_revision(run: &ToolRun, revisions: &[u64]) -> u64 {
let mut revision = 0xA11C_EA5E_D00D_2692u64 ^ ((run.start as u64) << 32) ^ (run.count as u64);
for idx in run.start..run.start.saturating_add(run.count) {
revision = revision.rotate_left(7) ^ revisions.get(idx).copied().unwrap_or(u64::MAX);
}
revision
}
impl Renderable for ChatWidget {
fn render(&self, _area: Rect, buf: &mut Buffer) {
// Use the passed render area, not self.content_area — those can
@@ -1741,12 +1805,17 @@ fn apply_detail_target_highlight(
top: usize,
target_cell: usize,
line_meta: &[TranscriptLineMeta],
original_index_map: &[usize],
) {
let highlight_bg = Color::Reset;
for (idx, line) in lines.iter_mut().enumerate() {
let line_index = top + idx;
if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
&& *cell_index == target_cell
&& original_index_map
.get(*cell_index)
.copied()
.unwrap_or(*cell_index)
== target_cell
{
for span in &mut line.spans {
span.style = span.style.bg(highlight_bg);
@@ -1761,6 +1830,7 @@ fn apply_send_flash(
top: usize,
history: &[HistoryCell],
line_meta: &[TranscriptLineMeta],
original_index_map: &[usize],
) {
// Find the last User cell index.
let last_user_cell = history
@@ -1775,7 +1845,11 @@ fn apply_send_flash(
for (idx, line) in lines.iter_mut().enumerate() {
let line_index = top + idx;
if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
&& *cell_index == target_cell
&& original_index_map
.get(*cell_index)
.copied()
.unwrap_or(*cell_index)
== target_cell
{
for span in &mut line.spans {
span.style = span.style.bg(flash_bg);
@@ -2611,22 +2685,22 @@ fn line_spans_with_selection<'a>(
mod tests {
use super::{
ApprovalWidget, COMPOSER_PANEL_HEIGHT, ChatWidget, ComposerWidget, Renderable,
SlashMenuEntry, apply_selection_to_line, build_empty_state_lines, composer_height,
composer_max_height, composer_min_input_rows, composer_top_padding, compute_takeover_area,
cursor_row_col, layout_input, pad_lines_to_bottom, placeholder_visual_lines,
push_command_entry, should_render_empty_state, slash_completion_hints, wrap_input_lines,
wrap_text,
SlashMenuEntry, apply_detail_target_highlight, apply_selection_to_line, apply_send_flash,
build_empty_state_lines, composer_height, composer_max_height, composer_min_input_rows,
composer_top_padding, compute_takeover_area, cursor_row_col, layout_input,
pad_lines_to_bottom, placeholder_visual_lines, push_command_entry,
should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text,
};
use crate::config::{ApiProvider, Config};
use crate::localization::Locale;
use crate::palette;
use crate::tui::app::{App, ComposerDensity, TuiOptions};
use crate::tui::app::{App, ComposerDensity, ToolCollapseMode, TuiOptions};
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus};
use crate::tui::scrolling::TranscriptScroll;
use crate::tui::scrolling::{TranscriptLineMeta, TranscriptScroll};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Style,
style::{Color, Style},
text::{Line, Span},
};
use std::path::PathBuf;
@@ -2668,6 +2742,131 @@ mod tests {
text
}
fn success_tool_cell(name: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Success,
input_summary: Some(format!("path: {name}.txt")),
output: Some(format!("full output from {name}")),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}))
}
fn add_dense_tool_run(app: &mut App) {
app.add_message(success_tool_cell("read_file"));
app.add_message(success_tool_cell("list_dir"));
app.add_message(success_tool_cell("web_search"));
}
#[test]
fn send_flash_uses_original_index_map_for_collapsed_rows() {
let history = vec![
success_tool_cell("read_file"),
success_tool_cell("list_dir"),
HistoryCell::User {
content: "sent".to_string(),
},
];
let mut lines = vec![Line::from("sent")];
let line_meta = vec![TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 0,
copy_prefix_width: 0,
copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline,
}];
let original_index_map = vec![2];
apply_send_flash(&mut lines, 0, &history, &line_meta, &original_index_map);
assert_eq!(lines[0].spans[0].style.bg, Some(Color::Rgb(30, 40, 55)));
}
#[test]
fn detail_highlight_uses_original_index_map_for_collapsed_rows() {
let mut lines = vec![Line::from("tool group")];
let line_meta = vec![TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 0,
copy_prefix_width: 0,
copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline,
}];
let original_index_map = vec![4];
apply_detail_target_highlight(&mut lines, 0, 4, &line_meta, &original_index_map);
assert_eq!(lines[0].spans[0].style.bg, Some(Color::Reset));
}
#[test]
fn chat_widget_collapses_dense_tool_runs_by_default() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
add_dense_tool_run(&mut app);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert_eq!(app.collapsed_cell_map, vec![0]);
assert!(rendered.contains("3 tools"), "{rendered}");
assert!(
!rendered.contains("full output from list_dir"),
"{rendered}"
);
}
#[test]
fn chat_widget_expands_dense_tool_runs_on_demand() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
add_dense_tool_run(&mut app);
app.expanded_tool_runs.insert(0);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 12,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]);
assert!(rendered.contains("full output from list_dir"), "{rendered}");
}
#[test]
fn chat_widget_expanded_mode_leaves_dense_tool_runs_visible() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Expanded;
app.tool_collapse_threshold = 3;
add_dense_tool_run(&mut app);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 12,
};
let _widget = ChatWidget::new(&mut app, area);
assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]);
}
#[test]
fn pad_lines_to_bottom_noop_when_already_filled() {
let mut lines = vec![Line::from("one"), Line::from("two")];
+4 -2
View File
@@ -48,6 +48,7 @@ harvest/stewardship commits:
| #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. |
| #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. |
| #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. |
| #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. |
| #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. |
@@ -119,7 +120,7 @@ harvest/stewardship commits:
| #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. |
| #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 | Do not merge as-is. The compaction idea matches the `/relay` direction, but the PR currently bypasses normal rendering, lacks expansion wiring, defaults to expanded mode, and has cache-key/index maintenance risks. Harvest only after completing those behaviors. |
| #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. |
## Issue Reduction Strategy
@@ -140,6 +141,7 @@ Issue count should drop through evidence-backed consolidation, not bulk closing.
## Immediate Next Actions
1. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627,
#2634, #2636, #2687, #2736, #2737, and already-harvested performance PRs.
#2634, #2636, #2687, #2736, #2737, #2738, and already-harvested performance
PRs.
2. Start file decomposition Phase 1 only after the PR harvest table has no
unknown high-priority provider/prompt/cache branches.