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>
This commit is contained in:
Erich Blume 2026-06-03 07:11:47 -07:00
commit ae2eff401c
5 changed files with 104 additions and 7 deletions

View file

@ -122,6 +122,17 @@ impl<B: Backend> App<B> {
self.tasks.get(self.task_cursor)
}
/// The node to open in the editor for the highlighted task: its
/// canonical-context doc (where the description/checklist live), falling
/// back to the task node itself.
pub fn selected_context_id(&self) -> Option<String> {
self.selected_task().map(|t| {
t.canonical_context_id
.clone()
.unwrap_or_else(|| t.node_id.clone())
})
}
fn current_target(&self) -> Option<Target> {
match self.sidebar.get(self.sidebar_cursor)? {
SidebarEntry::View { name, .. } => Some(Target::View(name.clone())),

View file

@ -0,0 +1,53 @@
//! The nvim context handoff (tech-spec §8.1): suspend the TUI, open the task's
//! canonical-context doc in the owner's nvim (live, via heph.nvim's buffer
//! surface), then resume. The KB↔task fusion — edit the description/checklist
//! in the real editor and come straight back to triage.
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use ratatui::crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::DefaultTerminal;
/// The nvim arguments that open a heph node buffer through heph.nvim. Node ids
/// are ULIDs (no quote/escaping hazard), so a plain `'<id>'` literal is safe.
pub fn nvim_args(node_id: &str) -> Vec<String> {
vec![format!("+lua require('heph.node').open('{node_id}')")]
}
/// Drop out of the alternate screen, run `nvim` on `node_id` (pointing its
/// heph.nvim at the same daemon via `$HEPH_SOCKET`), then restore the TUI.
pub fn edit_in_nvim(terminal: &mut DefaultTerminal, node_id: &str, socket: &Path) -> Result<()> {
disable_raw_mode()?;
execute!(std::io::stdout(), LeaveAlternateScreen)?;
let status = Command::new("nvim")
.args(nvim_args(node_id))
.env("HEPH_SOCKET", socket)
.status();
// Restore the TUI regardless of how nvim exited, then surface any error.
enable_raw_mode()?;
execute!(std::io::stdout(), EnterAlternateScreen)?;
terminal.clear()?;
status.context("launching nvim (is it on PATH?)")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn args_open_the_node_via_heph_nvim() {
let args = nvim_args("01ABCXYZ");
assert_eq!(args.len(), 1);
assert!(args[0].starts_with("+lua"));
assert!(args[0].contains("require('heph.node').open('01ABCXYZ')"));
}
}

View file

@ -8,6 +8,7 @@
pub mod app;
pub mod backend;
pub mod editor;
pub mod fmt;
pub mod ui;

View file

@ -5,10 +5,17 @@ use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use heph_tui::{app::App, backend::ClientBackend, ui, Focus};
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 {
@ -36,7 +43,7 @@ fn main() -> Result<()> {
let app = App::new(ClientBackend::new(client)).context("loading the agenda")?;
let mut terminal = ratatui::init();
let result = run(&mut terminal, app);
let result = run(&mut terminal, app, &socket);
ratatui::restore();
result
}
@ -44,12 +51,15 @@ fn main() -> 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 {
handle_key(&mut app, key);
if let Some(action) = handle_key(&mut app, key) {
perform(terminal, &mut app, socket, action)?;
}
}
}
if app.should_quit {
@ -58,14 +68,33 @@ fn run<B: heph_tui::Backend>(
}
}
/// Translate a key press into an [`App`] action (T1: navigation only).
fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) {
/// 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;
return None;
}
match key.code {
@ -82,8 +111,11 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) {
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>) {

View file

@ -15,7 +15,7 @@ use crate::backend::Backend;
use crate::fmt::{fmt_date, today_local};
const HINTS: &str =
" j/k move Tab pane x done d drop s skip A attn b→blue r refresh q quit";
" j/k move Tab pane x done d drop s skip A attn b→blue o edit r refresh q quit";
/// Draw the whole UI for the current frame.
pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {