fix(tui): diff gutters number one side, context keeps its indent, commands keep their lines

All three were seen in the same real DeepSeek session that found the
approval-before-refusal bug (CW/artifacts/readme-shot-20261018/).

Diff gutter: format_line_numbers printed both the old and the new line
number on every row, so a removed line showed a new-side number and an
added line an old-side number. The patch card for the slugkit fix read
"   9    9 -     words = ..." and "  10    9 +     lower = ...", which
looks like line 9 exists on both sides. A '+' row now leaves the old
column blank and a '-' row leaves the new column blank; context rows
keep both. The existing context-numbering assertion is unchanged.

Diff context indentation: every row's marker was removed with
trim_start_matches, which strips all leading copies of the character. On
a context row (' ' prefix) that removed the code's own indentation, so
unchanged lines rendered flush left while the -/+ rows beside them kept
theirs; on a '-' or '+' row it also ate content that began with the same
character (a removed "- item" Markdown line lost its dash). All seven
sites now drop exactly the one marker byte (&raw[1..]; is_added,
is_deleted and the ' ' check guarantee it is there).

Shell card command: render_command_mode wrapped the whole command with
wrap_text, which keeps '\n' inside a chunk, and the span renderer then
dropped it, so "echo first-line\necho second-line" displayed as
"command: echo first-lineecho second-line". It now wraps each line
separately, matching how the approval card already shows the command.

Also credits @Lstarsky0 for #7022 in both changelogs.

Checks:
- cargo test -p codewhale-tui --lib -- tools::shell core::engine
  tui::history diff approval: 1500 passed; 0 failed; 5 ignored (same run
  as the refusal commit, before the indentation change)
- after the indentation change: cargo test -p codewhale-tui --lib -- diff
  patch file_mutation: 493 passed; 0 failed
- clippy with CI's flags: clean on the tree before the &raw[1..] change;
  after it, only cargo build and the diff tests were run

Verified in a real TUI with a scripted mock edit (phase5 in
CW/artifacts/cw7087-20261018/mock/server.py; captures diff-after-1.txt and
diff-after4-1.txt in CW/artifacts/approval-card-20261018/):
     4    4   def slug(text):
     5    5       """Join words with dashes."""
     6      -     words = text.split()
          6 +     lower = text.lower()
          7 +     words = lower.split()
     7    8       return '-'.join(words)
and the refused two-line mock command shows "command: echo first-line"
with "echo second-line" on the next row (multiline-after4-1.txt).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-18 10:49:53 -07:00
1 parent 89d1407efd
commit afe5f9b30f
4 files changed
+31 -9

No files matched your search

+9
View File
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `/statusline` shows its last two items, Workspace and Git branch, without
scrolling. The picker had not counted its padding rows. Thanks @Lstarsky0
(#7022).
- Diffs in the TUI number each line only on its own side: a removed line
shows its old line number and an added line its new one. Unchanged
context lines keep their indentation instead of starting at the left
edge.
- A shell card shows a multiline command one line per row instead of running
the lines together.
- A shell card whose command is waiting on your approval now says
"waiting for approval" with a still mark, instead of "running (Ns)" with
a spinner. Its elapsed time starts when you approve and the command
+9
View File
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `/statusline` shows its last two items, Workspace and Git branch, without
scrolling. The picker had not counted its padding rows. Thanks @Lstarsky0
(#7022).
- Diffs in the TUI number each line only on its own side: a removed line
shows its old line number and an added line its new one. Unchanged
context lines keep their indentation instead of starting at the left
edge.
- A shell card shows a multiline command one line per row instead of running
the lines together.
- A shell card whose command is waiting on your approval now says
"waiting for approval" with a still mark, instead of "running (Ns)" with
a spinner. Its elapsed time starts when you approve and the command
+9 -7
View File
@@ -100,7 +100,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
}
if is_added(raw) {
let content = raw.trim_start_matches('+');
let content = &raw[1..];
collector.extend(render_diff_line(
content,
width,
@@ -120,7 +120,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
// A deleted run followed by an added run of the same length is a
// set of replaced lines: emphasise the words that changed within
// each pair. Any other shape renders line by line as before.
let mut removed = vec![raw.trim_start_matches('-')];
let mut removed = vec![&raw[1..]];
let mut added: Vec<&str> = Vec::new();
let mut removed_markers: Vec<(usize, &str)> = Vec::new();
let mut added_markers: Vec<(usize, &str)> = Vec::new();
@@ -135,7 +135,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
&& let Some(next) = lines.next_if(|next| is_deleted(next))
{
if removed.len() < INTRALINE_MAX_RUN {
removed.push(next.trim_start_matches('-'));
removed.push(&next[1..]);
} else {
flush_plain_run(
&mut collector,
@@ -148,7 +148,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
);
render_plain_diff_line(
&mut collector,
next.trim_start_matches('-'),
&next[1..],
width,
&mut old_line,
&mut new_line,
@@ -158,7 +158,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
}
} else if let Some(next) = lines.next_if(|next| is_added(next)) {
if added.len() < INTRALINE_MAX_RUN {
added.push(next.trim_start_matches('+'));
added.push(&next[1..]);
} else {
flush_plain_run(
&mut collector,
@@ -180,7 +180,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
);
render_plain_diff_line(
&mut collector,
next.trim_start_matches('+'),
&next[1..],
width,
&mut old_line,
&mut new_line,
@@ -252,7 +252,7 @@ pub fn render_diff_body_bounded(diff: &str, width: u16, max_rows: usize) -> Boun
}
if raw.starts_with(' ') {
let content = raw.trim_start_matches(' ');
let content = &raw[1..];
collector.extend(render_diff_line(
content,
width,
@@ -712,9 +712,11 @@ fn render_diff_line(
fn format_line_numbers(old_line: Option<usize>, new_line: Option<usize>, marker: char) -> String {
let old = old_line
.filter(|_| marker != '+')
.map(|value| format!("{value:>LINE_NUMBER_WIDTH$}"))
.unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
let new = new_line
.filter(|_| marker != '-')
.map(|value| format!("{value:>LINE_NUMBER_WIDTH$}"))
.unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
format!("{old} {new} {marker} ")
+4 -2
View File
@@ -2396,8 +2396,10 @@ fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec<Line<
RenderMode::Live => TOOL_COMMAND_LINE_LIMIT,
RenderMode::Transcript => usize::MAX,
};
for (count, chunk) in wrap_text(command, width.saturating_sub(4).max(1) as usize)
.into_iter()
let wrap_width = width.saturating_sub(4).max(1) as usize;
for (count, chunk) in command
.lines()
.flat_map(|line| wrap_text(line, wrap_width))
.enumerate()
{
if count >= cap {