yoi/crates/tui/src/main.rs
2026-05-05 18:30:25 +09:00

613 lines
19 KiB
Rust

mod app;
mod block;
mod cache;
mod client;
mod input;
mod markdown;
mod picker;
mod scroll;
mod spawn;
mod task;
mod tool;
mod ui;
use std::io;
use std::path::PathBuf;
use std::process::ExitCode;
use crossterm::event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use protocol::{Method, PodStatus};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use session_store::SessionId;
use crate::app::App;
use crate::client::PodClient;
use crate::picker::PickerOutcome;
use crate::spawn::{SpawnOutcome, SpawnReady};
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
if let Some(p) = override_path {
return p;
}
manifest::paths::pod_socket_path(pod_name).unwrap_or_else(|| {
PathBuf::from("/tmp")
.join("insomnia")
.join(pod_name)
.join("sock")
})
}
enum Mode {
Spawn,
Attach {
pod_name: String,
socket_override: Option<PathBuf>,
},
/// `tui -r` / `tui --resume`: open the session picker first, then
/// run the same name dialog as Spawn but in resume mode.
Resume,
/// `tui --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in.
ResumeWithSession(SessionId),
}
enum ParseError {
Conflict,
InvalidSession(String),
MissingValue(&'static str),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Conflict => write!(f, "--resume and --session are mutually exclusive"),
Self::InvalidSession(s) => write!(f, "invalid --session UUID: {s}"),
Self::MissingValue(flag) => write!(f, "{flag} requires a value"),
}
}
}
fn parse_args() -> Result<Mode, ParseError> {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut resume = false;
let mut session: Option<SessionId> = None;
let mut socket_override: Option<PathBuf> = None;
let mut positional: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-r" | "--resume" => {
resume = true;
i += 1;
}
"--session" => {
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--session"))?;
session = Some(
raw.parse::<SessionId>()
.map_err(|_| ParseError::InvalidSession(raw.clone()))?,
);
i += 2;
}
"--socket" => {
let raw = args
.get(i + 1)
.ok_or(ParseError::MissingValue("--socket"))?;
socket_override = Some(PathBuf::from(raw));
i += 2;
}
other if positional.is_none() && !other.starts_with('-') => {
positional = Some(other.to_string());
i += 1;
}
_ => {
// Unknown flag or extra positional — keep older
// behaviour of ignoring unknowns rather than aborting.
i += 1;
}
}
}
if resume && session.is_some() {
return Err(ParseError::Conflict);
}
if let Some(id) = session {
return Ok(Mode::ResumeWithSession(id));
}
if resume {
return Ok(Mode::Resume);
}
if let Some(pod_name) = positional {
return Ok(Mode::Attach {
pod_name,
socket_override,
});
}
Ok(Mode::Spawn)
}
#[tokio::main]
async fn main() -> ExitCode {
let mode = match parse_args() {
Ok(m) => m,
Err(e) => {
eprintln!("tui: {e}");
return ExitCode::FAILURE;
}
};
if let Err(e) = enable_raw_mode() {
eprintln!("tui: failed to enter raw mode: {e}");
return ExitCode::FAILURE;
}
if let Err(e) = execute!(io::stdout(), EnableBracketedPaste) {
let _ = disable_raw_mode();
eprintln!("tui: {e}");
return ExitCode::FAILURE;
}
let result = match mode {
Mode::Spawn => run_spawn(None).await,
Mode::Attach {
pod_name,
socket_override,
} => run_attach(pod_name, socket_override).await,
Mode::Resume => run_resume().await,
Mode::ResumeWithSession(id) => run_spawn(Some(id)).await,
};
// Always restore the terminal first so any pending eprintln below
// shows up cleanly in scrollback rather than inside an active
// alternate-screen buffer.
let mut stdout = io::stdout();
let _ = execute!(
stdout,
DisableMouseCapture,
LeaveAlternateScreen,
DisableBracketedPaste
);
let _ = disable_raw_mode();
let _ = execute!(stdout, crossterm::cursor::Show);
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
// SpawnError has already been painted into the inline
// viewport's final frame, so it's already visible in the
// user's scrollback — printing it again would be a noisy
// duplicate. Other errors (attach-mode failures, terminal
// setup hiccups, etc.) need surfacing here.
if e.downcast_ref::<spawn::SpawnError>().is_none() {
eprintln!("tui: {e}");
}
ExitCode::FAILURE
}
}
}
async fn run_attach(
pod_name: String,
socket_override: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let socket_path = resolve_socket(&pod_name, socket_override);
let mut terminal = enter_fullscreen()?;
run(&mut terminal, pod_name, &socket_path).await
}
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
// Phase 1: pick a session in its own inline viewport, dropping the
// viewport before the name dialog opens so each phase gets fresh
// vertical room.
let id = match picker::run().await? {
PickerOutcome::Picked(id) => id,
PickerOutcome::Cancelled => return Ok(()),
};
run_spawn(Some(id)).await
}
async fn run_spawn(resume_from: Option<SessionId>) -> Result<(), Box<dyn std::error::Error>> {
let ready = match spawn::run(resume_from).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
let SpawnReady {
pod_name,
socket_path,
} = ready;
let mut terminal = enter_fullscreen()?;
let result = run(&mut terminal, pod_name, &socket_path).await;
// Leave alt-screen explicitly before `main`'s terminal restore path.
let _ = execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
);
result
}
fn enter_fullscreen() -> Result<Terminal<CrosstermBackend<io::Stdout>>, Box<dyn std::error::Error>>
{
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
pod_name: String,
socket_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let mut app = App::new(pod_name);
match PodClient::connect(socket_path).await {
Ok(mut client) => {
app.connected = true;
let _ = client.send(&Method::GetHistory).await;
run_loop(terminal, &mut app, client).await?;
}
Err(e) => {
app.push_error(format!(
"Failed to connect to {}: {e}",
socket_path.display()
));
terminal.draw(|f| ui::draw(f, &mut app))?;
run_disconnected(&mut app)?;
}
}
Ok(())
}
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
mut client: PodClient,
) -> Result<(), Box<dyn std::error::Error>> {
terminal.draw(|f| ui::draw(f, app))?;
loop {
if app.quit {
break;
}
// Drain any already-buffered Pod events in a bounded batch before
// polling the terminal. This keeps status fresh without letting a
// busy event stream starve Ctrl-C / Ctrl-X input.
for _ in 0..32 {
match client.try_next_event() {
Some(ev) => app.handle_pod_event(ev),
None => break,
}
}
// Always give the terminal queue a non-blocking pass each frame.
// The awaited select below only waits after this pass found nothing.
let mut handled_term_event = false;
while event::poll(std::time::Duration::ZERO)? {
handled_term_event = true;
handle_terminal_event(app, &mut client, event::read()?).await?;
if app.quit {
break;
}
}
if app.quit {
break;
}
if handled_term_event {
terminal.draw(|f| ui::draw(f, app))?;
continue;
}
tokio::select! {
term_event = tokio::task::spawn_blocking(|| {
if event::poll(std::time::Duration::from_millis(50))? {
event::read().map(Some)
} else {
Ok(None)
}
}) => {
if let Some(term_event) = term_event?? {
handle_terminal_event(app, &mut client, term_event).await?;
}
}
event = client.next_event(), if app.connected => {
match event {
Some(ev) => app.handle_pod_event(ev),
None => {
app.connected = false;
app.push_error("Connection lost");
}
}
}
}
terminal.draw(|f| ui::draw(f, app))?;
}
Ok(())
}
async fn handle_terminal_event(
app: &mut App,
client: &mut PodClient,
event: TermEvent,
) -> Result<(), Box<dyn std::error::Error>> {
match event {
TermEvent::Key(key) => {
if let Some(method) = handle_key(app, key) {
client.send(&method).await?;
}
}
TermEvent::Mouse(mouse) => {
handle_mouse(app, mouse);
}
TermEvent::Paste(s) => {
app.insert_paste(s);
}
TermEvent::Resize(_, _) => {
// No-op: next draw repaints in full.
}
_ => {}
}
Ok(())
}
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
loop {
if event::poll(std::time::Duration::from_millis(100))?
&& let TermEvent::Key(key) = event::read()?
&& let KeyCode::Char('c') = key.code
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
break;
}
}
Ok(())
}
/// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so
/// hand-rolling through long histories isn't tedious, but slow enough
/// that a single notch doesn't blow past the section the user is
/// looking for.
const WHEEL_LINES: usize = 3;
/// Lines to advance per PageUp / PageDown when the task side pane is
/// open. Calibrated so a couple of presses moves through one entry's
/// subject + description block.
const PANE_SCROLL_LINES: usize = 5;
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
match mouse.kind {
MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES),
MouseEventKind::ScrollDown => app.scroll.scroll_down(WHEEL_LINES),
_ => {}
}
}
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
// Modifier-key bindings.
if let Some(method) = match key.code {
KeyCode::Up if shift => {
app.scroll.scroll_up(1);
Some(None)
}
KeyCode::Down if shift => {
app.scroll.scroll_down(1);
Some(None)
}
KeyCode::Home if ctrl => {
app.scroll.to_top();
Some(None)
}
KeyCode::End if ctrl => {
app.scroll.to_bottom();
Some(None)
}
KeyCode::Char('[') if ctrl => {
app.scroll.jump_prev_turn();
Some(None)
}
KeyCode::Char(']') if ctrl => {
app.scroll.jump_next_turn();
Some(None)
}
KeyCode::Char('o') if ctrl => {
app.mode = app.mode.cycle();
Some(None)
}
KeyCode::Char('t') if ctrl => {
app.toggle_task_pane();
Some(None)
}
KeyCode::Char('a') if ctrl => {
app.move_cursor_start();
Some(app.refresh_completion())
}
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(match app.pod_status {
PodStatus::Running => Some(Method::Cancel),
PodStatus::Paused | PodStatus::Idle => Some(Method::Shutdown),
}),
KeyCode::Char('d') if ctrl => {
app.quit = true;
Some(None)
}
KeyCode::Enter if alt => {
app.insert_newline();
Some(app.refresh_completion())
}
_ => None,
} {
return method;
}
// Unbound Ctrl+Char keys are ignored before the text-input path so
// holding Ctrl while typing never inserts control characters.
if ctrl && matches!(key.code, KeyCode::Char(_)) {
return None;
}
// Scroll / navigation. PageUp / PageDown defaults to history; while
// the task pane is open it scrolls the pane instead so the user can
// browse past entries without first closing the pane.
match key.code {
KeyCode::PageUp => {
if app.task_pane_open {
app.scroll_task_pane_up(PANE_SCROLL_LINES);
} else {
app.scroll.page_up();
}
return None;
}
KeyCode::PageDown => {
if app.task_pane_open {
app.scroll_task_pane_down(PANE_SCROLL_LINES);
} else {
app.scroll.page_down();
}
return None;
}
_ => {}
}
// Completion popup overrides — only when there's something to
// navigate / commit. An empty popup (request in flight) falls
// through to the default behaviour.
if app.completion.as_ref().is_some_and(|c| c.is_active()) {
match key.code {
KeyCode::Tab if !alt => {
// Insert the selected entry as raw text and let the
// re-triggered popup fetch fresh candidates (drill-in
// for directories, narrow-to-exact for files).
return app.apply_completion_text();
}
KeyCode::Enter if !alt => {
// While the popup has selectable entries, Enter
// commits the selection rather than submitting the
// message. The selected entry wins regardless of how
// much of its value the user has typed — Enter on a
// popup entry is "accept this suggestion". Directory
// entries are the exception: they fall through to
// text insertion so the popup re-fetches children
// for drill-in. After a successful chip we append a
// trailing space so the user can keep writing without
// a manual separator (the Space path already has the
// space the user typed, so it's not needed there).
if app.chipify_selected_completion_if_committable() {
app.insert_char(' ');
return None;
}
return app.apply_completion_text();
}
KeyCode::Up => {
app.move_completion_up();
return None;
}
KeyCode::Down => {
app.move_completion_down();
return None;
}
KeyCode::Esc => {
app.cancel_completion();
return None;
}
_ => {}
}
}
match key.code {
KeyCode::Esc => {
// Close the popup if it's still showing (covers the
// request-in-flight case where `is_active()` was false).
app.cancel_completion();
None
}
KeyCode::Enter => app.submit_input(),
KeyCode::Backspace => {
app.delete_char_before();
app.refresh_completion()
}
KeyCode::Delete => {
app.delete_char_after();
app.refresh_completion()
}
KeyCode::Left => {
app.move_cursor_left();
app.refresh_completion()
}
KeyCode::Right => {
app.move_cursor_right();
app.refresh_completion()
}
KeyCode::Up => {
app.move_cursor_up();
app.refresh_completion()
}
KeyCode::Down => {
app.move_cursor_down();
app.refresh_completion()
}
KeyCode::Home => {
app.move_cursor_home();
app.refresh_completion()
}
KeyCode::End => {
app.move_cursor_end();
app.refresh_completion()
}
KeyCode::Char(c) => {
// Whitespace ends an in-flight completion token. Try the
// auto-confirm path first so an exact match (e.g. typed
// `@src/main.rs` matches the only popup entry) becomes a
// chip on the way out. Directories also commit here —
// ending with a space is an explicit "I want this dir"
// signal, not a drill-in.
if c.is_whitespace() {
app.chipify_completion_if_exact_match();
}
app.insert_char(c);
app.refresh_completion()
}
_ => None,
}
}
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// Running → send `Method::Pause`.
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.pod_status == PodStatus::Running {
return Some(Method::Pause);
}
if let Some(t) = app.quit_confirm
&& t.elapsed() < CONFIRM_TIMEOUT
{
app.quit_confirm = None;
app.quit = true;
return None;
}
app.quit_confirm = Some(std::time::Instant::now());
app.push_error("Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).");
None
}