Changes: - the composer and user messages now have a colored background that stretches the entire width of the terminal. - the prompt character was changed from a cyan `▌` to a bold `›`. - the "working" shimmer now follows the "dark gray" color of the terminal, better matching the terminal's color scheme | Terminal + Background | Screenshot | |------------------------------|------------| | iTerm with dark bg | <img width="810" height="641" alt="Screenshot 2025-09-25 at 11 44 52 AM" src="https://github.com/user-attachments/assets/1317e579-64a9-4785-93e6-98b0258f5d92" /> | | iTerm with light bg | <img width="845" height="540" alt="Screenshot 2025-09-25 at 11 46 29 AM" src="https://github.com/user-attachments/assets/e671d490-c747-4460-af0b-3f8d7f7a6b8e" /> | | iTerm with color bg | <img width="825" height="564" alt="Screenshot 2025-09-25 at 11 47 12 AM" src="https://github.com/user-attachments/assets/141cda1b-1164-41d5-87da-3be11e6a3063" /> | | Terminal.app with dark bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 45 22 AM" src="https://github.com/user-attachments/assets/93fc4781-99f7-4ee7-9c8e-3db3cd854fe5" /> | | Terminal.app with light bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 46 04 AM" src="https://github.com/user-attachments/assets/19bf6a3c-91e0-447b-9667-b8033f512219" /> | | Terminal.app with color bg | <img width="577" height="367" alt="Screenshot 2025-09-25 at 11 45 50 AM" src="https://github.com/user-attachments/assets/dd7c4b5b-342e-4028-8140-f4e65752bd0b" /> |
524 lines
19 KiB
Rust
524 lines
19 KiB
Rust
use std::fmt;
|
|
use std::io;
|
|
use std::io::Write;
|
|
|
|
use crate::wrapping::word_wrap_lines_borrowed;
|
|
use crossterm::Command;
|
|
use crossterm::cursor::MoveTo;
|
|
use crossterm::queue;
|
|
use crossterm::style::Color as CColor;
|
|
use crossterm::style::Colors;
|
|
use crossterm::style::Print;
|
|
use crossterm::style::SetAttribute;
|
|
use crossterm::style::SetBackgroundColor;
|
|
use crossterm::style::SetColors;
|
|
use crossterm::style::SetForegroundColor;
|
|
use crossterm::terminal::Clear;
|
|
use crossterm::terminal::ClearType;
|
|
use ratatui::layout::Size;
|
|
use ratatui::prelude::Backend;
|
|
use ratatui::style::Color;
|
|
use ratatui::style::Modifier;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
|
|
/// Insert `lines` above the viewport using the terminal's backend writer
|
|
/// (avoids direct stdout references).
|
|
pub fn insert_history_lines<B>(terminal: &mut crate::custom_terminal::Terminal<B>, lines: Vec<Line>)
|
|
where
|
|
B: Backend + Write,
|
|
{
|
|
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
|
|
|
let mut area = terminal.viewport_area;
|
|
let mut should_update_area = false;
|
|
let last_cursor_pos = terminal.last_known_cursor_pos;
|
|
let writer = terminal.backend_mut();
|
|
|
|
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
|
|
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
|
|
let wrapped = word_wrap_lines_borrowed(&lines, area.width.max(1) as usize);
|
|
let wrapped_lines = wrapped.len() as u16;
|
|
let cursor_top = if area.bottom() < screen_size.height {
|
|
// If the viewport is not at the bottom of the screen, scroll it down to make room.
|
|
// Don't scroll it past the bottom of the screen.
|
|
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
|
|
|
|
// Emit ANSI to scroll the lower region (from the top of the viewport to the bottom
|
|
// of the screen) downward by `scroll_amount` lines. We do this by:
|
|
// 1) Limiting the scroll region to [area.top()+1 .. screen_height] (1-based bounds)
|
|
// 2) Placing the cursor at the top margin of that region
|
|
// 3) Emitting Reverse Index (RI, ESC M) `scroll_amount` times
|
|
// 4) Resetting the scroll region back to full screen
|
|
let top_1based = area.top() + 1; // Convert 0-based row to 1-based for DECSTBM
|
|
queue!(writer, SetScrollRegion(top_1based..screen_size.height)).ok();
|
|
queue!(writer, MoveTo(0, area.top())).ok();
|
|
for _ in 0..scroll_amount {
|
|
// Reverse Index (RI): ESC M
|
|
queue!(writer, Print("\x1bM")).ok();
|
|
}
|
|
queue!(writer, ResetScrollRegion).ok();
|
|
|
|
let cursor_top = area.top().saturating_sub(1);
|
|
area.y += scroll_amount;
|
|
should_update_area = true;
|
|
cursor_top
|
|
} else {
|
|
area.top().saturating_sub(1)
|
|
};
|
|
|
|
// Limit the scroll region to the lines from the top of the screen to the
|
|
// top of the viewport. With this in place, when we add lines inside this
|
|
// area, only the lines in this area will be scrolled. We place the cursor
|
|
// at the end of the scroll region, and add lines starting there.
|
|
//
|
|
// ┌─Screen───────────────────────┐
|
|
// │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
|
|
// │┆ ┆│
|
|
// │┆ ┆│
|
|
// │┆ ┆│
|
|
// │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
|
|
// │╭─Viewport───────────────────╮│
|
|
// ││ ││
|
|
// │╰────────────────────────────╯│
|
|
// └──────────────────────────────┘
|
|
queue!(writer, SetScrollRegion(1..area.top())).ok();
|
|
|
|
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
|
|
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
|
|
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
|
|
queue!(writer, MoveTo(0, cursor_top)).ok();
|
|
|
|
for line in wrapped {
|
|
queue!(writer, Print("\r\n")).ok();
|
|
queue!(
|
|
writer,
|
|
SetColors(Colors::new(
|
|
line.style
|
|
.fg
|
|
.map(std::convert::Into::into)
|
|
.unwrap_or(CColor::Reset),
|
|
line.style
|
|
.bg
|
|
.map(std::convert::Into::into)
|
|
.unwrap_or(CColor::Reset)
|
|
))
|
|
)
|
|
.ok();
|
|
queue!(writer, Clear(ClearType::UntilNewLine)).ok();
|
|
// Merge line-level style into each span so that ANSI colors reflect
|
|
// line styles (e.g., blockquotes with green fg).
|
|
let merged_spans: Vec<Span> = line
|
|
.spans
|
|
.iter()
|
|
.map(|s| Span {
|
|
style: s.style.patch(line.style),
|
|
content: s.content.clone(),
|
|
})
|
|
.collect();
|
|
write_spans(writer, merged_spans.iter()).ok();
|
|
}
|
|
|
|
queue!(writer, ResetScrollRegion).ok();
|
|
|
|
// Restore the cursor position to where it was before we started.
|
|
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y)).ok();
|
|
|
|
let _ = writer;
|
|
if should_update_area {
|
|
terminal.set_viewport_area(area);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SetScrollRegion(pub std::ops::Range<u16>);
|
|
|
|
impl Command for SetScrollRegion {
|
|
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
|
write!(f, "\x1b[{};{}r", self.0.start, self.0.end)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn execute_winapi(&self) -> std::io::Result<()> {
|
|
panic!("tried to execute SetScrollRegion command using WinAPI, use ANSI instead");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn is_ansi_code_supported(&self) -> bool {
|
|
// TODO(nornagon): is this supported on Windows?
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct ResetScrollRegion;
|
|
|
|
impl Command for ResetScrollRegion {
|
|
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
|
write!(f, "\x1b[r")
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn execute_winapi(&self) -> std::io::Result<()> {
|
|
panic!("tried to execute ResetScrollRegion command using WinAPI, use ANSI instead");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn is_ansi_code_supported(&self) -> bool {
|
|
// TODO(nornagon): is this supported on Windows?
|
|
true
|
|
}
|
|
}
|
|
|
|
struct ModifierDiff {
|
|
pub from: Modifier,
|
|
pub to: Modifier,
|
|
}
|
|
|
|
impl ModifierDiff {
|
|
fn queue<W>(self, mut w: W) -> io::Result<()>
|
|
where
|
|
W: io::Write,
|
|
{
|
|
use crossterm::style::Attribute as CAttribute;
|
|
let removed = self.from - self.to;
|
|
if removed.contains(Modifier::REVERSED) {
|
|
queue!(w, SetAttribute(CAttribute::NoReverse))?;
|
|
}
|
|
if removed.contains(Modifier::BOLD) {
|
|
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
|
|
if self.to.contains(Modifier::DIM) {
|
|
queue!(w, SetAttribute(CAttribute::Dim))?;
|
|
}
|
|
}
|
|
if removed.contains(Modifier::ITALIC) {
|
|
queue!(w, SetAttribute(CAttribute::NoItalic))?;
|
|
}
|
|
if removed.contains(Modifier::UNDERLINED) {
|
|
queue!(w, SetAttribute(CAttribute::NoUnderline))?;
|
|
}
|
|
if removed.contains(Modifier::DIM) {
|
|
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
|
|
}
|
|
if removed.contains(Modifier::CROSSED_OUT) {
|
|
queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
|
|
}
|
|
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
|
|
queue!(w, SetAttribute(CAttribute::NoBlink))?;
|
|
}
|
|
|
|
let added = self.to - self.from;
|
|
if added.contains(Modifier::REVERSED) {
|
|
queue!(w, SetAttribute(CAttribute::Reverse))?;
|
|
}
|
|
if added.contains(Modifier::BOLD) {
|
|
queue!(w, SetAttribute(CAttribute::Bold))?;
|
|
}
|
|
if added.contains(Modifier::ITALIC) {
|
|
queue!(w, SetAttribute(CAttribute::Italic))?;
|
|
}
|
|
if added.contains(Modifier::UNDERLINED) {
|
|
queue!(w, SetAttribute(CAttribute::Underlined))?;
|
|
}
|
|
if added.contains(Modifier::DIM) {
|
|
queue!(w, SetAttribute(CAttribute::Dim))?;
|
|
}
|
|
if added.contains(Modifier::CROSSED_OUT) {
|
|
queue!(w, SetAttribute(CAttribute::CrossedOut))?;
|
|
}
|
|
if added.contains(Modifier::SLOW_BLINK) {
|
|
queue!(w, SetAttribute(CAttribute::SlowBlink))?;
|
|
}
|
|
if added.contains(Modifier::RAPID_BLINK) {
|
|
queue!(w, SetAttribute(CAttribute::RapidBlink))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
|
|
where
|
|
I: IntoIterator<Item = &'a Span<'a>>,
|
|
{
|
|
let mut fg = Color::Reset;
|
|
let mut bg = Color::Reset;
|
|
let mut last_modifier = Modifier::empty();
|
|
for span in content {
|
|
let mut modifier = Modifier::empty();
|
|
modifier.insert(span.style.add_modifier);
|
|
modifier.remove(span.style.sub_modifier);
|
|
if modifier != last_modifier {
|
|
let diff = ModifierDiff {
|
|
from: last_modifier,
|
|
to: modifier,
|
|
};
|
|
diff.queue(&mut writer)?;
|
|
last_modifier = modifier;
|
|
}
|
|
let next_fg = span.style.fg.unwrap_or(Color::Reset);
|
|
let next_bg = span.style.bg.unwrap_or(Color::Reset);
|
|
if next_fg != fg || next_bg != bg {
|
|
queue!(
|
|
writer,
|
|
SetColors(Colors::new(next_fg.into(), next_bg.into()))
|
|
)?;
|
|
fg = next_fg;
|
|
bg = next_bg;
|
|
}
|
|
|
|
queue!(writer, Print(span.content.clone()))?;
|
|
}
|
|
|
|
queue!(
|
|
writer,
|
|
SetForegroundColor(CColor::Reset),
|
|
SetBackgroundColor(CColor::Reset),
|
|
SetAttribute(crossterm::style::Attribute::Reset),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::markdown_render::render_markdown_text;
|
|
use crate::test_backend::VT100Backend;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Color;
|
|
|
|
#[test]
|
|
fn writes_bold_then_regular_spans() {
|
|
use ratatui::style::Stylize;
|
|
|
|
let spans = ["A".bold(), "B".into()];
|
|
|
|
let mut actual: Vec<u8> = Vec::new();
|
|
write_spans(&mut actual, spans.iter()).unwrap();
|
|
|
|
let mut expected: Vec<u8> = Vec::new();
|
|
queue!(
|
|
expected,
|
|
SetAttribute(crossterm::style::Attribute::Bold),
|
|
Print("A"),
|
|
SetAttribute(crossterm::style::Attribute::NormalIntensity),
|
|
Print("B"),
|
|
SetForegroundColor(CColor::Reset),
|
|
SetBackgroundColor(CColor::Reset),
|
|
SetAttribute(crossterm::style::Attribute::Reset),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
String::from_utf8(actual).unwrap(),
|
|
String::from_utf8(expected).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vt100_blockquote_line_emits_green_fg() {
|
|
// Set up a small off-screen terminal
|
|
let width: u16 = 40;
|
|
let height: u16 = 10;
|
|
let backend = VT100Backend::new(width, height);
|
|
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
|
// Place viewport on the last line so history inserts scroll upward
|
|
let viewport = Rect::new(0, height - 1, width, 1);
|
|
term.set_viewport_area(viewport);
|
|
|
|
// Build a blockquote-like line: apply line-level green style and prefix "> "
|
|
let mut line: Line<'static> = Line::from(vec!["> ".into(), "Hello world".into()]);
|
|
line = line.style(Color::Green);
|
|
insert_history_lines(&mut term, vec![line]);
|
|
|
|
let mut saw_colored = false;
|
|
'outer: for row in 0..height {
|
|
for col in 0..width {
|
|
if let Some(cell) = term.backend().vt100().screen().cell(row, col)
|
|
&& cell.has_contents()
|
|
&& cell.fgcolor() != vt100::Color::Default
|
|
{
|
|
saw_colored = true;
|
|
break 'outer;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
saw_colored,
|
|
"expected at least one colored cell in vt100 output"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vt100_blockquote_wrap_preserves_color_on_all_wrapped_lines() {
|
|
// Force wrapping by using a narrow viewport width and a long blockquote line.
|
|
let width: u16 = 20;
|
|
let height: u16 = 8;
|
|
let backend = VT100Backend::new(width, height);
|
|
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
|
// Viewport is the last line so history goes directly above it.
|
|
let viewport = Rect::new(0, height - 1, width, 1);
|
|
term.set_viewport_area(viewport);
|
|
|
|
// Create a long blockquote with a distinct prefix and enough text to wrap.
|
|
let mut line: Line<'static> = Line::from(vec![
|
|
"> ".into(),
|
|
"This is a long quoted line that should wrap".into(),
|
|
]);
|
|
line = line.style(Color::Green);
|
|
|
|
insert_history_lines(&mut term, vec![line]);
|
|
|
|
// Parse and inspect the final screen buffer.
|
|
let screen = term.backend().vt100().screen();
|
|
|
|
// Collect rows that are non-empty; these should correspond to our wrapped lines.
|
|
let mut non_empty_rows: Vec<u16> = Vec::new();
|
|
for row in 0..height {
|
|
let mut any = false;
|
|
for col in 0..width {
|
|
if let Some(cell) = screen.cell(row, col)
|
|
&& cell.has_contents()
|
|
&& cell.contents() != "\0"
|
|
&& cell.contents() != " "
|
|
{
|
|
any = true;
|
|
break;
|
|
}
|
|
}
|
|
if any {
|
|
non_empty_rows.push(row);
|
|
}
|
|
}
|
|
|
|
// Expect at least two rows due to wrapping.
|
|
assert!(
|
|
non_empty_rows.len() >= 2,
|
|
"expected wrapped output to span >=2 rows, got {non_empty_rows:?}",
|
|
);
|
|
|
|
// For each non-empty row, ensure all non-space cells are using a non-default fg color.
|
|
for row in non_empty_rows {
|
|
for col in 0..width {
|
|
if let Some(cell) = screen.cell(row, col) {
|
|
let contents = cell.contents();
|
|
if !contents.is_empty() && contents != " " {
|
|
assert!(
|
|
cell.fgcolor() != vt100::Color::Default,
|
|
"expected non-default fg on row {row} col {col}, got {:?}",
|
|
cell.fgcolor()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn vt100_colored_prefix_then_plain_text_resets_color() {
|
|
let width: u16 = 40;
|
|
let height: u16 = 6;
|
|
let backend = VT100Backend::new(width, height);
|
|
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
|
let viewport = Rect::new(0, height - 1, width, 1);
|
|
term.set_viewport_area(viewport);
|
|
|
|
// First span colored, rest plain.
|
|
let line: Line<'static> = Line::from(vec![
|
|
Span::styled("1. ", ratatui::style::Style::default().fg(Color::LightBlue)),
|
|
Span::raw("Hello world"),
|
|
]);
|
|
|
|
insert_history_lines(&mut term, vec![line]);
|
|
|
|
let screen = term.backend().vt100().screen();
|
|
|
|
// Find the first non-empty row; verify first three cells are colored, following cells default.
|
|
'rows: for row in 0..height {
|
|
let mut has_text = false;
|
|
for col in 0..width {
|
|
if let Some(cell) = screen.cell(row, col)
|
|
&& cell.has_contents()
|
|
&& cell.contents() != " "
|
|
{
|
|
has_text = true;
|
|
break;
|
|
}
|
|
}
|
|
if !has_text {
|
|
continue;
|
|
}
|
|
|
|
// Expect "1. Hello world" starting at col 0.
|
|
for col in 0..3 {
|
|
let cell = screen.cell(row, col).unwrap();
|
|
assert!(
|
|
cell.fgcolor() != vt100::Color::Default,
|
|
"expected colored prefix at col {col}, got {:?}",
|
|
cell.fgcolor()
|
|
);
|
|
}
|
|
for col in 3..(3 + "Hello world".len() as u16) {
|
|
let cell = screen.cell(row, col).unwrap();
|
|
assert_eq!(
|
|
cell.fgcolor(),
|
|
vt100::Color::Default,
|
|
"expected default color for plain text at col {col}, got {:?}",
|
|
cell.fgcolor()
|
|
);
|
|
}
|
|
break 'rows;
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn vt100_deep_nested_mixed_list_third_level_marker_is_colored() {
|
|
// Markdown with five levels (ordered → unordered → ordered → unordered → unordered).
|
|
let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n";
|
|
let text = render_markdown_text(md);
|
|
let lines: Vec<Line<'static>> = text.lines.clone();
|
|
|
|
let width: u16 = 60;
|
|
let height: u16 = 12;
|
|
let backend = VT100Backend::new(width, height);
|
|
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
|
let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
|
|
term.set_viewport_area(viewport);
|
|
|
|
insert_history_lines(&mut term, lines);
|
|
|
|
let screen = term.backend().vt100().screen();
|
|
|
|
// Reconstruct screen rows as strings to locate the 3rd level line.
|
|
let rows: Vec<String> = screen.rows(0, width).collect();
|
|
|
|
let needle = "1. Third level (ordered)";
|
|
let row_idx = rows
|
|
.iter()
|
|
.position(|r| r.contains(needle))
|
|
.unwrap_or_else(|| {
|
|
panic!("expected to find row containing {needle:?}, have rows: {rows:?}")
|
|
});
|
|
let col_start = rows[row_idx].find(needle).unwrap() as u16; // column where '1' starts
|
|
|
|
// Verify that the numeric marker ("1.") at the third level is colored
|
|
// (non-default fg) and the content after the following space resets to default.
|
|
for c in [col_start, col_start + 1] {
|
|
let cell = screen.cell(row_idx as u16, c).unwrap();
|
|
assert!(
|
|
cell.fgcolor() != vt100::Color::Default,
|
|
"expected colored 3rd-level marker at row {row_idx} col {c}, got {:?}",
|
|
cell.fgcolor()
|
|
);
|
|
}
|
|
let content_col = col_start + 3; // skip '1', '.', and the space
|
|
if let Some(cell) = screen.cell(row_idx as u16, content_col) {
|
|
assert_eq!(
|
|
cell.fgcolor(),
|
|
vt100::Color::Default,
|
|
"expected default color for 3rd-level content at row {row_idx} col {content_col}, got {:?}",
|
|
cell.fgcolor()
|
|
);
|
|
}
|
|
}
|
|
}
|