feat(tui): heph-tui T3 — full-text search overlay (§8.1)

`/` 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) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-03 07:40:21 -07:00
commit dc8e06ecaa
6 changed files with 299 additions and 11 deletions

View file

@ -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<SearchHit>,
pub cursor: usize,
}
/// The attention cycle for the `A` gesture: default → top-of-mind → consequence
@ -93,6 +104,8 @@ pub struct App<B: Backend> {
pub preview: Preview,
pub focus: Focus,
pub mode: Mode,
/// When `Some`, a full-text search overlays the task list.
pub search: Option<SearchView>,
pub status: String,
pub should_quit: bool,
}
@ -129,6 +142,7 @@ impl<B: Backend> App<B> {
preview: Preview::default(),
focus: Focus::Sidebar,
mode: Mode::Normal,
search: None,
status: String::new(),
should_quit: false,
};
@ -378,6 +392,48 @@ impl<B: Backend> App<B> {
});
}
/// 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<String> {
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<B: Backend> App<B> {
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 {

View file

@ -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<String>;
/// The last `n` log lines for a task (the resumption breadcrumb).
fn log_tail(&mut self, task_id: &str, n: usize) -> Result<Vec<String>>;
/// Full-text search over titles + bodies (FTS5), best-match first.
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>>;
/// 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<Option<String>>;
// --- triage mutations (T2) ---
@ -108,6 +121,28 @@ impl Backend for ClientBackend {
Ok(serde_json::from_value(v)?)
}
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
let v = self.call("search", json!({ "query": query }))?;
let nodes: Vec<heph_core::Node> = 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<Option<String>> {
let v = self.call("links.outgoing", json!({ "id": task_id }))?;
let links: Vec<heph_core::Link> = 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(())

View file

@ -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<B: heph_tui::Backend>(
/// 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 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<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('k') | KeyCode::Up => 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(),

View file

@ -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<B: Backend>(frame: &mut Frame, app: &App<B>) {
@ -34,9 +36,42 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
.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<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
frame.render_widget(list, area);
}
fn render_search<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let Some(s) = &app.search else { return };
let items: Vec<ListItem> = 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<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
if !app.preview.title.is_empty() {
@ -203,8 +278,13 @@ fn render_preview<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
}
fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, 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)
};

View file

@ -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();

View file

@ -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<Project>,
by_project: HashMap<String, Vec<RankedTask>>,
bodies: HashMap<String, String>,
search_hits: Vec<SearchHit>,
contexts: HashMap<String, String>,
rec: Rc<RefCell<Recorder>>,
}
@ -70,6 +72,17 @@ impl Backend for Fake {
fn log_tail(&mut self, _task_id: &str, _n: usize) -> Result<Vec<String>> {
Ok(Vec::new())
}
fn search(&mut self, query: &str) -> Result<Vec<SearchHit>> {
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<Option<String>> {
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()));