mod dock;
mod ext_settings;
mod giff;
mod history;
mod hyperlink;
mod import;
mod input;
mod latex;
mod markdown;
mod mermaid;
mod pending;
mod session_selector;
mod settings_selector;
mod status;
mod suggestion;
pub(crate) mod image;
pub(crate) mod messages;
pub(crate) mod overlay;
pub(crate) mod panel;
pub(crate) mod startup;
pub(crate) mod syntax_hl;
pub(crate) mod trust;
use self::history::message_lines;
use super::{
app::{App, ClickRow, LinkRow, MouseSel},
giff::GiffView,
panel::PanelKind,
theme::{Theme, color},
};
use crate::{
core::extensions::{BannerLine, FooterLine, OverlaySize},
utils::{glyphs::DEF_SCROLLBAR_THUMB, terminal_caps::hyperlinks_enabled, terminal_image},
};
use image::ImageRect;
use messages::{ClickTarget, ImageSlot, LinkSpan};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect, Size},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use ratatui_image::sliced::{SignedPosition, SlicedImage};
use std::sync::Arc;
pub use markdown::render_markdown;
pub(crate) use panel::{oauth_url_lines, wrap_text};
type VisibleLines = (
Vec<Line<'static>>,
usize,
usize,
bool,
Vec<ClickTarget>,
Vec<LinkSpan>,
Vec<ImageSlot>,
);
const IMAGE_LEFT_PAD: u16 = 1;
pub fn layout_heights(app: &App, width: usize) -> (u16, u16, u16) {
let mut input_lines = if app.ext_settings.visible {
ext_settings::panel_height(app, width)
} else if app.settings_selector.active {
settings_selector::selector_height(app, width)
} else if app.session_selector.active {
session_selector::selector_height(app, width)
} else if app.panel.active {
panel::panel_height(app, width)
} else {
input::content_height(app, width) as u16 + 2 + suggestion::suggestion_height(app, width)
};
if matches!(
app.overlay.as_ref().map(|o| &o.view.size),
Some(OverlaySize::Panel { .. })
) {
input_lines = 0;
}
let status_lines = 1u16;
let pending_lines = pending::pending_lines(app) as u16;
(status_lines, pending_lines, input_lines)
}
pub fn render_frame(frame: &mut Frame, app: &mut App) {
render_frame_widgets(frame, app);
let url = app.oauth_login.as_ref().map(|login| login.auth_url());
hyperlink::apply(
frame,
hyperlinks_enabled(),
url.as_deref(),
&app.mouse.links,
);
draw_images(frame, app);
}
fn draw_images(frame: &mut Frame, app: &mut App) {
let Some(picker) = terminal_image::image_picker() else {
return;
};
let rects = std::mem::take(&mut app.image_rects);
for rect in &rects {
match image::request(picker, rect.key, &rect.data, rect.size, rect.pad) {
Some(protocol) => {
frame.render_widget(SlicedImage::new(&protocol, rect.position), rect.area);
}
None => draw_image_placeholder(frame, rect, &app.theme),
}
}
app.image_rects = rects;
}
fn draw_image_placeholder(frame: &mut Frame, rect: &ImageRect, theme: &Theme) {
let bottom = rect.position.y + rect.size.height as i16;
if rect.position.y >= rect.size.height as i16 || bottom <= 0 {
return;
}
let row = Rect {
x: rect.area.x,
y: rect.area.y + rect.position.y.max(0) as u16,
width: rect.area.width,
height: 1,
};
frame.render_widget(
Paragraph::new(messages::render_image_placeholder(theme)),
row,
);
}
fn render_frame_widgets(frame: &mut Frame, app: &mut App) {
let size = frame.area();
let width = size.width.max(20) as usize;
app.mouse.click_targets.clear();
app.mouse.links.clear();
app.image_rects.clear();
super::overlay::sync(app, size.width);
let banner_lines = status::collect_banner_lines(app, width, size.height as usize);
let banner_height = banner_lines.len() as u16;
app.refresh_git_branch();
let footer_lines = status::collect_footer_lines(app, width);
let footer_height = footer_lines.len() as u16;
let (status_lines, pending_lines, input_lines) = layout_heights(app, width);
let dock = dock::dock_render(app);
if app.dock_visible && dock.is_none() {
app.dock_visible = false;
app.dock_offset = 0;
app.dock_area = None;
}
let dock_h = dock.as_ref().map(dock::dock_height).unwrap_or(0);
let mut constraints = vec![
Constraint::Min(1),
Constraint::Length(pending_lines),
Constraint::Length(dock_h),
Constraint::Length(status_lines),
Constraint::Length(input_lines),
];
let has_banner = banner_height > 0;
if has_banner {
constraints.insert(0, Constraint::Length(banner_height));
}
let has_footer = footer_height > 0;
if has_footer {
constraints.push(Constraint::Length(footer_height));
}
if app.giff.is_some() {
render_fullscreen_giff(frame, size, app);
return;
}
if let Some(OverlaySize::Fullscreen) = app.overlay.as_ref().map(|o| o.view.size) {
render_fullscreen_overlay(frame, size, app, &banner_lines, &footer_lines);
return;
}
let overlay_h = insert_inline_overlay(&mut constraints, app, has_footer);
let chunks = Layout::vertical(constraints).split(size);
let mut idx = 0usize;
if has_banner {
status::render_top_bar(frame, chunks[idx], app, &banner_lines);
idx += 1;
}
render_message_area(frame, chunks[idx], app, width);
idx += 1;
pending::render_pending(frame, chunks[idx], app);
idx += 1;
if let Some(d) = &dock {
dock::render_dock(frame, chunks[idx], app, d);
}
idx += 1;
status::render_status_bar(frame, chunks[idx], app);
idx += 1;
let input_chunk = chunks[idx];
render_input_region(frame, input_chunk, app);
idx += 1;
if overlay_h > 0 {
overlay::render(
frame,
chunks[idx],
app,
OverlaySize::Inline { max_rows: u16::MAX },
);
idx += 1;
}
if has_footer {
status::render_bottom_bar(frame, chunks[idx], app, &footer_lines);
}
render_cursor(frame, input_chunk, app, width);
}
fn render_fullscreen_overlay(
frame: &mut Frame,
area: Rect,
app: &mut App,
banner_lines: &[BannerLine],
footer_lines: &[FooterLine],
) {
let banner_height = banner_lines.len() as u16;
let footer_height = footer_lines.len() as u16;
let separator_height = u16::from(footer_height > 0);
let mut constraints = vec![Constraint::Min(1)];
if banner_height > 0 {
constraints.insert(0, Constraint::Length(banner_height));
}
if separator_height > 0 {
constraints.push(Constraint::Length(separator_height));
}
if footer_height > 0 {
constraints.push(Constraint::Length(footer_height));
}
let chunks = Layout::vertical(constraints).split(area);
let mut idx = 0usize;
if banner_height > 0 {
status::render_top_bar(frame, chunks[idx], app, banner_lines);
idx += 1;
}
overlay::render(frame, chunks[idx], app, OverlaySize::Fullscreen);
idx += 1;
if separator_height > 0 {
render_overlay_separator(frame, chunks[idx], app);
idx += 1;
}
if footer_height > 0 {
status::render_bottom_bar(frame, chunks[idx], app, footer_lines);
}
}
fn render_fullscreen_giff(frame: &mut Frame, area: Rect, app: &mut App) {
let width = area.width.max(20) as usize;
let input_lines = if app.giff.as_ref().is_some_and(GiffView::is_input_active) {
layout_heights(app, width).2
} else {
0
};
let chunks = Layout::vertical([
Constraint::Min(1),
Constraint::Length(input_lines),
Constraint::Length(1),
])
.split(area);
let App {
giff: Some(view),
theme,
..
} = app
else {
return;
};
giff::render(frame, chunks[0], view, theme);
giff::render_keybar(frame, view, chunks[2]);
if input_lines > 0 {
render_input_region(frame, chunks[1], app);
render_cursor(frame, chunks[1], app, width);
}
}
fn render_overlay_separator(frame: &mut Frame, area: Rect, app: &App) {
let style = app.theme.style("dim", "#666666");
let line = Line::from(Span::styled("─".repeat(area.width as usize), style));
frame.render_widget(Paragraph::new(line), area);
}
fn insert_inline_overlay(constraints: &mut Vec<Constraint>, app: &App, has_footer: bool) -> u16 {
let overlay_h = super::overlay::outer_rows(app) as u16;
if overlay_h == 0 {
return 0;
}
let footer_idx = constraints.len() - usize::from(has_footer);
constraints.insert(footer_idx, Constraint::Length(overlay_h));
overlay_h
}
fn render_input_region(frame: &mut Frame, area: Rect, app: &mut App) {
if app.ext_settings.visible {
ext_settings::render(frame, area, app);
app.panel_area = None;
} else if app.settings_selector.active {
settings_selector::render(frame, area, app);
app.panel_area = None;
} else if app.session_selector.active {
session_selector::render(frame, area, app);
} else if app.panel.active {
app.panel_area = Some(area);
panel::render_panel_selector(frame, area, app);
} else {
input::render_input_box(frame, area, app);
app.panel_area = None;
}
}
fn render_cursor(frame: &mut Frame, area: Rect, app: &mut App, width: usize) {
if matches!(
app.overlay.as_ref().map(|o| &o.view.size),
Some(OverlaySize::Panel { .. })
) {
return;
}
if app.ext_settings.visible {
let (_, col) = app
.ext_settings
.filter
.visible_window(width.saturating_sub(4));
let cx = area.x + 2 + col as u16;
let cy = area.y + 2;
frame.set_cursor_position((cx, cy));
} else if app.settings_selector.active {
if app.settings_selector.submenu.is_none() {
let s = &app.settings_selector;
let (_, col) = s.filter.visible_window(width.saturating_sub(4));
let cx = area.x + 2 + col as u16;
let cy = area.y + 2;
frame.set_cursor_position((cx, cy));
}
} else if app.session_selector.active {
let s = &app.session_selector;
if s.rename_mode {
let (_, col) = s.rename_input.visible_window(width.saturating_sub(2));
let cx = area.x + 2 + col as u16;
let cy = area.y + 4;
frame.set_cursor_position((cx, cy));
} else {
let (_, col) = s.filter.visible_window(width.saturating_sub(2));
let cx = area.x + 2 + col as u16;
let cy = area.y + 6;
frame.set_cursor_position((cx, cy));
}
} else if app.panel.active {
let kind = app.panel.top().map(|l| l.kind);
let show_cursor = kind.is_some_and(|k| k.has_filter_input());
if show_cursor {
let input = app.panel.top().map(|l| &l.filter);
if let Some(input) = input {
let (_, col) = input.visible_window(width.saturating_sub(4));
let cx = area.x + 2 + col as u16;
let cy = if matches!(
kind,
Some(PanelKind::LoginProvider | PanelKind::LogoutProvider)
) {
area.y + 2
} else if kind == Some(PanelKind::LoginKey) {
area.y + 5
} else if kind == Some(PanelKind::Thinking) {
area.y + 6
} else if kind == Some(PanelKind::ScopedModels) {
area.y + 5
} else if kind == Some(PanelKind::Extension) {
area.y + 5
} else if kind == Some(PanelKind::Skill) {
area.y + 5 + panel::skills_error_line_count(app, width) as u16
} else if kind == Some(PanelKind::BugReportHint) {
area.y + 5 + panel::bug_report_note_line_count(app, width) as u16
} else if kind == Some(PanelKind::LoginOauth) {
let awaiting = app.oauth_login.as_ref().is_some_and(|o| o.awaiting_input());
if app.oauth_login.as_ref().is_some_and(|o| o.is_device_code()) && !awaiting {
return;
}
if awaiting {
area.y + 5
} else {
let n = app
.oauth_login
.as_ref()
.map(|o| {
crate::modes::interactive::render::oauth_url_lines(
&o.auth_url(),
area.width as usize,
)
})
.unwrap_or(1) as u16;
area.y + 9 + n
}
} else {
area.y + 4
};
frame.set_cursor_position((cx, cy));
}
}
} else if let Some((x, y)) = input::input_cursor_pos(area, app) {
frame.set_cursor_position((x, y));
}
}
fn render_message_area(frame: &mut Frame, area: Rect, app: &mut App, width: usize) {
let msg_area = area.height as usize;
if msg_area == 0 {
app.mouse.visible.clear();
app.mouse.links.clear();
app.image_rects.clear();
app.mouse.log_area_h = 0;
app.mouse.log_total = 0;
app.mouse.scroll_thumb = None;
app.max_scroll = 0;
app.scroll = 0;
app.last_render_total = 0;
return;
}
let streaming: Option<(String, Vec<String>, String)> = if app.busy {
Some((
app.streaming_thinking.clone(),
app.streaming_tools.clone(),
app.streaming_text.clone(),
))
} else {
None
};
let streaming_ref = streaming
.as_ref()
.map(|(t, tools, txt)| (t.as_str(), tools.as_slice(), txt.as_str()));
let last_scrollbar = app.last_has_scrollbar;
let init_width = if last_scrollbar {
width.saturating_sub(1).max(1)
} else {
width
};
let (mut visible, total, max_scroll, needs_scrollbar, click_targets, links, images) =
collect_visible_lines(
app,
msg_area,
width,
init_width,
last_scrollbar,
streaming_ref,
);
app.mouse.log_area = area.y;
app.mouse.log_area_h = area.height;
app.mouse.log_total = total;
app.mouse.view_start = total.saturating_sub(msg_area).saturating_sub(app.scroll);
app.mouse.view_width = if needs_scrollbar {
width.saturating_sub(1).max(1)
} else {
width
};
app.mouse.visible = visible.iter().map(line_text).collect();
let view_end = app.mouse.view_start + app.mouse.visible.len();
app.mouse.click_targets = click_targets
.iter()
.filter_map(|t| {
let s = t.start.max(app.mouse.view_start);
let e = t.end.min(view_end);
(s < e).then(|| ClickRow {
start: area.y + (s - app.mouse.view_start) as u16,
end: area.y + (e - app.mouse.view_start) as u16,
id: t.id,
default_expanded: t.default_expanded,
})
})
.collect();
apply_selection_highlight(&mut visible, &app.mouse, &app.theme);
let text_area = if needs_scrollbar {
Rect {
width: area.width.saturating_sub(1).max(1),
..area
}
} else {
area
};
app.mouse.links = links
.iter()
.filter(|l| l.line >= app.mouse.view_start && l.line < view_end)
.filter_map(|l| {
let col_start = text_area.x.saturating_add(l.start_col as u16);
let col_end = text_area
.x
.saturating_add(l.end_col as u16)
.min(text_area.right());
(col_start < col_end).then(|| LinkRow {
row: area.y + (l.line - app.mouse.view_start) as u16,
col_start,
col_end,
url: l.url.clone(),
})
})
.collect();
let para = Paragraph::new(visible)
.block(Block::default().borders(Borders::NONE))
.style(Style::default());
frame.render_widget(para, text_area);
app.image_rects = images
.iter()
.filter(|slot| {
let end = slot.line + slot.rows as usize;
slot.line < view_end && end > app.mouse.view_start
})
.map(|slot| ImageRect {
area: text_area,
position: SignedPosition {
x: IMAGE_LEFT_PAD as i16,
y: slot.line as i16 - app.mouse.view_start as i16,
},
key: slot.key,
size: Size::new(slot.cols, slot.rows),
data: slot.data.clone(),
pad: slot.pad,
})
.collect();
render_scrollbar(frame, area, app, msg_area, total, max_scroll);
}
fn collect_visible_lines(
app: &mut App,
msg_area: usize,
width: usize,
init_width: usize,
last_scrollbar: bool,
streaming_ref: Option<(&str, &[String], &str)>,
) -> VisibleLines {
let (hist, stream_lines, stream_links, needs_scrollbar): (
messages::HistoryRender,
Vec<Line<'static>>,
Vec<messages::LinkSpan>,
bool,
) = {
let mut hist = app.render_history_lines(init_width);
let mut pending_all = hist.pending.clone();
let mut stream_links: Vec<LinkSpan> = Vec::new();
let stream = match streaming_ref {
Some((t, tools, txt)) => messages::render_streaming(
t,
tools,
txt,
init_width,
&app.theme,
app.expand_all,
app.show_thinking,
&mut pending_all,
&app.tool_started_at,
&mut stream_links,
),
None => Vec::new(),
};
let needs = hist.total + stream.len() > msg_area;
if needs != last_scrollbar {
let w2 = if needs {
width.saturating_sub(1).max(1)
} else {
width
};
app.invalidate_render();
hist = app.render_history_lines(w2);
pending_all = hist.pending.clone();
let mut stream2_links: Vec<LinkSpan> = Vec::new();
let stream2 = match streaming_ref {
Some((t, tools, txt)) => messages::render_streaming(
t,
tools,
txt,
w2,
&app.theme,
app.expand_all,
app.show_thinking,
&mut pending_all,
&app.tool_started_at,
&mut stream2_links,
),
None => Vec::new(),
};
(hist, stream2, stream2_links, needs)
} else {
(hist, stream, stream_links, needs)
}
};
app.last_has_scrollbar = needs_scrollbar;
let hist_stream_len = stream_lines.len();
let click_targets: Vec<messages::ClickTarget> = match hist.anchor_end {
Some(anchor_line) if hist_stream_len > 0 && anchor_line <= hist.total => hist
.click_targets
.iter()
.map(|t| {
let shift = if t.start >= anchor_line {
hist_stream_len
} else {
0
};
messages::ClickTarget {
start: t.start + shift,
end: t.end + shift,
id: t.id,
default_expanded: t.default_expanded,
}
})
.collect(),
_ => hist.click_targets.clone(),
};
let total = hist.total + stream_lines.len();
let max_scroll = total.saturating_sub(msg_area);
app.max_scroll = max_scroll;
if app.scroll > 0 {
let prev_total = app.last_render_total;
if total > prev_total {
app.scroll = app.scroll.saturating_add(total - prev_total);
} else {
app.scroll = app.scroll.saturating_sub(prev_total - total);
}
}
app.last_render_total = total;
app.scroll = app.scroll.min(max_scroll);
let start = total.saturating_sub(msg_area).saturating_sub(app.scroll);
let end = total.min(start + msg_area).max(start);
let mut visible: Vec<Line<'static>> = Vec::new();
let stream_len = stream_lines.len();
if let Some(anchor_line) = hist.anchor_end
&& stream_len > 0
&& anchor_line <= hist.total
{
let mut placed = false;
let mut out: Vec<messages::HistorySegment> = Vec::with_capacity(hist.segments.len() + 1);
for (seg_start, lines, pending) in &hist.segments {
if !placed && *seg_start >= anchor_line {
out.push((
anchor_line,
Arc::new(stream_lines.clone()),
Arc::new(Vec::new()),
));
placed = true;
}
let shift = if *seg_start >= anchor_line {
stream_len
} else {
0
};
out.push((*seg_start + shift, lines.clone(), pending.clone()));
}
if !placed {
out.push((
anchor_line,
Arc::new(stream_lines.clone()),
Arc::new(Vec::new()),
));
}
for (seg_start, lines, _) in &out {
let seg_end = seg_start + lines.len();
if seg_end <= start || *seg_start >= end {
continue;
}
let a = start.saturating_sub(*seg_start);
let b = (end - *seg_start).min(lines.len());
for i in a..b {
visible.push(lines[i].clone());
}
}
} else {
for (seg_start, lines, _) in &hist.segments {
let seg_end = seg_start + lines.len();
if seg_end <= start || *seg_start >= end {
continue;
}
let a = start.saturating_sub(*seg_start);
let b = (end - *seg_start).min(lines.len());
for i in a..b {
visible.push(lines[i].clone());
}
}
if end > hist.total && start < total {
let a = start.saturating_sub(hist.total);
let b = (end - hist.total).min(stream_lines.len());
for line in stream_lines.iter().take(b).skip(a) {
visible.push(line.clone());
}
}
}
let mut links: Vec<LinkSpan> = Vec::new();
let mut images: Vec<ImageSlot> = Vec::new();
for link in hist.links.iter() {
let mut link = link.clone();
if stream_len > 0
&& let Some(anchor_line) = hist.anchor_end
&& link.line >= anchor_line
{
link.line += stream_len;
}
links.push(link);
}
for slot in hist.images.iter() {
let mut slot = slot.clone();
if stream_len > 0
&& let Some(anchor_line) = hist.anchor_end
&& slot.line >= anchor_line
{
slot.line += stream_len;
}
images.push(slot);
}
if stream_len > 0 {
let base = match hist.anchor_end {
Some(anchor_line) if anchor_line <= hist.total => anchor_line,
_ => hist.total,
};
links.extend(stream_links.iter().cloned().map(|mut link| {
link.line += base;
link
}));
}
(
visible,
total,
max_scroll,
needs_scrollbar,
click_targets,
links,
images,
)
}
fn render_scrollbar(
frame: &mut Frame,
area: Rect,
app: &mut App,
msg_area: usize,
total: usize,
max_scroll: usize,
) {
let scroll_x = area.x + area.width.saturating_sub(1);
if max_scroll > 0 && area.width >= 2 {
let thumb_style = app.theme.style("scrollbarThumb", "#6a6a78");
let scrollbar =
ratatui::widgets::Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(None)
.thumb_symbol(DEF_SCROLLBAR_THUMB)
.thumb_style(thumb_style);
let position =
((total - 1) as f64 * (1.0 - app.scroll as f64 / max_scroll as f64)).round() as usize;
let mut state = ratatui::widgets::ScrollbarState::new(total)
.position(position)
.viewport_content_length(msg_area);
frame.render_stateful_widget(
scrollbar,
Rect {
x: scroll_x,
width: 1,
..area
},
&mut state,
);
let denom = (total - 1 + msg_area).max(1);
let thumb_len = ((msg_area as f64 * msg_area as f64 / denom as f64).round() as usize)
.clamp(1, msg_area);
let thumb_start = ((position as f64 * msg_area as f64 / denom as f64).round() as usize)
.min(msg_area.saturating_sub(thumb_len));
app.mouse.scroll_thumb = Some((thumb_start, thumb_len));
app.mouse.scroll_x = Some(scroll_x);
} else {
app.mouse.scroll_thumb = None;
app.mouse.scroll_x = None;
}
}
pub fn render_messages_text(app: &App, width: usize) -> Vec<String> {
let mut out = Vec::new();
for msg in &app.messages {
for line in message_lines(msg, width, &app.theme, app.expand_all) {
out.push(line_text(&line));
}
}
out
}
fn line_text(line: &Line) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn apply_selection_highlight(visible: &mut [Line<'static>], mouse: &MouseSel, _theme: &Theme) {
use ratatui::text::Span;
let Some((ls, cs, le, ce)) = mouse.sel else {
return;
};
if mouse.visible.is_empty() {
return;
}
let (ls, cs, le, ce) = if (ls, cs) <= (le, ce) {
(ls, cs, le, ce)
} else {
(le, ce, ls, cs)
};
let start = mouse.view_start;
let rows = visible.len();
if le < start || ls >= start.saturating_add(rows) {
return;
}
let vs = ls.saturating_sub(start);
let ve = le.saturating_sub(start);
let sel_style = Style::default()
.fg(ratatui::style::Color::Black)
.bg(color("#a0a0a0"));
for (i, line) in visible.iter_mut().enumerate() {
if i < vs || i > ve {
continue;
}
let g = start + i;
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let chars: Vec<char> = text.chars().collect();
let a = if g == ls { cs.min(chars.len()) } else { 0 };
let b = if g == le {
ce.min(chars.len())
} else {
chars.len()
};
if a >= b {
continue;
}
let mut new_spans: Vec<ratatui::text::Span<'static>> = Vec::new();
let mut char_idx = 0usize;
for sp in std::mem::take(&mut line.spans) {
let text: String = sp.content.as_ref().to_string();
let n = text.chars().count();
let seg_start = char_idx;
let seg_end = char_idx + n;
char_idx = seg_end;
if seg_end <= a || seg_start >= b {
new_spans.push(sp);
continue;
}
let rel_a = a.saturating_sub(seg_start).min(n);
let rel_b = b.saturating_sub(seg_start).min(n);
let chars: Vec<char> = text.chars().collect();
if rel_a > 0 {
new_spans.push(Span::styled(
chars[..rel_a].iter().collect::<String>(),
sp.style,
));
}
new_spans.push(Span::styled(
chars[rel_a..rel_b].iter().collect::<String>(),
sp.style.patch(sel_style),
));
if rel_b < n {
new_spans.push(Span::styled(
chars[rel_b..].iter().collect::<String>(),
sp.style,
));
}
}
line.spans = new_spans;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modes::interactive::app::App;
fn image_cells_present(
terminal: &ratatui::Terminal<ratatui::backend::TestBackend>,
app: &App,
) -> bool {
let Some(rect) = app.image_rects.first() else {
return false;
};
let x = rect.area.x + rect.position.x.max(0) as u16;
let y = rect.area.y + rect.position.y.max(0) as u16;
let buf = terminal.backend().buffer();
(y..y + rect.size.height).any(|row| {
(x..x + rect.size.width).any(|col| {
buf.cell((col, row))
.map(|c| !c.symbol().trim().is_empty() && !c.symbol().contains("[image]"))
.unwrap_or(false)
})
})
}
fn draw_until_image(
terminal: &mut ratatui::Terminal<ratatui::backend::TestBackend>,
app: &mut App,
) -> bool {
for _ in 0..10 {
assert!(
super::image::wait_idle(std::time::Duration::from_secs(30)),
"后台编码应在超时前结束"
);
app.dirty = true;
terminal.draw(|f| render_frame(f, app)).unwrap();
if image_cells_present(terminal, app) {
return true;
}
}
false
}
#[test]
fn show_images_renders_inline_image_cells() {
use crate::core::provider::ContentBlock;
use ratatui::backend::TestBackend;
let data = crate::test_support::png_base64_two_tone(600, 200);
let build = |show: bool| {
let mut app = App::new();
app.set_show_images(show);
let mut msg = crate::core::provider::AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::Image {
data: data.clone(),
mime_type: "image/png".to_string(),
}];
app.messages.push(msg);
app
};
let mut off = build(false);
let mut terminal = ratatui::Terminal::new(TestBackend::new(80, 20)).unwrap();
terminal.draw(|f| render_frame(f, &mut off)).unwrap();
assert!(off.image_rects.is_empty());
assert!(
!off.mouse.visible.iter().any(|l| l.contains("[image]")),
"关闭时不应出现占位行: {:?}",
off.mouse.visible
);
let mut on = build(true);
let mut terminal = ratatui::Terminal::new(TestBackend::new(80, 20)).unwrap();
terminal.draw(|f| render_frame(f, &mut on)).unwrap();
assert_eq!(on.image_rects.len(), 1, "槽位应换算成一个屏幕区域");
let (x, y, width) = {
let rect = &on.image_rects[0];
(
rect.area.x + rect.position.x.max(0) as u16,
rect.area.y + rect.position.y.max(0) as u16,
rect.size.width,
)
};
let placeholder: String = (x..x + width)
.map(|col| {
terminal
.backend()
.buffer()
.cell((col, y))
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect();
assert!(
placeholder.contains("[image]"),
"编码未完成时应先画占位: {placeholder:?}"
);
assert!(
draw_until_image(&mut terminal, &mut on),
"图片区域内应写出协议 cell"
);
}
#[test]
fn image_requests_are_deduplicated_while_encoding() {
use ratatui::layout::Size;
let Some(picker) = crate::utils::terminal_image::image_picker() else {
return;
};
super::image::reset_cache();
let data: std::sync::Arc<str> =
std::sync::Arc::from(crate::test_support::png_base64_two_tone(600, 220).as_str());
let key = crate::utils::terminal_image::image_key(&data);
let size = Size::new(60, 10);
let first = super::image::request(picker, key, &data, size, None);
assert!(first.is_none(), "首次请求应登记后台任务并返回 None");
let second = super::image::request(picker, key, &data, size, None);
assert!(second.is_none(), "任务在跑时重复请求不应再起线程");
assert!(
super::image::wait_idle(std::time::Duration::from_secs(30)),
"后台编码应在超时前结束"
);
let hit = (0..10).any(|_| {
if super::image::request(picker, key, &data, size, None).is_some() {
return true;
}
_ = super::image::wait_idle(std::time::Duration::from_secs(30));
false
});
assert!(hit, "编码完成后应命中缓存");
}
#[test]
fn halfblocks_padding_uses_the_slot_background_color() {
use ratatui::layout::Size;
use ratatui_image::picker::Picker;
let picker: &'static Picker = Box::leak(Box::new(Picker::halfblocks()));
let font = picker.font_size();
let data: std::sync::Arc<str> =
std::sync::Arc::from(crate::test_support::png_base64(301, 50).as_str());
let key = crate::utils::terminal_image::image_key(&data);
let cols = 301u16.div_ceil(font.width);
let rows = 50u16.div_ceil(font.height);
let size = Size::new(cols, rows);
let bottom_row_colors = |pad: crate::utils::terminal_image::PadRgb| {
super::image::reset_cache();
let mut protocol = None;
for _ in 0..10 {
if let Some(p) = super::image::request(picker, key, &data, size, pad) {
protocol = Some(p);
break;
}
assert!(
super::image::wait_idle(std::time::Duration::from_secs(30)),
"后台编码应在超时前结束"
);
}
let protocol = protocol.expect("编码完成后应命中缓存");
let area = ratatui::layout::Rect::new(0, 0, size.width, size.height);
let mut buf = ratatui::buffer::Buffer::empty(area);
let widget = ratatui_image::sliced::SlicedImage::new(&protocol, (0, 0).into());
ratatui::widgets::Widget::render(widget, area, &mut buf);
(0..size.width)
.flat_map(|x| {
let c = buf.cell((x, size.height - 1)).unwrap();
[c.fg, c.bg]
})
.filter_map(|c| match c {
ratatui::style::Color::Rgb(r, g, b) => Some((r, g, b)),
_ => None,
})
.collect::<Vec<_>>()
};
let padded = bottom_row_colors(Some([0, 255, 0]));
assert!(
padded.iter().any(|(_, g, _)| *g >= 128),
"补边应填成槽位底色(绿),实际 {padded:?}"
);
let plain = bottom_row_colors(None);
assert!(
plain.iter().all(|(_, g, _)| *g < 128),
"不填底色时补边不该有绿色,实际 {plain:?}"
);
}
#[test]
fn inline_image_is_clipped_when_scrolled_half_out() {
use crate::core::provider::ContentBlock;
use ratatui::backend::TestBackend;
let mut app = App::new();
app.set_show_images(true);
let mut msg = crate::core::provider::AgentMessage::user_text("");
msg.role = "assistant".to_string();
msg.content = vec![ContentBlock::Image {
data: crate::test_support::png_base64_two_tone(600, 2000),
mime_type: "image/png".to_string(),
}];
app.messages.push(msg);
let mut terminal = ratatui::Terminal::new(TestBackend::new(80, 20)).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert!(app.max_scroll > 0, "内容应超出视口");
assert_eq!(app.image_rects.len(), 1);
assert!(draw_until_image(&mut terminal, &mut app));
app.scroll = app.max_scroll / 2;
app.dirty = true;
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let (negative, x) = {
let rect = &app.image_rects[0];
assert!(
rect.position.y < 0,
"图片上端应滚到视口之上: {:?}",
rect.position
);
(
rect.position.y < 0,
rect.area.x + rect.position.x.max(0) as u16,
)
};
assert!(negative);
let buf = terminal.backend().buffer();
let drawn = (app.image_rects[0].area.y..app.image_rects[0].area.bottom()).any(|row| {
(x..x + app.image_rects[0].size.width).any(|col| {
buf.cell((col, row))
.map(|c| !c.symbol().trim().is_empty())
.unwrap_or(false)
})
});
assert!(drawn, "滚动后可见部分仍应画出图片");
}
#[test]
fn message_links_become_clickable_rows() {
use ratatui::backend::TestBackend;
let mut app = App::new();
app.messages
.push(crate::core::provider::AgentMessage::user_text(
"see [docs](https://example.com/docs) now",
));
let w = 80u16;
let h = 20u16;
let backend = TestBackend::new(w, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let links = &app.mouse.links;
assert_eq!(links.len(), 1, "{links:?}");
assert_eq!(links[0].url, "https://example.com/docs");
assert!(
app.mouse
.link_at(links[0].col_start, links[0].row)
.is_some()
);
assert!(
app.mouse
.link_at(links[0].col_end - 1, links[0].row)
.is_some()
);
assert!(
app.mouse
.link_at(links[0].col_start.saturating_sub(1), links[0].row)
.is_none()
);
assert!(
app.mouse
.link_at(links[0].col_start, links[0].row + 1)
.is_none()
);
}
#[test]
fn links_from_several_messages_keep_their_own_rows() {
use ratatui::backend::TestBackend;
let mut app = App::new();
app.messages
.push(crate::core::provider::AgentMessage::user_text(
"[first](https://first.example)",
));
app.messages
.push(crate::core::provider::AgentMessage::user_text(
"[second](https://second.example)",
));
let backend = TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let links = &app.mouse.links;
assert_eq!(links.len(), 2, "{links:?}");
assert_eq!(links[0].url, "https://first.example");
assert_eq!(links[1].url, "https://second.example");
assert!(
links[0].row < links[1].row,
"第二条消息的链接应在更下面: {links:?}"
);
}
#[test]
fn pending_region_sits_between_messages_and_status() {
use ratatui::backend::TestBackend;
let mut app = App::new();
app.messages
.push(crate::core::provider::AgentMessage::user_text("hi"));
app.runtime_steer_inbox.lock().unwrap().push_back(
crate::core::provider::AgentMessage::user_text("continue faster"),
);
app.status = "Working...".to_string();
app.busy = true;
let h = 30u16;
let backend = TestBackend::new(60, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let row = |y: u16| -> String {
(0..60)
.map(|c| buf.cell((c, y)).unwrap().symbol().to_string())
.collect()
};
let (status_h, pending_h, input_h) = layout_heights(&app, 60);
let pend_start = h - (pending_h + status_h + input_h);
assert_eq!(pending_h, 3, "空行 + Steering + 提示行");
let msg_rows: Vec<String> = (0..pend_start).map(&row).collect();
assert!(msg_rows.join("\n").contains("hi"), "消息区在待发送区之上");
assert!(
row(pend_start).trim().is_empty(),
"待发送区首行空(对齐 pi Spacer(1)): {:?}",
row(pend_start)
);
assert!(
row(pend_start + 1).contains("Steering: continue faster"),
"row {}: {:?}",
pend_start + 1,
row(pend_start + 1)
);
assert!(
row(pend_start + 2).contains("↳ Alt+Up to edit all queued messages"),
"row {}: {:?}",
pend_start + 2,
row(pend_start + 2)
);
assert!(
row(pend_start + pending_h).contains("Working..."),
"状态区位于待发送区之下: {:?}",
row(pend_start + pending_h)
);
}
#[test]
fn panel_overlay_hides_the_main_input_region() {
use crate::core::extensions::{OverlaySize, OverlayView};
let mut app = App::new();
let (_, _, with_input) = layout_heights(&app, 80);
assert!(with_input > 0, "无覆盖层时输入区有高度");
crate::modes::interactive::overlay::open(
&mut app,
1,
OverlayView {
size: OverlaySize::Panel { max_rows: 10 },
..OverlayView::inline("Fleet", Vec::new(), 10)
},
);
let (_, _, without) = layout_heights(&app, 80);
assert_eq!(without, 0, "面板样式应隐藏主页输入框");
}
#[test]
fn fullscreen_overlay_has_a_separator_before_the_bottom_bar() {
use crate::core::extensions::{DockSpan, FooterSpan, OverlaySize, OverlayView};
use ratatui::backend::TestBackend;
let mut app = App::new();
let view = OverlayView {
title: "Viewer".to_string(),
lines: vec![vec![DockSpan::plain("transcript")]],
footer: vec![("Esc".to_string(), "close".to_string())],
input: None,
editor: None,
size: OverlaySize::Fullscreen,
header: Vec::new(),
messages: Vec::new(),
selected: None,
input_focus: false,
};
crate::modes::interactive::overlay::open(&mut app, 1, view);
let footer_lines = vec![vec![FooterSpan::new("dim", "#666666", "model · cwd")]];
let h = 10u16;
let w = 40u16;
let backend = TestBackend::new(w, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let area = Rect::new(0, 0, w, h);
terminal
.draw(|f| render_fullscreen_overlay(f, area, &mut app, &[], &footer_lines))
.unwrap();
let buf = terminal.backend().buffer();
let row = |y: u16| -> String {
(0..w)
.map(|c| buf.cell((c, y)).unwrap().symbol().to_string())
.collect()
};
assert!(row(0).contains("Viewer"), "覆盖层在顶: {:?}", row(0));
assert!(row(8).starts_with('─'), "分割线应在底栏之上: {:?}", row(8));
assert!(
row(8).chars().all(|c| c == '─'),
"整行都是分割线: {:?}",
row(8)
);
assert!(row(9).contains("model · cwd"), "底栏在最下: {:?}", row(9));
}
#[test]
fn input_area_height_is_exactly_cursor_line() {
let mut app = App::new();
let (_, _, input_h) = layout_heights(&app, 80);
assert_eq!(input_h, 3, "empty input = 1 content line + 2 borders");
app.editor.insert_text("short");
let (_, _, input_h) = layout_heights(&app, 80);
assert_eq!(input_h, 3, "single-line input + 2 borders");
app.editor.clear();
app.editor.insert_text(&"x".repeat(30));
let (_, _, input_h) = layout_heights(&app, 10);
assert!(
input_h >= 3,
"30 chars should wrap to 3+ lines at width 10, actual {}",
input_h
);
}
#[test]
fn status_height_fixed_one_line() {
let mut app = App::new();
app.status = "single-line status".to_string();
let (status_h, _, _) = layout_heights(&app, 80);
assert_eq!(
status_h, 1,
"状态区固定 1 行(消息区与输入框之间 1 行间隔)"
);
app.status = "first line\nsecond line".to_string();
let (status_h, _, _) = layout_heights(&app, 80);
assert_eq!(status_h, 1, "普通状态多行不撑高状态区");
app.ask_trust = true;
let (status_h, _, _) = layout_heights(&app, 80);
assert_eq!(status_h, 1, "项目信任面板不占状态区");
}
#[test]
fn pending_queue_reserves_layout_lines() {
let app = App::new();
let (_, pending_h, _) = layout_heights(&app, 80);
assert_eq!(pending_h, 0, "无排队不占布局");
app.runtime_steer_inbox
.lock()
.unwrap()
.push_back(crate::core::provider::AgentMessage::user_text("a"));
app.runtime_steer_inbox
.lock()
.unwrap()
.push_back(crate::core::provider::AgentMessage::user_text("b"));
let (_, pending_h, _) = layout_heights(&app, 80);
assert_eq!(pending_h, 4, "空行 + 2 条消息 + 提示行");
}
#[test]
fn scrollbar_appears_right_and_drag_maps_position() {
use ratatui::backend::TestBackend;
let mut app = App::new();
for i in 0..30 {
app.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"msg {}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let col49: Vec<String> = (0..10u16)
.map(|y| buf.cell((49, y)).unwrap().symbol().to_string())
.collect();
assert!(
col49.iter().any(|s| s == "█"),
"滚动条 thumb 出现在右侧列: {:?}",
col49
);
assert!(
app.mouse.scroll_x == Some(49),
"滚动条列已记录: {:?}",
app.mouse.scroll_x
);
assert!(
app.mouse.log_total > 30,
"总行数已记录: {}",
app.mouse.log_total
);
for y in 0..10u16 {
let s = (0..49u16)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect::<String>();
assert!(
s.trim_end().chars().count() <= 49,
"row{} 文本超宽: {:?}",
y,
s
);
}
}
#[test]
fn scroll_to_top_renders_first_message() {
use ratatui::backend::TestBackend;
let mut app = App::new();
for i in 0..30 {
app.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"msg {:02}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
app.scroll = app
.mouse
.log_total
.saturating_sub(app.mouse.log_area_h as usize);
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let row1: String = (0..50)
.map(|x| buf.cell((x, 1)).unwrap().symbol().to_string())
.collect();
assert!(
row1.contains("msg 00"),
"顶部区域应显示第一条消息: {:?} total={} h={}",
row1,
app.mouse.log_total,
app.mouse.log_area_h
);
let all: String = (0..10)
.flat_map(|y| (0..50).map(move |x| buf.cell((x, y)).unwrap().symbol().to_string()))
.collect();
assert!(!all.contains("msg 29"), "滚到顶部后不应显示最后一条消息");
}
#[test]
fn scroll_to_bottom_renders_last_message() {
use ratatui::backend::TestBackend;
let mut app = App::new();
for i in 0..30 {
app.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"msg {:02}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let all: String = (0..10)
.flat_map(|y| (0..50).map(move |x| buf.cell((x, y)).unwrap().symbol().to_string()))
.collect();
assert!(all.contains("msg 29"), "贴底时最后一条消息可见");
assert!(!all.contains("msg 00"), "贴底时不应看到第一条消息");
}
#[test]
fn scroll_anchors_content_when_output_grows() {
use ratatui::backend::TestBackend;
let mut app = App::new();
for i in 0..30 {
app.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"msg {:02}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
app.scroll = app
.mouse
.log_total
.saturating_sub(app.mouse.log_area_h as usize)
/ 2;
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let anchor = app.mouse.view_start;
assert!(anchor > 0, "上滚后视口应离开底部: anchor={anchor}");
app.messages
.push(crate::core::provider::AgentMessage::user_text(
"new output block",
));
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert_eq!(
app.mouse.view_start, anchor,
"新输出不得把视口往下推: start {} → {}",
anchor, app.mouse.view_start
);
let before = app.mouse.view_start;
app.scroll = app.scroll.saturating_add(3);
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert!(
app.mouse.view_start < before,
"上滚后视口应继续上移: {} → {}",
before,
app.mouse.view_start
);
}
#[test]
fn scroll_anchors_content_when_streaming_grows_and_shrinks() {
use ratatui::backend::TestBackend;
let mut app = App::new();
for i in 0..30 {
app.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"msg {:02}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
app.scroll = app
.mouse
.log_total
.saturating_sub(app.mouse.log_area_h as usize)
/ 2;
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let anchor = app.mouse.view_start;
assert!(anchor > 0, "上滚后视口应离开底部: anchor={anchor}");
app.busy = true;
app.streaming_text = "long streaming output line\nanother line\n".to_string();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert_eq!(
app.mouse.view_start, anchor,
"末尾内容变多时视口不得下滚: {} → {}",
anchor, app.mouse.view_start
);
app.streaming_text = "short".to_string();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert_eq!(
app.mouse.view_start, anchor,
"末尾内容变少时视口不得滚动: {} → {}",
anchor, app.mouse.view_start
);
app.streaming_text.clear();
app.busy = false;
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert!(
app.mouse.view_start > 0,
"清空后视口不应跳到底部/顶部: start={}",
app.mouse.view_start
);
}
#[test]
fn scrollbar_hidden_when_content_fits() {
use ratatui::backend::TestBackend;
let mut app = App::new();
app.messages
.push(crate::core::provider::AgentMessage::user_text("short"));
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let col49: Vec<String> = (0..10u16)
.map(|y| buf.cell((49, y)).unwrap().symbol().to_string())
.collect();
eprintln!(
"fits col49: {:?} total={} h={}",
col49, app.mouse.log_total, app.mouse.log_area_h
);
assert!(
app.mouse.scroll_x.is_none(),
"内容可容纳时不应有滚动条: total={} h={}",
app.mouse.log_total,
app.mouse.log_area_h
);
let c = buf.cell((49, 0)).unwrap();
assert_ne!(
c.style().bg,
None,
"内容 fits 时文本区应占满右侧列, style={:?}",
c.style()
);
}
#[test]
fn selection_follows_content_across_scroll() {
use ratatui::backend::TestBackend;
let mut app = App::new();
for i in 0..30 {
app.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"msg {:02}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
app.scroll = app
.mouse
.log_total
.saturating_sub(app.mouse.log_area_h as usize);
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert_eq!(app.mouse.view_start, 0, "贴顶视口顶行 = 内容行 0");
let texts = render_messages_text(&app, app.mouse.view_width.max(1));
let sel_line = 1usize;
assert!(
!texts[sel_line].trim().is_empty(),
"内容行 1 应为正文: {:?}",
texts[sel_line]
);
app.mouse.sel = Some((sel_line, 0, sel_line, usize::MAX));
let before = app.selected_text().unwrap();
assert_eq!(before, texts[sel_line], "提取 = 该行全文");
app.scroll = app.scroll.saturating_sub(1);
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert_eq!(app.mouse.view_start, 1);
assert_eq!(
app.selected_text(),
Some(before),
"滚动后选中文本不变(钉在内容上)"
);
let buf = terminal.backend().buffer();
let sel_bg = Some(color("#a0a0a0"));
let row0_has = (0..50).any(|x| buf.cell((x, 0)).unwrap().style().bg == sel_bg);
assert!(row0_has, "滚动后高亮应跟随内容出现在视口行 0");
let row1_has = (0..50).any(|x| buf.cell((x, 1)).unwrap().style().bg == sel_bg);
assert!(!row1_has, "视口行 1 已是新内容,不应带原选择高亮");
}
#[test]
fn selection_fully_above_viewport_highlights_nothing() {
use ratatui::backend::TestBackend;
let mut app = App::new();
for i in 0..40 {
app.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"msg {:02}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
app.scroll = app
.mouse
.log_total
.saturating_sub(app.mouse.log_area_h as usize);
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert_eq!(app.mouse.view_start, 0, "贴顶视口顶行 = 内容行 0");
app.mouse.sel = Some((1, 0, 1, usize::MAX));
app.scroll = 0;
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert!(
app.mouse.view_start > 1,
"选中行应整体滚出视口上方: start={}",
app.mouse.view_start
);
let buf = terminal.backend().buffer();
let sel_bg = Some(color("#a0a0a0"));
let any_sel =
(0..10u16).any(|y| (0..50).any(|x| buf.cell((x, y)).unwrap().style().bg == sel_bg));
assert!(!any_sel, "选择整体在视口上方时不得高亮任何行");
}
#[test]
fn selection_pushed_above_viewport_highlights_nothing() {
let mk = |rows: usize| -> Vec<Line<'static>> {
(0..rows).map(|i| Line::from(format!("row {i}"))).collect()
};
let sel_bg = Some(color("#a0a0a0"));
let has_sel = |lines: &[Line<'static>]| {
lines
.iter()
.any(|l| l.spans.iter().any(|s| s.style.bg == sel_bg))
};
let mut lines = mk(5);
let mouse = MouseSel {
visible: vec!["x".to_string(); 5],
view_start: 100,
sel: Some((1, 0, 1, usize::MAX)),
..Default::default()
};
apply_selection_highlight(&mut lines, &mouse, &Theme::default());
assert!(
!has_sel(&lines),
"选择整体在视口上方时不得高亮任何行(尤其顶部行)"
);
let mut lines = mk(5);
let mouse = MouseSel {
visible: vec!["x".to_string(); 5],
view_start: 100,
sel: Some((99, 0, 102, usize::MAX)),
..Default::default()
};
apply_selection_highlight(&mut lines, &mouse, &Theme::default());
assert!(
has_sel(&lines),
"选区延伸到视口内时应从顶部行起高亮相交部分"
);
let mut lines = mk(5);
let mouse = MouseSel {
visible: vec!["x".to_string(); 5],
view_start: 100,
sel: Some((110, 0, 120, usize::MAX)),
..Default::default()
};
apply_selection_highlight(&mut lines, &mouse, &Theme::default());
assert!(!has_sel(&lines), "选择整体在视口下方时不得高亮任何行");
}
#[test]
fn selection_reverse_drag_normalizes() {
use ratatui::backend::TestBackend;
let mut app = App::new();
for i in 0..30 {
app.messages
.push(crate::core::provider::AgentMessage::user_text(&format!(
"msg {:02}",
i
)));
}
let backend = TestBackend::new(50, 10);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let start = app.mouse.view_start;
let h = app.mouse.log_area_h as usize;
debug_assert!(h >= 3, "测试视口至少 3 行");
let (a, b) = (start + 1, start + h - 1);
let texts = render_messages_text(&app, 50);
let ca = texts[a].chars().count().saturating_sub(2);
let cb = 2usize;
let forward = {
app.mouse.sel = Some((a, cb, b, ca));
app.selected_text().unwrap()
};
app.mouse.sel = Some((b, ca, a, cb));
assert_eq!(app.selected_text(), Some(forward), "反向拖拽提取方向一致");
}
#[test]
fn bug_report_hint_cursor_sits_on_description_input_line() {
use crate::modes::interactive::app::PendingBugReport;
use crate::modes::interactive::panel::PanelKind;
use ratatui::backend::TestBackend;
let w = 60usize;
let mut app = App::new();
app.pending_bug_report = Some(PendingBugReport {
hint: "crash on start".to_string(),
include_session: false,
include_summary: false,
});
app.panel.open(
PanelKind::BugReportHint,
"Report a bug — what went wrong? (optional)".to_string(),
Vec::new(),
);
app.panel
.top_mut()
.unwrap()
.filter
.set_value("crash on start");
let (_, pending_h, input_h) = layout_heights(&app, w);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(w as u16, 2 + pending_h + input_h)).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let input_y = 1 + pending_h + 1; let pos = terminal.get_cursor_position().unwrap();
let notes = crate::modes::interactive::render::panel::bug_report_note_line_count(&app, w);
assert_eq!(
pos.y,
input_y + 5 + notes as u16,
"光标应在描述输入行: input_y={input_y} notes={notes} pos={pos:?}"
);
assert_eq!(
pos.x,
2 + "crash on start".len() as u16,
"cursor x 应在描述末尾: pos={pos:?}"
);
let buffer = terminal.backend().buffer();
let row: String = (0..w as u16)
.map(|x| buffer[(x, pos.y)].symbol().to_string())
.collect();
assert!(row.starts_with("> crash on start"), "输入行: {row:?}");
}
#[test]
fn scoped_models_cursor_sits_on_search_line() {
use crate::modes::interactive::panel::{PanelItem, PanelKind};
use ratatui::backend::TestBackend;
let w = 60usize;
let mut app = App::new();
app.panel.open(
PanelKind::ScopedModels,
"Model Configuration".to_string(),
vec![
PanelItem {
label: "[x] deepseek-v4-pro".to_string(),
value: "deepseek\0deepseek-v4-pro".to_string(),
desc: "[deepseek]".to_string(),
name: "deepseek-v4-pro".to_string(),
..Default::default()
},
PanelItem {
label: "[ ] deepseek-flash".to_string(),
value: "deepseek\0deepseek-flash".to_string(),
desc: "[deepseek]".to_string(),
name: "deepseek-flash".to_string(),
..Default::default()
},
],
);
app.panel.top_mut().unwrap().filter.set_value("a");
let (_, pending_h, input_h) = layout_heights(&app, w);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(w as u16, 2 + pending_h + input_h)).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let input_y = 1 + pending_h + 1; let pos = terminal.get_cursor_position().unwrap();
assert_eq!(
pos.y,
input_y + 5,
"光标应在搜索输入行: input_y={input_y} pos={pos:?}"
);
assert_eq!(pos.x, 3, "cursor x 应跟随过滤文本: pos={pos:?}");
}
#[test]
fn extension_cursor_sits_on_search_line() {
use crate::modes::interactive::panel::{PanelItem, PanelKind};
use ratatui::backend::TestBackend;
let w = 60usize;
let mut app = App::new();
app.panel.open(
PanelKind::Extension,
"/extension".to_string(),
vec![PanelItem {
label: "[x] footer(normal)".to_string(),
value: "footer(normal)".to_string(),
desc: "𝒊".to_string(),
..Default::default()
}],
);
app.panel.top_mut().unwrap().filter.set_value("a");
let (_, pending_h, input_h) = layout_heights(&app, w);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(w as u16, 2 + pending_h + input_h)).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let input_y = 1 + pending_h + 1;
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(
pos.y,
input_y + 5,
"光标应在搜索输入行: input_y={input_y} pos={pos:?}"
);
assert_eq!(pos.x, 3, "cursor x 应跟随过滤文本: pos={pos:?}");
}
#[test]
fn skill_cursor_sits_on_search_line() {
use crate::modes::interactive::panel::{PanelItem, PanelKind};
use ratatui::backend::TestBackend;
let w = 60usize;
let mut app = App::new();
app.panel.open(
PanelKind::Skill,
"/skills".to_string(),
vec![PanelItem {
label: "[x] grill-me".to_string(),
value: "grill-me".to_string(),
desc: "[model-off]".to_string(),
..Default::default()
}],
);
app.panel.top_mut().unwrap().filter.set_value("a");
let (_, pending_h, input_h) = layout_heights(&app, w);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(w as u16, 2 + pending_h + input_h)).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let input_y = 1 + pending_h + 1;
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(
pos.y,
input_y + 5,
"光标应在搜索输入行: input_y={input_y} pos={pos:?}"
);
assert_eq!(pos.x, 3, "cursor x 应跟随过滤文本: pos={pos:?}");
}
#[test]
fn skill_cursor_follows_error_lines() {
use crate::modes::interactive::panel::{PanelItem, PanelKind};
use ratatui::backend::TestBackend;
let w = 40usize;
let mut app = App::new();
app.skill_panel_error = Some(
"failed to save skills: permission denied writing settings.json backup copy"
.to_string(),
);
app.panel.open(
PanelKind::Skill,
"/skills".to_string(),
vec![PanelItem {
label: "[x] grill-me".to_string(),
value: "grill-me".to_string(),
..Default::default()
}],
);
let (_, pending_h, input_h) = layout_heights(&app, w);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(w as u16, 2 + pending_h + input_h)).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let input_y = 1 + pending_h + 1;
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(
pos.y,
input_y + 5 + panel::skills_error_line_count(&app, w) as u16,
"光标应随错误行下移: input_y={input_y} pos={pos:?}"
);
}
#[test]
fn history_clear_confirm_hides_cursor() {
use crate::modes::interactive::panel::{PanelItem, PanelKind};
use ratatui::backend::TestBackend;
let w = 60usize;
let mut app = App::new();
app.pending_history_clear = Some(crate::modes::interactive::app::PendingHistoryClear {
all: false,
detail: "proj · 3 entries\n/tmp/prux/history/proj.jsonl".to_string(),
});
app.panel.open(
PanelKind::HistoryClearConfirm,
"Clear prompt history for this project".to_string(),
vec![
PanelItem::new("Yes".to_string(), "yes".to_string()),
PanelItem::new("No".to_string(), "no".to_string()),
],
);
let (_, pending_h, input_h) = layout_heights(&app, w);
let mut terminal =
ratatui::Terminal::new(TestBackend::new(w as u16, 2 + pending_h + input_h)).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
assert!(
!terminal.backend().cursor_visible(),
"删除历史确认面板应隐藏光标"
);
}
#[test]
fn ext_settings_panel_replaces_input_area() {
use crate::core::extensions::{Extension, ExtensionSetting, ExtensionTool};
use ratatui::backend::TestBackend;
struct FakeExt;
impl Extension for FakeExt {
fn name(&self) -> &str {
"fake-frame-settings"
}
fn tools(&self) -> Vec<ExtensionTool> {
Vec::new()
}
fn settings(&self) -> Vec<ExtensionSetting> {
vec![ExtensionSetting::cycle(
"m",
"Mode",
"a settable mode",
"a",
["a", "b"],
)]
}
}
let _g = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::core::extensions::register_extension(FakeExt);
let mut app = App::new();
let (_, pending_h, plain_input_h) = layout_heights(&app, 60);
assert_eq!(plain_input_h, 3, "空输入框 = 1 内容行 + 2 边框");
app.ext_settings.open("fake-frame-settings");
let ph = ext_settings::panel_height(&app, 60);
let (status_h, pending_h2, input_h) = layout_heights(&app, 60);
assert_eq!(pending_h, pending_h2);
assert_eq!(input_h, ph, "打开面板后输入区应等于面板高度(输入框隐藏)");
assert!(input_h > plain_input_h, "面板应高于普通输入框");
let h = 1 + pending_h + status_h + input_h;
let mut terminal = ratatui::Terminal::new(TestBackend::new(60, h)).unwrap();
terminal.draw(|f| render_frame(f, &mut app)).unwrap();
let buf = terminal.backend().buffer();
let row = |y: u16| -> String {
(0..60u16)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect()
};
let input_y = 1 + pending_h + status_h;
assert!(
row(input_y).contains("fake-frame-settings"),
"面板标题应在顶部边框: {:?}",
row(input_y)
);
assert!(
row(input_y + 2).starts_with("> "),
"搜索行应位于边框下第二行: {:?}",
row(input_y + 2)
);
assert!(
row(input_y + input_h - 1).contains('─'),
"面板下边框应贴输入区底部: {:?}",
row(input_y + input_h - 1)
);
app.ext_settings.close();
let (_, _, restored) = layout_heights(&app, 60);
assert_eq!(restored, plain_input_h, "关闭后恢复输入框");
crate::core::extensions::unregister_extension("fake-frame-settings");
}
}