hephaestus/crates/heph-tui/src/main.rs
Erich Blume ae2eff401c feat(tui): heph-tui T2b — nvim context handoff (§8.1)
`o` on a task suspends the TUI, opens its canonical-context doc in the owner's
nvim via heph.nvim's live buffer surface (+lua require('heph.node').open), then
restores the alternate screen and reloads to pick up edits. The child nvim is
pointed at the same daemon via $HEPH_SOCKET, so it works under a custom
--socket too. This is the KB↔task fusion — edit the description/checklist in
the real editor and return straight to triage.

handle_key now returns an Action the event loop performs (the suspend/spawn is
terminal-owning, kept out of App). nvim arg builder unit-tested; the actual
suspend/spawn is interactive so it's exercised manually.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:11:47 -07:00

133 lines
4.4 KiB
Rust

//! `heph-tui` binary: terminal lifecycle + event loop. All state/logic lives in
//! the library ([`heph_tui::App`] + [`heph_tui::ui`]); this file is the I/O shell.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use heph_tui::{app::App, backend::ClientBackend, editor, ui, Focus};
use hephd::Client;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
/// A key action that the event loop (which owns the terminal) must perform —
/// kept out of `App` so app logic stays terminal-free.
enum Action {
/// Suspend and open this node in nvim, then reload.
EditContext(String),
}
#[derive(Parser)]
#[command(name = "heph-tui", about = "Hephaestus task agenda / triage TUI")]
struct Cli {
/// Path to the hephd unix socket. Falls back to $HEPH_SOCKET, then the
/// standard runtime path.
#[arg(long)]
socket: Option<PathBuf>,
}
fn resolve_socket(flag: Option<PathBuf>) -> PathBuf {
flag.or_else(|| std::env::var_os("HEPH_SOCKET").map(PathBuf::from))
.unwrap_or_else(hephd::default_socket_path)
}
fn main() -> Result<()> {
let cli = Cli::parse();
let socket = resolve_socket(cli.socket);
let client = Client::connect(&socket).with_context(|| {
format!(
"could not connect to hephd at {} — is it running? (try: heph daemon start)",
socket.display()
)
})?;
let app = App::new(ClientBackend::new(client)).context("loading the agenda")?;
let mut terminal = ratatui::init();
let result = run(&mut terminal, app, &socket);
ratatui::restore();
result
}
fn run<B: heph_tui::Backend>(
terminal: &mut ratatui::DefaultTerminal,
mut app: App<B>,
socket: &std::path::Path,
) -> Result<()> {
loop {
terminal.draw(|f| ui::render(f, &app))?;
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
if let Some(action) = handle_key(&mut app, key) {
perform(terminal, &mut app, socket, action)?;
}
}
}
if app.should_quit {
return Ok(());
}
}
}
/// Perform a terminal-affecting action (currently: the nvim handoff).
fn perform<B: heph_tui::Backend>(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App<B>,
socket: &std::path::Path,
action: Action,
) -> Result<()> {
match action {
Action::EditContext(id) => {
if let Err(e) = editor::edit_in_nvim(terminal, &id, socket) {
app.status = format!("error: {e}");
}
app.reload(); // pick up edits made in nvim
}
}
Ok(())
}
/// Translate a key press into an [`App`] mutation and/or an [`Action`] for the
/// event loop to perform.
fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<Action> {
// Any keypress clears a stale status message.
app.status.clear();
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
return None;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('r') => app.reload(),
KeyCode::Tab => app.toggle_focus(),
KeyCode::Char('j') | KeyCode::Down => move_down(app),
KeyCode::Char('k') | KeyCode::Up => move_up(app),
KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(),
KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(),
// triage mutations (act on the highlighted task)
KeyCode::Char('x') => app.complete_selected(),
KeyCode::Char('d') => app.drop_selected(),
KeyCode::Char('s') => app.skip_selected(),
KeyCode::Char('A') => app.cycle_attention_selected(),
KeyCode::Char('b') => app.push_to_blue_selected(),
// open the task's context doc in nvim (handled by the event loop)
KeyCode::Char('o') => return app.selected_context_id().map(Action::EditContext),
_ => {}
}
None
}
fn move_down<B: heph_tui::Backend>(app: &mut App<B>) {
match app.focus {
Focus::Sidebar => app.move_sidebar(1),
Focus::Tasks => app.move_task(1),
}
}
fn move_up<B: heph_tui::Backend>(app: &mut App<B>) {
match app.focus {
Focus::Sidebar => app.move_sidebar(-1),
Focus::Tasks => app.move_task(-1),
}
}