Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions crates/astra-cli/src/tui/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
Expand Down Expand Up @@ -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 `<tool_use_error>` wrappers) |
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions crates/astra-cli/src/tui/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
155 changes: 92 additions & 63 deletions crates/astra-cli/src/tui/history_cell/assistant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -63,7 +64,7 @@ struct MarkdownLayoutCache {
/// without retaining a second full copy merely for cache matching.
source_ptr: usize,
source_len: usize,
lines: Vec<Line<'static>>,
rows: Vec<MarkdownRow>,
}

/// Bounded live Markdown projection. It intentionally lags a burst by a
Expand Down Expand Up @@ -270,17 +271,13 @@ impl AssistantCell {
return;
}

let lines = render_markdown_text_with_width(source, Some(width))
.lines
.iter()
.map(line_to_static)
.collect::<Vec<_>>();
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,
});
}

Expand All @@ -290,7 +287,7 @@ impl AssistantCell {
width: usize,
start: usize,
maximum: usize,
) -> (Vec<Line<'static>>, usize) {
) -> (Vec<MarkdownRow>, usize) {
self.ensure_markdown_layout(source, width);
let cache = astra_core::sync_poison::recover_mutex_lock(&self.render_cache);
let cache = cache
Expand All @@ -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<Line<'static>> {
fn markdown_layout(&self, source: &str, width: usize) -> Vec<MarkdownRow> {
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<Line<'static>>, usize) {
) -> (Vec<MarkdownRow>, 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)
}

Expand Down Expand Up @@ -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<Line<'static>>, usize, bool) {
) -> (Vec<MarkdownRow>, usize, bool) {
debug_assert!(!self.live, "live assistant cells stay in the viewport");
if maximum == 0 {
return (Vec::new(), start, false);
Expand All @@ -545,11 +529,12 @@ impl AssistantCell {
.skip(start)
.take(maximum)
.cloned()
.map(MarkdownRow::from)
.collect::<Vec<_>>();
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);
Expand All @@ -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
Expand All @@ -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<Span<'static>> = 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()
Expand Down Expand Up @@ -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;
}
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand All @@ -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"))
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading