From da63d59ca283f8e1a1eff6df23cec1d198904154 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 29 Jun 2026 19:42:13 -0400 Subject: [PATCH] =?UTF-8?q?th-75b557:=20th=20claude=20tui=20=E2=80=94=20ra?= =?UTF-8?q?tatui=20control=20dashboard=20(mode=20toggle=20+=20attach)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `th claude tui`: a live dashboard of supervised Claude Code sessions with each one's mode and a snippet of its pane, plus single-key control — `d`/`m`/`p` flip driving/manual/paused, `a`/`enter` attach (suspends the TUI, hands the terminal to `tmux attach`, restores on return), `r` refresh, `q` quit. This is the "switch between Big Smooth driving and the session itself" surface from the orchestration plan. Key bindings, selection clamping, pane tailing, and list navigation are pure and unit-tested; the draw + event loop is the IO shell. Doc updated with `th claude mode` + `tui` (41 tests pass; clippy -D warnings clean). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DXqPyj8SvxyUbfyRPvBA6P --- .changeset/th-claude-tui.md | 14 ++ Cargo.lock | 2 + crates/smooth-cli/Cargo.toml | 2 + crates/smooth-cli/src/claude/mod.rs | 7 + crates/smooth-cli/src/claude/tui.rs | 303 ++++++++++++++++++++++++++++ docs/Engineering/Using-th-CLI.md | 14 +- 6 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 .changeset/th-claude-tui.md create mode 100644 crates/smooth-cli/src/claude/tui.rs diff --git a/.changeset/th-claude-tui.md b/.changeset/th-claude-tui.md new file mode 100644 index 00000000..33a57b75 --- /dev/null +++ b/.changeset/th-claude-tui.md @@ -0,0 +1,14 @@ +--- +"@smooai/smooth": patch +--- + +th claude tui: ratatui control dashboard for supervised sessions + +Adds `th claude tui` — a live dashboard listing supervised Claude Code sessions +with their mode and a snippet of each one's pane, plus single-key control: +`d`/`m`/`p` flip a session between driving / manual / paused, `a`/`enter` attach +(suspends the TUI, hands the terminal to `tmux attach`, then restores), `r` +refreshes, `q` quits. This is the "switch between Big Smooth driving and the +session itself" surface from the orchestration plan. The key bindings, selection +clamping, pane tailing, and list navigation are pure and unit tested; the draw + +event loop is the IO shell. diff --git a/Cargo.lock b/Cargo.lock index 0ca92f50..5604d9f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6206,6 +6206,7 @@ dependencies = [ "base64 0.22.1", "chrono", "clap", + "crossterm", "dialoguer", "dirs-next", "futures-util", @@ -6214,6 +6215,7 @@ dependencies = [ "owo-colors", "quick-xml", "rand 0.8.5", + "ratatui", "reqwest 0.12.28", "serde", "serde_json", diff --git a/crates/smooth-cli/Cargo.toml b/crates/smooth-cli/Cargo.toml index e5d13e4f..2b600192 100644 --- a/crates/smooth-cli/Cargo.toml +++ b/crates/smooth-cli/Cargo.toml @@ -22,6 +22,8 @@ admin = [] [dependencies] smooth-bench.workspace = true smooth-tmux.workspace = true +ratatui.workspace = true +crossterm.workspace = true smooth-bigsmooth.workspace = true smooth-bootstrap-bill = { workspace = true, default-features = false, features = ["server"] } smooth-code.workspace = true diff --git a/crates/smooth-cli/src/claude/mod.rs b/crates/smooth-cli/src/claude/mod.rs index 000828e6..a8df60fb 100644 --- a/crates/smooth-cli/src/claude/mod.rs +++ b/crates/smooth-cli/src/claude/mod.rs @@ -13,6 +13,7 @@ pub mod detect; pub mod governor; pub mod registry; pub mod supervisor; +pub mod tui; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -73,6 +74,11 @@ pub enum ClaudeCommands { /// `driving` | `manual` | `paused`. mode: String, }, + /// Interactive control dashboard: live list of supervised sessions + /// with their pane, plus keys to flip driving/manual/paused and + /// attach. The "switch between Big Smooth driving and the session + /// itself" surface. + Tui, } /// Dispatch a `th claude` subcommand. @@ -105,6 +111,7 @@ pub async fn cmd_claude(cmd: ClaudeCommands) -> Result<()> { ClaudeCommands::Ls { json } => ls(json), ClaudeCommands::Attach { id } => attach(&id), ClaudeCommands::Mode { id, mode } => set_mode(&id, &mode), + ClaudeCommands::Tui => tui::run_tui(), } } diff --git a/crates/smooth-cli/src/claude/tui.rs b/crates/smooth-cli/src/claude/tui.rs new file mode 100644 index 00000000..de299956 --- /dev/null +++ b/crates/smooth-cli/src/claude/tui.rs @@ -0,0 +1,303 @@ +//! `th claude tui` — a ratatui control dashboard for supervised sessions. +//! +//! Lists live sessions with their mode, shows a live snippet of the +//! selected session's pane, and lets you flip driving/manual/paused or +//! attach — the "switch between Big Smooth driving and the session +//! itself" surface. The supervisor owns the `TmuxDriver`; this separate +//! process reads the pane straight from tmux via the registry's socket. +//! +//! The decision logic (`key_action`, `clamp_selected`, `tail_lines`, and +//! the `App` navigation) is pure and unit tested; the draw + event loop +//! is the IO shell, verified by running it. + +use std::process::Command; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::layout::{Constraint, Layout}; +use ratatui::style::{Modifier, Style}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::{DefaultTerminal, Frame}; + +use super::control::{self, Mode}; +use super::registry::{self, SessionEntry}; + +/// What a keypress maps to. Pure so the binding table is unit tested. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TuiAction { + Quit, + Up, + Down, + SetMode(Mode), + Attach, + Refresh, + Ignore, +} + +/// Map a key to an action. +#[must_use] +pub fn key_action(code: KeyCode) -> TuiAction { + match code { + KeyCode::Char('q') | KeyCode::Esc => TuiAction::Quit, + KeyCode::Up | KeyCode::Char('k') => TuiAction::Up, + KeyCode::Down | KeyCode::Char('j') => TuiAction::Down, + KeyCode::Char('d') => TuiAction::SetMode(Mode::Driving), + KeyCode::Char('m') => TuiAction::SetMode(Mode::Manual), + KeyCode::Char('p') => TuiAction::SetMode(Mode::Paused), + KeyCode::Char('a') | KeyCode::Enter => TuiAction::Attach, + KeyCode::Char('r') => TuiAction::Refresh, + _ => TuiAction::Ignore, + } +} + +/// Clamp a selection index into `[0, len)` (or 0 when empty). +#[must_use] +pub fn clamp_selected(len: usize, selected: usize) -> usize { + if len == 0 { + 0 + } else { + selected.min(len - 1) + } +} + +/// Keep only the last `n` lines of `text` (the most recent pane output). +#[must_use] +pub fn tail_lines(text: &str, n: usize) -> String { + if n == 0 { + return String::new(); + } + let lines: Vec<&str> = text.lines().collect(); + let start = lines.len().saturating_sub(n); + lines[start..].join("\n") +} + +struct App { + sessions: Vec, + selected: usize, +} + +impl App { + fn loaded() -> Self { + let mut app = Self { + sessions: Vec::new(), + selected: 0, + }; + app.refresh(); + app + } + + fn refresh(&mut self) { + self.sessions = registry::read_live_and_prune(); + self.selected = clamp_selected(self.sessions.len(), self.selected); + } + + fn up(&mut self) { + if !self.sessions.is_empty() { + self.selected = if self.selected == 0 { self.sessions.len() - 1 } else { self.selected - 1 }; + } + } + + fn down(&mut self) { + if !self.sessions.is_empty() { + self.selected = (self.selected + 1) % self.sessions.len(); + } + } + + fn selected_entry(&self) -> Option<&SessionEntry> { + self.sessions.get(self.selected) + } +} + +/// Run the control dashboard. Sets up the terminal, runs the loop, and +/// always restores the terminal on exit. +/// +/// # Errors +/// On terminal draw / event-read failure. +pub fn run_tui() -> Result<()> { + let mut terminal = ratatui::init(); + let result = run_loop(&mut terminal); + ratatui::restore(); + result +} + +fn run_loop(terminal: &mut DefaultTerminal) -> Result<()> { + let mut app = App::loaded(); + let mut last_refresh = Instant::now(); + loop { + let selected = app.selected_entry().cloned(); + let pane = selected.as_ref().map(capture_pane).unwrap_or_default(); + let mode = selected.as_ref().map_or(Mode::default(), |e| control::read_mode(&e.id)); + terminal.draw(|f| render(f, &app, &pane, mode)).context("tui draw")?; + + if event::poll(Duration::from_millis(250)).context("tui event poll")? { + if let Event::Key(key) = event::read().context("tui event read")? { + if key.kind == KeyEventKind::Press { + match key_action(key.code) { + TuiAction::Quit => break, + TuiAction::Up => app.up(), + TuiAction::Down => app.down(), + TuiAction::Refresh => app.refresh(), + TuiAction::SetMode(m) => { + if let Some(e) = &selected { + let _ = control::write_mode(&e.id, m); + } + } + TuiAction::Attach => { + if let Some(e) = &selected { + attach_handoff(terminal, e)?; + app.refresh(); + } + } + TuiAction::Ignore => {} + } + } + } + } + + if last_refresh.elapsed() >= Duration::from_secs(1) { + app.refresh(); + last_refresh = Instant::now(); + } + } + Ok(()) +} + +/// Suspend the TUI, hand the terminal to `tmux attach`, then restore it. +fn attach_handoff(terminal: &mut DefaultTerminal, entry: &SessionEntry) -> Result<()> { + ratatui::restore(); + let status = Command::new("tmux").args(["-L", &entry.socket, "attach", "-t", &entry.session]).status(); + *terminal = ratatui::init(); + let _ = terminal.clear(); + status.context("running tmux attach")?; + Ok(()) +} + +fn capture_pane(entry: &SessionEntry) -> String { + Command::new("tmux") + .args(["-L", &entry.socket, "capture-pane", "-t", &entry.session, "-p"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default() +} + +fn render(f: &mut Frame, app: &App, pane: &str, mode: Mode) { + let [body, footer] = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(f.area()); + let [left, right] = Layout::horizontal([Constraint::Percentage(34), Constraint::Percentage(66)]).areas(body); + + let items: Vec = app + .sessions + .iter() + .map(|e| { + let m = control::read_mode(&e.id); + ListItem::new(format!("{} {:7} {}", e.id, m.as_str(), e.label.as_deref().unwrap_or("-"))) + }) + .collect(); + let mut state = ListState::default(); + if !app.sessions.is_empty() { + state.select(Some(app.selected)); + } + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(" sessions ")) + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); + f.render_stateful_widget(list, left, &mut state); + + let title = app + .selected_entry() + .map_or_else(|| " (no supervised sessions — th claude run ) ".to_string(), |e| format!(" {} [{mode}] ", e.id)); + // Show the most recent lines that fit the preview pane. + let visible_rows = usize::from(right.height.saturating_sub(2)); + let shown = tail_lines(pane, visible_rows); + let preview = Paragraph::new(shown) + .block(Block::default().borders(Borders::ALL).title(title)) + .wrap(Wrap { trim: false }); + f.render_widget(preview, right); + + let foot = Paragraph::new("↑/↓ select · d driving · m manual · p paused · a/enter attach · r refresh · q quit"); + f.render_widget(foot, footer); +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn entry(id: &str) -> SessionEntry { + SessionEntry { + id: id.to_string(), + session: format!("claude-{id}"), + socket: format!("sock-{id}"), + cwd: "/tmp".to_string(), + label: None, + pid: 1, + started_at: Utc::now(), + } + } + + fn app_with(n: usize) -> App { + App { + sessions: (0..n).map(|i| entry(&format!("s{i}"))).collect(), + selected: 0, + } + } + + #[test] + fn key_bindings() { + assert_eq!(key_action(KeyCode::Char('q')), TuiAction::Quit); + assert_eq!(key_action(KeyCode::Esc), TuiAction::Quit); + assert_eq!(key_action(KeyCode::Char('k')), TuiAction::Up); + assert_eq!(key_action(KeyCode::Up), TuiAction::Up); + assert_eq!(key_action(KeyCode::Char('j')), TuiAction::Down); + assert_eq!(key_action(KeyCode::Char('d')), TuiAction::SetMode(Mode::Driving)); + assert_eq!(key_action(KeyCode::Char('m')), TuiAction::SetMode(Mode::Manual)); + assert_eq!(key_action(KeyCode::Char('p')), TuiAction::SetMode(Mode::Paused)); + assert_eq!(key_action(KeyCode::Enter), TuiAction::Attach); + assert_eq!(key_action(KeyCode::Char('a')), TuiAction::Attach); + assert_eq!(key_action(KeyCode::Char('r')), TuiAction::Refresh); + assert_eq!(key_action(KeyCode::Char('z')), TuiAction::Ignore); + } + + #[test] + fn clamp_handles_empty_and_overflow() { + assert_eq!(clamp_selected(0, 5), 0); + assert_eq!(clamp_selected(3, 5), 2); + assert_eq!(clamp_selected(3, 1), 1); + } + + #[test] + fn tail_keeps_recent_lines() { + let text = "a\nb\nc\nd\ne"; + assert_eq!(tail_lines(text, 2), "d\ne"); + assert_eq!(tail_lines(text, 10), text); + assert_eq!(tail_lines(text, 0), ""); + } + + #[test] + fn navigation_wraps() { + let mut app = app_with(3); + assert_eq!(app.selected, 0); + app.up(); // wrap to last + assert_eq!(app.selected, 2); + app.down(); // wrap to first + assert_eq!(app.selected, 0); + app.down(); + assert_eq!(app.selected, 1); + } + + #[test] + fn navigation_noop_when_empty() { + let mut app = app_with(0); + app.up(); + app.down(); + assert_eq!(app.selected, 0); + assert!(app.selected_entry().is_none()); + } + + #[test] + fn selected_entry_tracks_index() { + let mut app = app_with(2); + assert_eq!(app.selected_entry().unwrap().id, "s0"); + app.down(); + assert_eq!(app.selected_entry().unwrap().id, "s1"); + } +} diff --git a/docs/Engineering/Using-th-CLI.md b/docs/Engineering/Using-th-CLI.md index 05e7d49a..d0c6e805 100644 --- a/docs/Engineering/Using-th-CLI.md +++ b/docs/Engineering/Using-th-CLI.md @@ -484,11 +484,23 @@ of leaving the turn dead on the screen. th claude run # launch + supervise an interactive session (attach to drive it) th claude run "fix the flaky test" --label fixer # launch with an initial prompt th claude run --cwd ../some-worktree # supervise a session rooted elsewhere -th claude ls # list live supervised sessions (prunes dead ones) +th claude ls # list live supervised sessions (id, mode, label) th claude ls --json th claude attach # hand your terminal to a session (tmux attach; Ctrl-b d to detach) +th claude mode driving|manual|paused # who drives: Big Smooth | you | nobody +th claude tui # live control dashboard (toggle mode + attach across sessions) ``` +`th claude tui` is the **control dashboard**: a live list of supervised sessions +with each one's pane, plus single-key control — `d`/`m`/`p` flip +driving/manual/paused, `a`/`enter` attach, `r` refresh, `q` quit. It's the +"switch between Big Smooth driving and the session itself" surface. The same +control is scriptable via `th claude mode`: + +- `driving` — Big Smooth sends input + rescues rate-limits. +- `manual` — you drive (attach); the supervisor only rescues *your* throttled turn. +- `paused` — the supervisor stands down. + How it decides what to do, per poll of the **visible** pane: - **`temporarily limiting requests` / `Rate limited`** → back off via the shared