diff --git a/crates/astra-cli/src/tui/ARCHITECTURE.md b/crates/astra-cli/src/tui/ARCHITECTURE.md index d4768fa979..33161e5988 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 +│ prose indented with plain spaces; code blocks verbatim │ │ │ │ • 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 prose rows are space-indented with no glyph marker and code-block rows retain logical line boundaries through the final writer without a gutter or width-generated hard breaks; 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) | @@ -257,3 +257,21 @@ not an interactive fallback dispatcher. The current product contract and phased implementation plan live in `plans/astra-tui-agent-workbench-productization-2026-07-11.md`. + +### Settled code in native scrollback + +The Markdown renderer marks code rows and the immutable cached rows retain that +classification through lazy history batching and the final terminal writer. +Code rows start at column zero and use terminal soft wrapping; ordinary prose +keeps its existing layout. Final writes count physical rows at the current +terminal width without inserting hard line breaks into the code itself. +Sanitization and structured-link trust remain independent of this layout hint. +Zellij lets content flow before reserving space for the input area, preserving +multi-screen code across input redraws and later replies. A full-height input +area uses the same operation to move output into native scrollback. + +This guarantees that Astra adds neither a gutter nor width-generated hard breaks +to sanitized code rows. Native clipboard selection still depends on the terminal +and multiplexer; tabs, Unicode width policy, and Markdown newline normalization +are not a portable byte-for-byte clipboard contract. The live streaming gutter +is a separate presentation path and is unchanged here. 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..6505da73a6 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/event_loop.rs b/crates/astra-cli/src/tui/event_loop.rs index efded31831..a7bc47bfcf 100644 --- a/crates/astra-cli/src/tui/event_loop.rs +++ b/crates/astra-cli/src/tui/event_loop.rs @@ -15515,9 +15515,9 @@ mod tests { let mut emitted = Vec::new(); let mut start = 0; loop { - let (chunk, next, complete) = assistant.scrollback_lines_chunk(80, start, 8); + let (chunk, next, complete) = assistant.scrollback_rows_chunk(80, start, 8); assert!(chunk.len() <= 8); - emitted.extend(chunk); + emitted.extend(chunk.into_iter().map(|row| row.line)); if complete { break; } diff --git a/crates/astra-cli/src/tui/history_cell/assistant.rs b/crates/astra-cli/src/tui/history_cell/assistant.rs index e004786131..660056b95d 100644 --- a/crates/astra-cli/src/tui/history_cell/assistant.rs +++ b/crates/astra-cli/src/tui/history_cell/assistant.rs @@ -11,12 +11,11 @@ //! 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 indent prose rows with plain spaces and carry no +//! glyph marker; code-block rows stay verbatim at column 0, 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 @@ -46,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; @@ -63,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 @@ -270,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, }); } @@ -290,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 @@ -302,42 +299,29 @@ 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 } - fn scrollback_body_lines( + fn scrollback_body_rows( &self, source: &str, width: u16, start: usize, maximum: usize, - ) -> (Vec>, usize) { + ) -> (Vec, usize) { if source.is_empty() || maximum == 0 { return (Vec::new(), 0); } 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| { - let mut spans = Vec::with_capacity(line.spans.len() + 1); - spans.push(Span::styled("█ ", gutter_style)); - spans.extend(line.spans); - Line::from(spans) - }) - .collect(); + let lines = rendered.into_iter().map(prefix_settled_row).collect(); (lines, total) } @@ -515,12 +499,12 @@ impl AssistantCell { /// Materialize only a bounded portion of a final reply for native /// scrollback. Long final messages are inserted over several frames so /// the event loop can continue accepting composer input between chunks. - pub(crate) fn scrollback_lines_chunk( + pub(crate) fn scrollback_rows_chunk( &self, width: u16, start: usize, maximum: usize, - ) -> (Vec>, usize, bool) { + ) -> (Vec, usize, bool) { debug_assert!(!self.live, "live assistant cells stay in the viewport"); if maximum == 0 { return (Vec::new(), start, false); @@ -545,11 +529,12 @@ impl AssistantCell { .skip(start) .take(maximum) .cloned() + .map(MarkdownRow::from) .collect::>(); let body_start = start.saturating_sub(headers.len()); let remaining = maximum.saturating_sub(lines.len()); let (body_lines, body_total) = - self.scrollback_body_lines(body_source, width, body_start, remaining); + self.scrollback_body_rows(body_source, width, body_start, remaining); lines.extend(body_lines); let next = start.saturating_add(lines.len()); let total = headers.len().saturating_add(body_total); @@ -560,7 +545,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,22 +563,29 @@ impl AssistantCell { return Vec::new(); } - let theme = crate::tui::theme::current(); - let gutter_style = Style::default().fg(theme.success); rendered .into_iter() - .map(|line| { - let mut spans: Vec> = Vec::with_capacity(line.spans.len() + 1); + .map(|row| { if prepend_gutter { - spans.push(Span::styled("█ ", gutter_style)); + prefix_settled_row(row).line + } else { + row.line } - spans.extend(line.spans.iter().cloned()); - Line::from(spans) }) .collect() } } +/// Decorate prose while retaining code-row semantics for the final writer. +/// Settled code starts at column zero and uses terminal soft wrapping; live +/// responses keep their distinct focus gutter supplied by `LiveFramedCell`. +fn prefix_settled_row(mut row: MarkdownRow) -> MarkdownRow { + if !row.verbatim { + row.line.spans.insert(0, Span::raw(" ")); + } + row +} + impl HistoryCell for AssistantCell { fn model_item_id(&self) -> Option<&str> { self.model_item_id.as_deref() @@ -1062,9 +1054,9 @@ mod tests { let mut actual = Vec::new(); let mut start = 0; loop { - let (chunk, next, complete) = c.scrollback_lines_chunk(80, start, 8); + let (chunk, next, complete) = c.scrollback_rows_chunk(80, start, 8); assert!(chunk.len() <= 8); - actual.extend(chunk); + actual.extend(chunk.into_iter().map(|row| row.line)); if complete { break; } @@ -1088,15 +1080,46 @@ mod tests { } #[test] - fn settled_assistant_guide_matches_terminal_success_marker() { - let c = AssistantCell::from_markdown("done"); - let lines = c.display_lines(40); - let guide = 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)); + fn settled_single_line_command_reply_copies_without_glyphs() { + for source in ["`git status`", "git status"] { + let c = AssistantCell::from_markdown(source); + let (scrollback, _, complete) = c.scrollback_rows_chunk(80, 0, usize::MAX); + assert!(complete); + for lines in [ + c.display_lines(80), + scrollback.into_iter().map(|row| row.line).collect(), + ] { + assert_eq!(line_text(&lines), " git status", "{source:?}"); + } + } + } + + #[test] + 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_rows_chunk(80, 0, usize::MAX); + assert!(complete); + for lines in [ + c.display_lines(80), + scrollback.into_iter().map(|row| row.line).collect(), + ] { + let text = line_text(&lines); + assert!( + text.contains(&format!("\n{block}\n")) || text.ends_with(&format!("\n{block}")), + "{source:?} rendered {text:?}" + ); + assert!(!text.contains('●'), "{text}"); + assert!(!text.contains('█'), "{text}"); + } + } } #[test] @@ -1136,17 +1159,22 @@ mod tests { .join("\n\n"), ); - let (first, next, complete) = c.scrollback_lines_chunk(80, 0, 3); + let (first, next, complete) = c.scrollback_rows_chunk(80, 0, 3); assert_eq!(first.len(), 3); assert_eq!(next, 3); assert!(!complete); - let (second, next, complete) = c.scrollback_lines_chunk(80, next, 3); + let (second, next, complete) = c.scrollback_rows_chunk(80, next, 3); 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!( + first + .iter() + .chain(&second) + .all(|row| row.line.spans[0].content.as_ref() == " "), + "every chunk indents prose rows with plain spaces" + ); } #[test] @@ -1157,10 +1185,11 @@ mod tests { c.prepare_scrollback_layout(80); assert!(c.has_scrollback_layout(80)); - let (lines, _, complete) = c.scrollback_lines_chunk(80, 0, 8); + let (lines, _, complete) = c.scrollback_rows_chunk(80, 0, 8); assert!(complete); - assert!(lines.iter().any(|line| { - line.spans + assert!(lines.iter().any(|row| { + row.line + .spans .iter() .any(|span| span.content.contains("Result")) })); 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..9eed516342 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..ea1ea56017 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..631adc3ebf 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/insert_history.rs b/crates/astra-cli/src/tui/insert_history.rs index 0741645ea3..43663f7ff3 100644 --- a/crates/astra-cli/src/tui/insert_history.rs +++ b/crates/astra-cli/src/tui/insert_history.rs @@ -1,5 +1,6 @@ use std::io::{self, Write}; +use super::markdown_render::MarkdownRow; use crossterm::{ cursor::MoveTo, queue, @@ -10,97 +11,95 @@ use ratatui::backend::Backend; use ratatui::layout::Size; use ratatui::style::{Color as RColor, Modifier, Style}; use ratatui::text::Line; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use super::custom_terminal; /// Insert history lines above the viewport into terminal scrollback. /// Matches Codex insert_history.rs Standard/Zellij paths. +/// Returns whether the viewport must be fully repainted after insertion. pub(crate) fn insert_history_lines_with_terminal( terminal: &mut custom_terminal::Terminal, - lines: &[Line<'_>], + rows: &[MarkdownRow], trusted_links: bool, is_zellij: bool, -) -> io::Result<()> { - if lines.is_empty() { - return Ok(()); +) -> io::Result { + if rows.is_empty() { + return Ok(false); } let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); let area = terminal.viewport_area; let last_cursor_pos = terminal.last_known_cursor_pos; - let wrap_width = area.width.max(1) as usize; - let mut should_update_area = false; + let wrap_width = screen_size.width.max(1) as usize; - // Pre-wrap lines using adaptive wrapping (URL-aware, span-preserving) + // Sanitization is independent of copy semantics. Code and pure URLs use + // terminal soft wrapping so only logical source rows emit CRLFs. let mut wrapped: Vec> = Vec::new(); let mut wrapped_rows: u16 = 0; - for line in lines { - let line_w: usize = line.spans.iter().map(|s| s.content.width()).sum(); - if line_w == 0 { - wrapped.push(if trusted_links { - super::render::line_utils::sanitize_line_for_terminal_with_links(line) - } else { - super::render::line_utils::line_to_static(line) - }); - wrapped_rows += 1; - } else if super::wrapping::line_contains_url_like(line) - && !super::wrapping::line_has_mixed_url_and_non_url_tokens(line) + for row in rows { + let line = if trusted_links { + super::render::line_utils::sanitize_line_for_terminal_with_links(&row.line) + } else { + super::render::line_utils::line_to_static(&row.line) + }; + let soft_wrap = row.verbatim + || (super::wrapping::line_contains_url_like(&line) + && !super::wrapping::line_has_mixed_url_and_non_url_tokens(&line)); + if soft_wrap { + let physical = soft_wrapped_rows(&line, wrap_width); + wrapped_rows = wrapped_rows.saturating_add(u16::try_from(physical).unwrap_or(u16::MAX)); + wrapped.push(line); + continue; + } + for line in + super::wrapping::adaptive_wrap_line(&line, super::wrapping::RtOptions::new(wrap_width)) { - // Pure URL line — don't wrap, let terminal handle it (keeps URL clickable) - let physical = line_w.max(1).div_ceil(wrap_width) as u16; - wrapped_rows += physical; + let physical = soft_wrapped_rows(&line, wrap_width); + wrapped_rows = wrapped_rows.saturating_add(u16::try_from(physical).unwrap_or(u16::MAX)); wrapped.push(if trusted_links { - super::render::line_utils::sanitize_line_for_terminal_with_links(line) + super::render::line_utils::sanitize_line_for_terminal_with_links(&line) } else { - super::render::line_utils::line_to_static(line) + super::render::line_utils::line_to_static(&line) }); - } else { - let line_wrapped = super::wrapping::adaptive_wrap_line( - line, - super::wrapping::RtOptions::new(wrap_width), - ); - for wl in &line_wrapped { - wrapped_rows += wl.width().max(1).div_ceil(wrap_width) as u16; - } - wrapped.extend(line_wrapped.into_iter().map(|l| { - if trusted_links { - crate::tui::render::line_utils::sanitize_line_for_terminal_with_links(&l) - } else { - crate::tui::render::line_utils::line_to_static(&l) - } - })); } } if wrapped_rows == 0 { - return Ok(()); + return Ok(false); } let writer = terminal.backend_mut(); - - if is_zellij { - // Zellij mode: emit newlines at bottom, write at absolute positions - let space_below = screen_size.height.saturating_sub(area.bottom()); - let shift_down = wrapped_rows.min(space_below); - let scroll_up = wrapped_rows.saturating_sub(shift_down); - - if scroll_up > 0 { - queue!(writer, MoveTo(0, screen_size.height.saturating_sub(1)))?; - for _ in 0..scroll_up { - queue!(writer, Print("\n"))?; - } - } - - let mut new_area = area; - if shift_down > 0 { - new_area.y += shift_down; - should_update_area = true; + let mut new_area = area; + + // A full-height viewport has no protected history region. Both it and + // Zellij need a repaint after content flows through the transient frame. + let needs_full_repaint = is_zellij || area.height == screen_size.height; + if needs_full_repaint { + let end = u32::from(area.top()) + u32::from(wrapped_rows); + new_area.y = end.min(u32::from(screen_size.height.saturating_sub(area.height))) as u16; + let reserve = end + .min(u32::from(screen_size.height)) + .saturating_sub(u32::from(new_area.top())) as u16; + + queue!(writer, SetAttribute(Attribute::Reset))?; + if area.top() < screen_size.height { + queue!(writer, MoveTo(0, area.top()))?; + } else { + // An empty viewport can sit just past the last screen row. + // Append rather than overwriting the final history row. + queue!( + writer, + MoveTo(0, screen_size.height.saturating_sub(1)), + Print("\r\n") + )?; } + queue!(writer, Clear(ClearType::FromCursorDown))?; - let cursor_top = area.top().saturating_sub(scroll_up + shift_down); - queue!(writer, MoveTo(0, cursor_top))?; - + // Write first: terminal autowrap naturally scrolls even a single + // multi-page code row. Pre-scrolling would clamp its origin to zero + // and leave its tail inside the composer. for (i, line) in wrapped.iter().enumerate() { if i > 0 { queue!(writer, Print("\r\n"))?; @@ -108,16 +107,13 @@ pub(crate) fn insert_history_lines_with_terminal( write_history_line(writer, line, trusted_links)?; } - // Restore cursor position - queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; - Write::flush(writer)?; - - let _ = writer; - if should_update_area { - terminal.set_viewport_area(new_area); - } - if wrapped_rows > 0 { - terminal.note_history_rows_inserted(wrapped_rows); + // Reserve the transient frame only after content has flowed. This + // adds at most one viewport height, independent of the batch size. + if reserve > 0 { + queue!(writer, MoveTo(0, screen_size.height.saturating_sub(1)))?; + for _ in 0..reserve { + queue!(writer, Print("\n"))?; + } } } else { // Standard mode — matches Codex insert_history.rs Standard path: @@ -128,7 +124,6 @@ pub(crate) fn insert_history_lines_with_terminal( // 3. Write history lines within that protected region. // 4. Reset scroll region and restore cursor. - let mut new_area = area; let cursor_top = if area.bottom() < screen_size.height { let scroll_amount = wrapped_rows.min(screen_size.height - area.bottom()); @@ -145,7 +140,6 @@ pub(crate) fn insert_history_lines_with_terminal( let ct = area.top().saturating_sub(1); new_area.y += scroll_amount; - should_update_area = true; ct } else { area.top().saturating_sub(1) @@ -163,21 +157,47 @@ pub(crate) fn insert_history_lines_with_terminal( queue!(writer, Print("\x1b[r"))?; } + } - // Restore cursor position - queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; - Write::flush(writer)?; + queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; + Write::flush(writer)?; - let _ = writer; - if should_update_area { - terminal.set_viewport_area(new_area); - } - if wrapped_rows > 0 { - terminal.note_history_rows_inserted(wrapped_rows); - } + let _ = writer; + if new_area != area { + terminal.set_viewport_area(new_area); } + terminal.note_history_rows_inserted(wrapped_rows); - Ok(()) + Ok(needs_full_repaint) +} + +/// Count terminal rows without materializing physical fragments. Carry the +/// column across styled spans and account for a wide glyph wrapping early. +fn soft_wrapped_rows(line: &Line<'_>, width: usize) -> usize { + let width = width.max(1); + let mut rows = 1usize; + let mut column = 0usize; + for span in &line.spans { + let visible = crate::cli::terminal_hyperlinks::strip_link_markers(&span.content); + for grapheme in visible.graphemes(true) { + if grapheme == "\t" { + // Default terminal tab stops advance without wrapping and + // clamp to the last column. No application changes tab stops. + column = ((column / 8 + 1) * 8).min(width - 1); + continue; + } + let cells = grapheme.width().min(width); + if cells == 0 { + continue; + } + if column > 0 && column + cells > width { + rows = rows.saturating_add(1); + column = 0; + } + column += cells; + } + } + rows } fn write_history_line( diff --git a/crates/astra-cli/src/tui/markdown_render.rs b/crates/astra-cli/src/tui/markdown_render.rs index c43c34b33d..b248e77b02 100644 --- a/crates/astra-cli/src/tui/markdown_render.rs +++ b/crates/astra-cli/src/tui/markdown_render.rs @@ -47,6 +47,35 @@ 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` prevents presentation wrapping +/// from inserting hard breaks into code. It grants no terminal-control trust. +#[derive(Debug, Clone)] +pub(crate) struct MarkdownRow { + pub line: Line<'static>, + pub verbatim: bool, +} + +impl From> for MarkdownRow { + fn from(line: Line<'static>) -> Self { + Self { + line, + verbatim: false, + } + } +} + +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 +83,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 +93,8 @@ struct Writer { style_stack: Vec