feat(tui): FTS-backed browse with chips, scope picker, preview pane, and help overlay - #25
Conversation
…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
left a comment
There was a problem hiding this comment.
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:
-
internal/tui/model.go:237-256focusedRowIDwalks 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, andgroupCursorTarget(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 butfocusedRowIDreturnsgroups[0].Rows[0].NodeID; cursor=1 is rendered as the first row butfocusedRowIDreturns the second row; cursor=2 rolls into group 1 and returnsgroups[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 inupdate.go:83, which uses the samefocusedRowID()as the "still relevant?" check. RoutefocusedRowIDthroughgroupCursorTargetso the three call sites agree on what the cursor points at. -
internal/tui/update.go:287-297enterhandling has the same shape of bug. On a group row (gi >= 0, ri >= 0), it falls through tom.enterMicrograph(m.filtered[m.cursor]).m.filteredis the anchor/node list, indexed by a cursor that was clamped usinggroupVisibleCount(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 resolvem.groups[gi].Rows[ri].NodeIDthroughm.graph.Node(id)and pass that*graph.NodetoenterMicrograph. -
internal/tui/update.go:69-80queryResultMsgreplacesm.results/m.groupswithout re-clampingm.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. Clampm.cursortogroupVisibleCount(m.groups) - 1(orlen(m.filteredAnchors) - 1if 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. -
Tests cover neither of the above.
model_test.goandpreview_cancel_test.gouse models with onlyfilteredpopulated;grouping_test.goexercisesgroupByTypein isolation; no test ever builds aModelwithm.groupsset and drivesUpdatewith navigation keys. That is what masked these.TestEnterOpensMicrographAndEscReturnsToListpasses on anchors and says nothing about grouped results.
Secondary Notes:
internal/tui/update.go:406rebuildsfilteredAnchorswith a directfilterAnchors(...)call on esc, instead of the existing(m *Model).applyAnchorFilters(). Tiny drift surface: ifapplyAnchorFiltersgrows a side effect later, this path silently misses it. Route through the method.internal/tui/model.go:249-250usesm.filteredAnchors != nilas the "are we in anchor mode" predicate. BecausefilterAnchorsonly returns nil when the inputanchorsslice 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() != ""orlen(m.results) > 0) or a dedicated flag. Not a blocker.issueQueryissues a fresh Cmd per keystroke. Becausesearch.Querier.Searchdoes respectctx, cancellation is cooperative. Good. But the Cmd goroutine still sends intotea.Msgplumbing for the canceled generation; that is only filtered inUpdateviagen != m.queryGen. That is fine by design; surfacing the pattern in therunQueryCmddoc 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
querierIfaceandpreviewFetcherinterfaces let the cancellation tests run without a real DB;TestPreviewCancellationvia the blocking stub is well-constructed. chipBar/ focus-mode routing keeps the key dispatch small and inspectable.quitModelcancels both in-flight Cmds beforetea.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.
|
All four findings addressed in
Tests in the new
Ready for re-review. |
|
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. |
| // 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 { |
There was a problem hiding this comment.
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.
| } | ||
| if ceiling <= 0 { | ||
| m.cursor = 0 | ||
| } else if m.cursor >= ceiling { |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
focusedRowIDviagroupCursorTargetis the right call (inline oninternal/tui/model.go:240). Pre-fix navigation clamped againstgroupVisibleCount(headers + rows) whilefocusedRowIDcounted 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 unrelatedm.filteredslice. Non-grouped enter bounds-checks the cursor and prefersm.filteredAnchorswhen set — both were latent out-of-range panics. queryResultMsgnow re-clamps the cursor when the result set shrinks. Minor DRY observation (inline oninternal/tui/update.go:91): the same ceiling resolution appears in three places; extractingvisibleCeiling()/clampCursor()would collapse them to one source of truth. Non-blocking.
What's Done Well
- Cancellation contracts are consistent and tested under
-race:querierIface+stubQuerierfor the query path,previewFetcher+stubPreviewFetcherfor 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). D002provenance for the copiedchipspackage,D003cancel-invariant nilling,D004typePriority+ alphabetical tiebreak, andD005empty-query fallback predicate (isExplorerFallback) are each named in the code that implements them.- Commit history is atomic and traceable;
fest committags 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.
|
Follow-up to the approve with non-blocking notes from DRY ceiling/clamp note (addressed): Justfile
|
obey-agent
left a comment
There was a problem hiding this comment.
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 shortfrom 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 priorsource_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.
Summary
Upgrades
camp-graph browsefrom an in-memory substring explorer into a live FTS5-backed search experience with parity ergonomics tocamp intent explore. Delivered across 46 commits under festivalcamp-graph-tui-search-upgrade-CG0005(99/99 tasks).issueQuerybumpsqueryGen, cancels any in-flightcontext.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 hittingQuerier.Search(per D005).camp/internal/intent/tui/filterchipunder D002 provenance): Type, Tracked, Mode. Focused viat/s/m; value changes reissue the query.copens,Cclears). Selection threads intoQueryOptions.Scopeand narrows both FTS and anchor-fallback paths.search.Related.tabfocuses the pane;j/kscroll without moving the list cursor. Per-focus context cancellation viapreviewCancelwith stale-drop inUpdate.tea.WindowSizeMsg.5j,10k,3gg),gg/G,ctrl+u/ctrl+dscaled to half list height,awiden gated to explorer fallback,?help overlay with the full UX_SPEC keybinding table,q/ctrl+ccancel in-flight Cmds before quit.querierIface,previewFetcher) with blocking stubs. buildOpts table covers 12 permutations including chip+scope cross-products; grouping goldens locktypePriority(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 passjust test all(unit + Docker integration) — 171+31 passjust lint— clean (go fmt ./...,go vet ./...)just build— cleango test -race ./internal/tui/... -run TestQueryCancellation -count=5— stablego test -race ./internal/tui/... -run TestPreviewCancellation -count=3— stablecamp intent explore(festival task 08_manual_parity_checklist)