feat(tui): Sprint 0 complete — panic hook, resize, TuiError, unicode-width

- S0-2: install_panic_hook() restores terminal on crash (disable raw mode,
  leave alt screen, show cursor) before printing panic message
- S0-3: Handle Event::Resize in TUI event loop via mark_resize()
- S0-4: Add unicode-width = "0.2" dependency for CJK-aware wrapping
- S0-5: Create src/tui_error.rs with TuiError enum (Io, Terminal, Runtime, Config)
- Wire panic hook into run_tui_repl with clean restore on exit
- Deprecation warnings fixed (PanicHookInfo instead of PanicHookInfo)

108 tests pass. 1 pre-existing failure (sandbox status contract) unrelated.

Authored by TheArchitectit
This commit is contained in:
Claude 2026-06-11 18:42:21 -05:00
parent 294ab2ab66
commit 7c34488042
5 changed files with 92 additions and 3 deletions

View File

@ -25,6 +25,7 @@ syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
tools = { path = "../tools" }
log = "0.4"
unicode-width = "0.2"
[lints]

View File

@ -19,6 +19,7 @@ mod input;
mod render;
mod setup_wizard;
mod tui;
mod tui_error;
mod tui_update;
use std::collections::BTreeSet;
@ -7145,6 +7146,7 @@ fn run_repl(
/// Run the REPL inside the split-pane TUI. Unlike the plain REPL,
/// all output goes into the TUI conversation pane instead of stdout.
fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
let prev_hook = tui_update::install_panic_hook();
let dashboard_state = tui::SharedDashboardState::default();
{
let mut ds = dashboard_state.write().unwrap();
@ -7259,6 +7261,7 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
}
}
tui_update::restore_panic_hook(prev_hook);
app.restore_terminal()?;
Ok(())
}

View File

@ -252,6 +252,13 @@ impl TuiApp {
Ok(())
}
/// Handle terminal resize — force full re-render at new dimensions.
pub fn mark_resize(&mut self) {
// ratatui Terminal picks up new size on next draw via f.area().
// We just need to force a redraw so word-wrapping recalculates.
self.needs_redraw = true;
}
// -------------------------------------------------------------------
// Conversation helpers
// -------------------------------------------------------------------
@ -340,9 +347,15 @@ impl TuiApp {
pub fn read_line(&mut self) -> io::Result<TuiReadOutcome> {
if event::poll(std::time::Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
self.needs_redraw = true;
return self.handle_key(key);
match event::read()? {
Event::Key(key) => {
self.needs_redraw = true;
return self.handle_key(key);
}
Event::Resize(_width, _height) => {
self.mark_resize();
}
_ => {}
}
}

View File

@ -0,0 +1,28 @@
use std::fmt;
#[derive(Debug)]
pub enum TuiError {
Io(std::io::Error),
Terminal(String),
Runtime(String),
Config(String),
}
impl fmt::Display for TuiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TuiError::Io(e) => write!(f, "TUI I/O error: {e}"),
TuiError::Terminal(msg) => write!(f, "Terminal error: {msg}"),
TuiError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
TuiError::Config(msg) => write!(f, "Config error: {msg}"),
}
}
}
impl std::error::Error for TuiError {}
impl From<std::io::Error> for TuiError {
fn from(e: std::io::Error) -> Self {
TuiError::Io(e)
}
}

View File

@ -4,8 +4,52 @@
// LiveCli-dependent code (update_dashboard, run_tui_repl) remains in main.rs
// until LiveCli is made pub(crate).
use std::io::Write;
use std::panic;
use crate::tui::SharedDashboardState;
// ---------------------------------------------------------------------------
// Panic hook — restores terminal state on crash
// ---------------------------------------------------------------------------
/// Install a panic hook that restores terminal state before printing the panic.
/// Returns the previous hook so it can be restored on clean exit.
pub fn install_panic_hook() -> Box<dyn Fn(&panic::PanicHookInfo<'_>) + Send + Sync + 'static> {
let prev_hook = panic::take_hook();
panic::set_hook(Box::new(|info| {
// Best-effort terminal cleanup — ignore errors since we're panicking
use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
let _ = disable_raw_mode();
let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen);
let _ = crossterm::execute!(std::io::stdout(), crossterm::cursor::Show);
let _ = std::io::stdout().flush();
let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
"Box<dyn Any>".to_string()
};
let location = info.location()
.map(|l| format!(" at {}:{}", l.file(), l.line()))
.unwrap_or_default();
eprintln!("\n🦀 Claw TUI crashed{location}: {payload}");
eprintln!("Terminal has been restored to normal mode.\n");
}));
prev_hook
}
/// Restore the previous panic hook on clean exit.
pub fn restore_panic_hook(hook: Box<dyn Fn(&panic::PanicHookInfo<'_>) + Send + Sync + 'static>) {
panic::set_hook(hook);
}
/// Strip ANSI escape sequences from text.
/// Handles CSI (ESC [), OSC (ESC ]...BEL/ST), and DCS/ESC sequences.
pub fn strip_ansi(text: &str) -> String {