227 lines
8.0 KiB
Rust
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();
|
|
}
|
|
}
|