claw-code/rust/crates/rusty-claude-cli/src/tui/app.rs

408 lines
15 KiB
Rust

//! Component-based TuiApp.
//!
//! Replaces the legacy god-struct `TuiApp` with a clean component architecture.
//! Uses the pre-borrow draw pattern to avoid the clone-everything problem.
use std::io;
use std::io::Write;
use std::sync::Arc;
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::Frame;
use crate::keybindings::{Action, KeyMap, KeyPreset, VimMode};
use crate::theme::TuiTheme;
use crate::tui::component::{Component, Overlay};
use crate::tui::components::command_palette::CommandPaletteOverlay;
use crate::tui::components::agent_view::AgentViewOverlay;
use crate::tui::components::conversation::ConversationPane;
use crate::tui::components::dashboard::Dashboard;
use crate::tui::components::input_bar::{InputBar, InputOutcome};
use crate::tui::components::status_bar::StatusBar;
use crate::tui::event::{EventBus, TuiEvent};
use crate::tui::legacy::{BannerLine, SharedDashboardState, TuiReadOutcome};
const SPINNER_FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
/// The new component-based TUI application.
pub struct TuiApp {
terminal: ratatui::Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
// Components (each owns its own state)
conversation: ConversationPane,
input_bar: InputBar,
dashboard: Dashboard,
status_bar: StatusBar,
command_palette: CommandPaletteOverlay,
agent_view: AgentViewOverlay,
// Shared state
theme: TuiTheme,
keymap: KeyMap,
chat_mode: crate::chat_mode::ChatMode,
// Event bus (Phase 3: will be crossbeam-channel)
_event_bus: EventBus,
// Lifecycle
should_exit: bool,
spinner_frame: usize,
}
impl TuiApp {
/// Initialize the TUI — enter alternate screen, enable raw mode.
pub fn init(dashboard_state: SharedDashboardState) -> Result<Self, Box<dyn std::error::Error>> {
crossterm::execute!(
io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0)
)?;
enable_raw_mode()?;
let backend = ratatui::backend::CrosstermBackend::new(io::stdout());
let mut terminal = ratatui::Terminal::new(backend)?;
terminal.hide_cursor()?;
let theme = TuiTheme::builtin("default").unwrap();
let input_bar = InputBar::new(&theme);
let app = Self {
conversation: ConversationPane::new(theme.clone()),
input_bar,
dashboard: Dashboard::new(dashboard_state.clone()),
status_bar: StatusBar::new(dashboard_state),
command_palette: CommandPaletteOverlay::new(),
agent_view: AgentViewOverlay::new(),
theme,
keymap: KeyMap::new(KeyPreset::Emacs),
chat_mode: crate::chat_mode::ChatMode::Code,
_event_bus: EventBus::new(),
should_exit: false,
spinner_frame: 0,
terminal,
};
Ok(app)
}
/// Suspend TUI for a blocking stdout operation.
pub fn suspend(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.terminal.show_cursor();
disable_raw_mode()?;
let _ = crossterm::execute!(io::stdout(), Clear(ClearType::All), crossterm::cursor::MoveTo(0, 0));
let _ = io::stdout().flush();
Ok(())
}
/// Resume TUI after suspend.
pub fn resume(&mut self) -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let _ = crossterm::execute!(io::stdout(), Clear(ClearType::All), crossterm::cursor::MoveTo(0, 0));
let _ = io::stdout().flush();
self.terminal.hide_cursor()?;
self.terminal.clear()?;
Ok(())
}
/// Leave the TUI before a turn: exit alt screen, disable raw mode,
/// leave cursor visible, and clear. Stdout during the turn now goes
/// to the normal terminal instead of corrupting the TUI frame.
pub fn leave_for_turn(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.terminal.show_cursor();
disable_raw_mode()?;
crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::terminal::Clear(ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::cursor::Show
)?;
io::stdout().flush()?;
Ok(())
}
/// Wait for one keypress before re-entering the TUI.
/// Gives the user a chance to read any tool output that printed
/// to the terminal during the turn.
pub fn wait_to_return(&mut self) -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
crossterm::execute!(
io::stdout(),
crossterm::style::Print("\nPress any key to return to TUI..."),
crossterm::cursor::MoveTo(0, 0)
)?;
io::stdout().flush()?;
// Block until a key is pressed
loop {
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(_) = event::read()? {
break;
}
}
}
Ok(())
}
/// Re-enter the TUI after a turn: alt screen, raw mode, clear, redraw.
pub fn reenter_after_turn(&mut self) -> Result<(), Box<dyn std::error::Error>> {
crossterm::execute!(
io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::terminal::Clear(ClearType::All),
crossterm::cursor::Hide
)?;
io::stdout().flush()?;
enable_raw_mode()?;
self.terminal.clear()?;
Ok(())
}
/// Restore terminal on exit.
pub fn restore_terminal(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let _ = self.terminal.show_cursor();
let _ = disable_raw_mode();
let _ = crossterm::execute!(
io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::cursor::Show
);
let _ = io::stdout().flush();
Ok(())
}
// -------------------------------------------------------------------
// Content helpers (same API as legacy TuiApp)
// -------------------------------------------------------------------
pub fn push_banner(&mut self, lines: &[BannerLine]) {
for bl in lines {
self.conversation.push_banner(bl.text.clone(), bl.color);
}
}
pub fn push_user_input(&mut self, text: &str) {
let color = self.theme.conversation_user.to_color();
self.conversation.push_user_input(text, color);
}
pub fn push_system_message(&mut self, text: &str) {
let color = self.theme.conversation_system.to_color();
self.conversation.push_system_message(text, color);
}
pub fn push_output(&mut self, text: &str, is_error: bool) {
self.conversation.push_output(text, is_error, &self.theme);
}
pub fn push_diff(&mut self, diff: &str) {
self.conversation.push_diff(diff);
}
pub fn set_status(&mut self, msg: &str) {
self.dashboard.set_status(msg);
}
pub fn set_theme(&mut self, theme: TuiTheme) {
self.conversation.set_theme(theme.clone());
self.input_bar.set_theme(&theme);
self.theme = theme;
}
pub fn set_key_preset(&mut self, preset: KeyPreset) {
self.keymap.set_preset(preset);
}
pub fn key_preset_name(&self) -> &'static str {
match self.keymap.preset() {
KeyPreset::Emacs => "Emacs",
KeyPreset::Vim => "Vim",
KeyPreset::Windows => "Windows",
}
}
// -------------------------------------------------------------------
// Main event loop
// -------------------------------------------------------------------
pub fn read_line(&mut self) -> io::Result<TuiReadOutcome> {
if event::poll(std::time::Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) => return self.handle_key(key),
Event::Resize(..) => {
// Force re-render at new dimensions
}
_ => {}
}
}
self.spinner_frame = (self.spinner_frame + 1) % SPINNER_FRAMES.len();
self.dashboard.tick_spinner();
self.status_bar.tick_spinner();
self.draw_screen()?;
Ok(TuiReadOutcome::Pending)
}
fn handle_key(&mut self, key: KeyEvent) -> io::Result<TuiReadOutcome> {
// Command palette intercepts all keys when active
if self.command_palette.is_active() {
let consumed = self.command_palette.handle_key(key, &self.keymap);
if consumed {
if let Some(action) = self.command_palette.selected_action() {
self.command_palette.close();
return self.dispatch_action(action);
}
self.draw_screen()?;
return Ok(TuiReadOutcome::Pending);
}
}
// Agent view intercepts all keys when active
if self.agent_view.is_active() {
let consumed = self.agent_view.handle_key(key, &self.keymap);
if consumed {
self.draw_screen()?;
return Ok(TuiReadOutcome::Pending);
}
}
// Input bar handles the key
let outcome = self.input_bar.process_key(key, &mut self.keymap);
match outcome {
InputOutcome::Submit(text) => {
self.input_bar.push_history(&text);
Ok(TuiReadOutcome::Submit(text))
}
InputOutcome::Cancel => Ok(TuiReadOutcome::Cancel),
InputOutcome::Exit => {
self.should_exit = true;
Ok(TuiReadOutcome::Exit)
}
InputOutcome::ProviderSwap => Ok(TuiReadOutcome::ProviderSwap),
InputOutcome::TeamToggle => Ok(TuiReadOutcome::TeamToggle),
InputOutcome::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
InputOutcome::None => {
self.draw_screen()?;
Ok(TuiReadOutcome::Pending)
}
}
}
fn dispatch_action(&mut self, action: Action) -> io::Result<TuiReadOutcome> {
match action {
Action::CommandPalette => { self.command_palette.open(); Ok(TuiReadOutcome::Pending) }
Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
Action::Help => {
let preset = self.key_preset_name().to_string();
let msg = format!("Keybindings ({preset}):\n\nEnter Submit Shift+Enter ↵\nCtrl+C Cancel Ctrl+D Exit\nCtrl+P Swap Ctrl+K Palette\nCtrl+A Agents Ctrl+T Team\n");
self.push_system_message(&msg);
Ok(TuiReadOutcome::Pending)
}
Action::ClearConversation => {
self.conversation.clear();
Ok(TuiReadOutcome::Pending)
}
Action::ScrollUp => { self.conversation.scroll_up(1); Ok(TuiReadOutcome::Pending) }
Action::ScrollDown => { self.conversation.scroll_down(1); Ok(TuiReadOutcome::Pending) }
Action::ScrollHalfUp => { self.conversation.scroll_up(5); Ok(TuiReadOutcome::Pending) }
Action::ScrollHalfDown => { self.conversation.scroll_down(5); Ok(TuiReadOutcome::Pending) }
Action::ScrollTop => { self.conversation.scroll_top(); Ok(TuiReadOutcome::Pending) }
Action::ScrollBottom => { self.conversation.scroll_bottom(); Ok(TuiReadOutcome::Pending) }
_ => Ok(TuiReadOutcome::Pending),
}
}
// -------------------------------------------------------------------
// Rendering — pre-borrow pattern
// -------------------------------------------------------------------
fn draw_screen(&mut self) -> io::Result<()> {
// Pre-borrow component references before the draw closure.
// Each component.render(&self) reads its own state — no cloning needed.
let conversation = &self.conversation;
let input_bar = &self.input_bar;
let dashboard = &self.dashboard;
let status_bar = &self.status_bar;
let command_palette = &self.command_palette;
let agent_view = &self.agent_view;
let theme = &self.theme;
self.terminal.draw(|f| {
let area = f.area();
// OpenCode-style focused layout:
// - full-width status bar on top
// - large conversation pane below it
// - compact input bar at the bottom
// - optional right-side dashboard (hidden if terminal is narrow)
let has_room_for_dashboard = area.width >= 100;
let dashboard_width = if has_room_for_dashboard { 32u16 } else { 0u16 };
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(40),
Constraint::Length(dashboard_width),
])
.split(area);
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // status bar
Constraint::Min(5), // conversation
Constraint::Length(6), // input
])
.split(main[0]);
status_bar.render(left[0], f, theme);
conversation.render(left[1], f, theme);
input_bar.render(left[2], f, theme);
if dashboard_width > 0 {
dashboard.render(main[1], f, theme);
}
// Overlays
if command_palette.is_active() {
command_palette.render_overlay(area, f, theme);
}
if agent_view.is_active() {
agent_view.render_overlay(area, f, theme);
}
})?;
// Clear dirty flags after successful render
self.conversation.mark_clean();
self.dashboard.clear_dirty();
self.status_bar.clear_dirty();
self.terminal.backend_mut().flush()?;
Ok(())
}
/// Force full redraw after a turn.
pub fn redraw_after_turn(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.terminal.clear()?;
self.draw_screen()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tui_app_init_requires_terminal() {
// TuiApp::init() requires a real terminal — just verify the type exists.
// Real tests run via `cargo test --bin claw -- tui::app`
}
}