From dc8e06ecaaf116d6b1ced28348f50560db4db8e1 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 3 Jun 2026 07:40:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(tui):=20heph-tui=20T3=20=E2=80=94=20full-t?= =?UTF-8?q?ext=20search=20overlay=20(=C2=A78.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/` opens a search prompt; submitting runs the FTS `search` RPC and overlays the results on the center pane (title + [kind]). j/k move, Enter opens the hit (a task hit opens its canonical-context doc via context_of; docs/journals open themselves) in nvim, Esc exits search. Backend gained `search` + `context_of`. Tests: fake-backend flow (results populate; task hit resolves to its context, doc hit to itself; clear) + a real-daemon integration test (seed a doc, search, assert the hit + that the Search pane renders). 183 workspace tests; clippy/fmt clean. Move-to-project is the last Todoist-parity gap; it needs a new task.set_project RPC (no link-remove RPC yet) and is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/heph-tui/src/app.rs | 74 +++++++++++++++++++++++- crates/heph-tui/src/backend.rs | 35 +++++++++++ crates/heph-tui/src/main.rs | 42 ++++++++++++-- crates/heph-tui/src/ui.rs | 90 +++++++++++++++++++++++++++-- crates/heph-tui/tests/agenda.rs | 23 ++++++++ crates/heph-tui/tests/navigation.rs | 46 ++++++++++++++- 6 files changed, 299 insertions(+), 11 deletions(-) diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index 19442bc..6ff1528 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -5,7 +5,7 @@ use anyhow::Result; use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS}; -use crate::backend::{Backend, Project}; +use crate::backend::{Backend, Project, SearchHit}; /// The interaction mode: normal navigation, or collecting a line of text. #[derive(Debug, Clone, PartialEq, Eq)] @@ -31,6 +31,17 @@ enum InputKind { Reschedule { task_id: String, }, + /// Full-text search query. + Search, +} + +/// An active full-text search: the query, its hits, and the highlighted row. +/// While `Some` on the App, the center pane shows results instead of tasks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchView { + pub query: String, + pub results: Vec, + pub cursor: usize, } /// The attention cycle for the `A` gesture: default → top-of-mind → consequence @@ -93,6 +104,8 @@ pub struct App { pub preview: Preview, pub focus: Focus, pub mode: Mode, + /// When `Some`, a full-text search overlays the task list. + pub search: Option, pub status: String, pub should_quit: bool, } @@ -129,6 +142,7 @@ impl App { preview: Preview::default(), focus: Focus::Sidebar, mode: Mode::Normal, + search: None, status: String::new(), should_quit: false, }; @@ -378,6 +392,48 @@ impl App { }); } + /// Open the full-text search prompt. + pub fn begin_search(&mut self) { + self.mode = Mode::Input(InputState { + prompt: "Search".into(), + buffer: String::new(), + kind: InputKind::Search, + }); + } + + /// Close the search overlay, returning to the selected view's task list. + pub fn clear_search(&mut self) { + self.search = None; + } + + /// Move the search-results cursor by `delta` (clamped). + pub fn search_move(&mut self, delta: isize) { + if let Some(s) = &mut self.search { + if s.results.is_empty() { + return; + } + let max = s.results.len() as isize - 1; + s.cursor = (s.cursor as isize + delta).clamp(0, max) as usize; + } + } + + /// The node id to open for the highlighted search hit — a task's + /// canonical-context doc, or the node itself. `None` if no hit is selected. + pub fn search_open_target(&mut self) -> Option { + let hit = self + .search + .as_ref()? + .results + .get(self.search.as_ref()?.cursor)?; + let (id, is_task) = (hit.id.clone(), hit.kind == "task"); + if is_task { + if let Ok(Some(ctx)) = self.backend.context_of(&id) { + return Some(ctx); + } + } + Some(id) + } + /// Append a typed character to the active input. pub fn input_push(&mut self, c: char) { if let Mode::Input(state) = &mut self.mode { @@ -443,6 +499,22 @@ impl App { Err(e) => self.status = format!("error: {e}"), } } + InputKind::Search => { + if buf.is_empty() { + return; + } + match self.backend.search(&buf) { + Ok(results) => { + self.status = format!("{} result(s) for {buf:?}", results.len()); + self.search = Some(SearchView { + query: buf, + results, + cursor: 0, + }); + } + Err(e) => self.status = format!("error: {e}"), + } + } InputKind::Reschedule { task_id } => { let patch = if buf.is_empty() { SchedulePatch { diff --git a/crates/heph-tui/src/backend.rs b/crates/heph-tui/src/backend.rs index 9b783fc..2664948 100644 --- a/crates/heph-tui/src/backend.rs +++ b/crates/heph-tui/src/backend.rs @@ -17,6 +17,14 @@ pub struct Project { pub title: String, } +/// A full-text search result row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchHit { + pub id: String, + pub title: String, + pub kind: String, +} + /// Everything the agenda surface asks of the daemon. pub trait Backend { /// All project nodes (for the sidebar), title-sorted. @@ -30,6 +38,11 @@ pub trait Backend { fn node_body(&mut self, id: &str) -> Result; /// The last `n` log lines for a task (the resumption breadcrumb). fn log_tail(&mut self, task_id: &str, n: usize) -> Result>; + /// Full-text search over titles + bodies (FTS5), best-match first. + fn search(&mut self, query: &str) -> Result>; + /// A task's canonical-context doc id (where its description/checklist live), + /// for opening a task search-hit at the useful node. `None` if it has none. + fn context_of(&mut self, task_id: &str) -> Result>; // --- triage mutations (T2) --- @@ -108,6 +121,28 @@ impl Backend for ClientBackend { Ok(serde_json::from_value(v)?) } + fn search(&mut self, query: &str) -> Result> { + let v = self.call("search", json!({ "query": query }))?; + let nodes: Vec = serde_json::from_value(v)?; + Ok(nodes + .into_iter() + .map(|n| SearchHit { + id: n.id, + title: n.title, + kind: n.kind.as_str().to_string(), + }) + .collect()) + } + + fn context_of(&mut self, task_id: &str) -> Result> { + let v = self.call("links.outgoing", json!({ "id": task_id }))?; + let links: Vec = serde_json::from_value(v)?; + Ok(links + .into_iter() + .find(|l| l.link_type == heph_core::LinkType::CanonicalContext) + .map(|l| l.dst_id)) + } + fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> { self.call("task.set_state", json!({ "id": task_id, "state": state }))?; Ok(()) diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index ee28461..6a651a7 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -5,7 +5,11 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::Parser; -use heph_tui::{app::App, backend::ClientBackend, editor, ui, Focus}; +use heph_tui::{ + app::{App, Mode}, + backend::ClientBackend, + editor, ui, Focus, +}; use hephd::Client; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; @@ -89,14 +93,40 @@ fn perform( /// 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 None; } + // While collecting input, all keys go to the prompt. + if matches!(app.mode, Mode::Input(_)) { + match key.code { + KeyCode::Esc => app.input_cancel(), + KeyCode::Enter => app.input_submit(), + KeyCode::Backspace => app.input_backspace(), + KeyCode::Char(c) => app.input_push(c), + _ => {} + } + return None; + } + + // While search results are shown, the center pane navigates them. + if app.search.is_some() { + app.status.clear(); + match key.code { + KeyCode::Esc => app.clear_search(), + KeyCode::Char('j') | KeyCode::Down => app.search_move(1), + KeyCode::Char('k') | KeyCode::Up => app.search_move(-1), + KeyCode::Enter => return app.search_open_target().map(Action::EditContext), + KeyCode::Char('q') => app.should_quit = true, + _ => {} + } + return None; + } + + // Any other keypress clears a stale status message. + app.status.clear(); + match key.code { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('r') => app.reload(), @@ -105,6 +135,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option move_up(app), KeyCode::Char('h') | KeyCode::Left => app.focus_sidebar(), KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => app.focus_tasks(), + // capture + reschedule + search (open an input prompt) + KeyCode::Char('a') => app.begin_add(), + KeyCode::Char('e') => app.begin_reschedule(), + KeyCode::Char('/') => app.begin_search(), // triage mutations (act on the highlighted task) KeyCode::Char('x') => app.complete_selected(), KeyCode::Char('d') => app.drop_selected(), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index a26c2e2..fbe0090 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -6,16 +6,18 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, Frame, }; -use crate::app::{App, Focus, SidebarEntry}; +use crate::app::{App, Focus, InputState, Mode, SidebarEntry}; 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 o edit r refresh q quit"; + " j/k move Tab pane a add x done e date A attn b→blue o edit / search q quit"; + +const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search"; /// Draw the whole UI for the current frame. pub fn render(frame: &mut Frame, app: &App) { @@ -34,9 +36,42 @@ pub fn render(frame: &mut Frame, app: &App) { .split(outer[0]); render_sidebar(frame, app, panes[0]); - render_tasks(frame, app, panes[1]); + if app.search.is_some() { + render_search(frame, app, panes[1]); + } else { + render_tasks(frame, app, panes[1]); + } render_preview(frame, app, panes[2]); render_status(frame, app, outer[1]); + + if let Mode::Input(state) = &app.mode { + render_input(frame, state); + } +} + +/// A centered single-line input popup (guided add / reschedule). +fn render_input(frame: &mut Frame, state: &InputState) { + let area = frame.area(); + let width = area.width.saturating_sub(8).clamp(20, 70); + let popup = Rect { + x: area.x + (area.width.saturating_sub(width)) / 2, + y: area.y + area.height / 3, + width, + height: 3, + }; + frame.render_widget(Clear, popup); + let line = Line::from(vec![ + Span::styled("> ", Style::default().fg(Color::Cyan)), + Span::raw(&state.buffer), + Span::styled("▏", Style::default().fg(Color::Cyan)), // cursor + ]); + let para = Paragraph::new(line).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(format!(" {} ", state.prompt)), + ); + frame.render_widget(para, popup); } fn pane_border(focused: bool) -> Style { @@ -167,6 +202,46 @@ fn render_tasks(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(list, area); } +fn render_search(frame: &mut Frame, app: &App, area: Rect) { + let Some(s) = &app.search else { return }; + let items: Vec = if s.results.is_empty() { + vec![ListItem::new(Line::from(Span::styled( + " (no matches — Esc to exit search)", + Style::default().fg(Color::DarkGray), + )))] + } else { + s.results + .iter() + .enumerate() + .map(|(i, hit)| { + let selected = i == s.cursor; + let title_style = if selected { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let cursor = if selected { "▌" } else { " " }; + ListItem::new(Line::from(vec![ + Span::styled(cursor, Style::default().fg(Color::Cyan)), + Span::styled( + format!("[{}] ", hit.kind), + Style::default().fg(Color::DarkGray), + ), + Span::styled(hit.title.clone(), title_style), + ])) + }) + .collect() + }; + let title = format!(" Search: {} ({}) ", s.query, s.results.len()); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(title), + ); + frame.render_widget(list, area); +} + fn render_preview(frame: &mut Frame, app: &App, area: Rect) { let mut lines: Vec = Vec::new(); if !app.preview.title.is_empty() { @@ -203,8 +278,13 @@ fn render_preview(frame: &mut Frame, app: &App, area: Rect) { } fn render_status(frame: &mut Frame, app: &App, area: Rect) { + let hints = if app.search.is_some() { + SEARCH_HINTS + } else { + HINTS + }; let text = if app.status.is_empty() { - HINTS.to_string() + hints.to_string() } else { format!(" {}", app.status) }; diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 518162e..25e9d0c 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -177,6 +177,29 @@ fn quick_add_captures_a_task_that_appears_in_the_view() { ); } +#[test] +fn search_finds_a_matching_node_and_overlays_results() { + let (socket, _dir) = spawn_daemon(); + let mut c = client(&socket); + c.call( + "node.create", + json!({ "kind": "doc", "title": "Plumbing notes", "body": "shutoff valve is in the garage" }), + ) + .unwrap(); + + let mut app = App::new(ClientBackend::new(client(&socket))).unwrap(); + app.begin_search(); + type_and_submit(&mut app, "plumbing"); + + let s = app.search.as_ref().expect("search active"); + assert!( + s.results.iter().any(|h| h.title == "Plumbing notes"), + "search missed the doc: {:?}", + s.results + ); + assert!(screen(&app).contains("Search:"), "search pane not rendered"); +} + #[test] fn reschedule_sets_a_do_date_on_the_task() { let (socket, _dir) = spawn_daemon(); diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 4d1e8cb..2ed1694 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -9,7 +9,7 @@ use anyhow::Result; use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState}; use heph_tui::{ app::{App, Focus}, - backend::{Backend, Project}, + backend::{Backend, Project, SearchHit}, }; /// A recorded `create_task`: (title, attention, do_date, recurrence, project_id). @@ -50,6 +50,8 @@ struct Fake { projects: Vec, by_project: HashMap>, bodies: HashMap, + search_hits: Vec, + contexts: HashMap, rec: Rc>, } @@ -70,6 +72,17 @@ impl Backend for Fake { fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result> { Ok(Vec::new()) } + fn search(&mut self, query: &str) -> Result> { + Ok(self + .search_hits + .iter() + .filter(|h| h.title.to_lowercase().contains(&query.to_lowercase())) + .cloned() + .collect()) + } + fn context_of(&mut self, task_id: &str) -> Result> { + Ok(self.contexts.get(task_id).cloned()) + } fn set_state(&mut self, _t: &str, _s: &str) -> Result<()> { Ok(()) } @@ -219,6 +232,37 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() { assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano) } +#[test] +fn search_populates_results_and_task_hits_open_their_context() { + let mut fake = fixture(); + fake.search_hits = vec![ + SearchHit { + id: "doc1".into(), + title: "Roof notes".into(), + kind: "doc".into(), + }, + SearchHit { + id: "task1".into(), + title: "Roof repair".into(), + kind: "task".into(), + }, + ]; + fake.contexts.insert("task1".into(), "ctx1".into()); + let mut app = App::new(fake).unwrap(); + + app.begin_search(); + type_and_submit(&mut app, "roof"); + assert_eq!(app.search.as_ref().unwrap().results.len(), 2); + + // A doc hit opens itself; a task hit opens its canonical-context doc. + assert_eq!(app.search_open_target().as_deref(), Some("doc1")); + app.search_move(1); + assert_eq!(app.search_open_target().as_deref(), Some("ctx1")); + + app.clear_search(); + assert!(app.search.is_none()); +} + #[test] fn quick_add_passes_inline_recurrence_and_project_through() { let rec = Rc::new(RefCell::new(Recorder::default()));