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:
+6
-1
@@ -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).
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user