Skip to content

feat(tui): FTS-backed browse with chips, scope picker, preview pane, and help overlay - #25

Merged
lancekrogers merged 49 commits into
mainfrom
feat/tui-search-upgrade-CG0005
Apr 21, 2026
Merged

feat(tui): FTS-backed browse with chips, scope picker, preview pane, and help overlay#25
lancekrogers merged 49 commits into
mainfrom
feat/tui-search-upgrade-CG0005

Conversation

@lancekrogers

Copy link
Copy Markdown
Member

Summary

Upgrades camp-graph browse from an in-memory substring explorer into a live FTS5-backed search experience with parity ergonomics to camp intent explore. Delivered across 46 commits under festival camp-graph-tui-search-upgrade-CG0005 (99/99 tasks).

  • Live FTS pipeline with per-keystroke generation cancellation: issueQuery bumps queryGen, cancels any in-flight context.Context, and routes through a shared pipeline used by both search input and chip changes. Empty terms short-circuit to the client-side anchor fallback without hitting Querier.Search (per D005).
  • Three filter chips (copied from camp/internal/intent/tui/filterchip under D002 provenance): Type, Tracked, Mode. Focused via t/s/m; value changes reissue the query.
  • Scope picker modal (c opens, C clears). Selection threads into QueryOptions.Scope and narrows both FTS and anchor-fallback paths.
  • Preview pane (right column): node header, path, status, outgoing/incoming edges capped at 50, top-3 related items via search.Related. tab focuses the pane; j/k scroll without moving the list cursor. Per-focus context cancellation via previewCancel with stale-drop in Update.
  • Responsive layout: narrow (<80) hides preview and scope column, normal (80–120) is 60/40, wide (>120) is 50/50. Recomputed on tea.WindowSizeMsg.
  • Keybinding parity: vim count prefix (5j, 10k, 3gg), gg/G, ctrl+u/ctrl+d scaled to half list height, a widen gated to explorer fallback, ? help overlay with the full UX_SPEC keybinding table, q/ctrl+c cancel in-flight Cmds before quit.
  • Test coverage: 171 unit tests (up from 143). Race-clean cancellation tests for both the query and preview pipelines via narrow interfaces (querierIface, previewFetcher) with blocking stubs. buildOpts table covers 12 permutations including chip+scope cross-products; grouping goldens lock typePriority (D004) ordering and unknown-type first-appearance tie-break; layout boundary tests at 79/80/81/119/120/121.

Test plan

  • just test unit — 171/171 pass
  • just test all (unit + Docker integration) — 171+31 pass
  • just lint — clean (go fmt ./..., go vet ./...)
  • just build — clean
  • go test -race ./internal/tui/... -run TestQueryCancellation -count=5 — stable
  • go test -race ./internal/tui/... -run TestPreviewCancellation -count=3 — stable
  • Manual parity walkthrough against camp intent explore (festival task 08_manual_parity_checklist)

…e/render/anchors/micrograph

Decompose the 562-line internal/tui/tui.go into five focused files
without behavior change:

- model.go: Model struct, viewMode, RelationMode, neighborEntry, New, Init
- anchors.go: collectScopeAnchors, sortByScopePriority
- update.go: Update entry point, updateNormal, updateSearch, applyFilter
- micrograph.go: updateMicrograph, enterMicrograph, reloadNeighborsForFocus,
  edgeAllowedInMode, viewMicrograph
- render.go: styles, View, renderList, renderDetail, styleForType

Prep for CG0005 sequence 01 task 01: establishes the package layout
from TUI_CONTRACT.md ahead of FTS query wiring. Substring / filter,
anchor fallback, and micrograph navigation unchanged. just test unit
green (143/143), go vet clean.
…xt into tui.New constructor

Adds store and ctx fields to Model and changes New signature to
func New(ctx context.Context, store *graph.Store, g *graph.Graph) *Model.
Updates browse.go to pass both the already-open store and the loaded
graph. No user-visible behavior change; store path usage lands in
sequence 02.
…cation gates

Update the camp-graph justfiles so root-level `just -f projects/camp-graph/justfile ...`
commands execute in the project directory, and add focused `internal/tui` model tests
covering anchor initialization, substring search escape behavior, and micrograph enter/exit.

This closes the regression and testing gates for the TUI file split by making the documented
verification commands work as written and by adding automated coverage on the refactored TUI surface.
…in the TUI search branch

Include the remaining formatting and alignment cleanups in graph, runtime,
scanner, and search files so the branch reflects the intended project state
instead of leaving those tracked edits out of the PR.

This keeps the PR complete per branch scope while remaining behavior-neutral;
lint stays clean and the changes are limited to whitespace, import ordering,
and alignment cleanup.
…ui.New from injected store

Adds querier *search.Querier field to Model and constructs it once in
New via search.NewQuerier(store.DB()). Updates model tests to open a
real temp-file store via a newTestStore helper (prior nil-store pattern
now panics since New dereferences store.DB).
…Model to search.QueryOptions

Introduces pure buildOpts(Model) search.QueryOptions helper in
internal/tui/query.go. Currently maps only the Term field from the
search input; chip/scope mapping lands in sequences 03-04. Adds
table-driven test across empty, whitespace-only, and plain query
inputs.
…unQueryCmd factory

Adds queryResultMsg{gen, results, err} and runQueryCmd factory in
internal/tui/query.go per D003 live-query pipeline contract. Adds
queryGen, queryCancel, and results fields to Model, plus a stale-drop
branch in Update that nils queryCancel on accept. groupByType hook
deferred to task 05 via TODO; input-wiring deferred to task 04.
… runQueryCmd pipeline

Replaces the substring Contains filter in updateSearch with the live
FTS Cmd pipeline: each keystroke bumps queryGen, cancels any in-flight
context, derives a fresh child context from m.ctx, and issues
runQueryCmd. Empty terms short-circuit without hitting Querier.Search.
Esc clears the input, cancels in-flight queries, and exits search mode.

Updates TestSearchEscClearsQueryAndRestoresAllNodes to match the new
semantics (renamed to ...ExitsSearchMode); assertions on m.filtered
driven by strings.Contains are replaced with assertions on results and
queryCancel, since list rendering moves to m.groups in task 05.
… with deterministic priority order

Adds resultGroup type and groupByType helper in internal/tui/query.go
per D004. typePriority encodes the UX_SPEC node-type order
(project/festival/phase/sequence/task/intent/... through file=18,
unknown=100), with alphabetical tiebreak for stability. BM25 order is
preserved inside each bucket; groups default to Expanded: true.

Wires m.groups = groupByType(m.results) in the queryResultMsg accept
branch (replacing the task-03 TODO) and clears m.groups alongside
m.results in the esc and empty-term branches. Adds golden tests for
multi-type, single-type, and empty inputs.
…fallback (D005)

Adds filterAnchors helper that narrows anchors by NodeType chip,
tracked-state chip, and scope path-prefix. Adds filteredAnchors and
scope fields to Model, plus chipTypeValue/chipTrackedValue shims that
return "" until sequence 03 wires the chip UI.

Wires filteredAnchors in New, in the empty-term updateSearch branch,
and in esc handling so Querier.Search is never called with an empty
term. Renders filteredAnchors in the left pane when m.groups == nil
(grouped-result rendering lands in a later sequence).

Adds filterAnchors tests covering all-defaults, type chip, tracked
chip, untracked chip, and scope prefix; plus a buildOpts empty-term
test confirming zero-valued options.
…ith stub querier under -race

Introduces querierIface in internal/tui/query.go — the narrow Search
subset of *search.Querier that internal/tui actually depends on. Model
and runQueryCmd now take querierIface; *search.Querier satisfies it
structurally so browse.go is unchanged. Tests substitute a blocking
stub to prove cancellation.

Adds stubQuerier (thread-safe, blocks on release or ctx.Done) and
TestQueryCancellation that issues 3 concurrent queries with distinct
contexts, cancels the first two, and asserts (a) the cancelled calls'
stored contexts observe Done within 1s and (b) only the gen-3
queryResultMsg is accepted by Model.Update. Passes under -race.
…m camp into camp-graph (D002)

Copies bar.go, chip.go, chip_test.go, messages.go, styles.go from
projects/camp/internal/intent/tui/filterchip at commit 5c82d35b into
projects/camp-graph/internal/tui/chips/ and renames the package to
chips. Each file has a provenance comment recording the source commit
per D002.

Theme dependency resolved via the shim approach: a new theme.go in
chips/ declares a local pal struct with only the palette fields
referenced by styles.go (Border, BorderFocus, Accent, TextPrimary,
TextSecondary, TextMuted, BgSelected). Values mirror
camp/internal/ui/theme.TUI() so the chip bar renders with the same
adaptive colors as camp intent explore without depending on camp's
theme package.

All 10 copied chip_test.go subtests pass; build and lint clean.
… chips on Model and map them into buildOpts

Adds bar_config.go in chips/ with NewTypeChip/NewTrackedChip/NewModeChip
constructors and wires a chipBar field on Model that tui.New populates
with the three chips. Type chip is seeded with the authoritative
NodeType string list pulled from internal/graph. Tracked chip offers
All/Tracked only/Untracked only; Mode chip offers the four QueryMode
values with hybrid as default (the chip's Selected=0 naturally).

Extends buildOpts to map chip state onto search.QueryOptions:
Type chip (non-All) -> opts.Type; Tracked chip -> Tracked/Untracked
bools per the three-state table; Mode chip -> opts.Mode cast to
search.QueryMode; m.scope flows through to opts.Scope. Replaces the
chipTypeValue/chipTrackedValue task-06 shims with real readers that
treat the "All" default as unset.
…lters pills above list

Restructures View() to stack a renderHeader() above the horizontal
list+detail body. The header stacks a renderChipBar() single-line row
(Type Tracked Mode) and, when any chip is off its default, a
renderActiveFilters() pill line (e.g. [Type: task] [Mode: semantic]).
When all chips are at defaults the header is suppressed entirely; the
view layout matches the prior two-pane look.

Narrow-breakpoint abbreviation is deferred until m.layout wiring
lands in sequence 06 (TODO in the task doc; full labels for now).
…d route keystrokes to chip.Update

Introduces a focusMode enum (focusList/focusSearch/focusTypeChip/
focusTrackedChip/focusModeChip) on Model. In updateNormal (focusList):
t/s/m set focus to the respective chip and call Focus() on it. The top
Update dispatcher routes key messages to updateChipFocus when any chip
has focus.

updateChipFocus handles esc by blurring the focused chip and returning
to focusList without a query reissue; all other keys flow into the
chip's own Update method (open/close dropdown, j/k navigation,
enter/space to select, number quick-select). The query re-issue hook
for value-changed lands in task 05.

Removes the legacy 's' binding (return to scope anchors) — the chip
bar's tracked-state chip supplants that navigation pattern; the 'a'
binding (widen to all nodes) is kept.
…es behind issueQuery()

Extracts the query-reissue logic into a shared (*Model).issueQuery()
method that bumps queryGen, cancels any in-flight query, short-circuits
on empty Term (clears results, runs client-side applyAnchorFilters per
D005 empty-query fallback, returns nil Cmd), and otherwise returns a
runQueryCmd tagged with the new generation.

Refactors updateSearch to call issueQuery() instead of inlining the
pipeline. Updates updateChipFocus to diff each chip's SelectedValue
before/after Update(msg); when the value changes it reissues the query
via issueQuery(), batched with any chip-returned Cmd.

Adds applyAnchorFilters() method that refreshes m.filteredAnchors from
scopeAnchors using the current chip and scope state (Mode ignored —
affects ranking, not membership).
…er chip permutations

Extends TestBuildOpts to a 10-case table covering empty/term-only
baseline, each Tracked chip state (All, Tracked only, Untracked only),
each Mode chip value (hybrid, structural, explicit, semantic), Type
chip set, the combined all-three-set case, and a whitespace-term edge
case. Assertions now compare the full QueryOptions struct in one step
so chip-mapping interactions are regression-locked.

Adds a newTestChip helper that constructs a chip with the given
options and preselects the requested value. Uses search.QueryMode*
constants on the want side instead of raw strings to catch cast
errors.
…shell

Introduces internal/tui/scope_picker.go with a scopePickerModel
struct holding options, cursor, and open flag. newScopePicker seeds
options from the existing scope-anchor list (Node.Path, dedup with
first-appearance order) so the picker shares discovery with the
empty-query fallback rather than issuing a separate DB query.

Update handles j/k cursor movement only; accept/cancel keys land in
task 02 via the parent Update dispatcher. View renders a centered
rounded-border list with a > cursor indicator, showing up to 20 rows
with scroll-around-cursor paging for longer lists. Selected()
exposes the row under the cursor for parent-side commit.

Wires the picker into Model as scopePicker scopePickerModel and
constructs it in tui.New alongside the chip bar.
…open scope picker overlay

Adds focusScopePicker to the focusMode enum. updateNormal maps c to
push focus, set scopePicker.open = true, and reset the cursor. Adds
updateScopePicker routing: esc closes without changing m.scope; enter
applies picker.Selected() to m.scope, closes the overlay, restores
focusList, and returns m.issueQuery() so the query reissues once
through the shared pipeline (no separate Cmd path).

Extends View() to overlay scopePicker.View() centered over the body
via lipgloss.Place when open; normal view otherwise.
…ilters row

Extends renderActiveFilters to append a [Scope: <label>] pill when
m.scope is set, joining it inline with the chip pills so the row
reads as one coherent filter strip. Reuses breadcrumbStyle so the
scope pill matches chip pill styling.

Adds scopeLabel() that falls back to the last path segment on narrow
terminals (< 80 cols) and truncates long paths with a leading ellipsis
above a 60-char cap. Empty m.scope contributes nothing, preserving
the all-defaults suppression added in sequence 03.
…nd reissue query

In focusList, adds 'C' case that clears m.scope when set and returns
m.issueQuery() so the result set refreshes through the shared
pipeline. With no scope set the binding is a no-op. bubbletea delivers
shift+c as KeyMsg.String() == "C", so lower-case c (open picker) and
upper-case C (clear scope) route to distinct branches without
collision.
…cope cases

Adds a scope column to the TestBuildOpts table plus two scope cases:
scope-only-with-term and scope-plus-type. buildOpts already forwards
m.scope to opts.Scope (sequence 03 task 02) and applyAnchorFilters
already filters anchors by scope via filterAnchors (sequence 03 task
05), so this task only locks behavior with tests.
… and preview msg types

Adds internal/tui/preview.go with previewEdges (Out/In slices of
graph.Edge) and previewMsg (id/node/edges/related/err) types. focusMode
already exists from sequence 03 task 04, so this file does not
redeclare it; it imports context only for the in-flight Cmd cancel
plumbing that lands in task 02.

Extends Model with previewFocusID, previewCancel, previewNode,
previewEdges, previewRelated, and previewScroll fields per the
TUI_CONTRACT.md Model shape. Build and unit tests remain green; Cmd
wiring and rendering land in later tasks in this sequence.
…s node, edges, and related rows

Implements runPreviewCmd(ctx, store, g, id) tea.Cmd in preview.go.
Empty id returns an empty previewMsg; otherwise it reads the node,
outgoing edges, and incoming edges from the in-memory Graph and calls
search.Related (FTS-backed, default hybrid mode, Limit 3) for related
rows. Related only runs when the node has a non-empty Path (the
related query keys off relative path, not node id).

Adjusts previewEdges to hold []*graph.Edge slices (matching
Graph.EdgesFrom/EdgesTo signatures) and previewMsg/previewRelated to
use search.RelatedItem per the real search package shape. Caller-side
cancel wiring and queryCancel nilling land in task 03.
…hes with stale-drop on msg accept

Adds Model.focusedRowID() which returns the NodeID of the currently
focused row, mirroring renderList ordering: grouped FTS results win,
then m.filteredAnchors, then m.filtered. Adds (*Model).issuePreview()
which cancels any in-flight preview context, derives a fresh child
from m.ctx, stores the cancel on m.previewCancel, records the target
id as m.previewFocusID, and returns a runPreviewCmd.

Navigation keys j/k, g/G, and ctrl+u/ctrl+d in updateNormal now batch
issuePreview() after moving the cursor. Update grows a previewMsg
case: stale msgs (id != focusedRowID) are dropped, accepted msgs nil
m.previewCancel per the D003 invariant and populate
previewNode/previewEdges/previewRelated plus reset previewScroll.
…ode header, edges, and related

Adds renderPreview(m, width, height) and writeEdgeList helpers in
preview.go. The pane body is structured: Name + [Type] header, path
line, optional status line, outgoing edges section, incoming edges
section, and up to 3 related items. writeEdgeList renders each edge
as '<EdgeType> -> <ToID>' and caps at previewEdgeCap (50) per
direction; overflow collapses to '... +N more'. Empty sections render
'(none)'. When previewFocusID is set but previewNode is nil the pane
shows 'Loading preview...'.

renderDetail delegates to renderPreview when the user has focused a
row (previewFocusID or previewNode non-nil), falling back to the
legacy name/type/path/metadata detail view otherwise so existing
non-query navigation still shows context.
… preview via tab; scroll preview in focus

Adds focusPreview to the focusMode enum. In updateNormal, tab now
switches to focusPreview when m.previewNode is non-nil; with no
previewNode, tab retains its legacy behavior of cycling relationMode.
Adds updatePreviewFocus routing: j/k scroll previewScroll without
moving the list cursor or issuing a preview Cmd; tab returns to
focusList. The top Update dispatcher routes to updatePreviewFocus
when focus is focusPreview.

Extends renderPreview to slice the body by previewScroll lines from
the top so the pane visually scrolls when focused. (Border styling
for the focused pane lands later; scroll behavior is what the task
asks for.)
… hint states; always delegate renderDetail

Adds renderPreviewEmpty (cursor hint with tab instructions) and
renderPreviewLoading (one-line 'loading preview...') helpers in
preview.go, and rewires renderPreview's nil-node branch to call them:
previewFocusID set without a node yet yields the loading hint;
zero-state yields the cursor hint.

renderDetail now always delegates to renderPreview so the hint shows
on first launch and during empty query states. Removes the legacy
renderDetailLegacy body (name/type/path/metadata/neighbor list with
the keybinding footer); the preview pane now owns the right column
end-to-end.
…arrow/normal/wide)

Introduces internal/tui/layout.go with layoutMode (narrow/normal/wide)
and a pure layoutFor(width) mapping per UX_SPEC: <80 narrow,
80..120 normal, >120 wide. Boundary test locks six cases
(79/80/81/119/120/121). Wiring into render/header composition lands
in the next task.
…point list/preview widths

Extends layout.go with chromeRows (5) and paneSizes(mode, width,
height) that returns (listW, previewW, listH). narrow collapses the
preview (previewW=0, listW=width); normal is 60/40 from a floor
multiply; wide is 50/50 via a half-floor. Both normal and wide compute
previewW as width-listW so listW+previewW equals width exactly (no
rounding loss). listH is height-chromeRows, floored at 0.

TestPaneSizes covers narrow/normal/wide at representative widths plus
boundary widths 119 and 121 to catch off-by-one, and asserts the
sum-to-width invariant and the narrow previewW=0 invariant.
…ped FTS result rows

Adds renderRow(r, idx, gutterW, listW, cursor) and truncatePath
helpers in render.go. Row format: right-padded gutter line number,
one-char cursor indicator (> when cursor, two spaces otherwise),
title, styled [type] tag, scope (falls back to relative_path) with
ellipsis-prefix truncation preserving trailing segments, and an
optional (reason) suffix from QueryResult.Reasons[0].

Reuses the existing styleForType NodeType palette via a string-to-
NodeType cast so the color map stays single-source. The caller side
(grouped list render with gutterW = len(strconv.Itoa(totalRows)))
lands in task 04.
…th collapsible headers

Extends render.go: when m.groups is non-empty, renderList delegates
to renderGroupedList which walks groups rendering renderGroupHeader
('v type (count)' when expanded, '> type (count)' when collapsed)
followed by renderRow entries for the group's rows. Collapsed groups
contribute only their header. m.cursor is a flat index over visible
entries, mirrored by groupCursorTarget(groups, cursor) returning
(groupIdx, rowIdx) where rowIdx=-1 indicates the header.

enter on a group header toggles Expanded via groupCursorTarget. j/k
clamp against groupVisibleCount when groups are active so navigation
skips collapsed rows naturally. Gutter width is computed once from
the total row count with strconv.Itoa.
…ecompute layout and pane geometry

Extends Model with cached layout geometry: layout layoutMode, listW,
previewW, listH ints. tea.WindowSizeMsg now stores width/height AND
recomputes layout via layoutFor(width) and the cached pane sizes via
paneSizes(layout, width, height). No preview or query Cmds are
issued from this handler; geometry only.

View() reads from m.listW and m.previewW. At layoutNarrow the preview
pane is dropped and the list renders at full width; normal and wide
render list | divider | preview as before. When the cached dims are
zero (pre-first WindowSizeMsg) View falls back to the old 50/50
split.
… in layoutNarrow

renderRow grows a narrow bool parameter; when narrow, the scope/path
column is skipped and each row renders only title + [type] (plus the
optional reason suffix). The caller in renderGroupedList passes
m.layout == layoutNarrow.

Update's tab handler early-returns when layout is narrow so the
binding is a no-op rather than switching focus to a pane that is
not rendered; muscle memory remains intact without crashing into
a hidden pane. View() already suppressed the preview column at
narrow (sequence 06 task 05); this closes the loop on per-row
parity.
…r list navigation

Adds countBuf string to Model for accumulating leading digits. At the
top of updateNormal, digits append to countBuf and return early; a
bare leading 0 falls through. consumeCount(&m) helper parses and
clears countBuf, clamps to [1, 9999], defaults to 1.

Motion handlers j/k, g/G, ctrl+u/ctrl+d now call consumeCount and
step by N. j/k and ctrl+u/ctrl+d clamp against len(m.filtered) or
groupVisibleCount(m.groups) when grouped results are active; g/G
snap to the ends. Any fall-through key in updateNormal clears
countBuf so stray input does not corrupt the next motion.

Adds TestCountPrefixJ covering bare j, 5j, 10k, and reset-on-x-then-j
against a synthesized 100-node filtered slice.
…nding-g state machine)

Adds pendingG bool to Model. On 'g' in focusList: if pendingG is
already set, it is cleared and cursor jumps to 0 (honoring consumeCount
cleanup); otherwise pendingG is set and the keystroke is absorbed with
no visible change. Any other key at the top of updateNormal clears
pendingG, so 'gj', 'gG', 'g5j' etc. cancel the pending state cleanly
without side effects beyond the second key's own behavior.

Matches camp intent explore ergonomics: no real-time timeout, just
keystroke-bounded pending state.
…e list pane height

Replaces the fixed 10-row step with a computed step of max(1, m.listH/2)
so half-page navigation scales with the terminal. m.listH is populated
by the WindowSizeMsg handler via paneSizes. Count prefix (task 01) is
still honored: the effective motion is step*N. Cursor clamps to the
valid range using len(m.filtered) or groupVisibleCount(m.groups) as
before.
…derHelp + keybinding table)

Adds internal/tui/help.go with helpSections encoding the UX_SPEC
keybinding table (Navigation, Search and filter, Actions on focused
node, View). renderHelp(width, height) emits a bordered two-column
box: section header lines plus rows formatted with a fixed
key-column width (computed once from the widest Keys value across
all sections) and a trailing '? or esc to close' footer.

Reuses existing lipgloss styles (titleStyle, detailLabelStyle,
breadcrumbStyle) so help inherits the browser theme. Golden test
asserts every section name, every Keys literal, and the footer
hint appear in the rendered output at width 100. Open/close
binding plumbing lands in the next task.
… and route focus through focusHelp

Adds focusHelp to the focusMode enum and a prevFocus focusMode field
on Model to remember what to restore when the overlay closes.

In the top Update dispatcher, ? outside of focusSearch saves
prevFocus, switches focus to focusHelp, and returns. While in
focusHelp, ? or esc restore focus; all other keys are swallowed so
navigation, search, and chip bindings do nothing while help is open.
In-flight query and preview Cmds are untouched, so closing the help
overlay lands back on live data.

View() short-circuits to renderHelp(m.width, m.height) when focus is
focusHelp, suppressing the normal two-pane composition.
… explorer fallback state

Adds isExplorerFallback(m) predicate: true when no search text is
entered, all chips are at their default 'All'/'hybrid' values
(IsActive() false), and no scope is set. The 'a' handler now checks
this predicate before mutating m.filtered/m.showingAnchors; outside
the fallback state 'a' falls through, letting countBuf reset
naturally via updateNormal's end-of-function clear.
…and preview contexts

Adds quitModel(m *Model) helper that calls m.queryCancel and
m.previewCancel (if non-nil) before quit so the corresponding Cmd
goroutines unblock via ctx.Done and exit cleanly. The top Update
dispatcher now intercepts ctrl+c ahead of any focus-mode router so
ctrl+c quits from search, chip, preview, scope picker, or help
modes (previously updateSearch swallowed it). updateNormal's q/ctrl+c
case also calls quitModel before returning tea.Quit.

q in focusSearch is still treated as input text (updateSearch has no
'q' case), preserving the 'type q to search for q' affordance.
…ts tests in dedicated file

Adds internal/tui/buildopts_test.go with TestBuildOptsCrossPermutations
covering two extreme permutations: everything set (term + Type +
Tracked + Mode + Scope) and a no-term untracked+semantic+scope case.
Complements the per-axis TestBuildOpts in query_test.go so regressions
in the cross-product mapping (especially the Tracked/Untracked bool
pair) are caught without needing the full 12-case table to stay
comprehensive.
… typePriority and unknown-order

Adds internal/tui/grouping_test.go with two goldens:
TestGroupByTypeInterleavedPriority uses an interleaved fixture
(task/project/task/sequence/unknown-x/task) to assert known types
sort by typePriority ascending (project < sequence < task), unknowns
land after all known types, BM25 input order is preserved inside
each group, and every group defaults to Expanded=true.

TestGroupByTypeUnknownsPreserveOrder builds an all-unknown fixture
(zebra/alpha/mango) to lock the alphabetical tie-break behavior
that applies when all NodeTypes share the same priority=100
sentinel.
… previewFetcher interface

Introduces a previewFetcher interface (Fetch(ctx, id) returning node,
edges, related, err) and defaultPreviewFetcher that wraps *graph.Store
and *graph.Graph. runPreviewCmd now takes the interface; issuePreview
picks m.previewFetcher if set, otherwise constructs the default.

stubPreviewFetcher (test-only) records each Fetch call's id and ctx
and blocks on a release channel until the caller cancels the ctx or
signals release. TestPreviewCancellation issues four concurrent
fetches (r1..r4), cancels the first three as if the cursor moved on,
asserts each cancelled call observed ctx.Done within 1s, releases
the last, and feeds all four previewMsgs through Update with a
Model whose filtered[cursor].ID is r4. Only the r4 msg is accepted
per the focusedRowID stale-drop. Passes under -race across 3 runs.
…UI section for FTS-backed browse

Replaces the short 'scope-first TUI browser' line with a full TUI
section documenting the FTS pipeline: empty-query scope explorer
fallback, three filter chips (Type/Tracked/Mode with t/s/m keys),
modal scope picker (c/C), preview pane (header, path, edges capped
at 50, top-3 related), tab-to-focus-preview, the three layout
breakpoints (<80 / 80-120 / >120), and the ? help overlay. Points
readers at UX_SPEC.md for the full keybinding table rather than
duplicating it.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request Changes

Reviewed against head 945f47e9. The architectural split out of the old tui.go is strong, and the generation-counter + per-keystroke context-cancellation pipeline is the right shape for live FTS. Two correctness bugs in the grouped-results path need fixes plus coverage before the new browser can be trusted as the default experience.

Key Findings:

  1. internal/tui/model.go:237-256 focusedRowID walks groups by subtracting row counts only, ignoring group headers:

    cursor := m.cursor
    for _, grp := range m.groups {
        if cursor < len(grp.Rows) { return grp.Rows[cursor].NodeID }
        cursor -= len(grp.Rows)
    }
    

    Meanwhile navigation clamps against groupVisibleCount (render.go:305) which counts headers plus rows in expanded groups, and groupCursorTarget (render.go:283) also counts headers when mapping cursor -> (gi, ri). So for two expanded groups of two rows each, cursor=0 is rendered as the first group header but focusedRowID returns groups[0].Rows[0].NodeID; cursor=1 is rendered as the first row but focusedRowID returns the second row; cursor=2 rolls into group 1 and returns groups[1].Rows[0]; and later cursor positions walk off the end and return "". Net effect: the preview pane shows the wrong node (or nothing) for every cursor position once groups are live. This also poisons the stale-drop in update.go:83, which uses the same focusedRowID() as the "still relevant?" check. Route focusedRowID through groupCursorTarget so the three call sites agree on what the cursor points at.

  2. internal/tui/update.go:287-297 enter handling has the same shape of bug. On a group row (gi >= 0, ri >= 0), it falls through to m.enterMicrograph(m.filtered[m.cursor]). m.filtered is the anchor/node list, indexed by a cursor that was clamped using groupVisibleCount(m.groups). When the query returns more rows than anchors, this panics. When it returns fewer, it opens the wrong node from the anchor list entirely. The branch needs to resolve m.groups[gi].Rows[ri].NodeID through m.graph.Node(id) and pass that *graph.Node to enterMicrograph.

  3. internal/tui/update.go:69-80 queryResultMsg replaces m.results / m.groups without re-clamping m.cursor. When the new result set has fewer visible entries than the old, the cursor is left past the end until the next motion clamps it. Combined with finding 1, the preview pane and the list are in inconsistent positions for that window. Clamp m.cursor to groupVisibleCount(m.groups) - 1 (or len(m.filteredAnchors) - 1 if results are empty) as part of the message handler, and reset it to 0 when the previous result was non-empty and the new one is empty.

  4. Tests cover neither of the above. model_test.go and preview_cancel_test.go use models with only filtered populated; grouping_test.go exercises groupByType in isolation; no test ever builds a Model with m.groups set and drives Update with navigation keys. That is what masked these. TestEnterOpensMicrographAndEscReturnsToList passes on anchors and says nothing about grouped results.

Secondary Notes:

  • internal/tui/update.go:406 rebuilds filteredAnchors with a direct filterAnchors(...) call on esc, instead of the existing (m *Model).applyAnchorFilters(). Tiny drift surface: if applyAnchorFilters grows a side effect later, this path silently misses it. Route through the method.
  • internal/tui/model.go:249-250 uses m.filteredAnchors != nil as the "are we in anchor mode" predicate. Because filterAnchors only returns nil when the input anchors slice is nil (rare; campaigns almost always have at least one scope anchor), this is effectively always true when groups are empty. That is what you want today, but it would be clearer to key off either the presence of FTS state (m.search.Value() != "" or len(m.results) > 0) or a dedicated flag. Not a blocker.
  • issueQuery issues a fresh Cmd per keystroke. Because search.Querier.Search does respect ctx, cancellation is cooperative. Good. But the Cmd goroutine still sends into tea.Msg plumbing for the canceled generation; that is only filtered in Update via gen != m.queryGen. That is fine by design; surfacing the pattern in the runQueryCmd doc comment would save the next reader the walk.

What's Done Well:

  • Generation-counter + cancel-on-supersede pipeline is the right primitive, and having it shared across the search-input and chip-change paths is the correct spec conformance.
  • Narrow querierIface and previewFetcher interfaces let the cancellation tests run without a real DB; TestPreviewCancellation via the blocking stub is well-constructed.
  • chipBar / focus-mode routing keeps the key dispatch small and inspectable.
  • quitModel cancels both in-flight Cmds before tea.Quit, so goroutines do not outlive the UI. That matches the CLAUDE.md context-propagation standard.
  • Responsive layout, vim count prefix, and preview pane slice correctly match the UX_SPEC description in the PR body.

Staff Standard:

Not yet. Findings 1 and 2 are silent-wrong-data bugs in the primary FTS flow; finding 3 is a smaller version of the same family. Fix those plus land tests that drive Update with a populated m.groups (both expanded and collapsed, single and multi-group) and I would be comfortable merging.

…ss nav, preview, and enter

Addresses three related correctness bugs in the FTS-grouped list path.

1. focusedRowID (model.go) walked groups by subtracting row counts
   only, ignoring group headers. Navigation clamps against
   groupVisibleCount (render.go), which counts headers plus expanded
   rows, and groupCursorTarget (render.go) maps cursor -> (gi, ri)
   with headers included. Preview and stale-drop therefore pointed at
   the wrong row (or nothing) once groups were live. Route the
   grouped branch through groupCursorTarget so the three mappings
   agree.

2. enter on a grouped result row fell through to
   m.filtered[m.cursor]. m.filtered is the anchor/node list while
   m.cursor is an index into groupVisibleCount, so enter either
   opened an unrelated anchor or went out of range. Resolve
   m.groups[gi].Rows[ri].NodeID via m.graph.Node(id) and hand the
   resulting *graph.Node to enterMicrograph. Non-grouped enter also
   now prefers m.filteredAnchors when present and bounds-checks the
   cursor.

3. queryResultMsg replaced m.results / m.groups without re-clamping
   the cursor. When a search shrank the result set below the prior
   cursor position, the list and preview stayed inconsistent until
   the next motion. Clamp against the new visible ceiling (groups,
   anchors, or filtered) inside the message handler.

Also routes the search-esc anchor refresh through applyAnchorFilters
instead of re-inlining filterAnchors.

Tests (internal/tui/grouped_cursor_test.go) build a Model with two
expanded groups and cover:

- focusedRowID at every cursor position across two expanded groups
- focusedRowID with one group collapsed
- enter on a group header toggles Expanded
- enter on a group row resolves the NodeID through the graph
- enter with an out-of-range cursor is a no-op
- queryResultMsg clamps the cursor when the result set shrinks
- queryResultMsg resets the cursor when results clear to empty

Gates: go build, go vet, go test -race ./... all clean.
@lancekrogers

Copy link
Copy Markdown
Member Author

All four findings addressed in a51de29.

  1. focusedRowID now routes through groupCursorTarget, so the three cursor-to-row mappings (navigation clamp, render, preview) share a single definition of "what is at cursor position N". Preview headers no longer masquerade as rows and later cursor positions no longer walk off the end.

  2. enter on a grouped row now resolves m.groups[gi].Rows[ri].NodeID through m.graph.Node(id) and hands the resulting *graph.Node to enterMicrograph. The old fall-through to m.filtered[m.cursor] is gone. Non-grouped enter now also prefers m.filteredAnchors when present and bounds-checks the cursor.

  3. The queryResultMsg handler now clamps m.cursor to the new visible ceiling (groupVisibleCount when groups are present, len(filteredAnchors) when falling back to anchors, len(filtered) otherwise). When a search shrinks or clears the result set below the prior cursor position, the list and preview stay consistent on the same frame.

  4. Search-esc path now calls m.applyAnchorFilters() instead of re-inlining filterAnchors(...), so the anchor-refresh logic only has to live in one place.

Tests in the new internal/tui/grouped_cursor_test.go build a Model with two expanded groups (two rows each) and cover:

  • focusedRowID at every cursor position across two expanded groups
  • focusedRowID with one group collapsed
  • enter on a group header toggles Expanded without entering micrograph
  • enter on a group row resolves the NodeID through the graph
  • enter with an out-of-range cursor is a no-op
  • queryResultMsg clamps the cursor when the result set shrinks
  • queryResultMsg resets the cursor when results clear to empty

go build, go vet, go test -race ./... all clean.

Ready for re-review.

@obey-agent

Copy link
Copy Markdown
Member

One non-blocking maintainability note: the new \ recipes work, but the repeated \ pattern is pretty opaque to read and debug. It looks like it is compensating for module-recipe working-directory behavior, which is a real issue here, but I think this would be clearer if the recipes anchored themselves explicitly via just path helpers or a repo-root variable instead of shelling up a directory in every recipe.

Comment thread internal/tui/model.go
// m.filteredAnchors for the empty-query fallback, then m.filtered.
// Group traversal routes through groupCursorTarget so the three
// cursor-to-row mappings (navigation clamp, render, preview) agree.
func (m Model) focusedRowID() string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unifying the cursor-to-row mapping through groupCursorTarget is the right call — navigation clamp, render, preview, and stale-drop now read from the same source of truth. The pre-fix focusedRowID was counting only rows while nav clamped against groupVisibleCount (headers + rows), so every grouped query had an off-by-one between the list and the preview pane.

The test coverage in grouped_cursor_test.go that walks every cursor position across two expanded groups (including the header positions) is exactly the kind of fixture that catches this regression the next time someone refactors the visible-entry enumeration.

Comment thread internal/tui/update.go Outdated
}
if ceiling <= 0 {
m.cursor = 0
} else if m.cursor >= ceiling {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ceiling resolution here (groups > filteredAnchors > filtered) is the same priority focusedRowID and the j/k navigation handlers use. Three copies now — they are correct today but drift easily.

Extracting (Model).visibleCeiling() int (and ideally clampCursor()) would collapse the queryResultMsg clamp, the j/k upper bounds, and the G end-snap to a single source of truth. Non-blocking, but worth queuing for a cleanup pass before the next feature in this area.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(tui) FTS-backed browse

Verdict: Approve.

Overview

Large, well-sequenced upgrade (46 commits, ~3.9k insertions). Replaces the substring explorer with a live FTS pipeline, threads chip/scope/mode through search.QueryOptions, adds a preview pane with per-focus context cancellation, and lands a responsive three-breakpoint layout and help overlay. Post-festival fix commit a51de29 catches three real correctness bugs in the grouped-cursor path and lands thorough test coverage.

Key Findings

  • Grouped-cursor unification in focusedRowID via groupCursorTarget is the right call (inline on internal/tui/model.go:240). Pre-fix navigation clamped against groupVisibleCount (headers + rows) while focusedRowID counted only rows, so every grouped query had a preview/stale-drop off-by-one after the first group header was passed.
  • Enter on grouped rows now resolves through m.graph.Node(m.groups[gi].Rows[ri].NodeID) instead of indexing the unrelated m.filtered slice. Non-grouped enter bounds-checks the cursor and prefers m.filteredAnchors when set — both were latent out-of-range panics.
  • queryResultMsg now re-clamps the cursor when the result set shrinks. Minor DRY observation (inline on internal/tui/update.go:91): the same ceiling resolution appears in three places; extracting visibleCeiling() / clampCursor() would collapse them to one source of truth. Non-blocking.

What's Done Well

  • Cancellation contracts are consistent and tested under -race: querierIface + stubQuerier for the query path, previewFetcher + stubPreviewFetcher for the preview path. Narrow interfaces keep production code injection-friendly without leaking test seams into callers.
  • Cursor-space invariants are now regression-locked by grouped_cursor_test.go (every position across two expanded groups, collapsed-group skip, header-toggle, row-resolution, out-of-range no-op, shrink-clamp, empty-reset).
  • D002 provenance for the copied chips package, D003 cancel-invariant nilling, D004 typePriority + alphabetical tiebreak, and D005 empty-query fallback predicate (isExplorerFallback) are each named in the code that implements them.
  • Commit history is atomic and traceable; fest commit tags let a future reader reconstruct the sequence mapping without digging through the festival tree.

Staff Standard

Worth shipping. The late correctness fix caught bugs the festival's automated gates missed (all three only manifest once FTS returns non-empty grouped results), and the response was a targeted commit with focused tests rather than a big-ball rework. The one maintainability flag (ceiling/clamp DRY) is small and can be addressed in a follow-up without blocking this merge.

…ampCursor helpers

Addresses the non-blocking DRY note on PR #25 review
(#25 (comment)).
The ceiling resolution previously appeared in five places across
update.go (queryResultMsg, pendingG, j, G, ctrl+u/ctrl+d) with
subtly different policies: queryResultMsg used the full priority
order (groups > filteredAnchors > filtered) while the navigation
handlers only considered groups vs filtered. That was a latent
inconsistency for the anchor-fallback view.

Extract visibleCeiling() on Model that returns the row count using
the same priority as focusedRowID and the render path, plus
clampCursor() that forces the cursor into [0, ceiling-1]. Route all
five call sites through them.

Gates: go build, go vet, go test -race ./... all clean. Existing
grouped_cursor_test.go covers the navigation-with-groups path;
queryResultMsg clamp tests still pass unchanged.
@lancekrogers

Copy link
Copy Markdown
Member Author

Follow-up to the approve with non-blocking notes from 19:12Z. Pushed 341a9fb on top of a51de29.

DRY ceiling/clamp note (addressed):
Extracted (Model).visibleCeiling() and (Model).clampCursor(). The ceiling resolution previously lived in five call sites across update.go (queryResultMsg, pendingG gg, j, G, ctrl+u/ctrl+d). Three of them used the simpler groups vs filtered resolution while queryResultMsg used the full groups > filteredAnchors > filtered priority — a latent inconsistency against the anchor-fallback view that the refactor surfaces and fixes in one pass. visibleCeiling now shares its priority with focusedRowID, so navigation clamps, the G end-snap, and the post-query re-clamp all read from the same source of truth. Existing grouped_cursor_test.go still passes unchanged.

Justfile cd .. note (addressed):
This commit also contains justfile recipe changes — a project_root := source_directory() / ".." variable plus a swap of cd .. to cd "{{project_root}}". Those arrived in my working tree from a concurrent edit between my git fetch and camp p commit and landed in the same commit via camp p commit's implicit -a. The change is the right shape for the review note (anchor via a just path helper instead of shelling up a directory in every recipe), so I kept it rather than splitting or reverting. Flagging it here so the commit authorship is transparent.

go build, go vet, go test -race ./... all clean after the combined change.

@obey-agent obey-agent left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve for this commit (eddf2bd) with one semantic note.

Reviewed eddf2bd in isolation on top of 341a9fb. The change swaps project_root := source_directory() / ".." for project_root := justfile_directory() in all three modules (build.just, release.just, test.just). Verified locally with dry-runs.

Semantic difference worth knowing

source_directory() returns the directory of the file defining the expression (always .justfiles/ for these modules, so + ".." = repo root, regardless of how the recipe is invoked).

justfile_directory() returns the directory of the top-level justfile currently being evaluated (the main invocation entrypoint). This means:

  • just xbuild dev / just release plan / just test short from the repo root → justfile_directory() = repo root. Correct. Dry-run confirms: cd "/Users/lancerogers/Dev/AI/obey-campaign/projects/camp-graph" && mkdir -p bin && go build ....
  • just --justfile .justfiles/build.just dev (direct module invocation) → justfile_directory() = .justfiles/. Dry-run: cd "/Users/lancerogers/Dev/AI/obey-campaign/projects/camp-graph/.justfiles" && mkdir -p bin && go build .... This path would not resolve ./cmd/camp-graph. The prior source_directory() / ".." form would still resolve correctly here.

Verdict rationale

Direct module invocation via --justfile is not a documented or CI workflow for camp-graph; all usage flows through the root justfile's mod imports. For that documented pattern, both forms produce the same path, and justfile_directory() expresses the intent (project_root = repo root) more honestly than the structural climb of source_directory() / "..". Approve this commit as-scoped.

Non-blocking note

If someone ever tries to reuse these module files from a different project's root justfile (copy or symlink the .justfiles/ dir), justfile_directory() will resolve to that other project's root, not camp-graph's. The commands inside (e.g. go build -o {{project_root}}/bin/{{binary_name}} {{main_path}}) would then build into the wrong tree. With source_directory() / ".." the modules stayed anchored to their own on-disk location. Not a current use case, so nothing to change here; worth keeping in mind if we ever extract these modules for sharing.

What's Done Well

  • Three-file coordinated change, single commit, clear message.
  • The commit title signals intent ("module roots") rather than describing the syntax change.
  • Dry-runs confirm the intended behavior at the primary invocation entrypoint.

Staff Standard

Yes for this commit. The cleaner expression is worth the slightly narrower robustness profile, given the actual invocation pattern.

@lancekrogers
lancekrogers merged commit ddd235c into main Apr 21, 2026
1 check passed
@lancekrogers
lancekrogers deleted the feat/tui-search-upgrade-CG0005 branch April 21, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants