From 5675499b4cd8e4947bc85e14b3a5bfe6a5ef8f67 Mon Sep 17 00:00:00 2001 From: lr90 Date: Mon, 26 Oct 2026 17:40:15 +0800 Subject: [PATCH 1/6] fix(tui): keep copied assistant scrollback free of gutter glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settled assistant replies prefixed every scrollback row with a literal `█ ` gutter. Terminal selection copies written cells, so commands copied from a reply came out as `█ git status` and did not run when pasted. Mark only the first body row with `● ` (same `theme.success` colour) and indent continuation rows with two plain spaces, matching the first-row marker convention used by tool cells. Layout width and colours are unchanged; the live `LiveFramedCell` gutter is out of scope. Refs #1142 Co-Authored-By: Claude Opus 5.5 --- crates/astra-cli/src/tui/ARCHITECTURE.md | 6 +- ...idget__turn_driver__canonical_turn_80.snap | 2 +- .../src/tui/history_cell/assistant.rs | 91 ++++++++++++++----- ...tant__tests__assistant_inline_code_60.snap | 2 +- ...istant__tests__assistant_paragraph_60.snap | 6 +- ...ant__tests__assistant_think_closed_60.snap | 2 +- crates/astra-cli/src/tui/terminal.rs | 3 +- 7 files changed, 77 insertions(+), 35 deletions(-) diff --git a/crates/astra-cli/src/tui/ARCHITECTURE.md b/crates/astra-cli/src/tui/ARCHITECTURE.md index d4768fa979..8acccadda3 100644 --- a/crates/astra-cli/src/tui/ARCHITECTURE.md +++ b/crates/astra-cli/src/tui/ARCHITECTURE.md @@ -10,8 +10,8 @@ │ │ │ › user message │ ← UserCell │ │ -│ █ assistant response, streamed word by word, markdown-rendered │ ← AssistantCell -│ █ with the accent gutter ("█ ") on every wrapped row │ +│ ● assistant response, streamed word by word, markdown-rendered │ ← AssistantCell +│ with a first-row marker and plain-space continuation indent │ │ │ │ • Ran bash (52ms) │ ← ToolCell │ │ $ echo hello │ @@ -104,7 +104,7 @@ All cells live in `history_cell/` and implement the `HistoryCell` trait | Cell | File | Role | |------|------|------| | **UserCell** | `user.rs` | `›` accent-bold prefix + user message text | -| **AssistantCell** | `assistant.rs` | Streaming markdown with `█ ` accent gutter; blinking cursor while live | +| **AssistantCell** | `assistant.rs` | Streaming markdown; settled body has a `● ` first-row marker and space-indented continuation rows so scrollback copies cleanly; blinking cursor while live | | **ReasoningCell** | `reasoning.rs` | `💭 Thinking` header + compact body; committed on `ReasoningDone` or first answer delta | | **ToolCell** | `tool.rs` | `• Ran tool (Nms)` header + `│` args + `└` output; diff-aware coloring | | **SystemCell** | `system.rs` | info / warning / error; error text is humanized (strips `` wrappers) | diff --git a/crates/astra-cli/src/tui/chat_widget/snapshots/astra__tui__chat_widget__turn_driver__canonical_turn_80.snap b/crates/astra-cli/src/tui/chat_widget/snapshots/astra__tui__chat_widget__turn_driver__canonical_turn_80.snap index ec3f7a9c97..b9d579dcb2 100644 --- a/crates/astra-cli/src/tui/chat_widget/snapshots/astra__tui__chat_widget__turn_driver__canonical_turn_80.snap +++ b/crates/astra-cli/src/tui/chat_widget/snapshots/astra__tui__chat_widget__turn_driver__canonical_turn_80.snap @@ -9,6 +9,6 @@ expression: "render_history(&w, 80)" ├ ls /tmp └ 3 entries -█ There are 3 files. +● There are 3 files. 1.2s total · ttft 400ms · 240 tokens · 1 tool diff --git a/crates/astra-cli/src/tui/history_cell/assistant.rs b/crates/astra-cli/src/tui/history_cell/assistant.rs index e004786131..8293070258 100644 --- a/crates/astra-cli/src/tui/history_cell/assistant.rs +++ b/crates/astra-cli/src/tui/history_cell/assistant.rs @@ -11,12 +11,10 @@ //! thinking window). One source of truth, one render path. See //! `docs/design/tui-refactor.md` §3.3. //! -//! Reply body keeps a stable left accent gutter once settled in -//! scrollback, so long numbered answers still scan like one unified -//! assistant block. Live cells still use the active-slot wrapper for -//! structure, but committed replies retain the visual anchor the old -//! TUI had. A -//! active response is kept visually distinct by the surrounding status line; +//! Settled reply bodies start with a `● ` marker and indent continuation +//! rows with plain spaces, so text selected from native scrollback copies +//! as written. Live cells still use the active-slot wrapper for structure. +//! An active response is kept visually distinct by the surrounding status line; //! its content itself stays free of transient transport counters. //! //! ## Inline thinking handling @@ -324,16 +322,12 @@ impl AssistantCell { } let inner_width = (width as usize).saturating_sub(2).max(20); let (rendered, total) = self.markdown_layout_window(source, inner_width, start, maximum); - let theme = crate::tui::theme::current(); - // A settled assistant response is a completed unit, so its guide uses - // the exact success colour of terminal `●` markers. Live responses - // use the distinct focus gutter supplied by `LiveFramedCell`. - let gutter_style = Style::default().fg(theme.success); let lines = rendered .into_iter() - .map(|line| { + .enumerate() + .map(|(offset, line)| { let mut spans = Vec::with_capacity(line.spans.len() + 1); - spans.push(Span::styled("█ ", gutter_style)); + spans.push(settled_body_prefix(start.saturating_add(offset))); spans.extend(line.spans); Line::from(spans) }) @@ -560,7 +554,7 @@ impl AssistantCell { if source.trim().is_empty() { return Vec::new(); } - // Settled cells prepend `█ ` (2 cols) to every line, so wrap text + // Settled cells prepend a 2-col prefix to every line, so wrap text // at `width - 2` to avoid terminal hard-wrap overflow. Live cells // already receive `width - 2` from `active_viewport` (the // `LiveFramedCell` wrapper paints its own gutter column). This means @@ -578,14 +572,13 @@ impl AssistantCell { return Vec::new(); } - let theme = crate::tui::theme::current(); - let gutter_style = Style::default().fg(theme.success); rendered .into_iter() - .map(|line| { + .enumerate() + .map(|(index, line)| { let mut spans: Vec> = Vec::with_capacity(line.spans.len() + 1); if prepend_gutter { - spans.push(Span::styled("█ ", gutter_style)); + spans.push(settled_body_prefix(index)); } spans.extend(line.spans.iter().cloned()); Line::from(spans) @@ -594,6 +587,24 @@ impl AssistantCell { } } +/// Two-column prefix for a settled response body line in native scrollback. +/// +/// Terminal selection copies every written cell, so a full-height glyph +/// gutter leaks into commands users copy from a reply. Only the first body +/// line carries the `● ` terminal marker, in the same success colour as tool +/// markers; continuation lines use plain spaces that shells ignore. Live +/// responses keep the distinct focus gutter supplied by `LiveFramedCell`. +fn settled_body_prefix(body_line: usize) -> Span<'static> { + if body_line == 0 { + Span::styled( + "● ", + Style::default().fg(crate::tui::theme::current().success), + ) + } else { + Span::raw(" ") + } +} + impl HistoryCell for AssistantCell { fn model_item_id(&self) -> Option<&str> { self.model_item_id.as_deref() @@ -1088,15 +1099,40 @@ mod tests { } #[test] - fn settled_assistant_guide_matches_terminal_success_marker() { + fn settled_assistant_marker_matches_terminal_success_marker() { let c = AssistantCell::from_markdown("done"); let lines = c.display_lines(40); - let guide = lines + let marker = lines .first() .and_then(|line| line.spans.first()) - .expect("settled assistant has a guide"); - assert_eq!(guide.content.as_ref(), "█ "); - assert_eq!(guide.style.fg, Some(crate::tui::theme::current().success)); + .expect("settled assistant has a marker"); + assert_eq!(marker.content.as_ref(), "● "); + assert_eq!(marker.style.fg, Some(crate::tui::theme::current().success)); + } + + #[test] + fn settled_assistant_command_lines_copy_without_gutter_glyphs() { + let c = AssistantCell::from_markdown( + "Run these:\n\n```sh\ngit status\ncargo test -p astra\n```\n\nThen `make lint`.", + ); + let (scrollback, _, complete) = c.scrollback_lines_chunk(80, 0, usize::MAX); + assert!(complete); + for lines in [c.display_lines(80), scrollback] { + let text = line_text(&lines); + assert_eq!(text.matches('●').count(), 1, "{text}"); + assert!(!text.contains('█'), "{text}"); + for command in ["git status", "cargo test -p astra"] { + let line = text + .lines() + .find(|line| line.contains(command)) + .expect("command line rendered"); + assert_eq!( + line.trim(), + command, + "selection copies only whitespace: {line:?}" + ); + } + } } #[test] @@ -1145,8 +1181,13 @@ mod tests { assert_eq!(second.len(), 3); assert_eq!(next, 6); assert!(!complete); - assert!(first[0].spans[0].content.starts_with("█ ")); - assert!(second[0].spans[0].content.starts_with("█ ")); + assert_eq!(first[0].spans[0].content.as_ref(), "● "); + assert_eq!(first[1].spans[0].content.as_ref(), " "); + assert_eq!( + second[0].spans[0].content.as_ref(), + " ", + "a later chunk continues the body instead of restarting the marker" + ); } #[test] diff --git a/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_inline_code_60.snap b/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_inline_code_60.snap index 0de637cb04..27d8e0750f 100644 --- a/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_inline_code_60.snap +++ b/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_inline_code_60.snap @@ -2,4 +2,4 @@ source: crates/astra-cli/src/tui/history_cell/assistant.rs expression: "render(&c, 60, 2)" --- -█ Use cargo build to compile the project. +● Use cargo build to compile the project. diff --git a/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_paragraph_60.snap b/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_paragraph_60.snap index 4c7df78546..8abc13db55 100644 --- a/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_paragraph_60.snap +++ b/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_paragraph_60.snap @@ -2,6 +2,6 @@ source: crates/astra-cli/src/tui/history_cell/assistant.rs expression: "render(&c, 60, 5)" --- -█ Here is the plan: -█ • step one -█ • step two +● Here is the plan: + • step one + • step two diff --git a/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_think_closed_60.snap b/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_think_closed_60.snap index 850f886ace..bb84f026d8 100644 --- a/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_think_closed_60.snap +++ b/crates/astra-cli/src/tui/history_cell/snapshots/astra__tui__history_cell__assistant__tests__assistant_think_closed_60.snap @@ -3,4 +3,4 @@ source: crates/astra-cli/src/tui/history_cell/assistant.rs expression: "render(&c, 60, 3)" --- ● Thought · 2 lines · 14 tokens -█ Hello — happy to help. +● Hello — happy to help. diff --git a/crates/astra-cli/src/tui/terminal.rs b/crates/astra-cli/src/tui/terminal.rs index b696017f25..5ab3dff9a0 100644 --- a/crates/astra-cli/src/tui/terminal.rs +++ b/crates/astra-cli/src/tui/terminal.rs @@ -827,7 +827,8 @@ mod tests { let (first, _) = take_pending_history_batch(&mut pending); assert_eq!(first.len(), MAX_HISTORY_LINES_PER_DRAW); assert!(!pending.is_empty(), "long reply tail remains queued"); - assert!(first.iter().all(|line| line.spans[0].content == "█ ")); + assert_eq!(first[0].spans[0].content, "● "); + assert!(first[1..].iter().all(|line| line.spans[0].content == " ")); let mut emitted = first; while !pending.is_empty() { From 6518d32f651cb8a2ba49927b4edb3ec7c643401e Mon Sep 17 00:00:00 2001 From: lr90 Date: Mon, 26 Oct 2026 20:08:27 +0800 Subject: [PATCH 2/6] fix(tui): copy settled code blocks verbatim from scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-space continuation prefix still leaked into copied code: a heredoc terminator became ` EOF` and no longer closed the heredoc, and indentation-sensitive code gained an extra indent. The Markdown renderer now marks code-block rows as verbatim, and settled assistant replies write those rows at column 0 without any prefix, so a selected block matches its source exactly. Prose rows keep the `● ` first-row marker and two-space indent. Tests compare the whole copied block instead of trimmed rows. Refs #1142 Co-Authored-By: Claude Opus 5.5 --- .../src/tui/history_cell/assistant.rs | 108 +++++++++--------- crates/astra-cli/src/tui/markdown_render.rs | 44 ++++++- crates/astra-cli/src/tui/tests.rs | 30 +++++ 3 files changed, 128 insertions(+), 54 deletions(-) diff --git a/crates/astra-cli/src/tui/history_cell/assistant.rs b/crates/astra-cli/src/tui/history_cell/assistant.rs index 8293070258..2e95af2021 100644 --- a/crates/astra-cli/src/tui/history_cell/assistant.rs +++ b/crates/astra-cli/src/tui/history_cell/assistant.rs @@ -12,8 +12,9 @@ //! `docs/design/tui-refactor.md` §3.3. //! //! Settled reply bodies start with a `● ` marker and indent continuation -//! rows with plain spaces, so text selected from native scrollback copies -//! as written. Live cells still use the active-slot wrapper for structure. +//! prose rows with plain spaces; code-block rows stay verbatim at column 0, +//! so a block selected from native scrollback copies exactly as written. +//! Live cells still use the active-slot wrapper for structure. //! An active response is kept visually distinct by the surrounding status line; //! its content itself stays free of transient transport counters. //! @@ -44,7 +45,9 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use super::HistoryCell; -use crate::tui::markdown_render::render_markdown_text_with_width; +use crate::tui::markdown_render::{ + MarkdownRow, render_markdown_rows_with_width, render_markdown_text_with_width, +}; use crate::tui::render::line_utils::line_to_static; use crate::tui::turn_event::TurnEvent; @@ -61,7 +64,7 @@ struct MarkdownLayoutCache { /// without retaining a second full copy merely for cache matching. source_ptr: usize, source_len: usize, - lines: Vec>, + rows: Vec, } /// Bounded live Markdown projection. It intentionally lags a burst by a @@ -268,17 +271,13 @@ impl AssistantCell { return; } - let lines = render_markdown_text_with_width(source, Some(width)) - .lines - .iter() - .map(line_to_static) - .collect::>(); + let rows = render_markdown_rows_with_width(source, Some(width)); *cache = Some(MarkdownLayoutCache { revision: self.render_revision, width, source_ptr: source.as_ptr() as usize, source_len: source.len(), - lines, + rows, }); } @@ -288,7 +287,7 @@ impl AssistantCell { width: usize, start: usize, maximum: usize, - ) -> (Vec>, usize) { + ) -> (Vec, usize) { self.ensure_markdown_layout(source, width); let cache = astra_core::sync_poison::recover_mutex_lock(&self.render_cache); let cache = cache @@ -300,13 +299,13 @@ impl AssistantCell { width, self.render_revision )); - let total = cache.lines.len(); + let total = cache.rows.len(); let start = start.min(total); let end = start.saturating_add(maximum).min(total); - (cache.lines[start..end].to_vec(), total) + (cache.rows[start..end].to_vec(), total) } - fn markdown_layout(&self, source: &str, width: usize) -> Vec> { + fn markdown_layout(&self, source: &str, width: usize) -> Vec { self.markdown_layout_window(source, width, 0, usize::MAX).0 } @@ -325,12 +324,7 @@ impl AssistantCell { let lines = rendered .into_iter() .enumerate() - .map(|(offset, line)| { - let mut spans = Vec::with_capacity(line.spans.len() + 1); - spans.push(settled_body_prefix(start.saturating_add(offset))); - spans.extend(line.spans); - Line::from(spans) - }) + .map(|(offset, row)| prefix_settled_row(start.saturating_add(offset), row)) .collect(); (lines, total) } @@ -575,34 +569,42 @@ impl AssistantCell { rendered .into_iter() .enumerate() - .map(|(index, line)| { - let mut spans: Vec> = Vec::with_capacity(line.spans.len() + 1); + .map(|(index, row)| { if prepend_gutter { - spans.push(settled_body_prefix(index)); + prefix_settled_row(index, row) + } else { + row.line } - spans.extend(line.spans.iter().cloned()); - Line::from(spans) }) .collect() } } -/// Two-column prefix for a settled response body line in native scrollback. +/// Prefix a settled response body row for native scrollback. /// -/// Terminal selection copies every written cell, so a full-height glyph -/// gutter leaks into commands users copy from a reply. Only the first body -/// line carries the `● ` terminal marker, in the same success colour as tool -/// markers; continuation lines use plain spaces that shells ignore. Live -/// responses keep the distinct focus gutter supplied by `LiveFramedCell`. -fn settled_body_prefix(body_line: usize) -> Span<'static> { - if body_line == 0 { +/// Terminal selection copies every written cell, so any prefix leaks into +/// text users copy from a reply. Code-block rows are therefore written +/// verbatim at column 0: a copied block (a heredoc, indentation-sensitive +/// code) matches its source exactly. Other rows get a 2-col prefix: the first +/// body row carries the `● ` terminal marker, in the same success colour as +/// tool markers, and the rest are indented with plain spaces. Live responses +/// keep the distinct focus gutter supplied by `LiveFramedCell`. +fn prefix_settled_row(body_row: usize, row: MarkdownRow) -> Line<'static> { + if row.verbatim { + return row.line; + } + let prefix = if body_row == 0 { Span::styled( "● ", Style::default().fg(crate::tui::theme::current().success), ) } else { Span::raw(" ") - } + }; + let mut spans = Vec::with_capacity(row.line.spans.len() + 1); + spans.push(prefix); + spans.extend(row.line.spans); + Line::from(spans) } impl HistoryCell for AssistantCell { @@ -1111,26 +1113,26 @@ mod tests { } #[test] - fn settled_assistant_command_lines_copy_without_gutter_glyphs() { - let c = AssistantCell::from_markdown( - "Run these:\n\n```sh\ngit status\ncargo test -p astra\n```\n\nThen `make lint`.", - ); - let (scrollback, _, complete) = c.scrollback_lines_chunk(80, 0, usize::MAX); - assert!(complete); - for lines in [c.display_lines(80), scrollback] { - let text = line_text(&lines); - assert_eq!(text.matches('●').count(), 1, "{text}"); - assert!(!text.contains('█'), "{text}"); - for command in ["git status", "cargo test -p astra"] { - let line = text - .lines() - .find(|line| line.contains(command)) - .expect("command line rendered"); - assert_eq!( - line.trim(), - command, - "selection copies only whitespace: {line:?}" + fn settled_code_blocks_copy_verbatim_from_scrollback() { + // A heredoc terminator and indentation only survive a copy when the + // selected rows match the source exactly, so compare the whole block + // rather than trimmed rows. + let block = "cat <<'EOF'\n indented\nEOF\ngit status"; + for source in [ + format!("Run these:\n\n```\n{block}\n```\n\nThen `make lint`."), + format!("```\n{block}\n```"), + ] { + let c = AssistantCell::from_markdown(source.clone()); + let (scrollback, _, complete) = c.scrollback_lines_chunk(80, 0, usize::MAX); + assert!(complete); + for lines in [c.display_lines(80), scrollback] { + let text = line_text(&lines); + assert!( + text.contains(&format!("\n{block}\n")) || text.ends_with(&format!("\n{block}")), + "{source:?} rendered {text:?}" ); + assert_eq!(text.matches('●').count(), 1, "{text}"); + assert!(!text.contains('█'), "{text}"); } } } diff --git a/crates/astra-cli/src/tui/markdown_render.rs b/crates/astra-cli/src/tui/markdown_render.rs index c43c34b33d..a8e573b817 100644 --- a/crates/astra-cli/src/tui/markdown_render.rs +++ b/crates/astra-cli/src/tui/markdown_render.rs @@ -47,6 +47,27 @@ pub(crate) fn render_markdown_text_with_width_and_cwd( width: Option, cwd: Option<&Path>, ) -> Text<'static> { + write_markdown(input, width, cwd).into_text() +} + +/// One rendered Markdown row. `verbatim` marks rows that reproduce +/// code-block source as written; callers that decorate rows must leave +/// these untouched so terminal selection copies the source exactly. +#[derive(Debug, Clone)] +pub(crate) struct MarkdownRow { + pub line: Line<'static>, + pub verbatim: bool, +} + +pub(crate) fn render_markdown_rows_with_width( + input: &str, + width: Option, +) -> Vec { + let cwd = std::env::current_dir().ok(); + write_markdown(input, width, cwd.as_deref()).into_rows() +} + +fn write_markdown(input: &str, width: Option, cwd: Option<&Path>) -> Writer { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TABLES); @@ -54,7 +75,7 @@ pub(crate) fn render_markdown_text_with_width_and_cwd( let mut writer = Writer::new(width, cwd.map(Path::to_path_buf)); writer.styles = MarkdownStyles::from_theme(); writer.run(parser); - writer.into_text() + writer } struct Writer { @@ -64,6 +85,8 @@ struct Writer { style_stack: Vec