From ae2eff401cb9c881ae869c00881c3490ad642a29 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 07:11:47 -0700 Subject: [PATCH] =?UTF-8?q?feat(tui):=20heph-tui=20T2b=20=E2=80=94=20nvim?= =?UTF-8?q?=20context=20handoff=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- crates/heph-tui/src/app.rs | 11 ++++++++ crates/heph-tui/src/editor.rs | 53 +++++++++++++++++++++++++++++++++++ crates/heph-tui/src/lib.rs | 1 + crates/heph-tui/src/main.rs | 44 +++++++++++++++++++++++++---- crates/heph-tui/src/ui.rs | 2 +- 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 crates/heph-tui/src/editor.rs diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index b399dcf..89e331c 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -122,6 +122,17 @@ impl App { 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 { + self.selected_task().map(|t| { + t.canonical_context_id + .clone() + .unwrap_or_else(|| t.node_id.clone()) + }) + } + fn current_target(&self) -> Option { match self.sidebar.get(self.sidebar_cursor)? { SidebarEntry::View { name, .. } => Some(Target::View(name.clone())), diff --git a/crates/heph-tui/src/editor.rs b/crates/heph-tui/src/editor.rs new file mode 100644 index 0000000..edb1023 --- /dev/null +++ b/crates/heph-tui/src/editor.rs @@ -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 `''` literal is safe. +pub fn nvim_args(node_id: &str) -> Vec { + 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')")); + } +} diff --git a/crates/heph-tui/src/lib.rs b/crates/heph-tui/src/lib.rs index 17bb71d..83efc5c 100644 --- a/crates/heph-tui/src/lib.rs +++ b/crates/heph-tui/src/lib.rs @@ -8,6 +8,7 @@ pub mod app; pub mod backend; +pub mod editor; pub mod fmt; pub mod ui; diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b1e596a..ee28461 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -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( terminal: &mut ratatui::DefaultTerminal, mut app: App, + 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( } } -/// Translate a key press into an [`App`] action (T1: navigation only). -fn handle_key(app: &mut App, key: KeyEvent) { +/// Perform a terminal-affecting action (currently: the nvim handoff). +fn perform( + terminal: &mut ratatui::DefaultTerminal, + app: &mut App, + 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(app: &mut App, key: KeyEvent) -> Option { // 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(app: &mut App, 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(app: &mut App) { diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index 69c5b7d..a26c2e2 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -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(frame: &mut Frame, app: &App) {