generated from eblume/project-template
Phase 1: v1 prototype #1
5 changed files with 104 additions and 7 deletions
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>
commit
ae2eff401c
|
|
@ -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())),
|
||||
|
|
|
|||
53
crates/heph-tui/src/editor.rs
Normal file
53
crates/heph-tui/src/editor.rs
Normal 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')"));
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
pub mod app;
|
||||
pub mod backend;
|
||||
pub mod editor;
|
||||
pub mod fmt;
|
||||
pub mod ui;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue