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) {