Skip to content

fix(tui): structure approval details and shell previews#2269

Open
tdccccc wants to merge 11 commits into
Hmbown:mainfrom
tdccccc:fix/approval-show-command
Open

fix(tui): structure approval details and shell previews#2269
tdccccc wants to merge 11 commits into
Hmbown:mainfrom
tdccccc:fix/approval-show-command

Conversation

@tdccccc
Copy link
Copy Markdown

@tdccccc tdccccc commented May 27, 2026

What

  • Replaces fix(tui): structure approval details and shell previews #1991 after rebasing onto the latest main (rebrand + v0.8.47 integration)
  • Render approval details with structured fields instead of raw JSON
  • Improve shell command formatting and special-case printf-based file writes into a readable preview
  • Preserve diff/pager indentation and keep Up/Down for scrolling
  • Cache diff preview, prominent details, shell parsing, and rendered diff panels at construction time — render loop never touches the filesystem
  • Add regression coverage for approval details, shell previews, diff indentation, and pager wrapping

Verification

  • cargo fmt --all --check
  • cargo test -p codewhale-tui

Greptile Summary

This PR rewrites the approval modal to show structured fields (command, file path, target, working directory) instead of raw JSON, adds an inline scrollable diff panel cached at construction time, special-cases printf-based file-write commands into a readable preview, and introduces a two-step confirmation flow for destructive approvals.

  • approval.rs: new ApprovalDiffPreview/ApprovalDetail types, build_diff_preview (single read per request, spawn_blocking in the modal path), shell command tokeniser, printf-write-file parser, two-step commit_or_stage.
  • diff_render.rs: new compact renderer for the popup, strip_prefix instead of trim_start_matches, parse_hunk_header upgraded to Option<Option<usize>> for zero-line hunks.
  • pager.rs: wrap_text_preserving_spaces keeps leading indent on continuation lines and prefers word boundaries over hard character breaks.
  • ui.rs/widgets/mod.rs/views/mod.rs: wiring for OpenStyledPager, the inline diff panel, and the confirmation banner; open_details_pager_for_cell enriched with a diff preview — but that call is made synchronously (see comment).

Confidence Score: 4/5

Safe to merge with one targeted fix: the detail-pager path in ui.rs reads files synchronously on the async event loop.

The approval modal path is careful to offload filesystem I/O with spawn_blocking, and the diff/pager/widget changes are well-tested. The one gap is open_details_pager_for_cell, which calls build_diff_preview directly without any blocking guard — on a large workspace file this freezes the TUI render thread for the duration of a full file read. That path is new in this PR and is the only issue that needs addressing before merge.

crates/tui/src/tui/ui.rs — the open_details_pager_for_cell function needs the same spawn_blocking treatment already applied to the approval modal path.

Important Files Changed

Filename Overview
crates/tui/src/tui/approval.rs Core of the PR: replaces raw JSON display with structured ApprovalDetail fields, adds ApprovalDiffPreview caching, printf-write-file special-casing, two-step destructive confirmation, and scrollable diff panel. Minor issue: None diff preview shows misleading 'content matches' text; staged-confirm cancellation UX text is inaccurate.
crates/tui/src/tui/ui.rs Approval modal path correctly offloads diff I/O to spawn_blocking; however, the new open_details_pager_for_cell path calls build_diff_preview synchronously in an async handler, reintroducing the blocking-I/O problem the PR set out to fix.
crates/tui/src/tui/diff_render.rs Adds render_diff_compact (drops summary/file headers for approval popup), fixes trim_start_matches to strip_prefix, changes parse_hunk_header to return Option<Option> for zero-line new-file hunks. Well-tested.
crates/tui/src/tui/pager.rs wrap_text rewritten as wrap_text_preserving_spaces: keeps leading indent on continuation lines, prefers word-boundary breaks, consolidates push_word_breaking_chars helper. Tests added.
crates/tui/src/tui/widgets/mod.rs ApprovalWidget updated: replaces About/Impact/Params rows with structured detail fields plus inline scrollable diff panel; adds two-step confirm banner; context-sensitive 'approve always' labels per ToolCategory.
crates/tui/src/tui/views/mod.rs Adds OpenStyledPager event variant carrying pre-rendered Vec<Line<'static>> so the approval detail pager skips the plaintext wrapping path.
crates/tui/src/core/events.rs Adds input: serde_json::Value to ApprovalRequired event so the tool input is forwarded with the event instead of being looked up from pending_tool_uses at render time.
crates/tui/src/core/engine/turn_loop.rs One-line change: passes tool_input.clone() into the ApprovalRequired event to satisfy the new field.
crates/tui/src/runtime_threads.rs Test fixtures updated with json! input fields to match the new ApprovalRequired struct field; no logic changes.

Comments Outside Diff (1)

  1. crates/tui/src/tui/ui.rs, line 7748-7753 (link)

    P1 Blocking filesystem I/O in async context

    build_diff_preview calls std::fs::metadata and std::fs::read_to_string (up to DIFF_PREVIEW_MAX_BYTES = 512 KB) synchronously. The PR carefully offloads the exact same call for the approval modal via tokio::task::spawn_blocking, but open_details_pager_for_cell is invoked directly from the async handle_view_events path with no blocking guard. A tool that modified a large file will freeze the TUI event loop for the duration of the read every time the user opens the detail pager.

    Fix in Codex Fix in Claude Code Fix in Cursor

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (4): Last reviewed commit: "fix(tui): address remaining Gemini revie..." | Re-trigger Greptile

tdccccc added 7 commits May 27, 2026 10:55
Replace the verbose approval popup (About, generic impacts, raw JSON
params) with a focused display that highlights the most important
information: Command for shell, File for writes, Target for network.

- Add prominent_details() to extract key params per tool category
- Pass workspace path to annotate current directory as "(current)"
- Pass tool input through ApprovalRequired event instead of lookup
- Show "Confirm: <key detail>" in the two-step confirmation footer
- Remove generic description/impacts fields from ApprovalRequest
- Normalize key param ordering: command before cmd in prominent_details
- Canonicalize paths for workspace dir comparison
- Add prominent_details_for_locale with Chinese label translations
- Match both English and Chinese labels in confirm_label
- Update zh-hans test to match localized output
The approval popup re-read files every render frame via
`std::fs::read_to_string(path)` with the raw (potentially relative)
path, so a `write_file` invoked from the agent against a
workspace-relative path silently produced an empty preview and the
whole diff panel disappeared. `apply_patch` also only inspected the
`patch` field and left popups blank when callers used the
`changes` array form.

Replace `Option<String>` with a cached `ApprovalDiffPreview` enum
built once at request construction:

- `Diff { text, added, deleted }` — normal unified diff (now with
  workspace-resolved paths)
- `NewFile { path, content }` — write_file against a missing file
- `NoChange { path }` — explicit "content matches current file"
  hint instead of swallowing the panel
- `MissingMatch { path, text, match_count }` — edit_file search
  not present; render a warning + search→replace fallback

The popup uses a new `render_diff_compact` that keeps `@@` hunk
headers + line numbers + colour but drops the summary / `--- +++`
metadata, so a 10-row preview window shows code instead of
headers. apply_patch's `changes` array now produces a multi-file
diff with synthetic `diff --git` headers so the same renderer
path applies.

Tests cover: workspace-relative path resolution, NoChange path,
NewFile path, simulated-replace edit_file, MissingMatch fallback,
apply_patch changes array, and the compact renderer stripping
file headers.
`open_details_pager_for_cell` was concatenating the diff into a
plain text body and feeding it through `PagerView::from_text`,
which wraps every line as `Span::raw`. The result lost all the
colour, line numbers, and hunk gutter the approval popup had been
showing, so `v` and the history detail view rendered the diff as
monochrome ASCII.

Reuse the cached `build_diff_preview` (now `pub` from approval.rs)
so the detail pager runs through the same workspace-resolved path
the popup does, then build a `Vec<Line<'static>>` directly:

- Section labels (`Tool ID:` / `Tool:` / `Changes:` / `Input:` /
  `Output:`) get the sky-blue bold style the rest of the TUI uses
  for muted/label spans.
- The diff section calls `diff_render::render_diff` so each line
  carries its `+/-` gutter colour and old/new line number prefix.
- Input/Output/Spillover stay as raw spans so the pager's own
  `Paragraph::wrap` handles long lines.

Push via `PagerView::new(title, lines)` instead of the
`from_text` shim that destroys structure.
The earlier approval-popup diff cache landed but the rendered diff
body started at column 0 while every other row in the popup uses a
two-space margin, so the hunk header and code rows visually broke
out of the card. Shell commands were also hard-clipped at 120
characters with a trailing ellipsis, hiding the dangerous tail
(the part that usually contains `> redirect` / `rm -rf` / piped
side effects) — exactly the part the user needs to read before
approving.

- Indent every diff body line (Diff / NewFile / MissingMatch
  variants) by two spaces and shrink the rendering width to match
  so the renderer's wrap decisions agree with what fits.
- Drop the synthetic `@@ -0,0 +1,N @@` hunk header for NewFile;
  the panel header already says "New file +N" and the hunk row
  just wasted one of the few preview rows.
- Hand-build the NewFile body lines so they carry the same
  line-number gutter and addition colour the diff path uses,
  without routing through render_diff_compact's hunk-header path.
- New `param_text` helper returns shell `command` / `cmd` values
  verbatim. The popup body uses `Paragraph::wrap`, so long lines
  fold naturally instead of needing in-band ellipsis truncation.
- Bump path / target / input previews from 96 to 200 chars so
  long file paths and URLs survive without `…` in the popup.
- Replace the local `count_diff_changes` loop with a call into
  `diff_render::summarize_diff` so the popup header's `+N -M`
  totals agree with the detail pager's summary (with a single-
  file fallback for fragments that lack a `diff --git` header).
Render approval details with structured fields instead of raw JSON so multiline edit, patch, and change payloads stay readable in the details view. Improve shell command formatting, special-case printf-based file writes into a clearer preview, and keep diff/pager indentation intact.\n\nAlso keep diff scrolling on Up/Down while j/k continues to move selection. Add regression coverage for the approval details, shell previews, diff indentation, and pager wrapping behavior.\n\nVerification:\n- cargo fmt --all\n- cargo fmt --check\n- cargo test -p deepseek-tui approval::tests -- --nocapture\n- cargo test -p deepseek-tui widgets::tests::approval_shell_command -- --nocapture\n- cargo test -p deepseek-tui diff_render::tests -- --nocapture\n- cargo test -p deepseek-tui pager::tests -- --nocapture\n- cargo build
Precompute approval popup details and parsed shell command lines when the approval request is created so the render loop no longer canonicalizes paths or reparses shell commands every frame.\n\nCache rendered diff preview panels by width and locale in ApprovalView, and add a size guard that skips inline diff generation for very large existing files instead of reading and diffing them synchronously in the TUI event path.\n\nUpdate pager text wrapping to prefer whitespace boundaries for natural language while preserving indentation and still character-wrapping long CJK or unbroken text.\n\nVerification:\n- cargo fmt --all\n- cargo fmt --all --check\n- git diff --check\n- cargo check -p codewhale-tui\n- cargo test -p codewhale-tui approval::tests -- --nocapture\n- cargo test -p codewhale-tui widgets::tests::approval_shell_command -- --nocapture\n- cargo test -p codewhale-tui pager::tests -- --nocapture\n- cargo build
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the TUI approval system to precompute and cache diff previews and prominent details at construction time, avoiding filesystem reads during render frames. It also introduces a compact diff renderer to save vertical space in the popup, implements a two-step confirmation flow for destructive actions, and improves text wrapping in the pager view. The review feedback highlights three important issues: a parsing bug in split_unquoted_redirect when handling escaped quotes, a broken preview in the details pager for new files because raw content is passed directly to render_diff, and potential TUI freezes caused by blocking synchronous filesystem I/O inside the async event loop.

Comment on lines +588 to +606
fn split_unquoted_redirect(command: &str) -> Option<(&str, &str)> {
let mut quote: Option<char> = None;
for (idx, ch) in command.char_indices() {
if matches!(ch, '"' | '\'') {
if quote == Some(ch) {
quote = None;
} else if quote.is_none() {
quote = Some(ch);
}
impacts
continue;
}
ToolCategory::FileWrite => {
let mut impacts = vec!["会写入工作区或已批准写入范围内的文件。".to_string()];
if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
impacts.push(format!("写入:{path}"));
}
impacts
if quote.is_none() && ch == '>' {
let before = &command[..idx];
let after = &command[idx + ch.len_utf8()..];
return Some((before, after));
}
ToolCategory::Shell => {
let mut impacts = vec!["执行 shell 命令。".to_string()];
if let Some(command) = param_preview(params, &["cmd", "command"], 96) {
impacts.push(format!("命令:{command}"));
}
None
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The split_unquoted_redirect function does not handle escaped quotes (e.g., \" or \\'). If an escaped quote appears inside a quoted string, the parser will incorrectly treat it as a closing quote. This causes subsequent quoted text to be treated as unquoted, which can lead to incorrect splitting if a > character appears later inside the quotes (e.g., printf '%s\n' "escaped \" quote > here" > file.txt).

To fix this, track backslash escapes when outside of single quotes.

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 == '\\' && quote != Some('\'') {
            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 == '>' {
            let before = &command[..idx];
            let after = &command[idx + ch.len_utf8()..];
            return Some((before, after));
        }
    }
    None
}

Comment thread crates/tui/src/tui/ui.rs
Comment on lines +7931 to +7943
if let Some(preview) = preview.as_ref() {
lines.push(Line::from(Span::styled("Changes:", label_style)));
let diff_text = preview.diff_text();
if diff_text.is_empty() {
lines.push(Line::from(Span::styled(
" (no textual changes — content matches current file)".to_string(),
muted_style,
)));
} else {
lines.extend(crate::tui::diff_render::render_diff(diff_text, diff_width));
}
lines.push(Line::from(""));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

For ApprovalDiffPreview::NewFile, preview.diff_text() returns the raw file content rather than a unified diff. Passing this raw content directly to render_diff will cause any lines that do not start with +, -, or to be completely ignored and skipped, resulting in a broken or empty preview in the details pager.

To fix this, match on the ApprovalDiffPreview variants directly (similar to how it is done in widgets/mod.rs) and synthesize a proper unified diff for the NewFile case.

        if let Some(preview) = preview.as_ref() {
            lines.push(Line::from(Span::styled("Changes:", label_style)));
            match preview {
                ApprovalDiffPreview::Diff { text, .. }
                | ApprovalDiffPreview::MissingMatch { text, .. } => {
                    if text.is_empty() {
                        lines.push(Line::from(Span::styled(
                            "  (no textual changes — content matches current file)".to_string(),
                            muted_style,
                        )));
                    } else {
                        lines.extend(crate::tui::diff_render::render_diff(text, diff_width));
                    }
                }
                ApprovalDiffPreview::NewFile { path, content } => {
                    let diff = crate::tools::diff_format::make_unified_diff(path, "", content);
                    lines.extend(crate::tui::diff_render::render_diff(&diff, diff_width));
                }
                ApprovalDiffPreview::SkippedLargeFile { size, limit, .. } => {
                    lines.push(Line::from(Span::styled(
                        format!("  (diff preview skipped - file is {size} bytes, limit is {limit} bytes)"),
                        muted_style,
                    )));
                }
                ApprovalDiffPreview::NoChange { .. } => {
                    lines.push(Line::from(Span::styled(
                        "  (no textual changes — content matches current file)".to_string(),
                        muted_style,
                    )));
                }
            }
            lines.push(Line::from(""));
        }

Comment on lines +680 to +696
fn read_file_for_diff(resolved: &Path, display_path: &str) -> PreviewFileRead {
if let Ok(metadata) = std::fs::metadata(resolved)
&& metadata.is_file()
&& metadata.len() > DIFF_PREVIEW_MAX_BYTES
{
return PreviewFileRead::Skipped(ApprovalDiffPreview::SkippedLargeFile {
path: display_path.to_string(),
size: metadata.len(),
limit: DIFF_PREVIEW_MAX_BYTES,
});
}

match std::fs::read_to_string(resolved) {
Ok(content) => PreviewFileRead::Content(content),
Err(_) => PreviewFileRead::Unreadable,
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing synchronous filesystem I/O (std::fs::metadata and std::fs::read_to_string) inside read_file_for_diff blocks the async executor thread because ApprovalRequest::new_with_workspace is called directly within the async run_event_loop in ui.rs. If the filesystem is slow (e.g., network shares, slow disks), this can cause the TUI to freeze or stutter. Consider using tokio::fs for asynchronous file reading, or offloading this synchronous construction to tokio::task::spawn_blocking.

Comment thread crates/tui/src/tui/approval.rs
Comment thread crates/tui/src/tui/widgets/mod.rs Outdated
Copy link
Copy Markdown
Owner

@Hmbown Hmbown left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved for the v0.8.48 candidate queue. CI and Greptile are clean after the maintainer follow-ups, and the final diff preserves approval preview context while keeping the shell/diff preview behavior focused. Thanks @tdccccc for the original approval-detail work.

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented May 27, 2026

Independent review:

Compile-check clean on pr-2269 (cargo check -p codewhale-tui). Merges into main and pr-2256 (workspace consolidation) without conflict despite touching ui.rs, runtime_threads.rs, turn_loop.rs — auto-merge handles it. No v0.8.48 blocker.

Scope check (2407/508 across 9 files): all changes are TUI display/input-handling. No orthogonal refactors hiding; the engine approval decision plumbing is unchanged (params flow through verbatim).

Findings beyond Greptile's two:

  1. Truncated path/URL in approval popup is informed-consent gap (approval.rs:418/427/437). FileWrite, Safe, Network categories render path/uri/url via param_preview(..., 200), which appends ... past 200 chars and feeds the truncated value to the rendered ApprovalDetail. For Shell, param_text correctly preserves the full command (covered by test_prominent_details_shell_does_not_truncate_long_command) — but Dir is truncated at 96 chars (line 408) and there's no equivalent test guarding it. A long /tmp/... path with a dangerous tail can render identically to a benign one. The full value is still acted on; only the user's basis for consent is clipped. Either bump the limit substantially, fold long values onto multiple visual lines (the existing Paragraph::wrap would handle it), or add a (full path in v pager) cue.

  2. Greptile's Diff/MissingMatch arm-share bug at approval.rs:1340 is not fixed in 04f275e. A MissingMatch with empty text still prints "no textual changes — content matches current file," which is the opposite of what happened (search string was missing). The suggested split is mechanical. Worth taking before the v0.8.48 cut.

  3. Pre-existing, not introduced here but expanded surface: render_diff_line (diff_render.rs:399) passes raw file content into Span::styled with no control-char / ANSI-escape stripping. A file containing \x1b[ sequences or \r could distort the rendered diff and mask additions. The new render_diff_compact inherits this. Out of scope for the PR but flagging since the structured approval display is now the primary "what am I approving" surface — worth a follow-up to render non-printable bytes as <U+XXXX> glyphs.

Two-step destructive confirm and pending_confirm are well-covered (destructive_y_first_press_*, destructive_a_first_press_*, destructive_enter_approves_selected_option, the 'q' no-op test). Switching staged option mid-flow (Once → Always) silently overwrites — minor, current behavior is defensible.

v0.8.48: no merge conflicts with PR #2256. None of the above blocks; (2) is a small follow-up worth catching pre-cut.

- Handle escaped quotes in split_unquoted_redirect
- Render NewFile/SkippedLargeFile variants properly in detail pager
- Offload diff preview I/O to spawn_blocking to avoid blocking the async executor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants