feat(tui): show focused approval details
Harvested the narrow approval-detail and shell-preview slice from #1991/#2269 by @tdccccc. Approval cards now show prominent command, dir, file, path, or target rows before falling back to raw params, and shell approvals preserve long command tails while splitting common shell chains for review. The maintained path keeps the existing #2381 intent-summary block visible and does not take the broader diff-preview/pager rewrite from #2269. Live shell companion tools are classified as shell so their approval cards use the same review posture. Co-authored-by: tdccccc <79492752+tdccccc@users.noreply.github.com>
This commit is contained in:
+8
-1
@@ -82,6 +82,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`Esc` while editing a queued follow-up restores the original queued message
|
||||
instead of cancelling the active turn or silently dropping the queued work
|
||||
(#2054).
|
||||
- Approval prompts now render prominent command, directory, file, path, or
|
||||
target rows before falling back to raw JSON params. Shell approvals preserve
|
||||
long command tails, split common shell chains for review, and show compact
|
||||
`printf > file` previews while keeping intent summaries visible (#1991,
|
||||
#2269).
|
||||
- Sidebar hover details now use row-level metadata for truncated Work, Tasks,
|
||||
and Agents rows. Mouse hover opens a bordered, wrapping popover with the full
|
||||
underlying row text, long turn/agent ids, and current sub-agent progress
|
||||
@@ -134,7 +139,9 @@ tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692,
|
||||
#2694), and the HarnessPosture config model for provider/model posture (#2741,
|
||||
#2693), and
|
||||
**@h3c-hexin** for the tool-agent model inheritance and configured
|
||||
`skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the
|
||||
`skills_dir` fixes (#2736, #2737), **@AresNing** for the turn-end observer hook
|
||||
work (#2578), and **@tdccccc** for the approval key-detail and shell-preview
|
||||
work (#1991, #2269). Thanks also to **@qiyuanlicn** for the
|
||||
checkpoint/resume report that shaped the sub-agent recovery slice (#2029),
|
||||
**@bevis-wong** for the long-running shell/task liveness report (#1786),
|
||||
**@shuxiangxuebiancheng** for the third-party OpenAI-compatible path report
|
||||
|
||||
@@ -720,6 +720,7 @@ Current and recurring contributors include:
|
||||
- **[yuanchenglu](https://github.com/yuanchenglu)** — Feishu per-chat model switching (#2149)
|
||||
- **[HUQIANTAO](https://github.com/HUQIANTAO)** — Xiaomi balance/status work, stalled-turn recovery, approval intent summaries, mobile smoke/QR support, Claude theme, and broad docs/test/CI coverage (#2257, #2267, #2283, #2384, #2385, #2389, #2403, #2440-#2458, #2460)
|
||||
- **[h3c-hexin](https://github.com/h3c-hexin)** — web-search URL decoding, prompt/instructions override hooks, sub-agent guidance, SSRF fake-IP trust configuration, and prompt-cache-friendly environment placement (#2245, #2311, #2313, #2314, #2354, #2355, #2356)
|
||||
- **[tdccccc](https://github.com/tdccccc)** — approval prompt key-detail and shell-preview work harvested into the maintained approval path (#1991, #2269)
|
||||
- **[AresNing](https://github.com/AresNing)** — first-run guide, message-submit hook transform design, and turn-end observer hook work harvested into the maintained hooks path (#2278, #2318, #2434, #2578)
|
||||
- **[Implementist](https://github.com/Implementist)** — Volcengine Ark search provider and reliability hardening (#2426, #2429, #2439)
|
||||
- **[lihuan215](https://github.com/lihuan215)** — Unix socket hook sink design harvested into the opt-in hook event path (#2333, #2430)
|
||||
|
||||
@@ -82,6 +82,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`Esc` while editing a queued follow-up restores the original queued message
|
||||
instead of cancelling the active turn or silently dropping the queued work
|
||||
(#2054).
|
||||
- Approval prompts now render prominent command, directory, file, path, or
|
||||
target rows before falling back to raw JSON params. Shell approvals preserve
|
||||
long command tails, split common shell chains for review, and show compact
|
||||
`printf > file` previews while keeping intent summaries visible (#1991,
|
||||
#2269).
|
||||
- Sidebar hover details now use row-level metadata for truncated Work, Tasks,
|
||||
and Agents rows. Mouse hover opens a bordered, wrapping popover with the full
|
||||
underlying row text, long turn/agent ids, and current sub-agent progress
|
||||
@@ -134,7 +139,9 @@ tool-call transcript collapse/sidebar detail direction (#2738, #2734, #2692,
|
||||
#2694), and the HarnessPosture config model for provider/model posture (#2741,
|
||||
#2693), and
|
||||
**@h3c-hexin** for the tool-agent model inheritance and configured
|
||||
`skills_dir` fixes (#2736, #2737). Thanks also to **@qiyuanlicn** for the
|
||||
`skills_dir` fixes (#2736, #2737), **@AresNing** for the turn-end observer hook
|
||||
work (#2578), and **@tdccccc** for the approval key-detail and shell-preview
|
||||
work (#1991, #2269). Thanks also to **@qiyuanlicn** for the
|
||||
checkpoint/resume report that shaped the sub-agent recovery slice (#2029),
|
||||
**@bevis-wong** for the long-running shell/task liveness report (#1786),
|
||||
**@shuxiangxuebiancheng** for the third-party OpenAI-compatible path report
|
||||
|
||||
@@ -140,6 +140,16 @@ pub struct ApprovalRequest {
|
||||
pub intent_summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Key approval details rendered prominently in the approval card.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApprovalDetail {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
/// Preformatted shell lines for commands that benefit from safe wrapping
|
||||
/// or a compact write-file preview. `value` remains the original command.
|
||||
pub shell_lines: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ApprovalRequest {
|
||||
#[cfg(test)]
|
||||
pub fn new(
|
||||
@@ -207,6 +217,18 @@ impl ApprovalRequest {
|
||||
_ => self.impacts.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the most important params for the approval card.
|
||||
#[must_use]
|
||||
pub fn prominent_detail_items(&self, locale: Locale) -> Vec<ApprovalDetail> {
|
||||
build_prominent_details(self.category, &self.params)
|
||||
.into_iter()
|
||||
.map(|mut detail| {
|
||||
detail.label = localize_detail_label(&detail.label, locale).to_string();
|
||||
detail
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the category for a tool by name
|
||||
@@ -215,7 +237,16 @@ pub fn get_tool_category(name: &str) -> ToolCategory {
|
||||
ToolCategory::FileWrite
|
||||
} else if matches!(name, "web_run" | "web_search" | "fetch_url") {
|
||||
ToolCategory::Network
|
||||
} else if name == "exec_shell" {
|
||||
} else if matches!(
|
||||
name,
|
||||
"exec_shell"
|
||||
| "task_shell_start"
|
||||
| "task_shell_wait"
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "exec_wait"
|
||||
| "exec_interact"
|
||||
) {
|
||||
ToolCategory::Shell
|
||||
} else if name.starts_with("list_mcp_")
|
||||
|| name.starts_with("read_mcp_")
|
||||
@@ -470,6 +501,287 @@ fn build_impact_summary_zh_hans(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_prominent_details(category: ToolCategory, params: &Value) -> Vec<ApprovalDetail> {
|
||||
let mut details = Vec::new();
|
||||
match category {
|
||||
ToolCategory::Shell => {
|
||||
if let Some(command) = param_text(params, &["command", "cmd"]) {
|
||||
details.push(ApprovalDetail {
|
||||
label: "Command".to_string(),
|
||||
shell_lines: Some(format_shell_command_for_approval(&command)),
|
||||
value: command,
|
||||
});
|
||||
}
|
||||
if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 96) {
|
||||
details.push(ApprovalDetail {
|
||||
label: "Dir".to_string(),
|
||||
value: workdir,
|
||||
shell_lines: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ToolCategory::FileWrite => {
|
||||
if let Some(path) = param_preview(params, &["path", "target", "destination"], 200) {
|
||||
details.push(ApprovalDetail {
|
||||
label: "File".to_string(),
|
||||
value: path,
|
||||
shell_lines: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ToolCategory::Safe => {
|
||||
if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 200) {
|
||||
details.push(ApprovalDetail {
|
||||
label: "Path".to_string(),
|
||||
value: path,
|
||||
shell_lines: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ToolCategory::Network => {
|
||||
if let Some(target) =
|
||||
param_preview(params, &["url", "q", "query", "location", "repo"], 200)
|
||||
{
|
||||
details.push(ApprovalDetail {
|
||||
label: "Target".to_string(),
|
||||
value: target,
|
||||
shell_lines: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ToolCategory::McpRead | ToolCategory::McpAction | ToolCategory::Unknown => {
|
||||
if let Some(input) = param_preview(
|
||||
params,
|
||||
&["command", "cmd", "path", "url", "q", "query", "ref_id"],
|
||||
200,
|
||||
) {
|
||||
details.push(ApprovalDetail {
|
||||
label: "Input".to_string(),
|
||||
value: input,
|
||||
shell_lines: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
fn param_text(params: &Value, keys: &[&str]) -> Option<String> {
|
||||
let Value::Object(map) = params else {
|
||||
return None;
|
||||
};
|
||||
|
||||
for key in keys {
|
||||
let Some(value) = map.get(*key) else {
|
||||
continue;
|
||||
};
|
||||
match value {
|
||||
Value::String(text) => return Some(text.clone()),
|
||||
Value::Number(number) => return Some(number.to_string()),
|
||||
Value::Bool(flag) => return Some(flag.to_string()),
|
||||
other => return Some(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn localize_detail_label(label: &str, locale: Locale) -> &str {
|
||||
match locale {
|
||||
Locale::ZhHans => match label {
|
||||
"Command" => "命令",
|
||||
"Dir" => "目录",
|
||||
"File" => "文件",
|
||||
"Path" => "路径",
|
||||
"Target" => "目标",
|
||||
"Input" => "输入",
|
||||
_ => label,
|
||||
},
|
||||
_ => label,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_shell_command_for_approval(command: &str) -> Vec<String> {
|
||||
if let Some(preview) = parse_printf_write_file_command(command) {
|
||||
return format_printf_write_file_preview(preview);
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for raw_line in command.lines() {
|
||||
split_shell_display_line(raw_line, &mut out);
|
||||
}
|
||||
if out.is_empty() && !command.trim().is_empty() {
|
||||
out.push(command.trim().to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn split_shell_display_line(line: &str, out: &mut Vec<String>) {
|
||||
let mut quote: Option<char> = None;
|
||||
let mut escaped = false;
|
||||
let mut current = String::new();
|
||||
let mut chars = line.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if escaped {
|
||||
current.push(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '\\' {
|
||||
current.push(ch);
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(ch, '"' | '\'') {
|
||||
if quote == Some(ch) {
|
||||
quote = None;
|
||||
} else if quote.is_none() {
|
||||
quote = Some(ch);
|
||||
}
|
||||
current.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if quote.is_none() {
|
||||
match ch {
|
||||
'&' if chars.peek() == Some(&'&') => {
|
||||
chars.next();
|
||||
push_shell_clause(out, &mut current, Some("&&"));
|
||||
continue;
|
||||
}
|
||||
'|' if chars.peek() == Some(&'|') => {
|
||||
chars.next();
|
||||
push_shell_clause(out, &mut current, Some("||"));
|
||||
continue;
|
||||
}
|
||||
'|' => {
|
||||
push_shell_clause(out, &mut current, Some("|"));
|
||||
continue;
|
||||
}
|
||||
';' => {
|
||||
push_shell_clause(out, &mut current, Some(";"));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
current.push(ch);
|
||||
}
|
||||
|
||||
push_shell_clause(out, &mut current, None);
|
||||
}
|
||||
|
||||
fn push_shell_clause(out: &mut Vec<String>, current: &mut String, operator: Option<&str>) {
|
||||
let trimmed = current.trim();
|
||||
if trimmed.is_empty() {
|
||||
if let Some(operator) = operator {
|
||||
out.push(operator.to_string());
|
||||
}
|
||||
} else if let Some(operator) = operator {
|
||||
out.push(format!("{trimmed} {operator}"));
|
||||
} else {
|
||||
out.push(trimmed.to_string());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PrintfWriteFilePreview {
|
||||
target: String,
|
||||
lines: Vec<String>,
|
||||
}
|
||||
|
||||
fn parse_printf_write_file_command(command: &str) -> Option<PrintfWriteFilePreview> {
|
||||
let (before_redirect, after_redirect) = split_unquoted_redirect(command)?;
|
||||
let before_redirect = before_redirect.trim();
|
||||
if !before_redirect.starts_with("printf") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tokens = shlex::split(before_redirect)?;
|
||||
if tokens.first()?.as_str() != "printf" {
|
||||
return None;
|
||||
}
|
||||
let target_parts = shlex::split(after_redirect.trim())?;
|
||||
if target_parts.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let target = target_parts
|
||||
.into_iter()
|
||||
.next()?
|
||||
.trim_matches(|ch| ch == '"' || ch == '\'')
|
||||
.to_string();
|
||||
if target.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let args = &tokens[1..];
|
||||
if args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let values = if args.len() >= 2 && args[0].contains('%') {
|
||||
&args[1..]
|
||||
} else {
|
||||
args
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
for value in values {
|
||||
let normalized = value.replace("\\n", "\n");
|
||||
for line in normalized.lines() {
|
||||
lines.push(line.to_string());
|
||||
}
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
Some(PrintfWriteFilePreview { target, lines })
|
||||
}
|
||||
|
||||
fn format_printf_write_file_preview(preview: PrintfWriteFilePreview) -> Vec<String> {
|
||||
const MAX_PREVIEW_LINES: usize = 12;
|
||||
let mut out = vec![format!("printf > {}", preview.target)];
|
||||
let total = preview.lines.len();
|
||||
for line in preview.lines.into_iter().take(MAX_PREVIEW_LINES) {
|
||||
out.push(format!(" {line}"));
|
||||
}
|
||||
if total > MAX_PREVIEW_LINES {
|
||||
out.push(format!(" ... (+{} more lines)", total - MAX_PREVIEW_LINES));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn split_unquoted_redirect(command: &str) -> Option<(&str, &str)> {
|
||||
let mut quote: Option<char> = None;
|
||||
let mut escaped = false;
|
||||
for (idx, ch) in command.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if matches!(ch, '"' | '\'') {
|
||||
if quote == Some(ch) {
|
||||
quote = None;
|
||||
} else if quote.is_none() {
|
||||
quote = Some(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if quote.is_none() && ch == '>' {
|
||||
return Some((&command[..idx], &command[idx + ch.len_utf8()..]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Indices into the option list shared by both variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApprovalOption {
|
||||
@@ -970,6 +1282,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_tool_category_shell_tools() {
|
||||
assert_eq!(get_tool_category("exec_shell"), ToolCategory::Shell);
|
||||
assert_eq!(get_tool_category("task_shell_start"), ToolCategory::Shell);
|
||||
assert_eq!(get_tool_category("task_shell_wait"), ToolCategory::Shell);
|
||||
assert_eq!(get_tool_category("exec_shell_wait"), ToolCategory::Shell);
|
||||
assert_eq!(
|
||||
get_tool_category("exec_shell_interact"),
|
||||
ToolCategory::Shell
|
||||
);
|
||||
assert_eq!(get_tool_category("exec_wait"), ToolCategory::Shell);
|
||||
assert_eq!(get_tool_category("exec_interact"), ToolCategory::Shell);
|
||||
assert_eq!(
|
||||
get_tool_category("mcp_linear_save_issue"),
|
||||
ToolCategory::McpAction
|
||||
@@ -1126,6 +1447,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prominent_details_shell_does_not_truncate_long_command() {
|
||||
let command = format!("printf '{}\\n' > /tmp/x && cat /tmp/x", "x".repeat(300));
|
||||
let request = ApprovalRequest::new(
|
||||
"test-id",
|
||||
"exec_shell",
|
||||
"Run a shell command",
|
||||
&json!({"command": command, "cwd": "/tmp/project"}),
|
||||
"test_key",
|
||||
);
|
||||
|
||||
let details = request.prominent_detail_items(Locale::En);
|
||||
|
||||
assert_eq!(details[0].label, "Command");
|
||||
assert_eq!(details[0].value, command);
|
||||
assert!(
|
||||
details[0]
|
||||
.shell_lines
|
||||
.as_ref()
|
||||
.is_some_and(|lines| lines.iter().any(|line| line.contains("cat /tmp/x"))),
|
||||
"shell preview should preserve the dangerous tail of long commands"
|
||||
);
|
||||
assert_eq!(details[1].label, "Dir");
|
||||
assert_eq!(details[1].value, "/tmp/project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prominent_details_file_write() {
|
||||
let request = ApprovalRequest::new(
|
||||
"test-id",
|
||||
"write_file",
|
||||
"Write a file to disk",
|
||||
&json!({"path": "src/main.rs", "content": "fn main() {}"}),
|
||||
"test_key",
|
||||
);
|
||||
|
||||
let details = request.prominent_detail_items(Locale::En);
|
||||
|
||||
assert_eq!(details[0].label, "File");
|
||||
assert_eq!(details[0].value, "src/main.rs");
|
||||
assert!(details[0].shell_lines.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_formatter_preserves_logical_or_operator() {
|
||||
let lines = format_shell_command_for_approval("cargo build || echo fallback");
|
||||
|
||||
assert_eq!(lines, vec!["cargo build ||", "echo fallback"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_formatter_detects_printf_write_file_preview() {
|
||||
let lines =
|
||||
format_shell_command_for_approval("printf '%s\\n' 'hello' 'world' > src/main.rs");
|
||||
|
||||
assert_eq!(lines[0], "printf > src/main.rs");
|
||||
assert!(lines.iter().any(|line| line.contains("hello")));
|
||||
assert!(lines.iter().any(|line| line.contains("world")));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ApprovalView Tests — Benign Variant (single-key approve)
|
||||
// ========================================================================
|
||||
|
||||
@@ -1270,21 +1270,35 @@ impl Renderable for ApprovalWidget<'_> {
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
let params_str = self.request.params_display();
|
||||
let params_width = card_area.width.saturating_sub(14) as usize;
|
||||
let params_truncated =
|
||||
crate::utils::truncate_with_ellipsis(¶ms_str, params_width.max(20), "...");
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
label_params(locale),
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
),
|
||||
Span::styled(
|
||||
params_truncated,
|
||||
Style::default().fg(palette::TEXT_SECONDARY),
|
||||
),
|
||||
]));
|
||||
let details = self.request.prominent_detail_items(locale);
|
||||
if details.is_empty() {
|
||||
let params_str = self.request.params_display();
|
||||
let params_width = card_area.width.saturating_sub(14) as usize;
|
||||
let params_truncated =
|
||||
crate::utils::truncate_with_ellipsis(¶ms_str, params_width.max(20), "...");
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
label_params(locale),
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
),
|
||||
Span::styled(
|
||||
params_truncated,
|
||||
Style::default().fg(palette::TEXT_SECONDARY),
|
||||
),
|
||||
]));
|
||||
} else {
|
||||
for detail in details.iter().take(4) {
|
||||
if self.request.category == ToolCategory::Shell
|
||||
&& matches!(detail.label.as_str(), "Command" | "命令")
|
||||
&& let Some(shell_lines) = detail.shell_lines.as_deref()
|
||||
{
|
||||
push_shell_command_lines(&mut lines, &detail.label, shell_lines);
|
||||
} else {
|
||||
push_detail_line(&mut lines, &detail.label, &detail.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
@@ -1507,6 +1521,43 @@ fn label_params(locale: Locale) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_detail_line(lines: &mut Vec<Line<'static>>, label: &str, value: &str) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{label:<7} "),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(value.to_string(), Style::default().fg(palette::TEXT_BODY)),
|
||||
]));
|
||||
}
|
||||
|
||||
fn push_shell_command_lines(lines: &mut Vec<Line<'static>>, label: &str, command_lines: &[String]) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{label}:"),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
|
||||
for line in command_lines {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
line.clone(),
|
||||
Style::default()
|
||||
.fg(palette::TEXT_BODY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
fn footer_controls(locale: Locale) -> &'static str {
|
||||
match locale {
|
||||
Locale::ZhHans => " · v:完整参数 · Esc:终止",
|
||||
@@ -4025,6 +4076,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_shell_command_detects_printf_write_file_preview() {
|
||||
let request = crate::tui::approval::ApprovalRequest::new(
|
||||
"approval-1",
|
||||
"exec_shell",
|
||||
"Run shell command",
|
||||
&serde_json::json!({
|
||||
"command": "printf '%s\\n' 'alpha' 'beta' > src/generated.txt",
|
||||
"cwd": "/tmp/project",
|
||||
}),
|
||||
"exec_shell:printf",
|
||||
);
|
||||
let view = crate::tui::approval::ApprovalView::new(request.clone());
|
||||
let widget = ApprovalWidget::new(&request, &view);
|
||||
let area = Rect::new(0, 0, 110, 32);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
widget.render(area, &mut buf);
|
||||
let rendered = buffer_text(&buf, area);
|
||||
|
||||
assert!(rendered.contains("Command:"), "{rendered}");
|
||||
assert!(
|
||||
rendered.contains("printf > src/generated.txt"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("alpha"), "{rendered}");
|
||||
assert!(rendered.contains("beta"), "{rendered}");
|
||||
assert!(rendered.contains("Dir"), "{rendered}");
|
||||
assert!(rendered.contains("/tmp/project"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_intent_summary_still_renders_with_shell_details() {
|
||||
let request = crate::tui::approval::ApprovalRequest::new_with_intent(
|
||||
"approval-1",
|
||||
"exec_shell",
|
||||
"Run shell command",
|
||||
&serde_json::json!({
|
||||
"command": "cargo build || echo fallback",
|
||||
"cwd": "/tmp/project",
|
||||
}),
|
||||
"exec_shell:cargo",
|
||||
Some("Need to verify the fallback build path before editing files."),
|
||||
);
|
||||
let view = crate::tui::approval::ApprovalView::new(request.clone());
|
||||
let widget = ApprovalWidget::new(&request, &view);
|
||||
let area = Rect::new(0, 0, 120, 34);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
widget.render(area, &mut buf);
|
||||
let rendered = buffer_text(&buf, area);
|
||||
|
||||
assert!(rendered.contains("Intent:"), "{rendered}");
|
||||
assert!(rendered.contains("fallback build path"), "{rendered}");
|
||||
assert!(rendered.contains("Command:"), "{rendered}");
|
||||
assert!(rendered.contains("cargo build ||"), "{rendered}");
|
||||
assert!(rendered.contains("echo fallback"), "{rendered}");
|
||||
}
|
||||
|
||||
/// Regression for issue #65: after `App::handle_resize`, the chat widget
|
||||
/// must produce a clean render at the new width — no stale wrapping,
|
||||
/// no panic, no content exceeding the requested width. Cycling through
|
||||
|
||||
@@ -96,7 +96,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit.
|
||||
| #2239 i18n Phase 1-4b | Conflicting | Defer until localization lane. |
|
||||
| #2242 typed persistent tool permission rules | Conflicting | Compare with #2721 stabilization and permissions model. |
|
||||
| #2256 workspace crate consolidation | Conflicting | Do not merge during v0.9 stabilization. |
|
||||
| #2269 approval details and shell previews | Conflicting | Review for small UI harvest only. |
|
||||
| #2269 approval details and shell previews | Conflicting / locally harvested | Narrow UI slice landed manually: approval cards now show prominent command/dir/file/path/target rows, preserve #2381 intent summaries, classify live shell companion tools as shell, split common shell chains, and show compact simple `printf > file` previews. Do not merge the broader diff-preview/pager rewrite. Close/comment after branch is public, crediting @tdccccc for #1991/#2269. |
|
||||
| #2318 message_submit hook transform | Draft/conflicting | Defer; hook behavior must match lifecycle policy. |
|
||||
| #2382 v0.8.48 release harvest | Draft/conflicting | Candidate to close as obsolete after confirming no unharvested commits. |
|
||||
| #2476 fork migration parent links | Conflicting / already harvested | Patch-equivalent work is already present on `origin/main` and this branch as `b76a11b99` plus follow-up `18550339a`. Close/comment original after the integration branch is public, crediting @cyq1017; close issue #2082 only after confirming the remaining `message_type` wording is obsolete. |
|
||||
|
||||
Reference in New Issue
Block a user