227 lines
8.0 KiB
Rust

use crate::client::tui::app::{App, ConfirmAction, Focus, Modal};
use crate::client::tui::components::*;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
pub fn render_ui(frame: &mut Frame, app: &App) {
let area = frame.area();
let input_height = app.input.lines().count().clamp(1, 6) as u16 + 2;
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(input_height),
Constraint::Length(1),
])
.split(area);
title_bar::render(frame, rows[0], app);
if area.width >= 80 {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(30)])
.split(rows[1]);
session_list::render(frame, columns[0], app);
chat_history::render(frame, columns[1], app);
} else {
chat_history::render(frame, rows[1], app);
}
input_area::render(frame, rows[2], app);
render_footer(frame, rows[3], app);
if app.show_command_menu && !app.get_filtered_commands().is_empty() {
let height = (app.get_filtered_commands().len().min(6) + 2) as u16;
let menu_area = Rect::new(
rows[2].x.saturating_add(1),
rows[2].y.saturating_sub(height),
rows[2].width.saturating_sub(2),
height,
);
command_menu::render(frame, menu_area, app);
}
if app.show_help {
help_popup::render(frame, centered_rect(72, 26, area));
}
if let Some(modal) = &app.modal {
render_modal(frame, centered_rect(64, 7, area), modal);
}
}
fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
let focus = match app.focus {
Focus::Input => "输入",
Focus::Sessions => "会话",
};
let status =
app.status_message
.as_deref()
.unwrap_or(if app.connected { "就绪" } else { "已断开" });
let line = Line::from(vec![
Span::styled(
format!(" {focus} "),
Style::default().fg(Color::Black).bg(Color::Cyan),
),
Span::raw(format!(" {status}")),
Span::styled(
" F1 帮助 Tab 切换焦点 Ctrl+N 新会话 ",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn render_modal(frame: &mut Frame, area: Rect, modal: &Modal) {
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
match modal {
Modal::Rename { input, cursor } => {
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"重命名会话",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(input.as_str()),
Line::from(Span::styled(
"Enter 保存 · Esc 取消",
Style::default().fg(Color::DarkGray),
)),
])
.block(block)
.wrap(Wrap { trim: false }),
area,
);
let cursor_x = area.x
+ 1
+ UnicodeWidthStr::width(&input[..*cursor])
.min(area.width.saturating_sub(3) as usize) as u16;
let cursor_y = area.y.saturating_add(3);
if cursor_x < area.right() && cursor_y < area.bottom() {
frame.set_cursor_position((cursor_x, cursor_y));
}
}
Modal::AttachPath { input, cursor } => {
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"添加附件路径",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(input.as_str()),
Line::from(Span::styled(
"Enter 上传 · Esc 取消",
Style::default().fg(Color::DarkGray),
)),
])
.block(block)
.wrap(Wrap { trim: false }),
area,
);
let cursor_x = area.x
+ 1
+ UnicodeWidthStr::width(&input[..*cursor])
.min(area.width.saturating_sub(3) as usize) as u16;
let cursor_y = area.y.saturating_add(3);
if cursor_x < area.right() && cursor_y < area.bottom() {
frame.set_cursor_position((cursor_x, cursor_y));
}
}
Modal::Confirm(action) => {
let prompt = match action {
ConfirmAction::Archive => "归档所选会话?",
ConfirmAction::Delete => "永久删除所选会话?此操作不可撤销。",
ConfirmAction::ClearHistory => "清空所选会话的全部历史?",
};
frame.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(prompt),
Line::from(""),
Line::from(Span::styled(
"Y / Enter 确认 · N / Esc 取消",
Style::default().fg(Color::DarkGray),
)),
])
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
}
}
fn centered_rect(max_width: u16, max_height: u16, area: Rect) -> Rect {
let width = max_width.min(area.width.saturating_sub(2)).max(1);
let height = max_height.min(area.height.saturating_sub(2)).max(1);
Rect::new(
area.x + area.width.saturating_sub(width) / 2,
area.y + area.height.saturating_sub(height) / 2,
width,
height,
)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
#[test]
fn narrow_terminal_renders_without_sidebar_or_panic() {
let backend = TestBackend::new(48, 14);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new();
app.input_insert_str("中文 input");
app.add_message(
crate::client::tui::app::MessageRole::Assistant,
"一条很长的响应,用于验证窄终端换行。".repeat(4),
);
terminal.draw(|frame| render_ui(frame, &app)).unwrap();
}
#[test]
fn active_reasoning_text_and_tool_snapshot_renders_without_panic() {
let backend = TestBackend::new(96, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new();
app.set_current_session(Some("session".into()));
let (controller, emitter, _) = crate::session::TurnController::start("session", "message");
emitter
.emit(crate::agent::TurnEvent::ReasoningDelta {
iteration: 0,
delta: "先检查状态".into(),
})
.unwrap();
emitter
.emit(crate::agent::TurnEvent::TextDelta {
iteration: 0,
delta: "正在处理".into(),
})
.unwrap();
emitter
.emit(crate::agent::TurnEvent::ToolStarted {
iteration: 0,
call: crate::providers::ToolCall {
id: "call".into(),
name: "bash".into(),
arguments: serde_json::json!({"cmd": "pwd"}),
},
})
.unwrap();
app.apply_turn_snapshot((*controller.snapshot()).clone());
terminal.draw(|frame| render_ui(frame, &app)).unwrap();
}
}