mod build_screen;
mod input_panel;
mod layout;
mod scroll;
use super::render::render_scanline_background;
use super::stats::CliStats;
use super::theme::{ACCENT_CYAN, ACCENT_RED, SUCCESS, TEXT_MUTED, TEXT_SUBTLE, WARNING};
use super::transcript::{
ThinkBlockMeta, TranscriptEntry, think_block_expanded, transcript_to_lines, wrap_trim_disabled,
};
use super::tui_utils::neon_breath_color;
use anyhow::Result;
use crossterm::ExecutableCommand;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use std::collections::BTreeSet;
#[cfg(unix)]
use std::fs::OpenOptions;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::time::Instant;
use build_screen::draw_home_screen;
#[cfg(unix)]
use std::fs::File;
const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const CLI_TUI_VERSION: &str = env!("CARGO_PKG_VERSION");
fn compact_cwd_path(fallback: &str) -> String {
let Ok(cwd) = std::env::current_dir() else {
return fallback.to_string();
};
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(home);
if let Ok(stripped) = cwd.strip_prefix(&home_path) {
if stripped.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", stripped.display());
}
}
cwd.display().to_string()
}
fn u16_from_usize_saturating(value: usize) -> u16 {
u16::try_from(value).unwrap_or(u16::MAX)
}
#[cfg(unix)]
type CliBackendWriter = File;
#[cfg(not(unix))]
type CliBackendWriter = std::io::Stdout;
pub(crate) struct CliTui {
terminal: Terminal<CrosstermBackend<CliBackendWriter>>,
pub(crate) spinner_idx: usize,
pub(crate) expand_tool_blocks: bool,
pub(crate) expand_think_all: bool,
pub(crate) think_detail_overrides: BTreeSet<u64>,
pub(crate) last_render_hash: Option<u64>,
pub(crate) last_conversation_area: Option<ratatui::layout::Rect>,
pub(crate) last_conversation_scroll: u16,
pub(crate) last_conversation_lines: Vec<String>,
pub(crate) last_conversation_think_map: Vec<Option<ThinkBlockMeta>>,
}
#[derive(Clone, Copy)]
pub(crate) enum MouseAction {
None,
ToggleToolDetails,
ToggleThinkDetails,
SetScrollBack(u16),
}
impl CliTui {
pub(crate) fn new() -> Result<Self> {
enable_raw_mode()?;
#[cfg(unix)]
let mut screen: CliBackendWriter =
OpenOptions::new().read(true).write(true).open("/dev/tty")?;
#[cfg(not(unix))]
let mut screen: CliBackendWriter = std::io::stdout();
screen.execute(EnterAlternateScreen)?;
screen.execute(EnableMouseCapture)?;
let _ = screen.execute(PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
));
let backend = CrosstermBackend::new(screen);
let terminal = Terminal::new(backend)?;
Ok(Self {
terminal,
spinner_idx: 0,
expand_tool_blocks: false,
expand_think_all: false,
think_detail_overrides: BTreeSet::new(),
last_render_hash: None,
last_conversation_area: None,
last_conversation_scroll: 0,
last_conversation_lines: Vec::new(),
last_conversation_think_map: Vec::new(),
})
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
pub(crate) fn render_hash(
&self,
transcript: &[TranscriptEntry],
input: &str,
cursor_idx: usize,
busy: bool,
awaiting_clear_confirm: bool,
provider_name: &str,
model_name: &str,
stats: &CliStats,
workspace: &str,
draft: &str,
session_title: &str,
modified_files: &[String],
files_collapsed: bool,
scroll_back: u16,
show_menu: bool,
) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
busy.hash(&mut h);
awaiting_clear_confirm.hash(&mut h);
provider_name.hash(&mut h);
model_name.hash(&mut h);
workspace.hash(&mut h);
input.hash(&mut h);
cursor_idx.hash(&mut h);
draft.hash(&mut h);
session_title.hash(&mut h);
files_collapsed.hash(&mut h);
scroll_back.hash(&mut h);
show_menu.hash(&mut h);
self.expand_tool_blocks.hash(&mut h);
self.expand_think_all.hash(&mut h);
for think_id in &self.think_detail_overrides {
think_id.hash(&mut h);
}
stats.user_messages.hash(&mut h);
stats.assistant_messages.hash(&mut h);
stats.tool_events.hash(&mut h);
stats.input_tokens.hash(&mut h);
stats.output_tokens.hash(&mut h);
for f in modified_files {
f.hash(&mut h);
}
for e in transcript {
(e.role as u8).hash(&mut h);
e.text.hash(&mut h);
}
h.finish()
}
pub(crate) fn handle_mouse_click(&mut self, x: u16, y: u16) -> bool {
matches!(
self.resolve_mouse_action(x, y, 0),
MouseAction::ToggleToolDetails | MouseAction::ToggleThinkDetails
)
}
pub(crate) fn resolve_mouse_action(
&mut self,
x: u16,
y: u16,
current_scroll_back: u16,
) -> MouseAction {
let Some(area) = self.last_conversation_area else {
return MouseAction::None;
};
if area.width < 2 || area.height < 1 {
return MouseAction::None;
}
let inner_x0 = area.x;
let inner_x1 = area.x.saturating_add(area.width.saturating_sub(1));
let inner_y0 = area.y;
let inner_y1 = area.y.saturating_add(area.height.saturating_sub(1));
if x < inner_x0 || x > inner_x1 || y < inner_y0 || y > inner_y1 {
return MouseAction::None;
}
let inner_h = usize::from(area.height);
let total = self.last_conversation_lines.len();
let base_scroll = u16_from_usize_saturating(total.saturating_sub(inner_h));
let scrollbar_x = area.x.saturating_add(area.width.saturating_sub(1));
let scrollbar_hit_x = scrollbar_x.saturating_sub(1);
if x >= scrollbar_hit_x && x <= scrollbar_x && inner_h > 0 && base_scroll > 0 {
let row = usize::from(y.saturating_sub(inner_y0));
let max_row = inner_h.saturating_sub(1).max(1);
let row_u64 = u64::try_from(row).unwrap_or(u64::MAX);
let max_row_u64 = u64::try_from(max_row).unwrap_or(u64::MAX);
let target_effective = u16::try_from(row_u64 * u64::from(base_scroll) / max_row_u64)
.unwrap_or(base_scroll);
let target_scroll_back = base_scroll.saturating_sub(target_effective);
return MouseAction::SetScrollBack(target_scroll_back);
}
let row = usize::from(y.saturating_sub(inner_y0));
let line_idx = usize::from(self.last_conversation_scroll) + row;
let Some(line) = self.last_conversation_lines.get(line_idx) else {
return MouseAction::None;
};
if let Some(think_meta) = self.last_conversation_think_map.get(line_idx).and_then(|v| *v) {
let current_expanded = think_block_expanded(
think_meta,
self.expand_think_all,
&self.think_detail_overrides,
);
let next_expanded = !current_expanded;
self.expand_think_all = false;
if next_expanded == think_meta.open {
self.think_detail_overrides.remove(&think_meta.id);
} else {
self.think_detail_overrides.insert(think_meta.id);
}
return MouseAction::ToggleThinkDetails;
}
if line.contains("[折叠") || line.contains("展开详情") || line.contains("收起详情")
{
self.expand_tool_blocks = !self.expand_tool_blocks;
return MouseAction::ToggleToolDetails;
}
MouseAction::SetScrollBack(current_scroll_back)
}
pub(crate) fn invalidate_render_cache(&mut self) {
self.last_render_hash = None;
}
pub(crate) fn tick(&mut self) {
self.spinner_idx = (self.spinner_idx + 1) % SPINNER_FRAMES.len();
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
pub(crate) fn draw(
&mut self,
transcript: &[TranscriptEntry],
input: &str,
cursor_idx: usize,
busy: bool,
awaiting_clear_confirm: bool,
provider_name: &str,
model_name: &str,
stats: &CliStats,
workspace: &str,
draft: &str,
session_title: &str,
modified_files: &[String],
files_collapsed: bool,
scroll_back: u16,
show_menu: bool,
) -> Result<()> {
let render_hash = self.render_hash(
transcript,
input,
cursor_idx,
busy,
awaiting_clear_confirm,
provider_name,
model_name,
stats,
workspace,
draft,
session_title,
modified_files,
files_collapsed,
scroll_back,
show_menu,
);
let now = Instant::now();
let pwd_path = compact_cwd_path(workspace);
let mut frame_conversation_area: Option<ratatui::layout::Rect> = None;
let mut frame_conversation_scroll: u16 = 0;
let mut frame_conversation_lines: Vec<String> = Vec::new();
let mut frame_conversation_think_map: Vec<Option<ThinkBlockMeta>> = Vec::new();
self.terminal.draw(|f| {
let area = f.area();
f.render_widget(Clear, area);
render_scanline_background(f, area);
let neon = neon_breath_color(self.spinner_idx);
let show_home = stats.user_messages == 0 && stats.assistant_messages == 0;
let input_lines = u16_from_usize_saturating(input.lines().count().max(1));
let input_height = (input_lines + 2).clamp(7, 13);
if show_home {
draw_home_screen(
f,
area,
input,
cursor_idx,
busy,
model_name,
workspace,
input_height,
neon,
self.spinner_idx,
show_menu,
);
return;
}
let layout = layout::main_layout(area);
let chunks = [
layout.header_area(),
layout.subheader_area(),
layout.body_area(),
layout.footer_area(),
];
let (status_text, status_color) =
if busy { ("● Running", WARNING) } else { ("● Ready", SUCCESS) };
let header_columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(24), Constraint::Min(1)])
.split(chunks[0]);
let logo_lines = vec![
Line::from(vec![
Span::styled(
"氛",
Style::default().fg(ACCENT_RED).add_modifier(Modifier::BOLD),
),
Span::styled(
"围",
Style::default().fg(ACCENT_CYAN).add_modifier(Modifier::BOLD),
),
Span::styled(
"视",
Style::default().fg(ACCENT_RED).add_modifier(Modifier::BOLD),
),
Span::styled(
"窗",
Style::default().fg(ACCENT_CYAN).add_modifier(Modifier::BOLD),
),
]),
Line::from(Span::styled("VibeWindow", Style::default().fg(TEXT_MUTED))),
];
let header_logo_area = ratatui::layout::Rect {
x: header_columns[0].x.saturating_add(1),
y: header_columns[0].y,
width: header_columns[0].width.saturating_sub(2),
height: 2,
};
f.render_widget(Paragraph::new(Text::from(logo_lines)), header_logo_area);
let info_area = ratatui::layout::Rect {
x: header_columns[1].x,
y: header_columns[1].y,
width: header_columns[1].width.saturating_sub(2).max(1),
height: header_columns[1].height,
};
let info_rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)])
.split(info_area);
let row1 = Line::from(vec![
Span::styled(format!("v{CLI_TUI_VERSION} "), Style::default().fg(TEXT_SUBTLE)),
Span::styled(
status_text,
Style::default().fg(status_color).add_modifier(Modifier::BOLD),
),
]);
let row2 = Line::from(vec![Span::styled(model_name, Style::default().fg(TEXT_MUTED))]);
let row3 = Line::from(vec![
Span::styled("路径 ", Style::default().fg(TEXT_SUBTLE)),
Span::styled(&pwd_path, Style::default().fg(TEXT_MUTED)),
]);
f.render_widget(Paragraph::new(row1).alignment(Alignment::Right), info_rows[0]);
f.render_widget(Paragraph::new(row2).alignment(Alignment::Right), info_rows[1]);
f.render_widget(Paragraph::new(row3).alignment(Alignment::Right), info_rows[2]);
let body_columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)])
.split(chunks[2]);
let body_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5),
Constraint::Length(input_height.saturating_add(2)),
Constraint::Length(0),
])
.split(body_columns[1]);
let conversation_area = body_chunks[0];
let (lines, think_map) = transcript_to_lines(
transcript,
self.expand_tool_blocks,
self.expand_think_all,
&self.think_detail_overrides,
draft,
);
let visible_height = usize::from(conversation_area.height);
let base_scroll = u16_from_usize_saturating(lines.len().saturating_sub(visible_height));
let effective_scroll = base_scroll.saturating_sub(scroll_back.min(base_scroll));
frame_conversation_area = Some(conversation_area);
frame_conversation_scroll = effective_scroll;
frame_conversation_lines = lines.iter().map(|l| l.to_string()).collect();
frame_conversation_think_map = think_map;
let transcript_area = ratatui::layout::Rect {
x: conversation_area.x,
y: conversation_area.y,
width: conversation_area.width.saturating_sub(2).max(1),
height: conversation_area.height,
};
let transcript_widget = Paragraph::new(Text::from(lines))
.wrap(wrap_trim_disabled())
.scroll((effective_scroll, 0));
f.render_widget(transcript_widget, transcript_area);
scroll::render_scrollbar(
f,
conversation_area,
&frame_conversation_lines,
effective_scroll,
);
let input_panel_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(0), Constraint::Min(1), Constraint::Length(0)])
.split(body_chunks[1]);
input_panel::render_input_panel(
f,
input_panel_chunks[1],
input,
cursor_idx,
busy,
model_name,
self.spinner_idx,
);
let mut footer = Vec::new();
if awaiting_clear_confirm {
footer.push(Span::styled(
"confirm clear: type y or yes",
Style::default().fg(WARNING).add_modifier(Modifier::BOLD),
));
}
f.render_widget(Paragraph::new(Line::from(footer)), chunks[3]);
if show_menu {
let overlay = layout::centered_overlay_rect(area, 2, 5, 10);
let lines =
vec![Line::from(Span::styled("暂无命令", Style::default().fg(TEXT_SUBTLE)))];
let popup = Paragraph::new(Text::from(lines))
.block(Block::default().borders(Borders::ALL).title("Commands"));
f.render_widget(Clear, overlay);
f.render_widget(popup, overlay);
}
})?;
self.last_conversation_area = frame_conversation_area;
self.last_conversation_scroll = frame_conversation_scroll;
self.last_conversation_lines = frame_conversation_lines;
self.last_conversation_think_map = frame_conversation_think_map;
self.last_render_hash = Some(render_hash);
let _ = now;
Ok(())
}
}
impl Drop for CliTui {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = self.terminal.backend_mut().execute(PopKeyboardEnhancementFlags);
let _ = self.terminal.backend_mut().execute(DisableMouseCapture);
let _ = self.terminal.backend_mut().execute(LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;