pub mod agent_card;
mod header;
pub mod key_hint;
pub mod pending_input_preview;
mod renderable;
pub mod tool_card;
pub mod workflow_panel;
pub use header::header_status_indicator_frame;
pub use renderable::Renderable;
use std::borrow::Cow;
use std::collections::HashSet;
use std::time::Duration;
use crate::commands;
#[cfg(test)]
use crate::config::ApiProvider;
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
#[cfg(test)]
use crate::provider_lake::all_catalog_models_for_provider;
use crate::tui::app::{App, AppMode, ComposerDensity, ViewportState};
use crate::tui::approval::{
ApprovalMode, ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel,
ToolCategory,
};
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus};
use crate::tui::menu_style;
use crate::tui::scrolling::TranscriptLineMeta;
use crate::tui::ui_text::{grapheme_display_width, text_display_width};
use crate::tui::underwater::ShellPhase;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, StatefulWidget, Widget, Wrap,
},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const SEND_FLASH_DURATION: Duration = Duration::from_millis(500);
#[cfg(test)]
const COMPOSER_PANEL_HEIGHT: u16 = 2;
const JUMP_TO_LATEST_BUTTON_WIDTH: u16 = 3;
const JUMP_TO_LATEST_BUTTON_HEIGHT: u16 = 3;
pub struct ChatWidget {
content_area: Rect,
lines: Vec<Line<'static>>,
line_links: Vec<Vec<crate::tui::osc8::LineLink>>,
scrollbar: Option<TranscriptScrollbar>,
jump_to_latest_button: Option<Rect>,
background: Color,
ocean_column: Option<crate::tui::ocean::OceanColumn>,
ambient_inks: Option<(Color, Color)>,
ocean_elapsed_ms: u128,
ocean_animated: bool,
life_presence_fixed: u16,
fish_flee_elapsed_ms: Option<u128>,
ambient_life: bool,
scroll_track: Color,
scroll_thumb: Color,
jump_border: Color,
jump_arrow: Color,
}
#[derive(Debug, Clone, Copy)]
struct TranscriptScrollbar {
top: usize,
visible: usize,
total: usize,
}
fn resolve_transcript_viewport_after_layout(
viewport: &mut ViewportState,
visible_lines: usize,
) -> (usize, usize, bool) {
let total_lines = viewport.transcript_cache.total_lines();
let line_meta = viewport.transcript_cache.line_meta();
if viewport.pending_scroll_delta != 0 {
viewport.transcript_scroll = viewport.transcript_scroll.scrolled_by(
viewport.pending_scroll_delta,
line_meta,
visible_lines,
);
viewport.pending_scroll_delta = 0;
}
let max_start = total_lines.saturating_sub(visible_lines);
let was_explicit_tail = viewport.transcript_scroll.is_at_tail();
let (scroll_state, top) = viewport.transcript_scroll.resolve_top(line_meta, max_start);
viewport.transcript_scroll = scroll_state;
viewport.last_transcript_top = top;
viewport.last_transcript_visible = visible_lines;
viewport.last_transcript_total = total_lines;
(total_lines, top, was_explicit_tail)
}
impl ChatWidget {
pub fn new(app: &mut App, area: Rect) -> Self {
let ocean_elapsed_ms = app.sample_ambient_clock_ms();
Self::new_with_ocean_elapsed(app, area, ocean_elapsed_ms)
}
fn new_with_ocean_elapsed(app: &mut App, area: Rect, ocean_elapsed_ms: u128) -> Self {
let content_area = area;
let background = app.ui_theme.surface_bg;
let ocean_ramp = app
.ocean_treatment
.is_ombre()
.then(|| crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme))
.flatten();
let ambient_inks = Some(crate::tui::ocean::ambient_inks(&app.ui_theme));
let completion_life_clock = app
.motion_policy()
.allows_decorative()
.then_some(())
.and(app.ocean_completion_started_at)
.map(|started| started.elapsed().as_millis());
let completion_elapsed_ms = completion_life_clock
.filter(|elapsed| *elapsed < crate::tui::ocean::COMPLETION_BREATH_MS);
let render_empty_state = should_render_empty_state(app);
let phase = ShellPhase::from_app(app);
let underwater_motion_enabled =
crate::tui::underwater::decorative_shell_motion_enabled(app);
let browsing_history = !app.viewport.transcript_scroll.is_at_tail();
let ocean_animated = underwater_motion_enabled
&& (render_empty_state
|| browsing_history
|| matches!(phase, ShellPhase::Working | ShellPhase::Verifying));
let life_presence = crate::tui::ocean::life_presence(
completion_life_clock,
app.turn_started_at
.map(|started| started.elapsed().as_millis()),
ocean_animated,
browsing_history,
render_empty_state,
);
let life_presence_fixed = (life_presence * 1000.0).round().clamp(0.0, 1000.0) as u16;
let ocean_column = ocean_ramp.map(|ramp| {
crate::tui::ocean::OceanColumn::new(
ramp,
content_area,
ocean_elapsed_ms,
completion_elapsed_ms,
phase,
ocean_animated,
life_presence_fixed,
)
});
let fish_flee_elapsed_ms = underwater_motion_enabled
.then_some(())
.and(app.turn_started_at)
.map(|started| started.elapsed().as_millis())
.filter(|elapsed| *elapsed < 800)
.filter(|_| matches!(phase, ShellPhase::Working | ShellPhase::Verifying));
let scroll_track = app.ui_theme.border;
let scroll_thumb = app.ui_theme.status_working;
let jump_border = app.ui_theme.border;
let jump_arrow = app.ui_theme.status_working;
let visible_lines = content_area.height as usize;
let render_options = app.transcript_render_options();
if render_empty_state {
let lines = build_empty_state_lines(app, content_area);
app.viewport.last_transcript_area = Some(content_area);
app.viewport.last_transcript_top = 0;
app.viewport.last_transcript_visible = visible_lines;
app.viewport.last_transcript_total = 0;
app.viewport.last_transcript_padding_top = 0;
app.viewport.jump_to_latest_button_area = None;
return Self {
content_area,
lines,
line_links: Vec::new(),
scrollbar: None,
jump_to_latest_button: None,
background,
ocean_column,
ambient_inks,
ocean_elapsed_ms,
ocean_animated,
life_presence_fixed,
fish_flee_elapsed_ms,
ambient_life: !app.attention_hold_active()
&& matches!(
phase,
ShellPhase::Idle
| ShellPhase::Typing
| ShellPhase::Working
| ShellPhase::Verifying
),
scroll_track,
scroll_thumb,
jump_border,
jump_arrow,
};
}
app.resync_history_revisions();
app.viewport.transcript_cache.set_streaming_source_receipt(
app.streaming_source_receipt.map(|receipt| {
crate::tui::transcript::StreamingSourceReceipt {
cell_index: receipt.cell_index,
from_revision: history_entry_revision(receipt.from_revision),
to_revision: history_entry_revision(receipt.to_revision),
content_len: receipt.content_len,
}
}),
);
let provisional_action_owner = app.transcript_action_owner();
let active_entries: &[HistoryCell] = app
.active_cell
.as_ref()
.map_or(&[], |active| active.entries());
let history_len = app.history.len();
let tool_runs = if app.tool_collapse_active() {
let cache_key_matches = app.tool_run_cache.history_version == app.history_version
&& app.tool_run_cache.active_cell_revision == app.active_cell_revision
&& app.tool_run_cache.active_len == active_entries.len()
&& app.tool_run_cache.threshold == app.tool_collapse_threshold
&& app.tool_run_cache.mode == app.tool_collapse_mode
&& app.tool_run_cache.calm_mode == app.calm_mode;
if !cache_key_matches {
app.tool_run_cache.runs = crate::tui::history::detect_tool_runs_from_slices(
&app.history,
active_entries,
app.tool_collapse_threshold,
);
app.tool_run_cache.history_version = app.history_version;
app.tool_run_cache.active_cell_revision = app.active_cell_revision;
app.tool_run_cache.active_len = active_entries.len();
app.tool_run_cache.threshold = app.tool_collapse_threshold;
app.tool_run_cache.mode = app.tool_collapse_mode;
app.tool_run_cache.calm_mode = app.calm_mode;
}
app.tool_run_cache.runs.clone()
} else {
Vec::new()
};
let collapsed_run_starts: HashSet<usize> = tool_runs
.iter()
.filter_map(|run| (!app.expanded_tool_runs.contains(&run.start)).then_some(run.start))
.collect();
let mut collapsed_tool_indices: HashSet<usize> = HashSet::new();
for run in &tool_runs {
if !collapsed_run_starts.contains(&run.start) {
continue;
}
for offset in 1..run.count {
collapsed_tool_indices.insert(run.start + offset);
}
}
let has_collapsed = !app.collapsed_cells.is_empty() || !collapsed_run_starts.is_empty();
if !has_collapsed {
let mut cell_revisions: Vec<u64> =
Vec::with_capacity(app.history.len() + active_entries.len());
cell_revisions.extend(
app.history_revisions
.iter()
.copied()
.map(history_entry_revision),
);
if !active_entries.is_empty() {
let active_rev = app.active_cell_revision;
for i in 0..active_entries.len() {
let salt = (i as u64).wrapping_add(1);
cell_revisions.push(active_entry_revision(active_rev, salt));
}
}
app.collapsed_cell_map = (0..app.history.len() + active_entries.len()).collect();
let shards: [&[HistoryCell]; 2] = [&app.history, active_entries];
app.viewport.transcript_cache.ensure_split(
&shards,
&cell_revisions,
content_area.width.max(1),
render_options,
&app.folded_thinking,
None,
provisional_action_owner,
);
} else {
let summary_cells: Vec<(usize, HistoryCell)> = tool_runs
.iter()
.filter(|run| collapsed_run_starts.contains(&run.start))
.map(|run| (run.start, tool_run_summary_cell(run)))
.collect();
let summary_cell_for = |idx: usize| -> Option<&HistoryCell> {
summary_cells
.iter()
.find(|(start, _)| *start == idx)
.map(|(_, cell)| cell)
};
let mut filtered_cells: Vec<&HistoryCell> =
Vec::with_capacity(history_len + active_entries.len());
let mut filtered_revs: Vec<u64> =
Vec::with_capacity(history_len + active_entries.len());
let mut filtered_to_original: Vec<usize> =
Vec::with_capacity(history_len + active_entries.len());
for (idx, cell) in app.history.iter().enumerate() {
if app.collapsed_cells.contains(&idx) {
continue;
}
if collapsed_tool_indices.contains(&idx) {
continue;
}
if let Some(run) = tool_runs
.iter()
.find(|run| run.start == idx && collapsed_run_starts.contains(&idx))
{
filtered_cells.push(summary_cell_for(idx).expect("summary cell materialized"));
filtered_revs.push(tool_run_summary_revision(
run,
&app.history_revisions,
history_len,
app.active_cell_revision,
));
filtered_to_original.push(idx);
continue;
}
filtered_cells.push(cell);
filtered_revs.push(history_entry_revision(app.history_revisions[idx]));
filtered_to_original.push(idx);
}
if !active_entries.is_empty() {
let active_rev = app.active_cell_revision;
for (i, cell) in active_entries.iter().enumerate() {
let original_idx = history_len + i;
if app.collapsed_cells.contains(&original_idx) {
continue;
}
if collapsed_tool_indices.contains(&original_idx) {
continue;
}
if let Some(run) = tool_runs.iter().find(|run| {
run.start == original_idx && collapsed_run_starts.contains(&original_idx)
}) {
filtered_cells
.push(summary_cell_for(original_idx).expect("summary materialized"));
filtered_revs.push(tool_run_summary_revision(
run,
&app.history_revisions,
history_len,
active_rev,
));
filtered_to_original.push(original_idx);
continue;
}
filtered_cells.push(cell);
let salt = (i as u64).wrapping_add(1);
filtered_revs.push(active_entry_revision(active_rev, salt));
filtered_to_original.push(original_idx);
}
}
app.collapsed_cell_map = filtered_to_original;
app.viewport.transcript_cache.ensure_filtered(
&filtered_cells,
&filtered_revs,
content_area.width.max(1),
render_options,
&app.folded_thinking,
Some(&app.collapsed_cell_map),
provisional_action_owner,
);
}
let (total_lines, top, was_explicit_tail) =
resolve_transcript_viewport_after_layout(&mut app.viewport, visible_lines);
let owner = app.transcript_action_owner();
let index_map = has_collapsed.then_some(app.collapsed_cell_map.as_slice());
app.viewport.transcript_cache.retarget(owner, index_map);
if let Some(receipt) = app.streaming_source_receipt.as_mut() {
receipt.from_revision = receipt.to_revision;
}
let line_meta = app.viewport.transcript_cache.line_meta();
if was_explicit_tail && total_lines > visible_lines {
app.user_scrolled_during_stream = false;
}
app.viewport.last_transcript_area = Some(content_area);
app.viewport.last_transcript_padding_top = 0;
let detail_target_cell = (!app.viewport.transcript_selection.is_active())
.then(|| app.detail_cell_index_for_viewport(top, visible_lines, line_meta))
.flatten();
let end = (top + visible_lines).min(total_lines);
let mut lines = if total_lines == 0 {
vec![Line::from("")]
} else {
app.viewport.transcript_cache.lines()[top..end].to_vec()
};
let line_links = if total_lines == 0 {
vec![Vec::new()]
} else {
app.viewport.transcript_cache.line_links()[top..end].to_vec()
};
if !app.low_motion
&& app.fancy_animations
&& let (Some(start), Some(started)) = (
app.ocean_receipt_settle_start,
app.ocean_completion_started_at,
)
{
apply_receipt_settle_cascade(
&mut lines,
top,
line_meta,
&app.collapsed_cell_map,
&app.history,
start,
started.elapsed().as_millis(),
);
}
if app.motion_policy().allows_decorative() {
if let Some(send_at) = app.last_send_at {
if send_at.elapsed() < SEND_FLASH_DURATION {
apply_send_flash(
&mut lines,
top,
&app.history,
line_meta,
&app.collapsed_cell_map,
);
} else {
app.last_send_at = None;
}
}
} else {
app.last_send_at = None;
}
if let Some(target_cell) = detail_target_cell {
apply_detail_target_highlight(
&mut lines,
top,
target_cell,
line_meta,
&app.collapsed_cell_map,
);
}
apply_selection(&mut lines, top, app);
app.viewport.last_transcript_padding_top = 0;
let scrollbar = (total_lines > visible_lines && content_area.width > 1).then_some(
TranscriptScrollbar {
top,
visible: visible_lines,
total: total_lines,
},
);
let jump_to_latest_button =
if app.use_mouse_capture && !app.viewport.transcript_scroll.is_at_tail() {
jump_to_latest_button_rect(content_area, scrollbar.is_some())
} else {
None
};
app.viewport.jump_to_latest_button_area = jump_to_latest_button;
Self {
content_area,
lines,
line_links,
scrollbar,
jump_to_latest_button,
background,
ocean_column,
ambient_inks,
ocean_elapsed_ms,
ocean_animated,
life_presence_fixed,
fish_flee_elapsed_ms,
ambient_life: !app.attention_hold_active()
&& (browsing_history
|| matches!(phase, ShellPhase::Working | ShellPhase::Verifying)),
scroll_track,
scroll_thumb,
jump_border,
jump_arrow,
}
}
#[must_use]
pub(crate) fn with_ocean_viewport(mut self, viewport: Rect) -> Self {
self.ocean_column = self
.ocean_column
.map(|column| column.with_viewport(viewport));
self
}
#[must_use]
pub(crate) fn ocean_column(&self) -> Option<crate::tui::ocean::OceanColumn> {
self.ocean_column
}
}
fn apply_receipt_settle_cascade(
lines: &mut [Line<'static>],
top: usize,
line_meta: &[TranscriptLineMeta],
filtered_to_original: &[usize],
history: &[HistoryCell],
start: usize,
elapsed_ms: u128,
) {
for (visible_index, line) in lines.iter_mut().enumerate() {
let Some((filtered_cell, _)) = line_meta
.get(top + visible_index)
.and_then(TranscriptLineMeta::cell_line)
else {
continue;
};
let original_cell = filtered_to_original
.get(filtered_cell)
.copied()
.unwrap_or(filtered_cell);
if original_cell < start
|| !matches!(
history.get(original_cell),
Some(HistoryCell::Tool(_) | HistoryCell::SubAgent(_))
)
|| !receipt_is_settling(original_cell - start, elapsed_ms)
{
continue;
}
for span in &mut line.spans {
span.style = span.style.add_modifier(Modifier::DIM);
}
}
}
#[must_use]
fn receipt_is_settling(receipt_order: usize, elapsed_ms: u128) -> bool {
let delay = u128::try_from(receipt_order.min(6)).unwrap_or(6) * 70;
elapsed_ms < delay + 140
}
fn tool_run_summary_cell(run: &ToolRun) -> HistoryCell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "activity_group".to_string(),
status: ToolStatus::Success,
input_summary: Some(crate::tui::history::tool_run_summary(run)),
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}))
}
fn tool_run_summary_revision(
run: &ToolRun,
revisions: &[u64],
history_len: usize,
active_rev: u64,
) -> u64 {
let mut revision = 0xA11C_EA5E_D00D_2692u64 ^ ((run.start as u64) << 32) ^ (run.count as u64);
for idx in run.start..run.start.saturating_add(run.count) {
let cell_revision = revisions
.get(idx)
.copied()
.map(history_entry_revision)
.unwrap_or_else(|| {
let active_idx = idx.saturating_sub(history_len);
active_entry_revision(active_rev, (active_idx as u64).wrapping_add(1))
});
revision = revision.rotate_left(7) ^ cell_revision;
}
let extends_into_active = run.start.saturating_add(run.count) > history_len;
revision_in_domain(revision, extends_into_active)
}
const ACTIVE_REVISION_DOMAIN: u64 = 1 << 63;
fn revision_in_domain(revision: u64, active: bool) -> u64 {
let payload = revision & !ACTIVE_REVISION_DOMAIN;
if active {
ACTIVE_REVISION_DOMAIN | payload
} else {
payload
}
}
fn history_entry_revision(revision: u64) -> u64 {
revision_in_domain(revision, false)
}
pub(crate) fn active_entry_revision(active_rev: u64, salt: u64) -> u64 {
let mixed = active_rev
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(salt);
revision_in_domain(mixed, true)
}
impl Renderable for ChatWidget {
fn render(&self, _area: Rect, buf: &mut Buffer) {
debug_assert_eq!(
_area, self.content_area,
"ChatWidget content_area drifted from render area: \
content_area={:?} render_area={:?}",
self.content_area, _area
);
let area = _area;
crate::tui::hover_layer::begin_frame();
Block::default()
.style(Style::default().bg(self.background))
.render(area, buf);
let paragraph =
Paragraph::new(self.lines.clone()).style(Style::default().bg(self.background));
paragraph.render(area, buf);
self.render_underwater_field(area, buf);
let link_area = Rect {
width: area
.width
.saturating_sub(u16::from(self.scrollbar.is_some())),
..area
};
let regions = crate::tui::osc8::link_regions_for_lines(link_area, &self.line_links);
crate::tui::osc8::set_frame_links(regions);
if let Some(scrollbar) = self.scrollbar {
let scrollable_range = scrollbar.total.saturating_sub(scrollbar.visible);
let mut state = ScrollbarState::new(scrollable_range)
.position(scrollbar.top.min(scrollable_range))
.viewport_content_length(scrollbar.visible);
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.track_style(Style::default().fg(self.scroll_track))
.thumb_symbol("┃")
.thumb_style(Style::default().fg(self.scroll_thumb))
.render(area, buf, &mut state);
}
if let Some(button_area) = self.jump_to_latest_button {
render_jump_to_latest_button(
button_area,
buf,
self.background,
self.jump_border,
self.jump_arrow,
);
}
let link_area = Rect {
width: area
.width
.saturating_sub(u16::from(self.scrollbar.is_some())),
..area
};
for region in crate::tui::osc8::link_regions_for_lines(link_area, &self.line_links) {
let width = region
.col_end
.saturating_sub(region.col_start)
.saturating_add(1);
let hit = Rect::new(region.col_start, region.row, width, 1);
crate::tui::hover_layer::register_rect(
crate::tui::hover_hit::HoverTargetKind::Link,
hit,
region.target,
true,
);
}
crate::tui::hover_layer::apply_resolved_effects(
buf,
!self.ocean_animated,
self.scroll_thumb,
);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
impl ChatWidget {
fn render_underwater_field(&self, area: Rect, buf: &mut Buffer) {
if let Some(column) = self.ocean_column {
let phase_tag = column.phase_tag();
let fingerprint = column.ramp_fingerprint();
let ramp = crate::tui::ambient_life::frame_ocean_ramp(
&column,
area.height,
area.y,
self.ocean_elapsed_ms,
phase_tag,
fingerprint,
);
for local_y in 0..area.height {
let protected = self
.lines
.get(usize::from(local_y))
.and_then(occupied_text_bounds);
let row_bg = ramp
.get(usize::from(local_y))
.copied()
.unwrap_or_else(|| column.color_at_y(area.y.saturating_add(local_y)));
for local_x in 0..area.width {
let is_protected = protected.is_some_and(|(start, end)| {
usize::from(local_x) >= start && usize::from(local_x) < end
});
let cell = &mut buf[(area.x + local_x, area.y + local_y)];
if !is_protected || cell.bg == self.background {
cell.set_bg(row_bg);
}
}
}
}
if self.ambient_life
&& let Some(inks) = self.ambient_inks
{
let cursor = crate::tui::ambient_life::AmbientCursor {
column: 0,
row: area.y.saturating_add(area.height / 2),
flee_elapsed_ms: self.fish_flee_elapsed_ms,
};
let whale = crate::tui::ambient_life::WhaleCameo {
elapsed_ms: self.ocean_column.and_then(|c| c.completion_elapsed_ms()),
anchor_x: area.x.saturating_add(area.width / 2),
anchor_y: area.y.saturating_add(area.height.saturating_mul(2) / 3),
};
let _ambient_stats = crate::tui::ambient_life::render_ambient_life(
area,
buf,
inks,
&self.lines,
self.ocean_elapsed_ms,
self.ocean_presence_f32(),
cursor,
whale,
);
if let Some(column) = self.ocean_column {
crate::tui::ambient_life::apply_caustic_shimmer(
area,
buf,
&column,
self.ocean_elapsed_ms,
self.ocean_animated,
&self.lines,
);
}
}
}
}
impl ChatWidget {
fn ocean_presence_f32(&self) -> f32 {
(f32::from(self.life_presence_fixed) / 1000.0).clamp(0.0, 1.0)
}
}
fn occupied_text_bounds(line: &Line<'_>) -> Option<(usize, usize)> {
crate::tui::ambient_life::occupied_text_bounds(line)
}
#[cfg(test)]
fn fish_flee_offset(elapsed_ms: u128) -> u16 {
crate::tui::ambient_life::fish_flee_offset(elapsed_ms)
}
#[cfg(test)]
fn fish_mark(facing_right: bool) -> &'static str {
if facing_right { "><>" } else { "<><" }
}
#[cfg(test)]
fn fish_heading(previous_x: u16, current_x: u16, next_x: u16, fallback_right: bool) -> bool {
if next_x != current_x {
next_x > current_x
} else if current_x != previous_x {
current_x > previous_x
} else {
fallback_right
}
}
fn jump_to_latest_button_rect(area: Rect, has_scrollbar: bool) -> Option<Rect> {
if area.width < JUMP_TO_LATEST_BUTTON_WIDTH + u16::from(has_scrollbar)
|| area.height < JUMP_TO_LATEST_BUTTON_HEIGHT
{
return None;
}
let scrollbar_gutter = u16::from(has_scrollbar);
Some(Rect {
x: area
.x
.saturating_add(area.width)
.saturating_sub(scrollbar_gutter)
.saturating_sub(JUMP_TO_LATEST_BUTTON_WIDTH),
y: area
.y
.saturating_add(area.height)
.saturating_sub(JUMP_TO_LATEST_BUTTON_HEIGHT),
width: JUMP_TO_LATEST_BUTTON_WIDTH,
height: JUMP_TO_LATEST_BUTTON_HEIGHT,
})
}
fn render_jump_to_latest_button(
area: Rect,
buf: &mut Buffer,
background: Color,
border: Color,
arrow: Color,
) {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border))
.style(Style::default().bg(background))
.render(area, buf);
let arrow_x = area.x.saturating_add(1);
let arrow_y = area.y.saturating_add(1);
buf[(arrow_x, arrow_y)]
.set_symbol("↓")
.set_style(Style::default().fg(arrow).add_modifier(Modifier::BOLD));
}
const COMPOSER_PROMPT_GUTTER_WIDTH: u16 = 2;
const COMPOSER_PANEL_MIN_WIDTH: u16 = 12;
fn enclosed_composer_panel_fits(show_panel: bool, area_width: u16, area_height: u16) -> bool {
show_panel && area_height >= 3 && area_width >= COMPOSER_PANEL_MIN_WIDTH
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ComposerContentGeometry {
pub(crate) text_area: Rect,
pub(crate) prompt_inset: u16,
}
impl ComposerContentGeometry {
#[must_use]
pub(crate) fn text_width(self) -> usize {
usize::from(self.text_area.width.max(1))
}
#[must_use]
fn prompt_padding(self) -> &'static str {
if self.prompt_inset == COMPOSER_PROMPT_GUTTER_WIDTH {
" "
} else {
""
}
}
#[must_use]
fn prompt_x(self) -> Option<u16> {
(self.prompt_inset > 0).then(|| self.text_area.x.saturating_sub(self.prompt_inset))
}
}
#[must_use]
pub(crate) fn composer_content_geometry(
inner_area: Rect,
history_search_active: bool,
) -> ComposerContentGeometry {
let prompt_inset = if !history_search_active
&& inner_area.width >= COMPOSER_PROMPT_GUTTER_WIDTH.saturating_add(1)
{
COMPOSER_PROMPT_GUTTER_WIDTH
} else {
0
};
ComposerContentGeometry {
text_area: Rect {
x: inner_area.x.saturating_add(prompt_inset),
y: inner_area.y,
width: inner_area.width.saturating_sub(prompt_inset),
height: inner_area.height,
},
prompt_inset,
}
}
pub struct ComposerWidget<'a> {
app: &'a App,
max_height: u16,
slash_menu_entries: &'a [SlashMenuEntry],
mention_menu_entries: &'a [String],
}
impl<'a> ComposerWidget<'a> {
pub fn new(
app: &'a App,
max_height: u16,
slash_menu_entries: &'a [SlashMenuEntry],
mention_menu_entries: &'a [String],
) -> Self {
Self {
app,
max_height,
slash_menu_entries,
mention_menu_entries,
}
}
fn active_menu_row_count(&self) -> usize {
if self.app.is_history_search_active() {
self.app.history_search_matches().len().max(1)
} else if !self.mention_menu_entries.is_empty() {
self.mention_menu_entries.len()
} else {
self.slash_menu_entries.len()
}
}
pub fn active_menu_reserved_rows(&self) -> usize {
let actual = self.active_menu_row_count();
if actual == 0 {
return 0;
}
if self.app.is_history_search_active() {
return actual;
}
actual.max(usize::from(self.max_height_cap()))
}
fn wants_enclosed_panel(&self) -> bool {
self.app.composer_border
}
pub(crate) fn has_panel(&self, area: Rect) -> bool {
enclosed_composer_panel_fits(self.wants_enclosed_panel(), area.width, area.height)
}
fn inner_area(&self, area: Rect) -> Rect {
if self.has_panel(area) {
Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.inner(area)
} else if area.height >= 2 {
Block::default().borders(Borders::TOP).inner(area)
} else {
area
}
}
fn mode_color(&self) -> Color {
match self.app.mode {
AppMode::Agent | AppMode::Auto | AppMode::Yolo => self.app.ui_theme.mode_agent,
AppMode::Plan => self.app.ui_theme.mode_plan,
AppMode::Operate => self.app.ui_theme.mode_operate,
}
}
fn max_height_cap(&self) -> u16 {
composer_max_height(self.app.composer_density)
}
}
impl Renderable for ComposerWidget<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let background = Style::default().bg(self.app.ui_theme.composer_bg);
let has_panel = self.has_panel(area);
let inner_area = self.inner_area(area);
let input_text = self.app.composer_display_input();
let input_cursor = self.app.composer_display_cursor();
let history_search_matches = if self.app.is_history_search_active() {
self.app.history_search_matches()
} else {
Vec::new()
};
let menu_lines = self.active_menu_row_count();
let menu_lines_for_budget = self.active_menu_reserved_rows().max(menu_lines);
let input_rows_budget =
composer_input_rows_budget(inner_area.height, menu_lines_for_budget);
let content_width = usize::from(inner_area.width.max(1));
let content_geometry =
composer_content_geometry(inner_area, self.app.is_history_search_active());
let input_content_width = content_geometry.text_width();
let (visible_lines, _cursor_row, _cursor_col, _scroll_offset, visible_char_indices) =
layout_input_with_scroll_and_char_indices(
input_text,
input_cursor,
input_content_width,
input_rows_budget,
);
if has_panel {
let hint_line = if self.app.is_history_search_active() {
Some(Line::from(vec![
Span::styled(
format!(
" {} ",
self.app.tr(crate::localization::MessageId::HistoryHintMove)
),
Style::default().fg(palette::TEXT_MUTED),
),
Span::styled(
format!(
"{} ",
self.app
.tr(crate::localization::MessageId::HistoryHintAccept)
),
Style::default().fg(palette::TEXT_MUTED),
),
Span::styled(
self.app
.tr(crate::localization::MessageId::HistoryHintRestore),
Style::default().fg(palette::TEXT_MUTED),
),
]))
} else if !self.slash_menu_entries.is_empty() {
Some(Line::from(Span::styled(
self.app
.tr(crate::localization::MessageId::ComposerSlashMenuHint),
Style::default().fg(self.app.ui_theme.text_hint),
)))
} else if !input_text.trim().is_empty() {
use crate::tui::app::{
ComposerSubmitAction, ComposerSubmitChord, SubmitDisposition,
};
let queue_count = self.app.queued_message_count();
let (label, color) =
match self.app.decide_composer_submit(ComposerSubmitChord::Enter) {
ComposerSubmitAction::Submit(SubmitDisposition::Immediate) => {
if queue_count > 0 {
(
Some(format!("↵ send ({queue_count} queued)")),
palette::WHALE_INFO,
)
} else {
(None, palette::TEXT_MUTED)
}
}
ComposerSubmitAction::Submit(SubmitDisposition::Queue) => {
if self.app.offline_mode {
let label = if self.app.onboarding_explore_offline {
"↵ offline queue · /provider connects".to_string()
} else {
"↵ offline queue".to_string()
};
(Some(label), palette::STATUS_WARNING)
} else if self.app.mode == crate::tui::app::AppMode::Operate {
let label = if queue_count > 0 {
format!(
"↵ queue task ({} waiting) · then ↵ steer",
queue_count.saturating_add(1)
)
} else {
"↵ queue task · then ↵ steer".to_string()
};
(Some(label), palette::WHALE_INFO)
} else {
let label = if queue_count > 0 {
format!(
"↵ queue ({} waiting) · then ↵ steer",
queue_count.saturating_add(1)
)
} else {
"↵ queue · then ↵ steer".to_string()
};
(Some(label), palette::TEXT_MUTED)
}
}
ComposerSubmitAction::Submit(SubmitDisposition::Steer) => {
(Some("↵ steering".to_string()), palette::WHALE_INFO)
}
ComposerSubmitAction::Submit(SubmitDisposition::QueueFollowUp) => (
Some(if self.app.mode == crate::tui::app::AppMode::Operate {
"↵ queued task · then ↵ steer".to_string()
} else {
"↵ queued · then ↵ steer".to_string()
}),
palette::TEXT_MUTED,
),
ComposerSubmitAction::SendQueuedNow => (
Some("↵ steer queued message".to_string()),
palette::WHALE_INFO,
),
ComposerSubmitAction::Noop => (None, palette::TEXT_MUTED),
};
label.map(|text| {
Line::from(vec![Span::styled(
format!(" {text} "),
Style::default().fg(color),
)])
})
} else {
None
};
let permission_color = match self.app.approval_mode {
ApprovalMode::Suggest | ApprovalMode::Never => self.app.ui_theme.permission_ask,
ApprovalMode::Auto => self.app.ui_theme.permission_auto_review,
ApprovalMode::Bypass => self.app.ui_theme.permission_full_access,
};
let mut top_border = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(permission_color))
.style(background);
if self.app.is_history_search_active() {
top_border = top_border.title(Line::from(Span::styled(
self.app
.tr(crate::localization::MessageId::HistorySearchTitle),
Style::default().fg(palette::TEXT_MUTED),
)));
}
if let Some(chip) = crate::tui::agent_focus::composer_chip_text(self.app) {
top_border = top_border.title_top(
Line::from(Span::styled(
format!(" {chip} "),
Style::default()
.fg(self.app.ui_theme.accent_action)
.add_modifier(Modifier::BOLD),
))
.right_aligned(),
);
}
top_border.render(area, buf);
let mut bottom_border = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(self.mode_color()))
.style(background);
if let Some(hint_line) = hint_line {
bottom_border = bottom_border.title_bottom(hint_line);
}
bottom_border.render(area, buf);
} else if area.height >= 2 {
let mut block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(self.app.ui_theme.border))
.style(background);
if let Some(chip) = crate::tui::agent_focus::composer_chip_text(self.app) {
block = block.title_top(
Line::from(Span::styled(
format!(" {chip} "),
Style::default()
.fg(self.app.ui_theme.accent_action)
.add_modifier(Modifier::BOLD),
))
.right_aligned(),
);
}
block.render(area, buf);
} else {
Block::default().style(background).render(area, buf);
}
let mut input_lines = Vec::new();
if input_text.is_empty() {
let (placeholder, style): (Cow<'_, str>, Style) = if let Some(ref suggestion) =
self.app.prompt_suggestion
&& !self.app.is_history_search_active()
{
(
Cow::Borrowed(suggestion.as_str()),
Style::default().fg(palette::TEXT_HINT),
)
} else {
(
composer_empty_hint_text(self.app),
Style::default().fg(self.app.ui_theme.text_soft),
)
};
input_lines.push(Line::from(vec![
Span::raw(content_geometry.prompt_padding()),
Span::styled(placeholder, style),
]));
} else if let Some((sel_start, sel_end)) = self.app.selection_range() {
let line_ranges: Vec<(usize, usize)> = visible_char_indices
.iter()
.map(|(start, text)| (*start, *start + text.chars().count()))
.collect();
for (line_text, (line_start, line_end)) in visible_lines.iter().zip(line_ranges.iter())
{
let mut spans = line_spans_with_selection(
line_text,
*line_start,
*line_end,
sel_start,
sel_end,
self.app.ui_theme.selection_bg,
);
if content_geometry.prompt_inset > 0 {
spans.insert(0, Span::raw(content_geometry.prompt_padding()));
}
input_lines.push(Line::from(spans));
}
} else {
for line in &visible_lines {
let mut spans = Vec::new();
if content_geometry.prompt_inset > 0 {
spans.push(Span::raw(content_geometry.prompt_padding()));
}
spans.push(Span::styled(
line.clone(),
Style::default().fg(palette::TEXT_PRIMARY),
));
input_lines.push(Line::from(spans));
}
}
let visual_rows = if input_text.is_empty() {
let hint: Option<Cow<'_, str>> = if let Some(ref suggestion) =
self.app.prompt_suggestion
&& !self.app.is_history_search_active()
{
Some(Cow::Borrowed(suggestion.as_str()))
} else {
Some(composer_empty_hint_text(self.app))
};
empty_composer_visual_rows(hint.as_deref(), input_content_width, input_rows_budget)
} else {
input_lines.len()
};
let top_padding = composer_top_padding(visual_rows, input_rows_budget);
let mut lines = Vec::new();
for _ in 0..top_padding {
lines.push(Line::from(""));
}
lines.extend(input_lines);
if self.app.is_history_search_active() {
if history_search_matches.is_empty() {
lines.push(Line::from(Span::styled(
self.app
.tr(crate::localization::MessageId::HistoryNoMatches),
Style::default().fg(palette::TEXT_MUTED),
)));
} else {
let selected = self
.app
.history_search_selected_index()
.min(history_search_matches.len().saturating_sub(1));
let menu_visible_rows = inner_area
.height
.saturating_sub(visual_rows as u16)
.saturating_sub(top_padding as u16)
.saturating_sub(1)
.max(1) as usize;
let menu_total = history_search_matches.len();
let menu_top = if menu_total <= menu_visible_rows {
0
} else {
let half = menu_visible_rows / 2;
if selected <= half {
0
} else if selected + half >= menu_total {
menu_total.saturating_sub(menu_visible_rows)
} else {
selected.saturating_sub(half)
}
};
let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
for (idx, entry) in history_search_matches
.iter()
.enumerate()
.take(menu_bottom)
.skip(menu_top)
{
let is_selected = idx == selected;
let style = if is_selected {
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
} else {
Style::default().fg(palette::TEXT_MUTED)
};
let marker = crate::tui::glyphs::selection_marker(is_selected);
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(marker, style),
Span::styled(" ", style),
Span::styled(entry.clone(), style),
]));
}
}
} else if !self.mention_menu_entries.is_empty() {
let selected = self
.app
.mention_menu_selected
.min(self.mention_menu_entries.len().saturating_sub(1));
let menu_visible_rows = inner_area
.height
.saturating_sub(visual_rows as u16)
.saturating_sub(top_padding as u16)
.saturating_sub(1)
.max(1) as usize;
let menu_total = self.mention_menu_entries.len();
let menu_top = if menu_total <= menu_visible_rows {
0
} else {
let half = menu_visible_rows / 2;
if selected <= half {
0
} else if selected + half >= menu_total {
menu_total.saturating_sub(menu_visible_rows)
} else {
selected.saturating_sub(half)
}
};
let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
for (idx, entry) in self
.mention_menu_entries
.iter()
.enumerate()
.take(menu_bottom)
.skip(menu_top)
{
let is_selected = idx == selected;
let style = if is_selected {
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
} else {
Style::default().fg(palette::TEXT_MUTED)
};
let marker = crate::tui::glyphs::selection_marker(is_selected);
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(marker, style),
Span::styled(" ", style),
Span::styled(format!("@{entry}"), style),
]));
}
} else if !self.slash_menu_entries.is_empty() {
let selected = self
.app
.slash_menu_selected
.min(self.slash_menu_entries.len().saturating_sub(1));
let menu_visible_rows = inner_area
.height
.saturating_sub(visual_rows as u16)
.saturating_sub(top_padding as u16)
.saturating_sub(1)
.max(1) as usize;
let menu_total = self.slash_menu_entries.len();
let menu_top = if menu_total <= menu_visible_rows {
0
} else {
let half = menu_visible_rows / 2;
if selected <= half {
0
} else if selected + half >= menu_total {
menu_total.saturating_sub(menu_visible_rows)
} else {
selected.saturating_sub(half)
}
};
let menu_bottom = (menu_top + menu_visible_rows).min(menu_total);
let label_width = self
.slash_menu_entries
.iter()
.take(menu_bottom)
.skip(menu_top)
.map(|e| {
if let Some(ref hint) = e.alias_hint {
format!("{} or /{}", e.name, hint).width()
} else {
e.name.width()
}
})
.max()
.unwrap_or(22)
.min(content_width.saturating_sub(4))
.max(8);
for (idx, entry) in self
.slash_menu_entries
.iter()
.enumerate()
.take(menu_bottom)
.skip(menu_top)
{
let is_selected = idx == selected;
let sel_style = if is_selected {
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
} else {
Style::default().fg(palette::TEXT_MUTED)
};
let marker = crate::tui::glyphs::selection_marker(is_selected);
let name_style = if entry.is_skill && !is_selected {
Style::default().fg(palette::WHALE_INFO)
} else {
sel_style
};
let desc_style = if is_selected {
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
} else {
Style::default().fg(palette::TEXT_DIM)
};
let display_name = if let Some(ref hint) = entry.alias_hint {
format!("{} or /{}", entry.name, hint)
} else {
entry.name.clone()
};
let name_display = {
let display_width: usize = display_name.width();
if display_width > label_width {
let mut s = String::new();
let mut w = 0;
for ch in display_name.chars() {
let cw = ch.width().unwrap_or(0);
if w + cw + 1 > label_width {
break;
}
s.push(ch);
w += cw;
}
s.push('…');
while s.width() < label_width {
s.push(' ');
}
s
} else {
let mut s = display_name;
while s.width() < label_width {
s.push(' ');
}
s
}
};
let skill_prefix = if entry.is_skill { "✦" } else { " " };
let prefix_display_width = 1 + 1 + skill_prefix.width() + label_width + 2;
let desc_capacity = content_width.saturating_sub(prefix_display_width);
let desc_display = {
let display_width: usize = entry.description.width();
if display_width > desc_capacity && desc_capacity > 0 {
let mut s = String::new();
let mut w = 0;
for ch in entry.description.chars() {
let cw = ch.width().unwrap_or(0);
if w + cw + 1 > desc_capacity {
break;
}
s.push(ch);
w += cw;
}
s.push('…');
s
} else {
entry.description.clone()
}
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(marker, sel_style),
Span::styled(skill_prefix, name_style),
Span::styled(name_display, name_style),
Span::styled(" ", desc_style),
Span::styled(desc_display, desc_style),
]));
}
}
let paragraph = Paragraph::new(lines)
.style(background)
.wrap(Wrap { trim: false });
paragraph.render(inner_area, buf);
if let Some(prompt_x) = content_geometry.prompt_x()
&& let Some((cursor_x, cursor_y)) = self.cursor_pos(area)
{
debug_assert!(cursor_x >= content_geometry.text_area.x);
buf[(prompt_x, cursor_y)]
.set_symbol("❯")
.set_style(Style::default().fg(self.app.ui_theme.accent_primary));
}
}
fn desired_height(&self, width: u16) -> u16 {
composer_height(
self.app.composer_display_input(),
width,
self.max_height.min(self.max_height_cap()),
self.active_menu_reserved_rows(),
self.app.composer_density,
self.wants_enclosed_panel(),
)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let inner_area = self.inner_area(area);
let input_text = self.app.composer_display_input();
let input_cursor = self.app.composer_display_cursor();
let content_geometry =
composer_content_geometry(inner_area, self.app.is_history_search_active());
let input_content_width = content_geometry.text_width();
let input_rows_budget =
composer_input_rows_budget(inner_area.height, self.active_menu_reserved_rows());
let (visible_lines, cursor_row, cursor_col) = layout_input(
input_text,
input_cursor,
input_content_width,
input_rows_budget,
);
let visual_rows = if input_text.is_empty() {
let hint: Option<Cow<'_, str>> = if let Some(ref suggestion) =
self.app.prompt_suggestion
&& !self.app.is_history_search_active()
{
Some(Cow::Borrowed(suggestion.as_str()))
} else {
Some(composer_empty_hint_text(self.app))
};
empty_composer_visual_rows(hint.as_deref(), input_content_width, input_rows_budget)
} else {
visible_lines.len()
};
let top_padding = composer_top_padding(visual_rows, input_rows_budget);
let cursor_x = content_geometry
.text_area
.x
.saturating_add(u16::try_from(cursor_col).unwrap_or(u16::MAX));
let cursor_y = inner_area
.y
.saturating_add(u16::try_from(top_padding + cursor_row).unwrap_or(u16::MAX));
if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
Some((cursor_x, cursor_y))
} else {
None
}
}
}
pub struct ApprovalWidget<'a> {
request: &'a ApprovalRequest,
view: &'a ApprovalView,
}
impl<'a> ApprovalWidget<'a> {
pub fn new(request: &'a ApprovalRequest, view: &'a ApprovalView) -> Self {
Self { request, view }
}
fn build_inline_content(&self, area: Rect) -> (Vec<Line<'static>>, Vec<Line<'static>>) {
let risk = self.request.risk;
let stakes = self.request.stakes();
let locale = self.view.locale();
let repo_law = self.request.is_repo_law_prompt();
let palette_colors = if repo_law {
repo_law_approval_palette()
} else {
approval_palette(stakes)
};
let critical = matches!(stakes, crate::tui::approval::ApprovalStakes::Critical);
let mut body: Vec<Line<'static>> = Vec::with_capacity(16);
body.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!(
" {} ",
if repo_law {
tr(locale, MessageId::ApprovalRepoLawBadge)
} else {
stakes_badge_text(stakes, locale)
}
),
Style::default()
.fg(palette::WHALE_BG)
.bg(palette_colors.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
if repo_law {
format!(
"{} · {}",
tr(locale, MessageId::ApprovalRepoLawTitle),
self.request.tool_name
)
} else {
self.request.tool_name.clone()
},
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
),
]));
if repo_law {
body.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"◆ ",
Style::default()
.fg(palette::STATUS_WARNING)
.add_modifier(Modifier::BOLD),
),
Span::styled(
tr(locale, MessageId::ApprovalRepoLawWarning),
Style::default()
.fg(palette::WHALE_ERROR)
.add_modifier(Modifier::BOLD),
),
]));
body.push(Line::from(vec![
Span::raw(" "),
Span::styled(
tr(locale, MessageId::ApprovalRepoLawRuleLabel),
Style::default().fg(palette::TEXT_HINT),
),
Span::styled(
self.request.description.clone(),
Style::default().fg(palette::TEXT_SECONDARY),
),
]));
}
let details = self.request.prominent_detail_items(locale);
if details.is_empty() {
push_params_detail_line(&mut body, self.request, locale, area.width);
} else {
let mut rendered_detail = false;
for detail in details.iter().take(4) {
let is_change_preview = matches!(detail.label.as_str(), "Preview" | "预览");
if let Some(shell_lines) = detail.shell_lines.as_deref() {
let command_width = area.width.saturating_sub(10) as usize;
let inline_shell_lines = prioritize_inline_shell_lines(
shell_lines,
is_change_preview,
area.height <= 24,
);
let max_rows = if is_change_preview {
if self.request.intent_summary.is_some() {
Some(3)
} else {
Some(5)
}
} else {
Some(8)
};
push_shell_command_lines(
&mut body,
&detail.label,
&inline_shell_lines,
command_width.max(20),
max_rows,
);
} else {
push_detail_line(&mut body, &detail.label, &detail.value);
}
rendered_detail = true;
}
if !rendered_detail {
push_params_detail_line(&mut body, self.request, locale, area.width);
}
}
if let Some(ref summary) = self.request.intent_summary {
let max_width = area.width.saturating_sub(14) as usize;
if max_width > 0 {
let intent_label = tr(locale, MessageId::ApprovalIntentLabel);
let summary_lines: Vec<&str> = summary.lines().collect();
let intent_lines = 3usize;
for (i, sline) in summary_lines.iter().take(intent_lines).enumerate() {
let prefix = if i == 0 {
intent_label.clone()
} else {
Cow::Borrowed(" ")
};
let truncated = crate::utils::truncate_with_ellipsis(sline, max_width, "...");
body.push(Line::from(vec![
Span::raw(" "),
Span::styled(
prefix,
if i == 0 {
Style::default().fg(palette::TEXT_HINT)
} else {
Style::default()
},
),
Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)),
]));
}
if summary_lines.len() > intent_lines {
let more = tr(locale, MessageId::ApprovalMoreLines)
.replace("{count}", &(summary_lines.len() - intent_lines).to_string());
body.push(Line::from(vec![
Span::raw(" "),
Span::styled(more, Style::default().fg(palette::TEXT_HINT)),
]));
}
}
}
if critical {
push_destructive_approval_semantics(&mut body, locale, false);
}
if critical || details.is_empty() {
body.push(Line::from(vec![
Span::raw(" "),
Span::styled(label_about(locale), Style::default().fg(palette::TEXT_HINT)),
Span::styled(
self.request.description_for_locale(locale),
Style::default().fg(palette::TEXT_BODY),
),
]));
}
if critical {
for impact in self.request.impacts_for_locale(locale).into_iter().take(4) {
body.push(Line::from(vec![
Span::raw(" "),
Span::styled(
label_impact(locale),
Style::default().fg(palette::TEXT_HINT),
),
Span::styled(impact, Style::default().fg(palette::TEXT_BODY)),
]));
}
let (cat_label, cat_color) = category_label_for(self.request.category, locale);
body.push(Line::from(vec![
Span::raw(" "),
Span::styled(label_type(locale), Style::default().fg(palette::TEXT_HINT)),
Span::styled(
cat_label,
Style::default().fg(cat_color).add_modifier(Modifier::BOLD),
),
]));
}
if let Some(preview) = self.request.ask_rule_save_preview() {
push_permission_rule_save_preview(
&mut body,
&preview,
palette_colors.shortcut,
area.width,
);
}
if let Some(preview) = self.request.allow_rule_save_preview() {
push_permission_rule_save_preview(
&mut body,
&preview,
palette_colors.shortcut,
area.width,
);
}
let controls = build_approval_controls(
self.request,
self.view,
risk,
locale,
palette_colors.accent,
palette_colors.shortcut,
);
(body, controls)
}
pub(crate) fn inline_region(&self, area: Rect) -> Rect {
if area.width == 0 || area.height == 0 {
return Rect {
x: area.x,
y: area.y.saturating_add(area.height),
width: 0,
height: 0,
};
}
if self.view.collapsed {
let h = area.height.min(1);
return Rect {
x: area.x,
y: area.y.saturating_add(area.height.saturating_sub(h)),
width: area.width,
height: h,
};
}
let (body, controls) = self.build_inline_content(area);
inline_region_for(area, &body, &controls)
}
}
impl Renderable for ApprovalWidget<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
if self.view.collapsed {
self.view.set_mouse_hitboxes(Vec::new());
let bar_y = area.y.saturating_add(area.height.saturating_sub(1));
let bar_area = Rect::new(area.x, bar_y, area.width, 1);
Clear.render(bar_area, buf);
let stakes = self.request.stakes();
let repo_law = self.request.is_repo_law_prompt();
let palette_colors = if repo_law {
repo_law_approval_palette()
} else {
approval_palette(stakes)
};
let summary = format!(
" {} — {} [Tab to expand] ",
if repo_law {
tr(self.view.locale(), MessageId::ApprovalRepoLawTitle)
} else {
Cow::Borrowed(self.request.tool_name.as_str())
},
if repo_law {
tr(self.view.locale(), MessageId::ApprovalRepoLawBadge)
} else {
stakes_badge_text(stakes, self.view.locale())
},
);
let line = Line::from(Span::styled(
summary,
Style::default()
.fg(palette::WHALE_BG)
.bg(palette_colors.accent)
.add_modifier(Modifier::BOLD),
));
Paragraph::new(line).render(bar_area, buf);
return;
}
let stakes = self.request.stakes();
let repo_law = self.request.is_repo_law_prompt();
let palette_colors = if repo_law {
repo_law_approval_palette()
} else {
approval_palette(stakes)
};
let (body, controls) = self.build_inline_content(area);
let region = inline_region_for(area, &body, &controls);
if region.width == 0 || region.height == 0 {
return;
}
Clear.render(region, buf);
Block::default()
.style(Style::default().bg(palette::WHALE_BG))
.render(region, buf);
let rule_glyph = if repo_law { "═" } else { "─" };
let rule: String = rule_glyph.repeat(region.width as usize);
buf.set_string(
region.x,
region.y,
&rule,
Style::default().fg(palette_colors.border),
);
let inner_top = region.y.saturating_add(1);
let inner_height = region.height.saturating_sub(1);
let control_rows = measure_wrapped_rows(&controls, region.width).min(inner_height);
let body_height = inner_height.saturating_sub(control_rows);
let body_rect = Rect {
x: region.x,
y: inner_top,
width: region.width,
height: body_height,
};
let control_rect = Rect {
x: region.x,
y: inner_top.saturating_add(body_height),
width: region.width,
height: control_rows,
};
let mut hitboxes = Vec::new();
let option_count =
approval_options_for_request(self.request, self.request.risk, self.view.locale()).len();
for index in 0..option_count {
let first_line = 1 + index;
let y_offset = measure_wrapped_rows(&controls[..first_line], region.width);
let next_offset = measure_wrapped_rows(&controls[..first_line + 1], region.width);
let y = control_rect.y.saturating_add(y_offset);
let height = next_offset.saturating_sub(y_offset).min(
control_rect
.y
.saturating_add(control_rect.height)
.saturating_sub(y),
);
if height > 0 {
hitboxes.push(Rect::new(control_rect.x, y, control_rect.width, height));
}
}
self.view.set_mouse_hitboxes(hitboxes);
let body_rows = measure_wrapped_rows(&body, region.width);
if body_rows > body_height && body_height > 0 {
let shown = body_height.saturating_sub(1);
if shown > 0 {
Paragraph::new(body).wrap(Wrap { trim: false }).render(
Rect {
height: shown,
..body_rect
},
buf,
);
}
buf.set_string(
region.x,
body_rect.y.saturating_add(shown),
approval_truncation_hint(self.view.locale()),
Style::default().fg(palette::TEXT_HINT),
);
} else {
Paragraph::new(body)
.wrap(Wrap { trim: false })
.render(body_rect, buf);
}
Paragraph::new(controls)
.wrap(Wrap { trim: false })
.render(control_rect, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
fn inline_region_for(area: Rect, body: &[Line<'static>], controls: &[Line<'static>]) -> Rect {
if area.width == 0 || area.height == 0 {
return Rect {
x: area.x,
y: area.y.saturating_add(area.height),
width: 0,
height: 0,
};
}
let width = area.width;
let body_rows = measure_wrapped_rows(body, width);
let control_rows = measure_wrapped_rows(controls, width);
let desired = 1u16.saturating_add(body_rows).saturating_add(control_rows);
let controls_floor = 1u16.saturating_add(control_rows).min(area.height);
let preview_rows = if area.height >= 16 {
body_rows.min(4)
} else {
0
};
let preview_floor = controls_floor.saturating_add(preview_rows).min(area.height);
let preferred_cap = area.height.div_ceil(2);
let short_frame_cap = area.height.saturating_mul(4).div_ceil(5);
let max_height = preferred_cap
.max(preview_floor.min(short_frame_cap))
.max(controls_floor)
.min(area.height);
let min_height = controls_floor;
let height = desired.clamp(min_height, max_height);
Rect {
x: area.x,
y: area.y.saturating_add(area.height.saturating_sub(height)),
width,
height,
}
}
fn measure_wrapped_rows(lines: &[Line<'static>], width: u16) -> u16 {
if width == 0 {
return lines.len() as u16;
}
let rows = Paragraph::new(lines.to_vec())
.wrap(Wrap { trim: false })
.line_count(width);
u16::try_from(rows).unwrap_or(u16::MAX)
}
fn build_approval_controls(
request: &ApprovalRequest,
view: &ApprovalView,
risk: RiskLevel,
locale: Locale,
accent: Color,
shortcut: Color,
) -> Vec<Line<'static>> {
let mut controls: Vec<Line<'static>> = Vec::with_capacity(6);
controls.push(Line::from(vec![
Span::raw(" "),
Span::styled(
approval_proceed_question(locale),
Style::default()
.fg(palette::TEXT_BODY)
.add_modifier(Modifier::BOLD),
),
]));
let options = approval_options_for_request(request, risk, locale);
for (i, opt) in options.iter().enumerate() {
let is_selected = i == view.selected();
let label_color = if opt.dangerous {
accent
} else {
palette::TEXT_BODY
};
let option_style = approval_option_style(is_selected, label_color);
let shortcut_style = approval_option_style(is_selected, shortcut);
let lead = if is_selected {
Span::styled("\u{276f} ", approval_selected_style())
} else {
Span::raw(" ")
};
controls.push(Line::from(vec![
lead,
Span::styled(
format!("[{}] ", opt.key_hint),
shortcut_style.add_modifier(Modifier::BOLD),
),
Span::styled(opt.label.to_string(), option_style),
]));
}
controls.push(Line::from(vec![
Span::raw(" "),
Span::styled(
footer_controls(locale),
Style::default().fg(palette::TEXT_MUTED),
),
if request.can_save_ask_rule() {
Span::styled(save_ask_rule_hint(locale), Style::default().fg(shortcut))
} else {
Span::raw("")
},
]));
controls
}
fn approval_proceed_question(locale: Locale) -> &'static str {
match locale {
Locale::ZhHans => "是否继续?",
_ => "Do you want to proceed?",
}
}
fn approval_truncation_hint(locale: Locale) -> Cow<'static, str> {
let details = crate::tui::shell_key_routing::tool_details_chord();
Cow::Owned(tr(locale, MessageId::ApprovalTruncationHint).replace("{details}", details.as_ref()))
}
struct ApprovalColors {
border: Color,
accent: Color,
shortcut: Color,
}
fn approval_palette(stakes: crate::tui::approval::ApprovalStakes) -> ApprovalColors {
use crate::tui::approval::ApprovalStakes;
match stakes {
ApprovalStakes::Routine => ApprovalColors {
border: palette::BORDER_COLOR,
accent: palette::WHALE_HUMAN,
shortcut: palette::WHALE_INFO,
},
ApprovalStakes::Elevated => ApprovalColors {
border: palette::WHALE_HUMAN,
accent: palette::WHALE_HUMAN,
shortcut: palette::WHALE_INFO,
},
ApprovalStakes::Critical => ApprovalColors {
border: palette::WHALE_ERROR,
accent: palette::WHALE_ERROR,
shortcut: palette::STATUS_WARNING,
},
}
}
fn repo_law_approval_palette() -> ApprovalColors {
ApprovalColors {
border: palette::STATUS_WARNING,
accent: palette::WHALE_ERROR,
shortcut: palette::STATUS_WARNING,
}
}
fn approval_selected_style() -> Style {
menu_style::selected_row_style()
}
fn approval_option_style(is_selected: bool, color: Color) -> Style {
if is_selected {
approval_selected_style()
} else {
Style::default().fg(color)
}
}
fn stakes_badge_text(
stakes: crate::tui::approval::ApprovalStakes,
locale: Locale,
) -> Cow<'static, str> {
use crate::tui::approval::ApprovalStakes;
match stakes {
ApprovalStakes::Routine => tr(locale, MessageId::ApprovalRiskReview),
ApprovalStakes::Elevated => tr(locale, MessageId::ApprovalRiskElevated),
ApprovalStakes::Critical => tr(locale, MessageId::ApprovalRiskDestructive),
}
}
fn category_label_for(category: ToolCategory, locale: Locale) -> (Cow<'static, str>, Color) {
let label = match category {
ToolCategory::Safe => tr(locale, MessageId::ApprovalCategorySafe),
ToolCategory::FileWrite => tr(locale, MessageId::ApprovalCategoryFileWrite),
ToolCategory::Shell => tr(locale, MessageId::ApprovalCategoryShell),
ToolCategory::Network => tr(locale, MessageId::ApprovalCategoryNetwork),
ToolCategory::McpRead => tr(locale, MessageId::ApprovalCategoryMcpRead),
ToolCategory::McpAction => tr(locale, MessageId::ApprovalCategoryMcpAction),
ToolCategory::Agent => tr(locale, MessageId::ApprovalCategoryAgent),
ToolCategory::Unknown => tr(locale, MessageId::ApprovalCategoryUnknown),
};
let color = match category {
ToolCategory::Safe => palette::STATUS_SUCCESS,
ToolCategory::FileWrite => palette::STATUS_WARNING,
ToolCategory::Shell => palette::STATUS_ERROR,
ToolCategory::Network => palette::STATUS_WARNING,
ToolCategory::McpRead => palette::WHALE_INFO,
ToolCategory::McpAction => palette::STATUS_WARNING,
ToolCategory::Agent => palette::WHALE_INFO,
ToolCategory::Unknown => palette::STATUS_ERROR,
};
(label, color)
}
fn label_type(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalFieldType)
}
fn label_about(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalFieldAbout)
}
fn label_impact(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalFieldImpact)
}
fn label_params(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalFieldParams)
}
fn push_detail_line(lines: &mut Vec<Line<'static>>, label: &str, value: &str) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{label:<7} "),
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
),
Span::styled(value.to_string(), Style::default().fg(palette::TEXT_BODY)),
]));
}
fn push_params_detail_line(
lines: &mut Vec<Line<'static>>,
request: &ApprovalRequest,
locale: Locale,
card_width: u16,
) {
let params_str = request.params_display();
let params_width = card_width.saturating_sub(14) as usize;
let params_truncated =
crate::utils::truncate_with_ellipsis(¶ms_str, params_width.max(20), "...");
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
label_params(locale),
Style::default().fg(palette::TEXT_HINT),
),
Span::styled(
params_truncated,
Style::default().fg(palette::TEXT_SECONDARY),
),
]));
}
fn push_permission_rule_save_preview(
lines: &mut Vec<Line<'static>>,
preview: &crate::tui::approval::PermissionRuleSavePreview,
shortcut: Color,
card_width: u16,
) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"Save: ",
Style::default().fg(shortcut).add_modifier(Modifier::BOLD),
),
Span::styled(preview.summary(), Style::default().fg(palette::TEXT_BODY)),
]));
let entry_width = card_width.saturating_sub(10) as usize;
let entries = preview.entries.join("; ");
let truncated = crate::utils::truncate_with_ellipsis(&entries, entry_width.max(20), "...");
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(truncated, Style::default().fg(palette::TEXT_SECONDARY)),
]));
if preview.omitted > 0 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("... {} more", preview.omitted),
Style::default().fg(palette::TEXT_HINT),
),
]));
}
}
fn push_shell_command_lines(
lines: &mut Vec<Line<'static>>,
label: &str,
command_lines: &[String],
command_width: usize,
max_rows: Option<usize>,
) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{label}:"),
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
),
]));
let mut rendered = 0usize;
for line in command_lines {
for wrapped in wrap_text(line, command_width) {
if max_rows.is_some_and(|limit| rendered >= limit) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
"...",
Style::default()
.fg(palette::TEXT_HINT)
.add_modifier(Modifier::BOLD),
),
]));
return;
}
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
wrapped,
Style::default()
.fg(palette::TEXT_BODY)
.add_modifier(Modifier::BOLD),
),
]));
rendered += 1;
}
}
}
fn prioritize_inline_shell_lines(
command_lines: &[String],
is_change_preview: bool,
compact: bool,
) -> Vec<String> {
if !compact || command_lines.len() < 2 {
return command_lines.to_vec();
}
let representative = if is_change_preview {
command_lines
.iter()
.enumerate()
.max_by_key(|(index, line)| (preview_line_priority(line), std::cmp::Reverse(*index)))
.map(|(index, _)| index)
} else {
command_lines
.iter()
.enumerate()
.max_by_key(|(index, line)| (command_line_priority(line), std::cmp::Reverse(*index)))
.map(|(index, _)| index)
};
let Some(representative) = representative.filter(|index| *index > 0) else {
return command_lines.to_vec();
};
let mut projected = Vec::with_capacity(command_lines.len());
projected.push(command_lines[representative].clone());
projected.extend(
command_lines
.iter()
.enumerate()
.filter(|(index, _)| *index != representative)
.map(|(_, line)| line.clone()),
);
projected
}
fn preview_line_priority(line: &str) -> u8 {
let trimmed = line.trim_start();
if trimmed.starts_with('+') && !trimmed.starts_with("+++") {
4
} else if trimmed.starts_with('-') && !trimmed.starts_with("---") {
3
} else if trimmed.starts_with("@@") {
2
} else if trimmed.starts_with("diff ")
|| trimmed.starts_with("---")
|| trimmed.starts_with("+++")
{
0
} else {
1
}
}
fn command_line_priority(line: &str) -> u8 {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return 0;
}
let tokens = trimmed
.split(|ch: char| ch.is_whitespace() || matches!(ch, ';' | '|' | '&' | '(' | ')'))
.filter(|token| !token.is_empty())
.map(|token| token.rsplit('/').next().unwrap_or(token))
.collect::<Vec<_>>();
if tokens.iter().any(|token| {
matches!(
*token,
"rm" | "rmdir"
| "unlink"
| "mv"
| "dd"
| "chmod"
| "chown"
| "kill"
| "pkill"
| "shutdown"
| "reboot"
| "mkfs"
)
}) || tokens.windows(2).any(|pair| {
matches!(
pair,
["git", "push"] | ["cargo", "publish"] | ["npm", "publish"]
)
}) || trimmed.contains('>')
{
return 4;
}
let first = tokens.first().copied().unwrap_or_default();
if matches!(
first,
"cd" | "pushd" | "popd" | "set" | "export" | "unset" | "pwd" | ":" | "true"
) {
1
} else if matches!(first, "echo" | "printf") {
2
} else {
3
}
}
fn push_destructive_approval_semantics(
lines: &mut Vec<Line<'static>>,
locale: Locale,
compact: bool,
) {
if compact {
let (label, value) = destructive_approval_compact_semantics(locale);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(label, Style::default().fg(palette::TEXT_HINT)),
Span::styled(value, Style::default().fg(palette::TEXT_SECONDARY)),
]));
return;
}
for (label, value) in destructive_approval_semantics(locale) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(label, Style::default().fg(palette::TEXT_HINT)),
Span::styled(value, Style::default().fg(palette::TEXT_SECONDARY)),
]));
}
}
fn destructive_approval_compact_semantics(locale: Locale) -> (&'static str, &'static str) {
match locale {
Locale::ZhHans => ("规则: ", "批准策略要求确认;拒绝跳过本次,Esc 中止整轮。"),
_ => (
"Policy: ",
"Approval policy requires review; d denies, Esc aborts.",
),
}
}
fn destructive_approval_semantics(locale: Locale) -> [(&'static str, &'static str); 2] {
match locale {
Locale::ZhHans => [
(
"规则: ",
"当前批准策略、审查规则或显式询问规则要求用户确认。",
),
("取消: ", "拒绝只跳过本次工具调用;Esc 会中止整轮。"),
],
_ => [
(
"Policy: ",
"The active approval policy, a review rule, or an explicit ask-rule requires confirmation.",
),
(
"Cancel: ",
"Deny rejects only this tool call; Esc aborts the whole turn.",
),
],
}
}
fn footer_controls(locale: Locale) -> Cow<'static, str> {
let details = crate::tui::shell_key_routing::tool_details_chord();
Cow::Owned(tr(locale, MessageId::ApprovalControlsHint).replace("{details}", details.as_ref()))
}
fn save_ask_rule_hint(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalSaveAskRuleHint)
}
#[derive(Clone)]
struct ApprovalOptionRow {
label: Cow<'static, str>,
key_hint: &'static str,
dangerous: bool,
}
fn approval_options_for(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 4] {
let dangerous = matches!(risk, RiskLevel::Destructive);
[
ApprovalOptionRow {
label: option_approve_once(locale),
key_hint: "1 / y",
dangerous,
},
ApprovalOptionRow {
label: option_approve_always(locale),
key_hint: "2 / a",
dangerous,
},
ApprovalOptionRow {
label: option_deny(locale),
key_hint: "3 / d / n",
dangerous: false,
},
ApprovalOptionRow {
label: option_abort(locale),
key_hint: "Esc",
dangerous: false,
},
]
}
fn workflow_approval_options(risk: RiskLevel, locale: Locale) -> [ApprovalOptionRow; 3] {
let dangerous = matches!(risk, RiskLevel::Destructive);
[
ApprovalOptionRow {
label: workflow_option_approve(locale),
key_hint: "1 / y",
dangerous,
},
ApprovalOptionRow {
label: workflow_option_edit_plan(locale),
key_hint: "2 / e",
dangerous: false,
},
ApprovalOptionRow {
label: workflow_option_cancel(locale),
key_hint: "3 / Esc",
dangerous: false,
},
]
}
fn approval_options_for_request(
request: &ApprovalRequest,
risk: RiskLevel,
locale: Locale,
) -> Vec<ApprovalOptionRow> {
if request.tool_name == "workflow" {
workflow_approval_options(risk, locale).to_vec()
} else {
let mut options = approval_options_for(risk, locale).to_vec();
if request.can_save_allow_rule() {
options.insert(
2,
ApprovalOptionRow {
label: tr(locale, MessageId::ApprovalOptionAllowExactRepo),
key_hint: "p",
dangerous: false,
},
);
}
options
}
}
fn workflow_option_approve(locale: Locale) -> Cow<'static, str> {
match locale {
Locale::ZhHans => Cow::Borrowed("批准"),
_ => Cow::Borrowed("Approve"),
}
}
fn workflow_option_edit_plan(locale: Locale) -> Cow<'static, str> {
match locale {
Locale::ZhHans => Cow::Borrowed("编辑计划"),
_ => Cow::Borrowed("Edit plan"),
}
}
fn workflow_option_cancel(locale: Locale) -> Cow<'static, str> {
match locale {
Locale::ZhHans => Cow::Borrowed("取消"),
_ => Cow::Borrowed("Cancel"),
}
}
fn option_approve_once(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalOptionApproveOnce)
}
fn option_approve_always(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalOptionApproveAlways)
}
fn option_deny(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalOptionDeny)
}
fn option_abort(locale: Locale) -> Cow<'static, str> {
tr(locale, MessageId::ApprovalOptionAbortTurn)
}
pub struct ElevationWidget<'a> {
request: &'a ElevationRequest,
selected: usize,
locale: Locale,
hitboxes: Option<&'a std::cell::RefCell<Vec<Rect>>>,
}
impl<'a> ElevationWidget<'a> {
#[allow(dead_code)]
pub fn new(request: &'a ElevationRequest, selected: usize, locale: Locale) -> Self {
Self {
request,
selected,
locale,
hitboxes: None,
}
}
pub fn new_with_hitboxes(
request: &'a ElevationRequest,
selected: usize,
locale: Locale,
hitboxes: &'a std::cell::RefCell<Vec<Rect>>,
) -> Self {
Self {
request,
selected,
locale,
hitboxes: Some(hitboxes),
}
}
}
impl Renderable for ElevationWidget<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
use crate::localization::MessageId;
use crate::localization::tr;
let popup_width = 70.min(area.width.saturating_sub(4));
let popup_height = 22.min(area.height.saturating_sub(4));
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
Clear.render(popup_area, buf);
let mut lines = vec![
Line::from(""),
Line::from(vec![Span::styled(
tr(self.locale, MessageId::ElevationTitleSandboxDenied),
Style::default()
.fg(palette::STATUS_ERROR)
.add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::raw(tr(self.locale, MessageId::ElevationFieldTool)),
Span::styled(
&self.request.tool_name,
Style::default()
.fg(palette::WHALE_INFO)
.add_modifier(Modifier::BOLD),
),
]),
];
if let Some(ref command) = self.request.command {
let cmd_display = crate::utils::truncate_with_ellipsis(command, 45, "...");
lines.push(Line::from(vec![
Span::raw(tr(self.locale, MessageId::ElevationFieldCmd)),
Span::styled(cmd_display, Style::default().fg(palette::TEXT_MUTED)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(tr(self.locale, MessageId::ElevationFieldReason)),
Span::styled(
&self.request.denial_reason,
Style::default().fg(palette::STATUS_WARNING),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
tr(self.locale, MessageId::ElevationImpactHeader),
Style::default().fg(palette::TEXT_MUTED),
)));
if self
.request
.options
.iter()
.any(|option| matches!(option, ElevationOption::WithNetwork))
{
lines.push(Line::from(Span::styled(
tr(self.locale, MessageId::ElevationImpactNetwork),
Style::default().fg(palette::TEXT_PRIMARY),
)));
}
if self
.request
.options
.iter()
.any(|option| matches!(option, ElevationOption::WithWriteAccess(_)))
{
lines.push(Line::from(Span::styled(
tr(self.locale, MessageId::ElevationImpactWrite),
Style::default().fg(palette::TEXT_PRIMARY),
)));
}
lines.push(Line::from(Span::styled(
tr(self.locale, MessageId::ElevationImpactFullAccess),
Style::default().fg(palette::TEXT_PRIMARY),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
tr(self.locale, MessageId::ElevationPromptProceed),
Style::default().fg(palette::TEXT_MUTED),
)));
lines.push(Line::from(""));
let option_start = lines.len();
for (i, option) in self.request.options.iter().enumerate() {
let is_selected = i == self.selected;
let style = if is_selected {
menu_style::selected_row_bg_style().fg(palette::SELECTION_TEXT)
} else {
Style::default()
};
let (key, label_id, desc_id) = match option {
ElevationOption::WithNetwork => (
"n",
MessageId::ElevationOptionNetwork,
MessageId::ElevationOptionNetworkDesc,
),
ElevationOption::WithWriteAccess(_) => (
"w",
MessageId::ElevationOptionWrite,
MessageId::ElevationOptionWriteDesc,
),
ElevationOption::FullAccess => (
"f",
MessageId::ElevationOptionFullAccess,
MessageId::ElevationOptionFullAccessDesc,
),
ElevationOption::Abort => (
"a",
MessageId::ElevationOptionAbort,
MessageId::ElevationOptionAbortDesc,
),
};
let label_color = match option {
ElevationOption::Abort => palette::TEXT_MUTED,
ElevationOption::FullAccess => palette::STATUS_ERROR,
_ => palette::TEXT_PRIMARY,
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("[{key}] "),
Style::default().fg(palette::STATUS_SUCCESS),
),
Span::styled(tr(self.locale, label_id), style.fg(label_color)),
]));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
tr(self.locale, desc_id),
Style::default().fg(palette::TEXT_MUTED),
),
]));
}
let title = tr(self.locale, MessageId::ElevationTitleRequired);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::WHALE_BG))
.padding(Padding::uniform(1));
if let Some(hitboxes) = self.hitboxes {
hitboxes.borrow_mut().clear();
let content = block.inner(popup_area);
for i in 0..self.request.options.len() {
let y = content
.y
.saturating_add(u16::try_from(option_start + i * 2).unwrap_or(u16::MAX));
let height = 2u16.min(content.y.saturating_add(content.height).saturating_sub(y));
if height > 0 {
hitboxes
.borrow_mut()
.push(Rect::new(content.x, y, content.width, height));
}
}
}
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
paragraph.render(popup_area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) {
let Some((start, end)) = app.viewport.transcript_selection.ordered_endpoints() else {
return;
};
let selection_style = Style::default()
.bg(app.ui_theme.selection_bg)
.fg(palette::SELECTION_TEXT);
for (idx, line) in lines.iter_mut().enumerate() {
let line_index = top + idx;
if line_index < start.line_index || line_index > end.line_index {
continue;
}
let (col_start, col_end) = if start.line_index == end.line_index {
(start.column, end.column)
} else if line_index == start.line_index {
(start.column, usize::MAX)
} else if line_index == end.line_index {
(0, end.column)
} else {
(0, usize::MAX)
};
if col_start == 0 && col_end == usize::MAX {
for span in &mut line.spans {
span.style = span.style.patch(selection_style);
}
continue;
}
line.spans = apply_selection_to_line(line, col_start, col_end, selection_style);
}
}
fn apply_detail_target_highlight(
lines: &mut [Line<'static>],
top: usize,
target_cell: usize,
line_meta: &[TranscriptLineMeta],
original_index_map: &[usize],
) {
let highlight_bg = Color::Reset;
for (idx, line) in lines.iter_mut().enumerate() {
let line_index = top + idx;
if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
&& original_index_map
.get(*cell_index)
.copied()
.unwrap_or(*cell_index)
== target_cell
{
for span in &mut line.spans {
span.style = span.style.bg(highlight_bg);
}
}
}
}
fn apply_send_flash(
lines: &mut [Line<'static>],
top: usize,
history: &[HistoryCell],
line_meta: &[TranscriptLineMeta],
original_index_map: &[usize],
) {
let last_user_cell = history
.iter()
.rposition(|cell| matches!(cell, HistoryCell::User { .. }));
let Some(target_cell) = last_user_cell else {
return;
};
let flash_bg = palette::SURFACE_TOOL_ACTIVE;
for (idx, line) in lines.iter_mut().enumerate() {
let line_index = top + idx;
if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
&& original_index_map
.get(*cell_index)
.copied()
.unwrap_or(*cell_index)
== target_cell
{
for span in &mut line.spans {
span.style = span.style.bg(flash_bg);
}
}
}
}
fn apply_selection_to_line(
line: &Line<'static>,
col_start: usize,
col_end: usize,
selection_style: Style,
) -> Vec<Span<'static>> {
let mut result = Vec::with_capacity(line.spans.len().saturating_add(2));
let mut current_col = 0usize;
for span in &line.spans {
let span_text: &str = span.content.as_ref();
let span_width = text_display_width(span_text);
let span_end = current_col.saturating_add(span_width);
if span_end <= col_start || current_col >= col_end {
result.push(span.clone());
} else if current_col >= col_start && span_end <= col_end {
result.push(Span::styled(
span.content.clone(),
span.style.patch(selection_style),
));
} else {
let mut before = String::new();
let mut selected = String::new();
let mut after = String::new();
let mut grapheme_col = current_col;
for grapheme in span_text.graphemes(true) {
let grapheme_width = grapheme_display_width(grapheme);
let grapheme_start = grapheme_col;
let grapheme_end = grapheme_col.saturating_add(grapheme_width);
if grapheme_end <= col_start {
before.push_str(grapheme);
} else if grapheme_start >= col_end {
after.push_str(grapheme);
} else {
selected.push_str(grapheme);
}
grapheme_col = grapheme_end;
}
if !before.is_empty() {
result.push(Span::styled(before, span.style));
}
if !selected.is_empty() {
result.push(Span::styled(selected, span.style.patch(selection_style)));
}
if !after.is_empty() {
result.push(Span::styled(after, span.style));
}
}
current_col = span_end;
}
result
}
pub(crate) fn should_render_empty_state(app: &App) -> bool {
let active_is_empty = app
.active_cell
.as_ref()
.is_none_or(crate::tui::active_cell::ActiveCell::is_empty);
app.history.is_empty()
&& active_is_empty
&& !app.is_loading
&& !app.is_compacting
&& !app.is_purging
&& !app.attention_hold_active()
&& !app
.task_panel
.iter()
.any(|task| task.kind == crate::tui::app::TaskPanelEntryKind::Background)
&& !app
.todos
.try_lock()
.map(|todos| !todos.snapshot().is_empty())
.unwrap_or(true)
&& app.hunt.quarry.is_none()
&& app.paused_quarry.is_none()
}
fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
crate::tui::underwater::empty_state_lines(app, area)
}
pub fn composer_input_rows_budget(inner_height: u16, extra_lines: usize) -> usize {
usize::from(inner_height).saturating_sub(extra_lines).max(1)
}
fn composer_top_padding(content_lines: usize, rows_budget: usize) -> usize {
crate::tui::composer_chrome::top_padding(content_lines, rows_budget)
}
#[cfg(test)]
const COMPOSER_PLACEHOLDER: &str = "Write a task or use /.";
#[cfg(test)]
fn placeholder_visual_lines(content_width: usize) -> usize {
placeholder_visual_lines_for(COMPOSER_PLACEHOLDER, content_width)
}
#[cfg(test)]
fn placeholder_visual_lines_for(placeholder: &str, content_width: usize) -> usize {
wrap_text(placeholder, content_width).len().max(1)
}
pub(crate) fn composer_empty_hint_text(app: &App) -> Cow<'static, str> {
if let Some(placeholder) = crate::tui::agent_focus::composer_placeholder(app) {
Cow::Owned(placeholder)
} else if app.is_history_search_active() {
app.tr(crate::localization::MessageId::HistorySearchPlaceholder)
} else if app.mode == crate::tui::app::AppMode::Operate {
app.tr(crate::localization::MessageId::ComposerOperatePlaceholder)
} else {
app.tr(crate::localization::MessageId::ComposerPlaceholder)
}
}
pub(crate) fn empty_composer_visual_rows(
_hint: Option<&str>,
_content_width: usize,
_rows_budget: usize,
) -> usize {
1
}
fn composer_max_height(density: ComposerDensity) -> u16 {
crate::tui::composer_chrome::ComposerChrome::for_density(density, false).max_total_rows
}
fn composer_height(
input: &str,
area_width: u16,
available_height: u16,
extra_lines: usize,
density: ComposerDensity,
show_panel: bool,
) -> u16 {
let has_panel = enclosed_composer_panel_fits(show_panel, area_width, available_height);
let content_width = usize::from(
area_width
.saturating_sub(COMPOSER_PROMPT_GUTTER_WIDTH)
.max(1),
);
let mut line_count = wrap_input_lines(input, content_width).len();
if line_count == 0 {
line_count = 1;
}
crate::tui::composer_chrome::desired_height(
line_count,
extra_lines,
available_height,
density,
has_panel,
)
}
pub(crate) struct SlashMenuEntry {
pub name: String,
pub description: String,
pub is_skill: bool,
pub alias_hint: Option<String>,
}
fn fuzzy_chars_in_order(needle: &str, haystack: &str) -> bool {
let mut chars = needle.chars();
let mut current = match chars.next() {
Some(c) => c,
None => return true,
};
for ch in haystack.chars() {
if ch == current {
if let Some(next) = chars.next() {
current = next;
} else {
return true;
}
}
}
false
}
#[cfg(test)]
pub(crate) fn slash_completion_hints(
input: &str,
limit: usize,
cached_skills: &[(String, String)],
locale: crate::localization::Locale,
workspace: Option<&std::path::Path>,
api_provider: ApiProvider,
) -> Vec<SlashMenuEntry> {
let model_candidates = all_catalog_models_for_provider(api_provider);
slash_completion_hints_with_model_candidates(
input,
limit,
cached_skills,
locale,
workspace,
&model_candidates,
)
}
pub(crate) fn slash_completion_hints_with_model_candidates(
input: &str,
limit: usize,
cached_skills: &[(String, String)],
locale: crate::localization::Locale,
workspace: Option<&std::path::Path>,
model_candidates: &[String],
) -> Vec<SlashMenuEntry> {
if !super::app::looks_like_slash_command_input(input) {
return Vec::new();
}
let trimmed = input.trim_start();
if trimmed.starts_with('$') {
let prefix = trimmed.trim_start_matches('$').to_ascii_lowercase();
let mut entries: Vec<SlashMenuEntry> = Vec::new();
for (skill_name, skill_desc) in cached_skills {
let skill_name_lower = skill_name.to_ascii_lowercase();
if skill_name_lower.starts_with(&prefix)
|| skill_name_lower.contains(&prefix)
|| fuzzy_chars_in_order(&prefix, &skill_name_lower)
{
entries.push(SlashMenuEntry {
name: format!("${skill_name}"),
description: skill_desc.clone(),
is_skill: true,
alias_hint: None,
});
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries.dedup_by(|a, b| a.name == b.name);
return entries.into_iter().take(limit).collect();
}
let prefix = input.trim_start_matches('/');
let completing_skill_arg = prefix.strip_prefix("skill ").map(str::trim_start);
let completing_model_arg = prefix.strip_prefix("model ").map(str::trim_start);
if input.contains(char::is_whitespace)
&& completing_skill_arg.is_none()
&& completing_model_arg.is_none()
{
return Vec::new();
}
let mut entries: Vec<SlashMenuEntry> = Vec::new();
let prefix_lower = prefix.to_ascii_lowercase();
if completing_skill_arg.is_none() && completing_model_arg.is_none() {
commands::user_registry::with_registry_for_workspace(workspace, |registry| {
let all_user_commands = registry.iter().collect::<Vec<_>>();
let user_commands = all_user_commands
.iter()
.copied()
.filter(|cmd| !cmd.hidden)
.collect::<Vec<_>>();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for name in
all_command_names_matching_loaded(prefix, &user_commands, &all_user_commands)
{
seen.insert(name.clone());
let command_key = name.trim_start_matches('/');
push_command_entry(
&mut entries,
&name,
command_key,
&prefix_lower,
locale,
&all_user_commands,
);
}
for cmd in commands::command_infos() {
let name = format!("/{}", cmd.name);
if seen.contains(&name) {
continue;
}
let cmd_lower = cmd.name.to_ascii_lowercase();
let name_match = cmd_lower.contains(&prefix_lower);
let alias_matches =
|alias: &str| alias.to_ascii_lowercase().contains(&prefix_lower);
if builtin_visible_for_completion_match(
cmd,
&all_user_commands,
&prefix_lower,
name_match,
alias_matches,
) {
seen.insert(name.clone());
push_command_entry(
&mut entries,
&name,
cmd.name,
&prefix_lower,
locale,
&all_user_commands,
);
}
}
for cmd in &user_commands {
let name = format!("/{}", cmd.name);
if seen.contains(&name) {
continue;
}
let alias_match = cmd.aliases.iter().any(|a| a.contains(&prefix_lower));
if cmd.name.contains(&prefix_lower) || alias_match {
seen.insert(name.clone());
push_command_entry(
&mut entries,
&name,
&cmd.name,
&prefix_lower,
locale,
&all_user_commands,
);
}
}
for cmd in commands::command_infos() {
let name = format!("/{}", cmd.name);
if seen.contains(&name) {
continue;
}
let cmd_lower = cmd.name.to_ascii_lowercase();
let name_match = fuzzy_chars_in_order(&prefix_lower, &cmd_lower);
let alias_matches = |alias: &str| fuzzy_chars_in_order(&prefix_lower, alias);
if builtin_visible_for_completion_match(
cmd,
&all_user_commands,
&prefix_lower,
name_match,
alias_matches,
) {
seen.insert(name.clone());
push_command_entry(
&mut entries,
&name,
cmd.name,
&prefix_lower,
locale,
&all_user_commands,
);
}
}
for cmd in &user_commands {
let name = format!("/{}", cmd.name);
if seen.contains(&name) {
continue;
}
let alias_match = cmd
.aliases
.iter()
.any(|a| fuzzy_chars_in_order(&prefix_lower, a));
if fuzzy_chars_in_order(&prefix_lower, &cmd.name) || alias_match {
seen.insert(name.clone());
push_command_entry(
&mut entries,
&name,
&cmd.name,
&prefix_lower,
locale,
&all_user_commands,
);
}
}
});
}
if let Some(model_prefix) = completing_model_arg {
let model_prefix = model_prefix.to_ascii_lowercase();
for model_name in model_candidates {
let lower = model_name.to_ascii_lowercase();
if lower.starts_with(&model_prefix)
|| lower.contains(&model_prefix)
|| fuzzy_chars_in_order(&model_prefix, &lower)
{
entries.push(SlashMenuEntry {
name: format!("/model {model_name}"),
description: String::from("Switch to this model"),
is_skill: false,
alias_hint: None,
});
}
}
}
let skill_prefix = completing_skill_arg.unwrap_or(prefix).to_ascii_lowercase();
if completing_skill_arg.is_some() {
for (skill_name, skill_desc) in cached_skills {
let skill_name_lower = skill_name.to_ascii_lowercase();
if skill_name_lower.starts_with(&skill_prefix) {
entries.push(SlashMenuEntry {
name: format!("/skill {skill_name}"),
description: skill_desc.clone(),
is_skill: true,
alias_hint: None,
});
}
}
for (skill_name, skill_desc) in cached_skills {
let skill_name_lower = skill_name.to_ascii_lowercase();
if skill_name_lower.contains(&skill_prefix)
&& !entries
.iter()
.any(|e| e.name == format!("/skill {skill_name}"))
{
entries.push(SlashMenuEntry {
name: format!("/skill {skill_name}"),
description: skill_desc.clone(),
is_skill: true,
alias_hint: None,
});
}
}
for (skill_name, skill_desc) in cached_skills {
let skill_name_lower = skill_name.to_ascii_lowercase();
if !skill_name_lower.starts_with(&skill_prefix)
&& !skill_name_lower.contains(&skill_prefix)
&& fuzzy_chars_in_order(&skill_prefix, &skill_name_lower)
{
entries.push(SlashMenuEntry {
name: format!("/skill {skill_name}"),
description: skill_desc.clone(),
is_skill: true,
alias_hint: None,
});
}
}
}
if entries.iter().any(|e| e.name == "/model") && prefix_lower.eq_ignore_ascii_case("model") {
for model_name in model_candidates {
entries.push(SlashMenuEntry {
name: format!("/model {model_name}"),
description: String::from("Switch to this model"),
is_skill: false,
alias_hint: None,
});
}
}
let rank = |entry: &SlashMenuEntry| -> u8 {
if entry.is_skill {
return 3;
}
let command_key = entry.name.trim_start_matches('/');
if command_key.eq_ignore_ascii_case(&prefix_lower) {
return 0;
}
if let Some(info) = commands::get_command_info(command_key)
&& info
.aliases
.iter()
.any(|a| a.eq_ignore_ascii_case(&prefix_lower))
{
return 0;
}
if command_key.to_ascii_lowercase().starts_with(&prefix_lower) {
return 1;
}
2
};
entries.sort_by(|a, b| rank(a).cmp(&rank(b)).then_with(|| a.name.cmp(&b.name)));
entries.dedup_by(|a, b| a.name == b.name);
entries.into_iter().take(limit).collect()
}
fn all_command_names_matching_loaded(
prefix: &str,
user_commands: &[&commands::user_registry::UserCommandMetadata],
all_user_commands: &[&commands::user_registry::UserCommandMetadata],
) -> Vec<String> {
let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase();
let mut result: Vec<String> = commands::command_infos()
.iter()
.filter(|cmd| {
builtin_visible_for_completion_match(
cmd,
all_user_commands,
&prefix,
cmd.name.starts_with(&prefix),
|alias| alias.starts_with(&prefix),
)
})
.map(|cmd| format!("/{}", cmd.name))
.collect();
result.extend(user_commands.iter().filter_map(|command| {
let name_matches = command.name.starts_with(&prefix);
let alias_matches = command
.aliases
.iter()
.any(|alias| alias.starts_with(&prefix));
(name_matches || alias_matches).then(|| format!("/{}", command.name))
}));
result.sort();
result.dedup();
result
}
fn builtin_visible_for_completion_match(
builtin: &commands::CommandInfo,
user_commands: &[&commands::user_registry::UserCommandMetadata],
prefix: &str,
canonical_name_matches: bool,
alias_matches: impl Fn(&str) -> bool,
) -> bool {
if !builtin.show_in_slash_completion(prefix) {
return false;
}
if commands::discovery::user_command_shadows_builtin_canonical(builtin, user_commands) {
return false;
}
if canonical_name_matches {
return true;
}
builtin.aliases.iter().any(|alias| {
alias_matches(alias)
&& !commands::discovery::user_command_shadows_builtin_alias(alias, user_commands)
})
}
fn push_command_entry(
entries: &mut Vec<SlashMenuEntry>,
name: &str,
command_key: &str,
prefix_lower: &str,
locale: crate::localization::Locale,
user_commands: &[&commands::user_registry::UserCommandMetadata],
) {
let user_command = user_commands
.iter()
.find(|command| command.name == command_key);
let (description, alias_hint) = if let Some(command) = user_command {
let mut description = command
.description
.clone()
.unwrap_or_else(|| String::from("User-defined command"));
if let Some(hint) = command.display_usage() {
description.push_str(" ");
description.push_str(hint);
}
let alias_hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) {
command
.aliases
.iter()
.find(|alias| {
alias.starts_with(prefix_lower)
|| alias.contains(prefix_lower)
|| fuzzy_chars_in_order(prefix_lower, alias)
})
.cloned()
} else {
None
};
(description, alias_hint)
} else if let Some(info) = commands::get_command_info(command_key) {
let unshadowed_aliases = info
.aliases
.iter()
.copied()
.filter(|alias| {
!commands::discovery::user_command_shadows_builtin_alias(alias, user_commands)
})
.collect::<Vec<_>>();
let hint = if !command_key.to_ascii_lowercase().starts_with(prefix_lower) {
unshadowed_aliases
.iter()
.copied()
.find(|a| {
a.to_ascii_lowercase().starts_with(prefix_lower)
|| a.to_ascii_lowercase().contains(prefix_lower)
|| fuzzy_chars_in_order(prefix_lower, &a.to_ascii_lowercase())
})
.map(str::to_string)
} else {
None
};
let remaining_aliases: Vec<&str> = unshadowed_aliases
.into_iter()
.filter(|alias| hint.as_deref() != Some(*alias))
.collect();
let desc = if remaining_aliases.is_empty() {
info.description_for(locale).to_string()
} else {
format!(
"{} (aliases: {})",
info.description_for(locale),
remaining_aliases
.iter()
.map(|a| format!("/{a}"))
.collect::<Vec<_>>()
.join(", ")
)
};
(desc, hint)
} else {
(String::from("User-defined command"), None)
};
entries.push(SlashMenuEntry {
name: name.to_string(),
description,
is_skill: false,
alias_hint,
});
}
fn layout_input(
input: &str,
cursor: usize,
width: usize,
max_height: usize,
) -> (Vec<String>, usize, usize) {
let (visible, visible_cursor_row, visible_cursor_col, _) =
layout_input_with_scroll(input, cursor, width, max_height);
(visible, visible_cursor_row, visible_cursor_col)
}
pub fn layout_input_with_scroll(
input: &str,
cursor: usize,
width: usize,
max_height: usize,
) -> (Vec<String>, usize, usize, usize) {
let mut lines = wrap_input_lines(input, width);
if lines.is_empty() {
lines.push(String::new());
}
let (cursor_row, cursor_col) = cursor_row_col(input, cursor, width.max(1));
let max_height = max_height.max(1);
let mut start = 0usize;
if cursor_row >= max_height {
start = cursor_row + 1 - max_height;
}
if start + max_height > lines.len() {
start = lines.len().saturating_sub(max_height);
}
let visible = lines
.into_iter()
.skip(start)
.take(max_height)
.collect::<Vec<_>>();
let visible_cursor_row = cursor_row.saturating_sub(start);
(
visible,
visible_cursor_row,
cursor_col.min(width.saturating_sub(1)),
start,
)
}
fn layout_input_with_scroll_and_char_indices(
input: &str,
cursor: usize,
width: usize,
max_height: usize,
) -> (Vec<String>, usize, usize, usize, Vec<(usize, String)>) {
let (all_lines, all_with_indices) = wrap_input_lines_internal(input, width);
let lines = if all_lines.is_empty() {
vec![String::new()]
} else {
all_lines
};
let (cursor_row, cursor_col) = cursor_row_col(input, cursor, width.max(1));
let max_height = max_height.max(1);
let mut start = 0usize;
if cursor_row >= max_height {
start = cursor_row + 1 - max_height;
}
if start + max_height > lines.len() {
start = lines.len().saturating_sub(max_height);
}
let visible = lines
.into_iter()
.skip(start)
.take(max_height)
.collect::<Vec<_>>();
let visible_cursor_row = cursor_row.saturating_sub(start);
let visible_with_indices = all_with_indices
.into_iter()
.skip(start)
.take(max_height)
.collect();
(
visible,
visible_cursor_row,
cursor_col.min(width.saturating_sub(1)),
start,
visible_with_indices,
)
}
fn cursor_row_col(input: &str, cursor: usize, width: usize) -> (usize, usize) {
let (_, lines_with_indices) = wrap_input_lines_internal(input, width.max(1));
cursor_row_col_in_lines(&lines_with_indices, cursor)
}
fn cursor_row_col_in_lines(
lines_with_indices: &[(usize, String)],
cursor: usize,
) -> (usize, usize) {
let mut row = 0usize;
let mut line_start = 0usize;
let mut line: &str = "";
let mut found = false;
for (i, (start, l)) in lines_with_indices.iter().enumerate() {
if *start <= cursor {
row = i;
line_start = *start;
line = l.as_str();
found = true;
} else {
break;
}
}
if !found {
return (0, 0);
}
let offset = cursor.saturating_sub(line_start);
let byte_end = line
.char_indices()
.nth(offset)
.map(|(b, _)| b)
.unwrap_or(line.len());
let col = line[..byte_end].width();
(row, col)
}
fn wrap_input_lines_internal(input: &str, width: usize) -> (Vec<String>, Vec<(usize, String)>) {
let mut lines = Vec::new();
let mut lines_with_indices = Vec::new();
let mut char_idx = 0usize;
if input.is_empty() {
lines_with_indices.push((0, String::new()));
return (lines, lines_with_indices);
}
for raw_line in input.split('\n') {
if raw_line.is_empty() {
lines.push(String::new());
if width != 0 {
lines_with_indices.push((char_idx, String::new()));
}
char_idx += 1; continue;
}
let wrapped = wrap_text(raw_line, width);
if wrapped.is_empty() {
lines.push(String::new());
if width != 0 {
lines_with_indices.push((char_idx, String::new()));
}
} else {
for wrapped_line in &wrapped {
let line_char_len: usize = wrapped_line.chars().count();
lines.push(wrapped_line.clone());
if width != 0 {
lines_with_indices.push((char_idx, wrapped_line.clone()));
}
char_idx += line_char_len;
}
}
char_idx += 1; }
(lines, lines_with_indices)
}
fn wrap_input_lines(input: &str, width: usize) -> Vec<String> {
let (lines, _) = wrap_input_lines_internal(input, width);
lines
}
pub fn wrap_input_lines_for_mouse(input: &str, width: usize) -> Vec<(usize, String)> {
if input.is_empty() || width == 0 {
return vec![(0, String::new())];
}
let (_, lines_with_indices) = wrap_input_lines_internal(input, width);
lines_with_indices
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
if text.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0;
let mut break_at: Option<(usize, usize)> = None;
macro_rules! flush {
() => {{
match break_at.take() {
Some((byte, _)) if byte < current.len() => {
let remainder = current.split_off(byte);
lines.push(std::mem::replace(&mut current, remainder));
current_width = current.width();
}
_ => {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
}
}};
}
for grapheme in text.graphemes(true) {
if grapheme == "\n" {
break_at = None;
lines.push(std::mem::take(&mut current));
current_width = 0;
continue;
}
let grapheme_width = grapheme.width();
if current_width + grapheme_width > width && current_width != 0 {
flush!();
}
current.push_str(grapheme);
current_width += grapheme_width;
if grapheme == " " && !current.trim_start().is_empty() {
break_at = Some((current.len(), current_width));
}
if current_width >= width {
flush!();
}
}
lines.push(current);
lines
}
fn line_spans_with_selection<'a>(
line: &'a str,
line_start: usize,
line_end: usize,
sel_start: usize,
sel_end: usize,
highlight_bg: Color,
) -> Vec<Span<'a>> {
let normal_style = Style::default().fg(palette::TEXT_PRIMARY);
let sel_style = Style::default().fg(palette::TEXT_PRIMARY).bg(highlight_bg);
if line_end <= sel_start || line_start >= sel_end {
return vec![Span::styled(line, normal_style)];
}
let local_sel_start = sel_start.saturating_sub(line_start);
let local_sel_end = sel_end.min(line_end).saturating_sub(line_start);
let mut byte_offsets: Vec<usize> = line.char_indices().map(|(i, _)| i).collect();
byte_offsets.push(line.len());
let b0 = byte_offsets
.get(local_sel_start)
.copied()
.unwrap_or(line.len());
let b1 = byte_offsets
.get(local_sel_end)
.copied()
.unwrap_or(line.len());
let mut spans = Vec::with_capacity(3);
if b0 > 0 {
spans.push(Span::styled(&line[..b0], normal_style));
}
if b1 > b0 {
spans.push(Span::styled(&line[b0..b1], sel_style));
}
if b1 < line.len() {
spans.push(Span::styled(&line[b1..], normal_style));
}
spans
}
#[cfg(test)]
mod tests {
use super::{
ACTIVE_REVISION_DOMAIN, ApprovalMode, ApprovalWidget, COMPOSER_PANEL_HEIGHT,
COMPOSER_PLACEHOLDER, COMPOSER_PROMPT_GUTTER_WIDTH, ChatWidget, ComposerWidget, Renderable,
SlashMenuEntry, active_entry_revision, apply_detail_target_highlight,
apply_selection_to_line, apply_send_flash, approval_palette, approval_truncation_hint,
build_empty_state_lines, composer_content_geometry, composer_empty_hint_text,
composer_height, composer_max_height, composer_top_padding, cursor_row_col,
empty_composer_visual_rows, enclosed_composer_panel_fits, fish_flee_offset, fish_heading,
fish_mark, history_entry_revision, layout_input, layout_input_with_scroll,
placeholder_visual_lines, push_command_entry, receipt_is_settling, revision_in_domain,
should_render_empty_state, slash_completion_hints, tool_run_summary_revision,
wrap_input_lines, wrap_input_lines_for_mouse, wrap_text,
};
use crate::config::{ApiProvider, Config};
use crate::localization::{Locale, MessageId, tr};
use crate::palette;
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::{
App, AppMode, ComposerDensity, TaskPanelEntry, TaskPanelEntryKind, ToolCollapseMode,
TranscriptSpacing, TuiOptions,
};
use crate::tui::history::{
ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus,
};
use crate::tui::scrolling::{TranscriptLineMeta, TranscriptScroll};
use ratatui::{
Terminal,
backend::TestBackend,
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
};
use std::{path::PathBuf, time::Instant};
use unicode_width::UnicodeWidthStr;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v4-flash".to_string(),
start_in_agent_mode: true,
..crate::test_support::test_tui_options(PathBuf::from("."))
};
let mut app = App::new(options, &Config::default());
app.ui_locale = Locale::En;
app.composer.vim_enabled = false;
app
}
fn buffer_text(buf: &Buffer, area: Rect) -> String {
let mut text = String::new();
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
text.push_str(buf[(x, y)].symbol());
}
text.push('\n');
}
text
}
#[test]
fn approval_palette_reserves_signal_gold_for_human_decisions() {
use crate::tui::approval::ApprovalStakes;
let routine = approval_palette(ApprovalStakes::Routine);
let elevated = approval_palette(ApprovalStakes::Elevated);
let critical = approval_palette(ApprovalStakes::Critical);
assert_eq!(routine.accent, palette::WHALE_HUMAN);
assert_eq!(routine.shortcut, palette::WHALE_ACTION);
assert_eq!(elevated.border, palette::WHALE_HUMAN);
assert_eq!(elevated.accent, palette::WHALE_HUMAN);
assert_eq!(critical.accent, palette::WHALE_ERROR);
}
#[test]
fn first_active_tool_settles_when_flushed_to_history() {
let mut app = create_test_app();
app.clear_history();
app.next_history_revision = 1;
app.active_cell_revision = 0;
let mut active = ActiveCell::new();
active.push_tool("user_shell_1", running_user_shell_cell());
app.active_cell = Some(active);
let area = Rect::new(0, 0, 100, 20);
let mut running_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut running_buf);
let running = buffer_text(&running_buf, area);
assert!(running.contains("run running"), "{running}");
app.finalize_active_cell_as_interrupted();
let HistoryCell::Tool(ToolCell::Exec(exec)) = &app.history[0] else {
panic!("expected settled exec history cell")
};
assert_eq!(exec.status, ToolStatus::Failed);
let mut settled_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut settled_buf);
let settled = buffer_text(&settled_buf, area);
assert!(
!settled.contains("run running"),
"flushed terminal state reused the active cache entry:\n{settled}"
);
assert!(settled.contains("run issue"), "{settled}");
}
fn render_approval_request(
request: &crate::tui::approval::ApprovalRequest,
area: Rect,
) -> String {
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(request, &view);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
buffer_text(&buf, area)
}
fn row_text(buf: &Buffer, area: Rect, row: u16) -> String {
let mut text = String::new();
for x in area.x..area.x.saturating_add(area.width) {
text.push_str(buf[(x, row)].symbol());
}
text
}
fn success_tool_cell(name: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Success,
input_summary: Some(format!("path: {name}.txt")),
output: Some(format!("full output from {name}")),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}))
}
fn running_user_shell_cell() -> HistoryCell {
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "sleep 30".to_string(),
status: ToolStatus::Running,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: None,
duration_ms: None,
stale_elapsed_since_output_ms: None,
source: ExecSource::User,
interaction: None,
output_summary: None,
}))
}
fn add_dense_tool_run(app: &mut App) {
app.add_message(success_tool_cell("read_file"));
app.add_message(success_tool_cell("list_dir"));
app.add_message(success_tool_cell("web_search"));
}
fn spacer_rows_after_transcript_cell(app: &App, target_cell: usize) -> usize {
let mut saw_target = false;
let mut spacer_rows = 0;
for meta in app.viewport.transcript_cache.line_meta() {
match meta {
TranscriptLineMeta::CellLine { cell_index, .. } if *cell_index == target_cell => {
saw_target = true;
spacer_rows = 0;
}
TranscriptLineMeta::Spacer { .. } if saw_target => spacer_rows += 1,
TranscriptLineMeta::CellLine { .. } if saw_target => break,
TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => {}
}
}
spacer_rows
}
#[test]
fn chat_widget_breathes_between_groups_without_padding_tool_rows_at_any_width() {
for (width, height) in [(40, 8), (120, 12)] {
let mut app = create_test_app();
app.low_motion = true;
app.fancy_animations = false;
app.transcript_spacing = TranscriptSpacing::Comfortable;
for turn in 0..4 {
app.add_message(HistoryCell::User {
content: format!("turn {turn}: inspect the release receipts"),
});
app.add_message(HistoryCell::Assistant {
content: format!("I will inspect receipt group {turn}."),
streaming: false,
});
app.add_message(success_tool_cell(&format!("read_{turn}")));
app.add_message(success_tool_cell(&format!("verify_{turn}")));
app.add_message(HistoryCell::Assistant {
content: format!("receipt group {turn} is complete"),
streaming: false,
});
}
let area = Rect::new(0, 0, width, height);
app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
let mut top_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut top_buf);
assert_eq!(app.viewport.last_transcript_top, 0, "width={width}");
assert!(
app.viewport.last_transcript_total > usize::from(height),
"fixture must scroll at width={width}"
);
assert_eq!(
spacer_rows_after_transcript_cell(&app, 0),
1,
"the top-level user turn needs a breathing row at width={width}"
);
assert_eq!(
spacer_rows_after_transcript_cell(&app, 1),
1,
"answer to tool-group transition needs a breathing row at width={width}"
);
assert_eq!(
spacer_rows_after_transcript_cell(&app, 2),
0,
"calls inside one tool group must stay compact at width={width}"
);
assert_eq!(
spacer_rows_after_transcript_cell(&app, 3),
1,
"the completed tool group needs a breathing row at width={width}"
);
assert!(
buffer_text(&top_buf, area).contains("turn 0"),
"top scroll source drifted at width={width}"
);
let total = app.viewport.last_transcript_total;
app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
let mut tail_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut tail_buf);
assert_eq!(app.viewport.last_transcript_total, total, "width={width}");
assert!(app.viewport.last_transcript_top > 0, "width={width}");
assert!(
buffer_text(&tail_buf, area).contains("receipt group 3 is complete"),
"tail scroll lost the final source-backed cell at width={width}"
);
assert!(
app.viewport
.transcript_cache
.line_meta()
.iter()
.all(|meta| match meta {
TranscriptLineMeta::CellLine { cell_index, .. } => {
*cell_index < app.history.len()
}
TranscriptLineMeta::Spacer { .. } => true,
}),
"spacing rows must not invent source-cell ownership at width={width}"
);
}
}
#[test]
fn send_flash_uses_original_index_map_for_collapsed_rows() {
let history = vec![
success_tool_cell("read_file"),
success_tool_cell("list_dir"),
HistoryCell::User {
content: "sent".to_string(),
},
];
let mut lines = vec![Line::from("sent")];
let line_meta = vec![TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 0,
copy_prefix_width: 0,
copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline,
}];
let original_index_map = vec![2];
apply_send_flash(&mut lines, 0, &history, &line_meta, &original_index_map);
assert_eq!(
lines[0].spans[0].style.bg,
Some(palette::SURFACE_TOOL_ACTIVE)
);
}
#[test]
fn detail_highlight_uses_original_index_map_for_collapsed_rows() {
let mut lines = vec![Line::from("tool group")];
let line_meta = vec![TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 0,
copy_prefix_width: 0,
copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline,
}];
let original_index_map = vec![4];
apply_detail_target_highlight(&mut lines, 0, 4, &line_meta, &original_index_map);
assert_eq!(lines[0].spans[0].style.bg, Some(Color::Reset));
}
#[test]
fn tool_run_summary_revision_separates_128_entry_history_and_active_alias() {
let active_rev = 17;
let run = ToolRun {
start: 0,
count: 128,
tool_families: Vec::new(),
activity: Default::default(),
};
let history_revisions = (1..=run.count)
.map(|salt| active_entry_revision(active_rev, salt as u64))
.collect::<Vec<_>>();
let history_key =
tool_run_summary_revision(&run, &history_revisions, run.count, active_rev);
let active_key = tool_run_summary_revision(&run, &[], 0, active_rev);
assert_eq!(
history_key & !ACTIVE_REVISION_DOMAIN,
active_key & !ACTIVE_REVISION_DOMAIN,
"fixture must exercise the 128-entry payload alias"
);
assert_eq!(history_key & ACTIVE_REVISION_DOMAIN, 0);
assert_eq!(active_key & ACTIVE_REVISION_DOMAIN, ACTIVE_REVISION_DOMAIN);
assert_ne!(history_key, active_key);
}
#[test]
fn high_bit_raw_revision_remains_distinct_across_history_and_active_domains() {
let raw = ACTIVE_REVISION_DOMAIN | 0x2692;
let history_key = history_entry_revision(raw);
let active_key = revision_in_domain(raw, true);
assert_eq!(history_key, 0x2692);
assert_eq!(active_key, ACTIVE_REVISION_DOMAIN | 0x2692);
assert_ne!(history_key, active_key);
}
#[test]
fn chat_widget_collapses_dense_tool_runs_by_default() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
add_dense_tool_run(&mut app);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert_eq!(app.collapsed_cell_map, vec![0]);
assert!(
rendered.contains("Explored 2 files, 1 search"),
"{rendered}"
);
assert!(!rendered.contains("activity_group"), "{rendered}");
assert!(
!rendered.contains("full output from list_dir"),
"{rendered}"
);
}
#[test]
fn chat_widget_collapses_dense_active_tool_runs_by_default() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
let active = app.active_cell.get_or_insert_with(ActiveCell::new);
active.push_untracked(success_tool_cell("read_file"));
active.push_untracked(success_tool_cell("list_dir"));
active.push_untracked(success_tool_cell("web_search"));
app.bump_active_cell_revision();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert_eq!(app.collapsed_cell_map, vec![0]);
assert!(
rendered.contains("Explored 2 files, 1 search"),
"{rendered}"
);
assert!(!rendered.contains("activity_group"), "{rendered}");
assert!(
!rendered.contains("full output from list_dir"),
"{rendered}"
);
}
#[test]
fn collapsed_slow_path_does_not_reuse_running_active_cache_after_flush() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
add_dense_tool_run(&mut app);
app.next_history_revision = ACTIVE_REVISION_DOMAIN | 1;
app.active_cell_revision = 0;
let mut active = ActiveCell::new();
active.push_tool("user_shell_slow_path", running_user_shell_cell());
app.active_cell = Some(active);
let area = Rect::new(0, 0, 100, 20);
let mut running_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut running_buf);
let running = buffer_text(&running_buf, area);
assert!(running.contains("run running"), "{running}");
assert_eq!(app.collapsed_cell_map, vec![0, 3]);
app.finalize_active_cell_as_interrupted();
assert_eq!(
app.history_revisions[3],
ACTIVE_REVISION_DOMAIN | 1,
"fixture must force the old raw-revision collision"
);
let mut settled_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut settled_buf);
let settled = buffer_text(&settled_buf, area);
assert!(
!settled.contains("run running"),
"history cell reused the active slow-path cache entry:\n{settled}"
);
assert!(settled.contains("run issue"), "{settled}");
}
#[test]
fn chat_widget_expands_dense_tool_runs_on_demand() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
add_dense_tool_run(&mut app);
app.expanded_tool_runs.insert(0);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 12,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]);
assert!(rendered.contains("read_file.txt"), "{rendered}");
assert!(rendered.contains("list_dir.txt"), "{rendered}");
assert!(rendered.contains("web_search.txt"), "{rendered}");
assert!(
!rendered.contains("full output from list_dir"),
"{rendered}"
);
}
#[test]
fn chat_widget_expanded_mode_leaves_dense_tool_runs_visible() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Expanded;
app.tool_collapse_threshold = 3;
add_dense_tool_run(&mut app);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 12,
};
let _widget = ChatWidget::new(&mut app, area);
assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]);
}
#[test]
fn chat_widget_collapse_path_stable_across_frames() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
add_dense_tool_run(&mut app);
app.add_message(HistoryCell::User {
content: "trailing prompt".to_string(),
});
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 10,
};
let mut first_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut first_buf);
let first = buffer_text(&first_buf, area);
let first_map = app.collapsed_cell_map.clone();
let first_total = app.viewport.last_transcript_total;
let mut second_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut second_buf);
let second = buffer_text(&second_buf, area);
assert_eq!(first, second, "collapse path is frame-stable");
assert_eq!(first_map, app.collapsed_cell_map);
assert_eq!(first_total, app.viewport.last_transcript_total);
assert!(first.contains("Explored 2 files, 1 search"), "{first}");
assert!(first.contains("trailing prompt"), "{first}");
}
#[test]
fn chat_widget_collapses_run_spanning_history_and_active_entries() {
let mut app = create_test_app();
app.tool_collapse_mode = ToolCollapseMode::Compact;
app.tool_collapse_threshold = 3;
app.add_message(success_tool_cell("read_file"));
app.add_message(success_tool_cell("list_dir"));
let active = app.active_cell.get_or_insert_with(ActiveCell::new);
active.push_untracked(success_tool_cell("web_search"));
app.bump_active_cell_revision();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let mut buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert_eq!(app.collapsed_cell_map, vec![0]);
assert!(
rendered.contains("Explored 2 files, 1 search"),
"run spanning the history/active boundary renders one summary: {rendered}"
);
let rev_before = app.active_cell_revision;
app.bump_active_cell_revision();
assert_ne!(rev_before, app.active_cell_revision);
let mut second_buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut second_buf);
let second = buffer_text(&second_buf, area);
assert!(second.contains("Explored 2 files, 1 search"), "{second}");
}
#[test]
fn cursor_basic_ascii() {
assert_eq!(cursor_row_col("hello", 0, 10), (0, 0));
assert_eq!(cursor_row_col("hello", 3, 10), (0, 3));
assert_eq!(cursor_row_col("hello", 5, 10), (0, 5));
}
#[test]
fn cursor_at_wrap_boundary() {
let (row, col) = cursor_row_col("abcde", 5, 5);
assert_eq!(row, 1, "cursor at end of full line should wrap");
assert_eq!(col, 0, "cursor should be at start of next line");
}
#[test]
fn cursor_with_cjk_characters() {
assert_eq!(cursor_row_col("a中b", 0, 10), (0, 0)); assert_eq!(cursor_row_col("a中b", 1, 10), (0, 1)); assert_eq!(cursor_row_col("a中b", 2, 10), (0, 3)); assert_eq!(cursor_row_col("a中b", 3, 10), (0, 4)); }
#[test]
fn cursor_cjk_at_wrap_boundary() {
let lines = wrap_text("abcd中", 5);
assert_eq!(lines, vec!["abcd", "中"]);
let (row, col) = cursor_row_col("abcd中", 5, 5);
assert_eq!(row, 1);
assert_eq!(col, 2);
}
#[test]
fn composer_wraps_on_word_boundaries_without_losing_a_character() {
let text = "Mark inferences as inferences. A short PRD where each \
section decides something beats a long one.";
for width in [20usize, 33, 47, 60, 79] {
let lines = wrap_text(text, width);
assert_eq!(
lines.concat(),
text,
"wrapping must be lossless at width={width}: {lines:?}"
);
for line in &lines {
assert!(
line.width() <= width,
"line exceeds width={width}: {line:?}"
);
}
for line in lines.iter().take(lines.len().saturating_sub(1)) {
assert!(
line.is_empty() || line.ends_with(' '),
"wrapped line broke mid-word at width={width}: {line:?}"
);
}
}
}
#[test]
fn composer_hard_breaks_words_longer_than_the_line() {
let text = "see https://example.com/a/very/long/path/that/never/breaks?x=1 now";
let lines = wrap_text(text, 24);
assert_eq!(lines.concat(), text, "{lines:?}");
for line in &lines {
assert!(line.width() <= 24, "line exceeds width: {line:?}");
}
assert!(
lines.len() > 2,
"an unbreakable token must still be split across lines: {lines:?}"
);
}
#[test]
fn composer_wrapping_respects_wide_character_width() {
let text = "中文字符串没有空格可以换行";
let lines = wrap_text(text, 7);
assert_eq!(lines.concat(), text, "{lines:?}");
for line in &lines {
assert!(line.width() <= 7, "line exceeds width: {line:?}");
}
}
#[test]
fn cursor_with_combining_marks() {
let input = "e\u{0301}"; assert_eq!(input.chars().count(), 2);
assert_eq!(cursor_row_col(input, 0, 10), (0, 0));
assert_eq!(cursor_row_col(input, 1, 10), (0, 1));
assert_eq!(cursor_row_col(input, 2, 10), (0, 1)); }
#[test]
fn cursor_with_emoji() {
let input = "a😀b";
let (_row, col) = cursor_row_col(input, 2, 10);
assert!((2..=3).contains(&col), "col = {col}, expected 2 or 3");
}
#[test]
fn cursor_with_emoji_zwj_sequence() {
let input = "👨👩👧👦";
let cursor = input.chars().count();
let (row, col) = cursor_row_col(input, cursor, 10);
assert_eq!(row, 0);
assert_eq!(col, input.width());
}
#[test]
fn cursor_with_newlines() {
assert_eq!(cursor_row_col("ab\ncd", 0, 10), (0, 0)); assert_eq!(cursor_row_col("ab\ncd", 2, 10), (0, 2)); assert_eq!(cursor_row_col("ab\ncd", 3, 10), (1, 0)); assert_eq!(cursor_row_col("ab\ncd", 5, 10), (1, 2)); }
#[test]
fn wrap_input_lines_preserves_empty_lines() {
let lines = wrap_input_lines("a\n\nb", 10);
assert_eq!(lines, vec!["a", "", "b"]);
}
#[test]
fn wrap_input_lines_trailing_newline() {
let lines = wrap_input_lines("a\n", 10);
assert_eq!(lines, vec!["a", ""]);
}
#[test]
fn wrap_input_lines_for_mouse_empty_input() {
let result = wrap_input_lines_for_mouse("", 10);
assert_eq!(result, vec![(0, String::new())]);
let result_zero = wrap_input_lines_for_mouse("", 0);
assert_eq!(result_zero, vec![(0, String::new())]);
}
#[test]
fn cursor_and_wrap_consistency() {
let test_cases = vec![
("hello world", 5),
("abcdefghij", 3),
("中文测试", 6),
("a\nb\nc", 10),
];
for (input, width) in test_cases {
let lines = wrap_input_lines(input, width);
let (cursor_row, _) = cursor_row_col(input, input.chars().count(), width);
assert!(
cursor_row <= lines.len(),
"cursor_row={cursor_row} should be <= lines.len()={} for input={input:?}",
lines.len()
);
}
}
#[test]
fn slash_completion_hints_include_links_and_config() {
let hints = slash_completion_hints("/", 128, &[], Locale::En, None, ApiProvider::Deepseek);
assert!(hints.iter().any(|hint| hint.name == "/config"));
assert!(hints.iter().any(|hint| hint.name == "/links"));
}
#[test]
fn slash_completion_hints_rank_exact_alias_above_prefix_alias() {
let hints = slash_completion_hints("/q", 128, &[], Locale::En, None, ApiProvider::Deepseek);
let names: Vec<&str> = hints.iter().map(|h| h.name.as_str()).collect();
let exit_pos = names
.iter()
.position(|n| *n == "/exit")
.expect("/exit should appear when typing /q (alias `q`)");
let clear_pos = names
.iter()
.position(|n| *n == "/clear")
.expect("/clear should still appear when typing /q (alias `qingping`)");
assert!(
exit_pos < clear_pos,
"expected /exit to rank above /clear for prefix /q, got {names:?}"
);
}
#[test]
fn slash_completion_does_not_repeat_alias_already_in_label() {
let hints = slash_completion_hints("/p", 128, &[], Locale::En, None, ApiProvider::Deepseek);
let clear = hints
.iter()
.find(|h| h.name == "/clear")
.expect("/clear should appear for /p via qingping");
assert_eq!(
clear.alias_hint.as_deref(),
Some("qingping"),
"label should surface the matching alias"
);
assert!(
!clear.description.contains("(aliases:"),
"description should omit alias list when the only alias is already in the label: {}",
clear.description
);
assert!(
!clear.description.contains("/qingping"),
"description must not repeat /qingping: {}",
clear.description
);
}
#[test]
fn slash_completion_hints_keep_prefix_match_alphabetical_within_tier() {
let hints =
slash_completion_hints("/co", 128, &[], Locale::En, None, ApiProvider::Deepseek);
let names: Vec<&str> = hints
.iter()
.map(|h| h.name.as_str())
.filter(|n| n.starts_with("/co"))
.collect();
let sorted = {
let mut copy = names.clone();
copy.sort();
copy
};
assert_eq!(
names, sorted,
"tied entries (no exact-alias match) should stay alphabetical"
);
}
#[test]
fn slash_completion_hints_exclude_set_and_deepseek_commands() {
let hints = slash_completion_hints("/", 128, &[], Locale::En, None, ApiProvider::Deepseek);
assert!(!hints.iter().any(|hint| hint.name == "/set"));
assert!(!hints.iter().any(|hint| hint.name == "/codewhale"));
}
#[test]
fn slash_completion_hints_hide_toolbox_commands_until_typed() {
let root = slash_completion_hints("/", 128, &[], Locale::En, None, ApiProvider::Deepseek);
assert!(root.iter().any(|hint| hint.name == "/provider"));
assert!(root.iter().any(|hint| hint.name == "/model"));
assert!(root.iter().any(|hint| hint.name == "/fleet"));
assert!(root.iter().any(|hint| hint.name == "/config"));
assert!(root.iter().any(|hint| hint.name == "/statusline"));
assert!(!root.iter().any(|hint| hint.name == "/rlm"));
assert!(!root.iter().any(|hint| hint.name == "/modeldb"));
assert!(!root.iter().any(|hint| hint.name == "/models"));
assert!(!root.iter().any(|hint| hint.name == "/plugin"));
assert!(!root.iter().any(|hint| hint.name == "/subagents"));
let rlm = slash_completion_hints("/rl", 128, &[], Locale::En, None, ApiProvider::Deepseek);
assert!(rlm.iter().any(|hint| hint.name == "/rlm"));
let modeldb =
slash_completion_hints("/modeld", 128, &[], Locale::En, None, ApiProvider::Deepseek);
assert!(modeldb.iter().any(|hint| hint.name == "/modeldb"));
let plugin =
slash_completion_hints("/pl", 128, &[], Locale::En, None, ApiProvider::Deepseek);
assert!(plugin.iter().any(|hint| hint.name == "/plugin"));
let subagents =
slash_completion_hints("/sub", 128, &[], Locale::En, None, ApiProvider::Deepseek);
assert!(subagents.iter().any(|hint| hint.name == "/subagents"));
}
#[test]
fn slash_completion_hints_use_user_command_frontmatter_description() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".deepseek").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("git-scan.md"),
"---\ndescription: Scan nested git repositories\n---\nscan",
)
.unwrap();
let hints = slash_completion_hints(
"/git",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let entry = hints
.iter()
.find(|hint| hint.name == "/git-scan")
.expect("custom command should be present");
assert_eq!(entry.description, "Scan nested git repositories");
}
#[test]
fn slash_completion_hints_use_user_command_argument_hint() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".deepseek").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("deploy.md"),
"---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy",
)
.unwrap();
let hints = slash_completion_hints(
"/deploy",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let entry = hints
.iter()
.find(|hint| hint.name == "/deploy")
.expect("custom command should be present");
assert_eq!(entry.description, "Deploy target <env>");
}
#[test]
fn slash_completion_uses_frontmatter_name_and_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("workflow-file.md"),
"---\nname: inspect\ndescription: Inspect target\nusage: /inspect <path>\narguments: <path>\n---\ninspect",
)
.unwrap();
let hints = slash_completion_hints(
"/ins",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let entry = hints
.iter()
.find(|hint| hint.name == "/inspect")
.expect("frontmatter name should complete");
assert_eq!(entry.description, "Inspect target /inspect <path>");
assert!(!hints.iter().any(|hint| hint.name == "/workflow-file"));
}
#[test]
fn slash_completion_uses_arguments_when_usage_and_legacy_hint_are_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("deploy.md"),
"---\ndescription: Deploy target\narguments: <environment>\n---\ndeploy",
)
.unwrap();
let hints = slash_completion_hints(
"/deploy",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let entry = hints
.iter()
.find(|hint| hint.name == "/deploy")
.expect("custom command should be present");
assert_eq!(entry.description, "Deploy target <environment>");
}
#[test]
fn slash_completion_hints_exclude_hidden_user_commands() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("secret.md"),
"---\ndescription: Internal command\nhidden: true\n---\nsecret",
)
.unwrap();
let hints = slash_completion_hints(
"/secret",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
assert!(!hints.iter().any(|hint| hint.name == "/secret"));
}
#[test]
fn hidden_name_override_filters_shadowed_builtin_from_slash_completion() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("private-help.md"),
"---\nname: help\nhidden: true\n---\nprivate help",
)
.unwrap();
let hints = slash_completion_hints(
"/help",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
assert!(!hints.iter().any(|hint| hint.name == "/help"));
}
#[test]
fn slash_completion_hints_match_user_command_aliases() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("deploy-target.md"),
"---\ndescription: Deploy target\nalias: ship\n---\ndeploy",
)
.unwrap();
let hints = slash_completion_hints(
"/ship",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let entry = hints
.iter()
.find(|hint| hint.name == "/deploy-target")
.expect("user command should be matched by alias");
assert_eq!(entry.alias_hint.as_deref(), Some("ship"));
assert_eq!(entry.description, "Deploy target");
}
#[test]
fn slash_completion_omits_rejected_user_alias_collisions() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("alpha.md"),
"---\ndescription: Alpha command\nalias: beta\n---\nalpha",
)
.unwrap();
std::fs::write(
commands_dir.join("beta.md"),
"---\ndescription: Beta command\n---\nbeta",
)
.unwrap();
let hints = slash_completion_hints(
"/bet",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
assert!(hints.iter().any(|hint| hint.name == "/beta"));
assert!(
!hints.iter().any(|hint| hint.name == "/alpha"),
"a command must not match through an alias rejected by the registry"
);
}
#[test]
fn slash_completion_hints_keep_builtin_canonical_when_only_builtin_alias_is_shadowed() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("attach-review.md"),
"---\ndescription: Review image\nalias: image\n---\nreview image",
)
.unwrap();
let canonical_hints = slash_completion_hints(
"/att",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let attach = canonical_hints
.iter()
.find(|hint| hint.name == "/attach")
.expect(
"canonical /attach should remain visible when only its /image alias is shadowed",
);
assert!(
!attach.description.contains("/image"),
"canonical completion must not advertise a user-shadowed alias"
);
let alias_hints = slash_completion_hints(
"/image",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
assert!(
alias_hints.iter().any(|hint| hint.name == "/attach-review"),
"user command should complete through its /image alias"
);
assert!(
!alias_hints.iter().any(|hint| hint.name == "/attach"),
"built-in /attach should not complete through shadowed /image alias"
);
}
#[test]
fn slash_completion_accepted_user_alias_claims_builtin_canonical_token() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("assistant.md"),
"---\ndescription: My assistant\nalias: help\n---\nassistant",
)
.unwrap();
let hints = slash_completion_hints(
"/help",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
assert!(
!hints.iter().any(|hint| hint.name == "/help"),
"built-in /help must be absent when a user alias claims the token"
);
assert!(
hints.iter().any(|hint| hint.name == "/assistant"),
"the user command must appear for the claimed token"
);
}
#[test]
fn slash_completion_hints_prefer_user_metadata_for_shadowed_builtin() {
let tmp = tempfile::TempDir::new().unwrap();
let commands_dir = tmp.path().join(".codewhale").join("commands");
std::fs::create_dir_all(&commands_dir).unwrap();
std::fs::write(
commands_dir.join("help.md"),
"---\ndescription: Custom help workflow\nargument-hint: <topic>\n---\nhelp",
)
.unwrap();
let hints = slash_completion_hints(
"/help",
128,
&[],
Locale::En,
Some(tmp.path()),
ApiProvider::Deepseek,
);
let help_entries: Vec<_> = hints.iter().filter(|hint| hint.name == "/help").collect();
assert_eq!(help_entries.len(), 1);
assert_eq!(help_entries[0].description, "Custom help workflow <topic>");
}
#[test]
fn review_regression_push_command_entry_uses_preloaded_user_command_frontmatter() {
let registry = crate::commands::user_registry::UserCommandRegistry::from_loaded(vec![(
"deploy".to_string(),
"---\ndescription: Deploy target\nargument-hint: <env>\n---\ndeploy".to_string(),
)]);
let user_commands: Vec<_> = registry.iter().collect();
let mut entries = Vec::new();
push_command_entry(
&mut entries,
"/deploy",
"deploy",
"deploy",
Locale::En,
&user_commands,
);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "/deploy");
assert_eq!(entries[0].description, "Deploy target <env>");
}
#[test]
fn slash_completion_hints_hide_skills_from_top_level_menu() {
let cached_skills = vec![
("search-files".to_string(), "Search files".to_string()),
("my-review".to_string(), "Review code".to_string()),
];
let hints = slash_completion_hints(
"/",
128,
&cached_skills,
Locale::En,
None,
ApiProvider::Deepseek,
);
assert!(hints.iter().any(|hint| hint.name == "/skill"));
assert!(hints.iter().any(|hint| hint.name == "/skills"));
assert!(!hints.iter().any(|hint| hint.is_skill));
}
#[test]
fn slash_completion_hints_hide_skills_from_top_level_prefix() {
let cached_skills = vec![
("search-files".to_string(), "Search files".to_string()),
("my-review".to_string(), "Review code".to_string()),
];
let hints = slash_completion_hints(
"/se",
128,
&cached_skills,
Locale::En,
None,
ApiProvider::Deepseek,
);
assert!(!hints.iter().any(|hint| hint.name == "/skill search-files"));
assert!(!hints.iter().any(|hint| hint.name == "/skill my-review"));
}
#[test]
fn slash_completion_hints_complete_skill_argument_all() {
let cached_skills = vec![
("search-files".to_string(), "Search files".to_string()),
("my-review".to_string(), "Review code".to_string()),
];
let hints = slash_completion_hints(
"/skill ",
128,
&cached_skills,
Locale::En,
None,
ApiProvider::Deepseek,
);
assert_eq!(hints.len(), 2);
assert!(hints.iter().any(|hint| hint.name == "/skill search-files"));
assert!(hints.iter().any(|hint| hint.name == "/skill my-review"));
assert!(hints.iter().all(|hint| hint.is_skill));
}
#[test]
fn slash_completion_hints_complete_skill_argument_prefix() {
let cached_skills = vec![
("search-files".to_string(), "Search files".to_string()),
("my-review".to_string(), "Review code".to_string()),
];
let hints = slash_completion_hints(
"/skill my",
128,
&cached_skills,
Locale::En,
None,
ApiProvider::Deepseek,
);
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].name, "/skill my-review");
assert!(hints[0].is_skill);
}
#[test]
fn slash_completion_hints_model_deepseek_provider_uses_bare_ids() {
let hints =
slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::Deepseek);
let names = hints
.iter()
.map(|hint| hint.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"/model deepseek-v4-pro"));
assert!(names.contains(&"/model deepseek-v4-flash"));
assert!(!names.contains(&"/model deepseek-ai/deepseek-v4-pro"));
assert!(!names.contains(&"/model deepseek/deepseek-v4-pro"));
}
#[test]
fn slash_completion_hints_model_provider_uses_provider_specific_ids() {
let hints =
slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::NvidiaNim);
let names = hints
.iter()
.map(|hint| hint.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"/model deepseek-ai/deepseek-v4-pro"));
assert!(!names.contains(&"/model deepseek/deepseek-v4-pro"));
}
#[test]
fn slash_completion_hints_model_ollama_has_no_static_remote_models() {
let hints =
slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::Ollama);
let names = hints
.iter()
.map(|hint| hint.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"/model"));
assert!(!names.contains(&"/model deepseek-v4-pro"));
assert!(!names.contains(&"/model deepseek-v4-flash"));
assert!(!names.contains(&"/model deepseek-coder:1.3b"));
}
#[test]
fn selection_style_uses_explicit_selection_text_role() {
let line = Line::from(Span::styled(
"hello world",
Style::default().fg(palette::TEXT_PRIMARY),
));
let selection_style = Style::default()
.bg(palette::SELECTION_BG)
.fg(palette::SELECTION_TEXT);
let styled = apply_selection_to_line(&line, 0, 5, selection_style);
assert_eq!(styled.len(), 2);
assert_eq!(styled[0].content.as_ref(), "hello");
assert_eq!(styled[0].style.fg, Some(palette::SELECTION_TEXT));
assert_eq!(styled[0].style.bg, Some(palette::SELECTION_BG));
assert_eq!(styled[1].content.as_ref(), " world");
}
#[test]
fn selection_keeps_keycap_grapheme_intact() {
let line = Line::from(Span::raw("A1\u{fe0f}\u{20e3}B"));
let selection_style = Style::default().bg(palette::SELECTION_BG);
let styled = apply_selection_to_line(&line, 2, 3, selection_style);
assert_eq!(styled.len(), 3);
assert_eq!(styled[0].content.as_ref(), "A");
assert_eq!(styled[1].content.as_ref(), "1\u{fe0f}\u{20e3}");
assert_eq!(styled[1].style.bg, Some(palette::SELECTION_BG));
assert_eq!(styled[2].content.as_ref(), "B");
}
#[test]
fn composer_layout_helpers_stay_consistent() {
let input = "line one wraps nicely\nline two wraps as well";
let width = 16;
let available_height = 6;
let menu_lines = 2;
let height = composer_height(
input,
width,
available_height,
menu_lines,
ComposerDensity::Comfortable,
true,
);
let has_panel = enclosed_composer_panel_fits(true, width, available_height);
let chrome_height = if has_panel {
usize::from(COMPOSER_PANEL_HEIGHT)
} else {
1
};
let content_width = usize::from(width.saturating_sub(COMPOSER_PROMPT_GUTTER_WIDTH).max(1));
let input_height_budget = usize::from(height)
.saturating_sub(menu_lines)
.saturating_sub(chrome_height)
.max(1);
let (visible, cursor_row, cursor_col) = layout_input(
input,
input.chars().count(),
content_width,
input_height_budget,
);
assert!(visible.len().saturating_add(menu_lines) <= usize::from(height));
assert!(!visible.is_empty());
assert!(cursor_row < visible.len());
assert!(cursor_col < content_width.max(1));
assert!(height >= 5);
}
#[test]
fn composer_height_prefers_panel_shape_when_space_allows() {
let height = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
assert_eq!(height, 3);
}
#[test]
fn composer_panel_height_and_render_policy_agree_at_width_boundary() {
let mut app = create_test_app();
app.composer_border = true;
app.composer_density = ComposerDensity::Comfortable;
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 8, &slash_menu_entries, &mention_menu_entries);
for (width, expected_panel, expected_height) in
[(11, false, 2), (12, true, 3), (13, true, 3), (14, true, 3)]
{
let height = widget.desired_height(width);
let area = Rect::new(0, 0, width, height);
assert_eq!(height, expected_height, "width={width}");
assert_eq!(widget.has_panel(area), expected_panel, "width={width}");
assert_eq!(
widget.inner_area(area).height,
1,
"width={width} auto-fit composer reserves one input row plus \
every rendered border row"
);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
assert_eq!(
buf[(1, area.bottom().saturating_sub(1))].symbol() == "\u{2500}",
expected_panel,
"width={width} bottom border disagrees with height policy"
);
}
}
#[test]
fn composer_expands_for_multiline_input_and_collapses_again() {
let height_for =
|input| composer_height(input, 40, 12, 0, ComposerDensity::Comfortable, true);
let collapsed = height_for("short");
let expanded = height_for("one\ntwo\nthree\nfour\nfive\nsix");
let collapsed_again = height_for("short");
assert_eq!(collapsed, 3);
assert_eq!(expanded, 8);
assert!(expanded > collapsed);
assert_eq!(collapsed_again, collapsed);
}
#[test]
fn composer_auto_fits_typed_lines_and_returns_to_one_row_on_submit_or_clear() {
const WIDTH: u16 = 40;
const AVAILABLE: u16 = 24;
fn measure(app: &App) -> (u16, u16) {
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget =
ComposerWidget::new(app, AVAILABLE, &slash_menu_entries, &mention_menu_entries);
let total = widget.desired_height(WIDTH);
let inner = widget.inner_area(Rect::new(0, 0, WIDTH, total)).height;
(total, inner)
}
let mut app = create_test_app();
app.composer_border = true;
app.composer_density = ComposerDensity::Comfortable;
assert_eq!(measure(&app), (3, 1), "empty composer");
app.insert_str("one line");
assert_eq!(measure(&app), (3, 1), "single-line composer");
for n in 2..=7u16 {
app.clear_input();
let text = (1..=n)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
app.insert_str(&text);
assert_eq!(measure(&app), (n + 2, n), "{n} typed lines");
}
app.clear_input();
app.insert_str(&vec!["over"; 40].join("\n"));
let cap = composer_max_height(ComposerDensity::Comfortable);
assert_eq!(measure(&app), (cap, cap - 2), "content beyond the cap");
assert!(app.submit_input().is_some());
assert_eq!(measure(&app), (3, 1), "after submit");
app.insert_str("a\nb\nc\nd");
assert_eq!(measure(&app), (6, 4), "four-line draft");
app.clear_input();
assert_eq!(measure(&app), (3, 1), "after clear");
}
#[test]
fn composer_height_uses_quiet_rule_when_panel_is_not_needed() {
let with_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
let without_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, false);
assert_eq!(with_border, 3);
assert_eq!(without_border, 2);
assert!(without_border < with_border);
}
#[test]
fn composer_density_changes_height_cap() {
assert!(
composer_max_height(ComposerDensity::Spacious)
> composer_max_height(ComposerDensity::Compact)
);
}
#[test]
fn composer_content_geometry_is_the_single_prompt_adjusted_text_rect() {
let inner = Rect::new(10, 4, 7, 3);
let normal = composer_content_geometry(inner, false);
assert_eq!(normal.prompt_inset, 2);
assert_eq!(normal.text_area, Rect::new(12, 4, 5, 3));
assert_eq!(normal.text_width(), 5);
let history = composer_content_geometry(inner, true);
assert_eq!(history.prompt_inset, 0);
assert_eq!(history.text_area, inner);
let narrow = composer_content_geometry(Rect::new(3, 2, 2, 1), false);
assert_eq!(narrow.prompt_inset, 0);
assert_eq!(narrow.text_area, Rect::new(3, 2, 2, 1));
}
#[test]
fn composer_wrap_boundary_cursor_scroll_and_mouse_lines_share_text_width() {
let geometry = composer_content_geometry(Rect::new(0, 0, 7, 2), false);
let input = "abcde";
let cursor = input.chars().count();
let width = geometry.text_width();
let (absolute_row, absolute_col) = cursor_row_col(input, cursor, width);
let (visible, visible_row, visible_col, scroll_offset) =
layout_input_with_scroll(input, cursor, width, 1);
let mouse_lines = wrap_input_lines_for_mouse(input, width);
assert_eq!((absolute_row, absolute_col), (1, 0));
assert_eq!(scroll_offset, 1);
assert_eq!((visible_row, visible_col), (0, 0));
assert_eq!(visible, vec![String::new()]);
assert_eq!(mouse_lines[scroll_offset], (cursor, String::new()));
}
#[test]
fn empty_composer_keeps_prompt_and_hint_on_one_row() {
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 5,
};
assert_eq!(
empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 40, 3),
1
);
assert_eq!(widget.cursor_pos(area), Some((2, 2)));
}
#[test]
fn empty_composer_cursor_accounts_for_wrapped_placeholder_hint() {
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 14,
height: 5,
};
assert_eq!(placeholder_visual_lines(14), 2);
assert_eq!(
empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 14, 3),
1
);
assert_eq!(widget.cursor_pos(area), Some((2, 2)));
}
#[test]
fn empty_composer_renders_prompt_and_hint_on_cursor_row() {
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 5,
};
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let Some((cursor_x, cursor_y)) = widget.cursor_pos(area) else {
panic!("empty composer should expose cursor position");
};
let rendered = buffer_text(&buf, area);
assert_eq!(buf[(cursor_x, cursor_y)].symbol(), "W");
assert_eq!(
buf[(cursor_x, cursor_y)].fg,
app.ui_theme.text_soft,
"the idle prompt should use the readable soft-text role"
);
assert!(
!buf[(cursor_x, cursor_y)]
.modifier
.contains(Modifier::ITALIC),
"the idle prompt should remain upright at distance"
);
assert!(
rendered.contains(COMPOSER_PLACEHOLDER),
"placeholder hint should render on the prompt row: {rendered}"
);
assert!(
row_text(&buf, area, cursor_y).contains(COMPOSER_PLACEHOLDER),
"prompt and hint should share one row: {rendered}"
);
assert!(
row_text(&buf, area, cursor_y.saturating_add(1))
.trim()
.is_empty(),
"comfortable composer should keep a quiet row before the footer: {rendered}"
);
}
#[test]
fn composer_keeps_prompt_anchored_after_first_keystroke() {
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
app.input = "hello".to_string();
app.cursor_position = app.input.len();
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let (cursor_x, cursor_y) = widget
.cursor_pos(area)
.expect("composer with input should expose a cursor");
assert_eq!(buf[(0, cursor_y)].symbol(), "❯");
assert_eq!(buf[(2, cursor_y)].symbol(), "h");
assert_eq!(cursor_x, 7, "cursor keeps the prompt gutter reserved");
}
#[test]
fn composer_border_omits_session_title_chrome() {
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
app.session_title = Some("my-session".to_string());
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 96,
height: 5,
};
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(!rendered.contains("Composer"));
assert!(!rendered.contains("my-session"));
}
#[test]
fn composer_border_omits_active_turn_receipt_chrome() {
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
app.set_receipt_text("✓ turn completed · 2 tool(s) used");
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 96,
height: 5,
};
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(!rendered.contains("Composer"));
assert!(!rendered.contains("turn completed"));
assert!(!rendered.contains("tool(s) used"));
}
#[test]
fn composer_border_edges_encode_warm_permission_and_cool_mode_ramps() {
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let area = Rect::new(0, 0, 40, 5);
for theme_id in palette::SELECTABLE_THEMES {
let theme = theme_id.ui_theme();
for (approval_mode, expected) in [
(ApprovalMode::Suggest, theme.permission_ask),
(ApprovalMode::Never, theme.permission_ask),
(ApprovalMode::Auto, theme.permission_auto_review),
(ApprovalMode::Bypass, theme.permission_full_access),
] {
let mut app = create_test_app();
app.ui_theme = theme;
app.approval_mode = approval_mode;
let widget =
ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
assert_eq!(
buf[(1, area.top())].fg,
expected,
"{} {approval_mode:?}",
theme_id.name()
);
}
for (mode, expected) in [
(AppMode::Plan, theme.mode_plan),
(AppMode::Agent, theme.mode_agent),
(AppMode::Operate, theme.mode_operate),
] {
let mut app = create_test_app();
app.ui_theme = theme;
app.mode = mode;
let widget =
ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
assert_eq!(
buf[(1, area.bottom().saturating_sub(1))].fg,
expected,
"{} {mode:?}",
theme_id.name()
);
}
}
}
#[test]
fn composer_border_keeps_mode_titles_contextual() {
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let area = Rect {
x: 0,
y: 0,
width: 96,
height: 5,
};
let mut normal_app = create_test_app();
normal_app.composer_density = ComposerDensity::Comfortable;
let normal_widget =
ComposerWidget::new(&normal_app, 5, &slash_menu_entries, &mention_menu_entries);
let mut normal_buf = Buffer::empty(area);
normal_widget.render(area, &mut normal_buf);
let normal_rendered = buffer_text(&normal_buf, area);
assert!(!normal_rendered.contains("Composer"));
assert!(!normal_rendered.contains("Draft"));
assert!(
!normal_rendered
.contains(&*normal_app.tr(crate::localization::MessageId::HistorySearchTitle))
);
let mut draft_app = create_test_app();
draft_app.composer_density = ComposerDensity::Comfortable;
draft_app.insert_str("first line\nsecond line");
let draft_widget =
ComposerWidget::new(&draft_app, 5, &slash_menu_entries, &mention_menu_entries);
let mut draft_buf = Buffer::empty(area);
draft_widget.render(area, &mut draft_buf);
assert!(!buffer_text(&draft_buf, area).contains("Draft"));
let mut search_app = create_test_app();
search_app.composer_density = ComposerDensity::Comfortable;
search_app.start_history_search();
let search_widget =
ComposerWidget::new(&search_app, 5, &slash_menu_entries, &mention_menu_entries);
let mut search_buf = Buffer::empty(area);
search_widget.render(area, &mut search_buf);
assert!(
buffer_text(&search_buf, area)
.contains(&*search_app.tr(crate::localization::MessageId::HistorySearchTitle))
);
}
#[test]
fn slash_menu_open_locks_composer_height_against_match_count_changes() {
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
app.input = "/skill".to_string();
let many_matches: Vec<SlashMenuEntry> = (0..5)
.map(|i| SlashMenuEntry {
name: format!("/skill{i}"),
description: String::new(),
is_skill: false,
alias_hint: None,
})
.collect();
let one_match = vec![SlashMenuEntry {
name: "/skill".to_string(),
description: String::new(),
is_skill: false,
alias_hint: None,
}];
let no_matches = Vec::<SlashMenuEntry>::new();
let widget_many = ComposerWidget::new(&app, 9, &many_matches, &[]);
let widget_one = ComposerWidget::new(&app, 9, &one_match, &[]);
let widget_none = ComposerWidget::new(&app, 9, &no_matches, &[]);
let height_many = widget_many.desired_height(40);
let height_one = widget_one.desired_height(40);
assert_eq!(
height_many, height_one,
"slash menu height must not jitter as the matched-entry count changes"
);
let height_none = widget_none.desired_height(40);
assert!(
height_none < height_many,
"with the menu closed the composer should release the reserved rows; got {height_none} vs locked {height_many}"
);
}
#[test]
fn empty_composer_cursor_follows_idle_prompt_when_border_disabled() {
let mut app = create_test_app();
app.composer_density = ComposerDensity::Comfortable;
app.composer_border = false;
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 3, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 40,
height: 3,
};
assert_eq!(widget.cursor_pos(area), Some((2, 2)));
}
#[test]
fn operate_composer_invites_ordinary_parallel_tasks() {
let mut app = create_test_app();
app.mode = AppMode::Operate;
assert_eq!(
composer_empty_hint_text(&app),
"Describe the goal — Codewhale keeps working until it's done"
);
app.ui_locale = Locale::Es419;
assert_eq!(
composer_empty_hint_text(&app),
"Describe el objetivo — Codewhale seguirá trabajando hasta terminarlo"
);
assert_ne!(
composer_empty_hint_text(&app),
tr(Locale::En, MessageId::ComposerOperatePlaceholder),
"Operate mode must use the active non-English locale"
);
}
#[test]
fn localized_composer_placeholders_render_at_narrow_widths() {
for locale in [Locale::Ja, Locale::ZhHans, Locale::PtBr] {
let mut app = create_test_app();
app.ui_locale = locale;
app.composer_density = ComposerDensity::Comfortable;
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 18,
height: 5,
};
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let Some((cursor_x, cursor_y)) = widget.cursor_pos(area) else {
panic!("localized composer should expose cursor position");
};
assert!(cursor_x < area.width, "{locale:?} cursor x overflow");
assert!(cursor_y < area.height, "{locale:?} cursor y overflow");
}
}
#[test]
fn composer_top_padding_uses_clamp() {
assert_eq!(composer_top_padding(0, 3), 1);
assert_eq!(composer_top_padding(1, 3), 1);
assert_eq!(composer_top_padding(3, 3), 0);
assert_eq!(composer_top_padding(5, 3), 0);
}
#[test]
fn empty_state_renders_only_without_transcript_activity() {
let mut app = create_test_app();
assert!(should_render_empty_state(&app));
app.add_message(crate::tui::history::HistoryCell::User {
content: "hello".to_string(),
});
assert!(!should_render_empty_state(&app));
}
#[test]
fn durable_tasks_suppress_the_launch_tableau() {
let mut app = create_test_app();
app.task_panel.push(TaskPanelEntry {
id: "shell_1".to_string(),
status: "running".to_string(),
prompt_summary: "cargo test".to_string(),
duration_ms: Some(100),
kind: TaskPanelEntryKind::Background,
stale: false,
elapsed_since_output_ms: None,
owner_agent_id: None,
owner_agent_name: None,
current_tool: None,
role: None,
files_touched: 0,
});
assert!(!should_render_empty_state(&app));
}
#[test]
fn chat_widget_publishes_wrapped_url_regions_without_touching_cells() {
let mut app = create_test_app();
app.low_motion = true;
let target = "https://example.test/a/very/long/path/that/wraps/across/chat/rows";
app.add_message(HistoryCell::Assistant {
content: target.to_string(),
streaming: false,
});
let area = Rect::new(4, 2, 20, 10);
let mut buf = Buffer::empty(area);
let _ = crate::tui::osc8::take_frame_links();
ChatWidget::new(&mut app, area).render(area, &mut buf);
let regions = crate::tui::osc8::take_frame_links();
assert!(regions.len() > 1, "narrow chat should wrap: {regions:?}");
assert!(regions.iter().all(|region| region.target == target));
assert!(regions.iter().all(|region| {
area.contains(ratatui::layout::Position {
x: region.col_start,
y: region.row,
}) && area.contains(ratatui::layout::Position {
x: region.col_end,
y: region.row,
})
}));
assert!((area.y..area.bottom()).all(|y| {
(area.x..area.right()).all(|x| {
let symbol = buf[(x, y)].symbol();
!symbol.contains('\x1b') && !symbol.contains("]8;;")
})
}));
}
#[test]
fn waiting_state_freezes_the_whole_ocean_field() {
let mut app = create_test_app();
app.low_motion = false;
app.fancy_animations = true;
app.view_stack
.push(crate::tui::views::HelpView::new_for_locale(app.ui_locale));
let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20));
assert!(!widget.ocean_animated);
assert!(!widget.ambient_life);
assert!(!should_render_empty_state(&app));
}
#[test]
fn reduced_motion_gets_no_ambient_life_through_the_completion_breath() {
for (low_motion, fancy_animations) in [(true, true), (false, false)] {
let mut app = create_test_app();
app.low_motion = low_motion;
app.fancy_animations = fancy_animations;
app.ocean_completion_started_at = Some(Instant::now());
let widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20));
assert_eq!(
widget.life_presence_fixed, 0,
"low_motion={low_motion} fancy={fancy_animations} leaked ambient life"
);
}
let mut full = create_test_app();
full.low_motion = false;
full.fancy_animations = true;
full.ocean_completion_started_at = Some(Instant::now());
let widget = ChatWidget::new(&mut full, Rect::new(0, 0, 100, 20));
assert!(
widget.life_presence_fixed > 0,
"full motion should still get the completion breath"
);
}
#[test]
fn reduced_and_still_modes_clear_the_one_shot_send_flash() {
for (low_motion, fancy_animations) in [(true, true), (false, false)] {
let mut app = create_test_app();
app.low_motion = low_motion;
app.fancy_animations = fancy_animations;
app.last_send_at = Some(Instant::now());
app.add_message(HistoryCell::User {
content: "semantic receipt".to_string(),
});
let _widget = ChatWidget::new(&mut app, Rect::new(0, 0, 100, 20));
assert!(
app.last_send_at.is_none(),
"non-full motion must not retain a time-based flash"
);
}
let mut full = create_test_app();
full.low_motion = false;
full.fancy_animations = true;
full.last_send_at = Some(Instant::now());
full.add_message(HistoryCell::User {
content: "animated receipt".to_string(),
});
let _widget = ChatWidget::new(&mut full, Rect::new(0, 0, 100, 20));
assert!(
full.last_send_at.is_some(),
"full motion should retain the active send-flash window"
);
}
#[test]
fn empty_state_shows_startup_context() {
let mut app = create_test_app();
app.onboarding_needs_api_key = false;
app.workspace = PathBuf::from("/tmp/codewhale-test-workspace");
app.mcp_configured_count = 2;
let lines = build_empty_state_lines(&app, Rect::new(0, 0, 100, 20));
let rendered = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("Codewhale"));
assert!(rendered.contains("/tmp/codewhale-test-workspace · no git · mcp 2"));
assert!(rendered.contains("Fleet ready /fleet setup"));
assert!(
!rendered.contains("Fleet setup /fleet setup"),
"the idle action must not imply that built-in Fleet roles still require setup"
);
assert!(rendered.contains("/help or Ctrl+K"));
assert!(!rendered.contains("Model /model"));
assert!(!rendered.contains("Rules /constitution"));
}
#[test]
fn empty_state_uses_readable_brand_and_command_hierarchy() {
let mut app = create_test_app();
app.onboarding_needs_api_key = false;
let lines = build_empty_state_lines(&app, Rect::new(0, 0, 100, 20));
let span_for = |needle: &str| {
lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content.contains(needle))
.unwrap_or_else(|| panic!("missing empty-state span {needle:?}"))
};
let brand = span_for("Codewhale");
assert_eq!(brand.style.fg, Some(app.ui_theme.text_body));
assert!(brand.style.add_modifier.contains(Modifier::BOLD));
let fleet_label = span_for("Fleet ready");
assert_eq!(fleet_label.style.fg, Some(app.ui_theme.text_soft));
assert!(!fleet_label.style.add_modifier.contains(Modifier::BOLD));
let fleet_command = span_for("/fleet setup");
assert_eq!(fleet_command.style.fg, Some(app.ui_theme.accent_primary));
assert!(fleet_command.style.add_modifier.contains(Modifier::BOLD));
let help_command = span_for("/help or Ctrl+K");
assert_eq!(help_command.style.fg, Some(app.ui_theme.accent_primary));
assert!(help_command.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn empty_state_does_not_claim_fleet_ready_without_a_provider_route() {
let mut app = create_test_app();
app.onboarding_needs_api_key = true;
for area in [Rect::new(0, 0, 40, 12), Rect::new(0, 0, 100, 20)] {
let rendered = build_empty_state_lines(&app, area)
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(rendered.contains("Fleet /provider"), "{rendered}");
assert!(!rendered.contains("Fleet ready"), "{rendered}");
assert!(!rendered.contains("/fleet setup"), "{rendered}");
}
}
#[test]
fn empty_state_centers_startup_block_by_actual_text_width() {
let mut app = create_test_app();
app.workspace = PathBuf::from("/tmp/codewhale-test-workspace");
let lines = build_empty_state_lines(&app, Rect::new(0, 0, 100, 20));
let text_lines = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
let context = "/tmp/codewhale-test-workspace · no git · mcp 0";
let context_line = text_lines
.iter()
.find(|line| line.trim_start() == context)
.expect("context line");
let expected_padding = (100usize - UnicodeWidthStr::width(context)) / 2;
let actual_padding = context_line.chars().take_while(|ch| *ch == ' ').count();
assert_eq!(actual_padding, expected_padding);
}
#[test]
fn underwater_launch_is_visibly_deep_and_preserves_text_cells() {
let mut app = create_test_app();
app.ui_theme = palette::UI_THEME;
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Ombre;
app.low_motion = false;
app.fancy_animations = true;
app.workspace = PathBuf::from("codewhale-test-workspace");
app.model = "deepseek-v4-pro".to_string();
let area = Rect::new(0, 0, 100, 20);
let base = app.ui_theme.surface_bg;
let context = format!("{} · no git · mcp 0", app.workspace.display());
let mut buf = Buffer::empty(area);
ChatWidget::new_with_ocean_elapsed(&mut app, area, 0).render(area, &mut buf);
assert_ne!(buf[(0, 0)].bg, buf[(0, 19)].bg);
let rendered = buffer_text(&buf, area);
let rightward = rendered.matches("><>").count() + rendered.matches("><o>").count();
let leftward = rendered.matches("<><").count() + rendered.matches("<o><").count();
assert!(
rightward == 0 || leftward == 0,
"one school shares one direction:\n{rendered}"
);
let fish_count = rightward + leftward;
assert!(
(4..=7).contains(&fish_count),
"wide idle water should show one cohesive wedge school (got {fish_count}):\n{rendered}"
);
let leads = rendered.matches("><o>").count() + rendered.matches("<o><").count();
assert_eq!(leads, 1, "exactly one eyed lead fish:\n{rendered}");
let context_x = ((100usize - UnicodeWidthStr::width(context.as_str())) / 2) as u16;
let context_cell = (0..area.height)
.find_map(|y| (buf[(context_x, y)].symbol() == "c").then_some((context_x, y)))
.expect("context line");
assert_eq!(
buf[context_cell].bg,
buf[(0, context_cell.1)].bg,
"ordinary transcript text must share its row's water color"
);
assert_ne!(
buf[context_cell].bg, base,
"the water column should continue behind ordinary text"
);
}
#[test]
fn compact_launch_states_that_fleet_is_ready_without_ambient_clutter() {
let mut app = create_test_app();
app.onboarding_needs_api_key = false;
let rendered = build_empty_state_lines(&app, Rect::new(0, 0, 40, 12))
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(rendered.contains("Fleet ready /fleet setup"));
assert!(!rendered.contains("▗▄▄"));
}
#[test]
fn launch_hierarchy_survives_responsive_gate_sizes() {
for (width, height) in [(40, 12), (60, 16), (80, 24), (100, 32), (140, 40)] {
let mut app = create_test_app();
app.onboarding_needs_api_key = false;
app.low_motion = false;
app.fancy_animations = true;
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
terminal
.draw(|frame| {
let area = frame.area();
let widget = ChatWidget::new(&mut app, area);
widget.render(area, frame.buffer_mut());
})
.expect("responsive idle draw");
let area = Rect::new(0, 0, width, height);
let rendered = buffer_text(terminal.backend().buffer(), area);
assert!(
rendered.contains("Fleet ready") && rendered.contains("/fleet setup"),
"Fleet readiness must remain explicit at {width}x{height}:\n{rendered}"
);
if height < 14 {
assert!(
!rendered.contains("▗▄▄"),
"the decorative whale must yield before the Fleet action at {width}x{height}"
);
} else if width >= 60 && height >= 16 {
assert!(
rendered.contains("▗▄▄"),
"the idle whale should remain visible at {width}x{height}:\n{rendered}"
);
}
}
}
#[test]
fn flat_treatment_keeps_theme_surface_and_ambient_life() {
let mut app = create_test_app();
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Flat;
app.low_motion = false;
app.fancy_animations = true;
let area = Rect::new(0, 0, 100, 20);
let base = app.ui_theme.surface_bg;
let mut buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut buf);
assert_eq!(buf[(0, 0)].bg, base);
assert_eq!(buf[(0, 19)].bg, base, "flat keeps the plain theme surface");
let rendered = buffer_text(&buf, area);
assert!(
rendered.contains("><>") || rendered.contains("<><"),
"flat means a plain surface, not a lifeless ocean — idle fish must survive:\n{rendered}"
);
assert!(
(0..area.height).any(|y| (0..area.width).any(|x| buf[(x, y)].symbol() == "F")),
"Fleet setup remains available in flat mode"
);
}
#[test]
fn solarized_light_ombre_keeps_canonical_surface_and_ambient_life() {
let mut app = create_test_app();
app.ui_theme = crate::palette::SOLARIZED_LIGHT_UI_THEME;
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Ombre;
app.low_motion = false;
app.fancy_animations = true;
let area = Rect::new(0, 0, 100, 30);
let canonical_base3 = Color::Rgb(0xfd, 0xf6, 0xe3);
let mut buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut buf);
assert_eq!(buf[(0, 0)].bg, canonical_base3);
assert_eq!(
buf[(0, 16)].bg,
canonical_base3,
"Solarized Light must not regress to the reported #e1e9da tint"
);
assert_eq!(
buf[(0, 29)].bg,
canonical_base3,
"Solarized Light must keep canonical Base3 through the viewport"
);
let rendered = buffer_text(&buf, area);
assert!(
rendered.contains("><>") || rendered.contains("<><"),
"preserving the background must not remove ambient life:\n{rendered}"
);
}
#[test]
fn solarized_light_custom_background_keeps_ombre() {
let mut app = create_test_app();
let custom = Color::Rgb(0x1a, 0x1b, 0x26);
app.ui_theme = crate::palette::SOLARIZED_LIGHT_UI_THEME.with_background_color(custom);
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Ombre;
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut buf);
assert_ne!(buf[(0, 0)].bg, custom);
assert_ne!(
buf[(0, 0)].bg,
buf[(0, 29)].bg,
"custom Solarized Light backgrounds must retain ombre depth"
);
}
#[test]
fn terminal_owned_background_still_carries_foreground_life() {
let mut app = create_test_app();
app.ui_theme = crate::palette::TERMINAL_UI_THEME;
app.low_motion = false;
app.fancy_animations = true;
let area = Rect::new(0, 0, 100, 20);
let mut buf = Buffer::empty(area);
ChatWidget::new(&mut app, area).render(area, &mut buf);
assert!(
(0..area.height).all(|y| (0..area.width).all(|x| buf[(x, y)].bg == Color::Reset)),
"the Terminal treatment must never paint a background"
);
let rendered = buffer_text(&buf, area);
assert!(
rendered.contains("><>") || rendered.contains("<><"),
"Terminal keeps foreground ambient life without owning the background:\n{rendered}"
);
}
#[test]
fn ascii_safe_tier_covers_whole_rendered_surfaces() {
let mut app = create_test_app();
app.low_motion = false;
app.fancy_animations = true;
let transcript_area = Rect::new(0, 0, 100, 32);
let mut transcript = Buffer::empty(transcript_area);
ChatWidget::new(&mut app, transcript_area).render(transcript_area, &mut transcript);
app.launch.visible = true;
let launch_area = Rect::new(0, 0, 100, 32);
let mut launch = Buffer::empty(launch_area);
crate::tui::underwater::render_launch_screen(launch_area, &mut launch, &app);
app.launch.visible = false;
let header_area = Rect::new(0, 0, 100, 2);
let mut header = Buffer::empty(header_area);
crate::tui::underwater::render_header(header_area, &mut header, &app);
app.is_loading = true;
let footer_area = Rect::new(0, 0, 100, 1);
let mut footer = Buffer::empty(footer_area);
crate::tui::underwater::render_footer(footer_area, &mut footer, &mut app);
app.is_loading = false;
for (surface, buf, rect) in [
("idle transcript", &transcript, transcript_area),
("launch", &launch, launch_area),
("header", &header, header_area),
("footer", &footer, footer_area),
] {
for y in rect.y..rect.bottom() {
for x in rect.x..rect.right() {
let mut cell = buf[(x, y)].clone();
crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
assert!(
cell.symbol().is_ascii(),
"{surface} cell ({x},{y}) {:?} lacks an ASCII-safe alternative",
buf[(x, y)].symbol()
);
}
}
}
}
#[test]
fn reduced_motion_freezes_the_ocean_without_removing_depth() {
let mut app = create_test_app();
app.low_motion = true;
app.fancy_animations = true;
let area = Rect::new(0, 0, 100, 20);
let mut first = Buffer::empty(area);
ChatWidget::new_with_ocean_elapsed(&mut app, area, 2_000).render(area, &mut first);
let mut second = Buffer::empty(area);
ChatWidget::new_with_ocean_elapsed(&mut app, area, 11_000).render(area, &mut second);
assert_ne!(first[(0, 0)].bg, first[(0, 19)].bg);
assert_eq!(first[(0, 0)].bg, second[(0, 0)].bg);
assert_eq!(first[(11, 14)].symbol(), second[(11, 14)].symbol());
}
#[test]
fn fish_glyph_always_matches_screen_direction() {
assert_eq!(fish_mark(true), "><>");
assert_eq!(fish_mark(false), "<><");
assert!(fish_heading(8, 9, 10, false));
assert!(!fish_heading(10, 9, 8, true));
assert!(fish_heading(8, 9, 9, false));
assert!(!fish_heading(10, 9, 9, true));
assert!(!fish_heading(74, 73, 72, true));
}
#[test]
fn browsing_history_keeps_fish_in_available_water() {
let mut app = create_test_app();
app.low_motion = false;
app.fancy_animations = true;
for index in 0..30 {
app.add_message(HistoryCell::Assistant {
content: format!("history row {index}"),
streaming: false,
});
}
app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
let area = Rect::new(0, 0, 100, 20);
let widget = ChatWidget::new(&mut app, area);
assert!(widget.ambient_life);
assert!(widget.ocean_animated);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(
rendered.contains("><>") || rendered.contains("<><"),
"scrollback should keep fish in collision-free cells:\n{rendered}"
);
}
#[test]
fn long_tool_result_lines_fit_requested_width() {
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "todo_write".to_string(),
status: ToolStatus::Success,
input_summary: Some("items: <2 items>".to_string()),
output: Some("hello world ".repeat(420)),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}));
for width in [40u16, 80, 111, 165] {
let lines = cell.lines(width);
for (idx, line) in lines.iter().enumerate() {
let visual: usize = line
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
let rail_adjust = if line.spans.first().is_some_and(|s| {
let c = s.content.as_ref();
c == "\u{256D} " || c == "\u{2502} " || c == "\u{2570} "
}) {
2usize
} else {
0
};
assert!(
visual.saturating_sub(rail_adjust) <= usize::from(width),
"line {idx} at width {width} has visual width {visual} > {width}"
);
}
}
}
#[test]
fn chat_widget_does_not_bleed_into_sidebar_for_long_tool_result() {
let cases: Vec<(u16, u16)> = vec![(80, 50), (120, 80), (165, 111), (200, 140)];
for (total_width, chat_width) in cases {
let mut app = create_test_app();
let long_value: String = "hello world ".repeat(420);
let json_payload = format!(
"{{\n \"items\": [\n {{ \"id\": 1, \"content\": \"{long_value}\", \"status\": \"pending\" }}\n ]\n}}"
);
let output = format!("Todo list updated (1 items, 0% complete)\n{json_payload}");
app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "todo_write".to_string(),
status: ToolStatus::Success,
input_summary: Some("todos: <1 items>".to_string()),
output: Some(output),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})));
let height: u16 = 30;
let chat_area = Rect {
x: 0,
y: 0,
width: chat_width,
height,
};
let full_area = Rect {
x: 0,
y: 0,
width: total_width,
height,
};
let mut buf = Buffer::empty(full_area);
let widget = ChatWidget::new(&mut app, chat_area);
widget.render(chat_area, &mut buf);
let default_symbol = " ";
for y in 0..height {
for x in chat_width..total_width {
let cell = &buf[(x, y)];
let sym = cell.symbol();
assert!(
sym == default_symbol || sym.is_empty(),
"[{total_width}x{height}, chat={chat_width}] cell ({x},{y}) leaked content {sym:?} outside chat_area"
);
}
}
}
}
#[test]
fn chat_widget_uses_configured_surface_background() {
let mut app = create_test_app();
let custom = ratatui::style::Color::Rgb(26, 27, 38);
app.ui_theme = app.ui_theme.with_background_color(custom);
app.ocean_treatment = crate::tui::ocean::OceanTreatment::Flat;
app.add_message(HistoryCell::Assistant {
content: "ready".to_string(),
streaming: false,
});
let area = Rect {
x: 0,
y: 0,
width: 30,
height: 5,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
assert_eq!(buf[(area.x, area.y)].bg, custom);
assert_eq!(
buf[(area.x + area.width - 1, area.y + area.height - 1)].bg,
custom
);
}
#[test]
fn chat_widget_does_not_render_turn_receipt_as_transcript_content() {
let mut app = create_test_app();
for i in 0..8 {
app.add_message(HistoryCell::Assistant {
content: format!("assistant line {i}"),
streaming: false,
});
}
app.set_receipt_text("✓ turn completed · 2 tool(s) used");
let area = Rect {
x: 0,
y: 0,
width: 48,
height: 6,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(!rendered.contains("turn completed"));
assert!(
rendered.contains("assistant line 7"),
"receipt should not displace the latest transcript line: {rendered:?}"
);
}
#[test]
fn chat_widget_reserves_scrollbar_gutter_when_scrollbar_visible() {
let mut app = create_test_app();
for i in 0..200 {
app.add_message(HistoryCell::User {
content: format!("user message {i}"),
});
}
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let scrollbar_track = "│";
let scrollbar_thumb = "┃";
let mut scrollbar_seen = false;
for y in 0..area.height {
let last = buf[(area.width - 1, y)].symbol();
let penult = buf[(area.width - 2, y)].symbol();
if last == scrollbar_track || last == scrollbar_thumb {
scrollbar_seen = true;
}
assert!(
penult != scrollbar_track && penult != scrollbar_thumb,
"scrollbar leaked into column {} (cell {:?}) at row {y}",
area.width - 2,
penult
);
}
assert!(
scrollbar_seen,
"scrollbar should be visible for a long history"
);
}
#[test]
fn chat_widget_shows_jump_to_latest_button_when_scrolled_up() {
let mut app = create_test_app();
app.use_mouse_capture = true;
for i in 0..80 {
app.add_message(HistoryCell::User {
content: format!("user message {i}"),
});
}
app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let button = app
.viewport
.jump_to_latest_button_area
.expect("button appears when transcript is not at tail");
assert_eq!(button.width, 3);
assert_eq!(button.height, 3);
assert_eq!(buf[(button.x + 1, button.y + 1)].symbol(), "↓");
}
#[test]
fn chat_widget_uses_light_theme_scroll_chrome() {
let mut app = create_test_app();
app.ui_theme = palette::LIGHT_UI_THEME;
app.use_mouse_capture = true;
for i in 0..120 {
app.add_message(HistoryCell::User {
content: format!("user message {i}"),
});
}
app.viewport.transcript_scroll = TranscriptScroll::at_line(0);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let mut saw_track = false;
let mut saw_thumb = false;
for y in 0..area.height {
let cell = &buf[(area.width - 1, y)];
match cell.symbol() {
"│" => {
saw_track = true;
assert_eq!(cell.fg, palette::LIGHT_UI_THEME.border);
}
"┃" => {
saw_thumb = true;
assert_eq!(cell.fg, palette::LIGHT_UI_THEME.status_working);
}
_ => {}
}
}
assert!(saw_track, "scrollbar track should render");
assert!(saw_thumb, "scrollbar thumb should render");
let button = app
.viewport
.jump_to_latest_button_area
.expect("button appears when transcript is not at tail");
assert_eq!(
buf[(button.x + 1, button.y + 1)].fg,
palette::LIGHT_UI_THEME.status_working
);
}
#[test]
fn chat_widget_hides_jump_to_latest_button_at_tail() {
let mut app = create_test_app();
app.use_mouse_capture = true;
for i in 0..80 {
app.add_message(HistoryCell::User {
content: format!("user message {i}"),
});
}
app.viewport.transcript_scroll = TranscriptScroll::to_bottom();
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 8,
};
let _widget = ChatWidget::new(&mut app, area);
assert!(
app.viewport.jump_to_latest_button_area.is_none(),
"button should hide while following the live tail"
);
assert!(app.viewport.transcript_scroll.is_at_tail());
}
#[test]
fn chat_widget_renders_cleanly_after_resize_during_long_task() {
let mut app = create_test_app();
for i in 0..30 {
app.add_message(HistoryCell::User {
content: format!("user message {i} during a long-running task"),
});
}
for (width, height) in [(140u16, 40u16), (90, 28), (60, 20), (140, 40)] {
app.handle_resize(width, height);
let area = Rect {
x: 0,
y: 0,
width,
height,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let mut non_empty = 0usize;
for y in 0..height {
for x in 0..width {
let sym = buf[(x, y)].symbol();
if sym != " " && !sym.is_empty() {
non_empty += 1;
}
}
}
assert!(
non_empty > 0,
"resize at {width}x{height} produced an empty buffer (#582)"
);
}
}
#[test]
fn approval_inline_band_stays_within_short_terminal() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"exec_shell",
"Run git commit",
&serde_json::json!({ "command": "git commit -m fix" }),
"exec_shell:git commit",
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
for area in [Rect::new(0, 0, 162, 17), Rect::new(0, 0, 39, 17)] {
let region = widget.inline_region(area);
assert!(region.x >= area.x);
assert!(region.right() <= area.right());
assert!(region.bottom() <= area.bottom());
assert_eq!(
region.bottom(),
area.bottom(),
"approval band must be bottom-anchored at {area:?}"
);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
}
}
#[test]
fn approval_inline_band_caps_at_half_the_viewport_and_keeps_actions_visible() {
let command = (0..24)
.map(|index| format!("printf command-{index}"))
.collect::<Vec<_>>()
.join("\n");
let request = crate::tui::approval::ApprovalRequest::new(
"approval-long",
"exec_shell",
"Run a long shell command",
&serde_json::json!({ "command": command }),
"exec_shell:long",
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 100, 30);
let region = widget.inline_region(area);
assert_eq!(region.bottom(), area.bottom());
assert!(region.height <= area.height.div_ceil(2), "{region:?}");
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(rendered.contains("[1 / y]"), "{rendered}");
assert!(rendered.contains("[Esc]"), "{rendered}");
assert!(rendered.contains("truncated"), "{rendered}");
}
#[test]
fn approval_compact_tiers_preserve_command_before_falling_back_to_details() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-tiers",
"exec_shell",
"Print a localized verification marker",
&serde_json::json!({ "command": "printf '安全確認'" }),
"exec_shell:printf",
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
for area in [Rect::new(0, 0, 80, 24), Rect::new(0, 0, 60, 16)] {
let region = widget.inline_region(area);
assert_eq!(region.bottom(), area.bottom());
assert!(region.height < area.height, "{area:?}: {region:?}");
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(rendered.contains("Command:"), "{area:?}: {rendered}");
for marker in ['安', '全', '確', '認'] {
assert!(rendered.contains(marker), "{area:?}: {rendered}");
}
assert!(rendered.contains("[1 / y]"), "{area:?}: {rendered}");
assert!(rendered.contains("[Esc]"), "{area:?}: {rendered}");
}
let tiny = Rect::new(0, 0, 40, 12);
let mut buf = Buffer::empty(tiny);
widget.render(tiny, &mut buf);
let rendered = buffer_text(&buf, tiny);
assert!(rendered.contains("[1 / y]"), "{rendered}");
assert!(rendered.contains("[Esc]"), "{rendered}");
assert!(
rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
"{rendered}"
);
}
#[test]
fn approval_truncation_hint_uses_platform_details_chord_in_every_locale() {
let details = crate::tui::shell_key_routing::tool_details_chord();
for locale in Locale::shipped() {
let hint = approval_truncation_hint(*locale);
assert!(hint.contains(details.as_ref()), "{locale:?}: {hint}");
assert!(!hint.contains("[v]"), "{locale:?}: {hint}");
}
}
#[test]
fn repo_law_approval_has_distinct_authority_grammar() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-law",
"edit_file",
"Repo law holds this write: \"manifest review\" protects Cargo.toml (matched Cargo.toml, .codewhale/constitution.json)",
&serde_json::json!({ "path": "Cargo.toml", "old": "a", "new": "b" }),
"edit_file:Cargo.toml",
);
assert!(request.is_repo_law_prompt());
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 120, 30);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(rendered.contains("REPO LAW"), "{rendered}");
assert!(rendered.contains("Repository constitution"), "{rendered}");
assert!(rendered.contains("approval-gated postures"), "{rendered}");
assert!(rendered.contains("Cargo.toml"), "{rendered}");
assert!((0..area.height).any(|y| {
let cell = &buf[(1, y)];
cell.symbol() == "═" && cell.fg == palette::STATUS_WARNING
}));
}
#[test]
fn approval_selected_destructive_option_uses_contrasting_highlight() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"exec_shell",
"Run git commit",
&serde_json::json!({ "command": "git commit -m fix" }),
"exec_shell:git commit",
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let selected_row = (area.y..area.y.saturating_add(area.height))
.find(|&y| {
(area.x..area.x.saturating_add(area.width))
.any(|x| buf[(x, y)].bg == palette::SELECTION_BG)
})
.expect("selected approval row should use selection background");
let highlighted_cells = (area.x..area.x.saturating_add(area.width))
.filter(|&x| {
let cell = &buf[(x, selected_row)];
!cell.symbol().trim().is_empty()
&& cell.bg == palette::SELECTION_BG
&& cell.fg == palette::SELECTION_TEXT
})
.count();
assert!(
highlighted_cells >= 4,
"selected destructive option should render visible selection text"
);
}
#[test]
fn approval_inline_marks_selected_row_and_separator_rule() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"exec_shell",
"Run git commit",
&serde_json::json!({ "command": "git commit -m fix" }),
"exec_shell:git commit",
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(
rendered.contains('\u{276f}'),
"selected option row should show a caret:\n{rendered}"
);
assert!(
rendered.contains('\u{2500}'),
"inline prompt should show a top separator rule:\n{rendered}"
);
}
#[test]
fn approval_inline_keeps_action_row_and_leaves_transcript_visible() {
let request = crate::tui::approval::ApprovalRequest::new_with_intent(
"approval-1",
"exec_shell",
"Run shell command",
&serde_json::json!({
"command": "rm -rf ./build && find . -name '*.tmp' -delete && cargo clean && echo done",
}),
"exec_shell:cleanup",
Some(
"Clearing stale build artifacts and temp files before a fresh run so the next build is reproducible.",
),
std::path::Path::new("/tmp/project"),
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
for (w, h) in [(40u16, 14u16), (80, 24), (100, 50), (60, 10)] {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(
rendered.contains("[1 / y]") && rendered.contains("[3 / d / n]"),
"action row must stay visible at {w}x{h}:\n{rendered}"
);
let region = widget.inline_region(area);
assert!(region.right() <= area.right() && region.bottom() <= area.bottom());
assert_eq!(
region.bottom(),
area.bottom(),
"band must be bottom-anchored at {w}x{h}"
);
if h >= 40 {
assert!(
region.y > area.y,
"tall frame must leave transcript visible above the band at {w}x{h}"
);
}
}
}
#[test]
fn approval_option_two_reads_as_session_scoped_not_always() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"exec_shell",
"Run git commit",
&serde_json::json!({ "command": "git commit -m fix" }),
"exec_shell:git commit",
);
let full = render_approval_request(&request, Rect::new(0, 0, 100, 30));
let full_session_option = full
.lines()
.find(|line| line.contains("[2 / a]"))
.expect("full approval card should render the session option");
assert!(
full_session_option.to_lowercase().contains("this session")
&& !full_session_option.to_lowercase().contains("always"),
"full approval option must state session scope without saying always:\n{full}"
);
let compact = render_approval_request(&request, Rect::new(0, 0, 60, 17));
let compact_session_option = compact
.lines()
.find(|line| line.contains("[2 / a]"))
.expect("short approval card should render the session option");
assert!(
compact_session_option.to_lowercase().contains("session")
&& !compact_session_option.to_lowercase().contains("always"),
"short-terminal controls must label [2 / a] as session-scoped:\n{compact}"
);
}
#[test]
fn approval_shell_command_detects_printf_write_file_preview() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"exec_shell",
"Run shell command",
&serde_json::json!({
"command": "printf '%s\\n' 'alpha' 'beta' > src/generated.txt",
"cwd": "/tmp/project",
}),
"exec_shell:printf",
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 110, 32);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(rendered.contains("Command:"), "{rendered}");
assert!(
rendered.contains("printf > src/generated.txt"),
"{rendered}"
);
assert!(rendered.contains("alpha"), "{rendered}");
assert!(rendered.contains("beta"), "{rendered}");
assert!(rendered.contains("Dir"), "{rendered}");
assert!(rendered.contains("/tmp/project"), "{rendered}");
}
#[test]
fn approval_card_renders_shell_ask_rule_save_preview() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"exec_shell",
"Run shell command",
&serde_json::json!({ "command": "cargo test --workspace" }),
"exec_shell:cargo-test",
);
let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
assert!(
rendered.contains("s allow once + always ask exact rule"),
"{rendered}"
);
assert!(
rendered.contains("Always allow this exact rule in this repo"),
"{rendered}"
);
assert!(rendered.contains("Save:"), "{rendered}");
assert!(rendered.contains("1 ask rule"), "{rendered}");
assert!(rendered.contains("1 allow rule"), "{rendered}");
assert!(
rendered.contains("tool=exec_shell command=cargo test --workspace"),
"{rendered}"
);
assert!(rendered.contains("command_exact=true"), "{rendered}");
assert!(rendered.contains("workspace=/workspace"), "{rendered}");
}
#[test]
fn approval_card_renders_file_ask_rule_save_previews() {
let cases = [
(
"write_file",
serde_json::json!({
"path": "src/main.rs",
"content": "fn main() {}\n",
}),
"tool=write_file path=src/main.rs",
),
(
"edit_file",
serde_json::json!({
"path": "/workspace/src/lib.rs",
"old_string": "old",
"new_string": "new",
}),
"tool=edit_file path=src/lib.rs",
),
];
for (tool_name, params, expected_rule) in cases {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
tool_name,
"Modify a file",
¶ms,
&format!("{tool_name}:src"),
);
let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
assert!(rendered.contains("Save:"), "{tool_name}:\n{rendered}");
assert!(rendered.contains("1 ask rule"), "{tool_name}:\n{rendered}");
assert!(
rendered.contains("1 allow rule"),
"{tool_name}:\n{rendered}"
);
assert!(
rendered.contains(expected_rule),
"{tool_name} should preview {expected_rule}:\n{rendered}"
);
}
}
#[test]
fn approval_card_renders_apply_patch_multi_rule_save_preview() {
let patch = "diff --git a/src/a.rs b/src/a.rs\n\
--- a/src/a.rs\n\
+++ b/src/a.rs\n\
@@ -1,1 +1,1 @@\n\
-old\n\
+new\n\
diff --git a/src/b.rs b/src/b.rs\n\
--- a/src/b.rs\n\
+++ b/src/b.rs\n\
@@ -1,1 +1,1 @@\n\
-old\n\
+new\n";
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"apply_patch",
"Apply a patch",
&serde_json::json!({ "patch": patch }),
"apply_patch:multi",
);
let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
assert!(rendered.contains("Save:"), "{rendered}");
assert!(rendered.contains("2 ask rules"), "{rendered}");
assert!(rendered.contains("2 allow rules"), "{rendered}");
assert!(
rendered.contains("tool=apply_patch path=src/a.rs"),
"{rendered}"
);
assert!(
rendered.contains("tool=apply_patch path=src/b.rs"),
"{rendered}"
);
}
#[test]
fn approval_card_truncates_apply_patch_ask_rule_save_preview() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"apply_patch",
"Apply a patch",
&serde_json::json!({
"replace": [
{ "path": "src/a.rs", "content": "a" },
{ "path": "src/b.rs", "content": "b" },
{ "path": "src/c.rs", "content": "c" },
{ "path": "src/d.rs", "content": "d" },
{ "path": "src/e.rs", "content": "e" }
]
}),
"apply_patch:many",
);
let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
assert!(rendered.contains("5 ask rules"), "{rendered}");
assert!(
rendered.contains("tool=apply_patch path=src/a.rs"),
"{rendered}"
);
assert!(rendered.contains("... 1 more"), "{rendered}");
assert!(
!rendered.contains("tool=apply_patch path=src/e.rs"),
"truncated rule should not render directly:\n{rendered}"
);
}
#[test]
fn approval_card_omits_ask_rule_save_preview_when_rule_is_unavailable() {
let unsafe_path = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"write_file",
"Write a file",
&serde_json::json!({
"path": "../escape.rs",
"content": "unsafe\n",
}),
"write_file:escape",
);
let preflight_failed = crate::tui::approval::ApprovalRequest::new(
"approval-2",
"apply_patch",
"Apply a patch",
&serde_json::json!({ "patch": "@@ -1 +1 @@\n-old\n+new\n" }),
"apply_patch:invalid",
);
for request in [unsafe_path, preflight_failed] {
let rendered = render_approval_request(&request, Rect::new(0, 0, 120, 40));
assert!(
!rendered.contains("s allow once + always ask exact rule"),
"S shortcut should stay hidden:\n{rendered}"
);
assert!(
!rendered.contains("Save:"),
"save preview should stay hidden:\n{rendered}"
);
assert!(
!rendered.contains("ask rule"),
"ask-rule details should stay hidden:\n{rendered}"
);
}
}
#[test]
fn approval_file_write_modal_renders_proposed_change_preview() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"write_file",
"Write a file",
&serde_json::json!({
"path": "src/main.rs",
"content": "fn main() {\n println!(\"visible before approval\");\n}\n",
}),
"write_file:src/main.rs",
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 120, 34);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(rendered.contains("Preview:"), "{rendered}");
assert!(rendered.contains("+ fn main() {"), "{rendered}");
assert!(
rendered.contains("visible before approval"),
"approval modal should show proposed file content before approval:\n{rendered}"
);
}
#[test]
fn apply_patch_approval_shows_preview_and_reserved_controls_on_short_terminal() {
let request = crate::tui::approval::ApprovalRequest::new(
"approval-1",
"apply_patch",
"Apply a patch",
&serde_json::json!({
"patch": "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1 +1 @@\n-old\n+new\n",
}),
"apply_patch:src/lib.rs",
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 80, 20);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(rendered.contains("Preview:"), "{rendered}");
assert!(rendered.contains("+new"), "{rendered}");
assert!(rendered.contains("truncated"), "{rendered}");
assert!(
rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
"{rendered}"
);
assert!(rendered.contains("[1 / y]"), "{rendered}");
assert!(rendered.contains("[3 / d / n]"), "{rendered}");
}
#[test]
fn approval_intent_summary_still_renders_with_shell_details() {
let request = crate::tui::approval::ApprovalRequest::new_with_intent(
"approval-1",
"exec_shell",
"Run shell command",
&serde_json::json!({
"command": "cargo build || echo fallback",
"cwd": "/tmp/project",
}),
"exec_shell:cargo",
Some("Need to verify the fallback build path before editing files."),
std::path::Path::new("/tmp/project"),
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 120, 34);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(rendered.contains("Intent:"), "{rendered}");
assert!(rendered.contains("fallback build path"), "{rendered}");
assert!(rendered.contains("Command:"), "{rendered}");
assert!(rendered.contains("cargo build ||"), "{rendered}");
assert!(rendered.contains("echo fallback"), "{rendered}");
}
#[test]
fn approval_shell_modal_stays_useful_on_short_terminals() {
let request = crate::tui::approval::ApprovalRequest::new_with_intent(
"approval-1",
"exec_shell",
"Built-in safety gate requires approval: destructive background/headless actions cannot auto-approve",
&serde_json::json!({
"command": "cd /Volumes/VIXinSSD/codewhale; cargo clippy -p codewhale-tui --all-targets --locked -- -D warnings 2>&1 | tee /tmp/codewhale-clippy.log",
"cwd": "/Volumes/VIXinSSD/codewhale",
}),
"exec_shell:cargo-clippy",
Some("Confirmed - passes in isolation, so this is the documentation gate."),
std::path::Path::new("/Volumes/VIXinSSD/codewhale"),
);
let view = crate::tui::approval::ApprovalView::new(request.clone());
let widget = ApprovalWidget::new(&request, &view);
let area = Rect::new(0, 0, 80, 20);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered = buffer_text(&buf, area);
assert!(
!rendered.contains("Built-in safety gate requires approval"),
"policy internals should not be the modal summary:\n{rendered}"
);
assert!(
!rendered.contains("Impact: Command"),
"command should only render in the command block:\n{rendered}"
);
assert!(rendered.contains("Command:"), "{rendered}");
assert!(rendered.contains("cargo clippy"), "{rendered}");
assert!(rendered.contains("truncated"), "{rendered}");
assert!(
rendered.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
"{rendered}"
);
assert!(rendered.contains("[1 / y]"), "{rendered}");
assert!(rendered.contains("[2 / a]"), "{rendered}");
assert!(rendered.contains("[3 / d / n]"), "{rendered}");
}
#[test]
fn chat_widget_renders_cleanly_after_resize_cycle() {
let mut app = create_test_app();
for i in 0..40 {
app.add_message(HistoryCell::User {
content: format!("user message {i} with enough text to wrap at 30 columns easily"),
});
}
let widths_to_cycle = [120u16, 80, 40, 60, 100, 30];
let height: u16 = 20;
for width in widths_to_cycle {
app.handle_resize(width, height);
let area = Rect {
x: 0,
y: 0,
width,
height,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let mut non_empty = 0usize;
for y in 0..height {
for x in 0..width {
let sym = buf[(x, y)].symbol();
if sym != " " && !sym.is_empty() {
non_empty += 1;
}
}
}
assert!(
non_empty > 0,
"render at {width}x{height} produced an empty buffer after resize"
);
}
}
#[test]
fn transcript_cache_invalidates_on_width_change() {
let mut app = create_test_app();
for i in 0..10 {
app.add_message(HistoryCell::User {
content: format!("a fairly long user message number {i} that needs to wrap"),
});
}
let area_wide = Rect {
x: 0,
y: 0,
width: 120,
height: 20,
};
let area_narrow = Rect {
x: 0,
y: 0,
width: 30,
height: 20,
};
let mut buf_wide = Buffer::empty(area_wide);
let widget_wide = ChatWidget::new(&mut app, area_wide);
widget_wide.render(area_wide, &mut buf_wide);
let wide_total_lines = app.viewport.transcript_cache.total_lines();
let mut buf_narrow = Buffer::empty(area_narrow);
let widget_narrow = ChatWidget::new(&mut app, area_narrow);
widget_narrow.render(area_narrow, &mut buf_narrow);
let narrow_total_lines = app.viewport.transcript_cache.total_lines();
assert!(
narrow_total_lines > wide_total_lines,
"narrow render should produce more wrapped lines (got {narrow_total_lines}, wide={wide_total_lines})"
);
}
#[test]
fn ghost_text_renders_when_suggestion_set_and_input_empty() {
let mut app = create_test_app();
app.prompt_suggestion = Some("What about error handling?".to_string());
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 5,
};
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let rendered: String = buf
.content
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.join("");
assert!(
rendered.contains("What about error handling?"),
"ghost text should render the suggestion. Got: {rendered}"
);
}
#[test]
fn ghost_text_hidden_when_input_not_empty() {
let mut app = create_test_app();
app.prompt_suggestion = Some("A suggestion".to_string());
app.input = "hello".to_string();
app.cursor_position = 5;
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 5,
};
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let has_suggestion = buf
.content
.iter()
.any(|c| c.symbol().contains("A suggestion"));
assert!(
!has_suggestion,
"suggestion should not render when input is non-empty"
);
}
#[test]
fn ghost_text_hidden_when_no_suggestion() {
let mut app = create_test_app();
app.prompt_suggestion = None;
let slash_menu_entries = Vec::<SlashMenuEntry>::new();
let mention_menu_entries = Vec::<String>::new();
let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries);
let area = Rect {
x: 0,
y: 0,
width: 80,
height: 5,
};
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
let has_placeholder_like_text = buf.content.iter().any(|c| !c.symbol().trim().is_empty());
assert!(
has_placeholder_like_text,
"some non-empty text should render as placeholder"
);
}
#[test]
fn receipt_settle_cascade_is_bounded_and_ordered() {
assert!(receipt_is_settling(0, 0));
assert!(!receipt_is_settling(0, 140));
assert!(receipt_is_settling(1, 140));
assert!(!receipt_is_settling(6, 560));
assert!(!receipt_is_settling(60, 560));
}
#[test]
fn fish_flee_is_one_shot_and_returns_to_ambient_origin() {
assert_eq!(fish_flee_offset(0), 0);
assert!(fish_flee_offset(400) >= 8);
assert_eq!(fish_flee_offset(800), 0);
assert_eq!(fish_flee_offset(8_000), 0);
}
}