Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions openspec/changes/archive/palette-suggestions/archive-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Archive Report: palette-suggestions

**Archived**: 2026-05-12
**Status**: SHIPPED on branch `feat/palette-suggestions`

---

## Executive Summary

Adds live book-name suggestions to the reader's palette. As the user types, a subsequence-scored fuzzy match against `BOOK_ALIASES` (~130 entries) produces up to 5 ranked suggestions rendered under the input. `↑`/`↓` navigates the list (clamped at edges; no wrap); selected row uses the accent color matching the verse cursor pattern. `Tab` accepts the selected suggestion — rewrites the input to `{DisplayName} ` (with trailing space, ready for chapter typing) and clears the list. `Enter` always submits the query as-typed, no ambiguity with suggestion selection. Driver's `awaiting`-state gate is split to allow `up`/`down`/`tab` through while suppressing everything else (except `q`/`Q` quit, which sits above the gate). New pure domain module `src/domain/book-suggestions.ts` with subsequence + light-scoring algorithm (exact-match, prefix, density bonuses); display names derived from longest alias per USFM code with regex transform for numbered books. 178/178 tests pass.

---

## Branch Status

**Branch**: `feat/palette-suggestions`
**Commits**: 5 — 3 from the original SDD apply, the SDD-trail/verify-report commit, then one post-verify bug fix from manual smoke.
**Working tree**: clean, ready to push.

### Commit Log

| # | SHA | Message |
|---|---|---|
| 1 | `04c9716` | feat(domain): book-suggestions module with subsequence-scored fuzzy match |
| 2 | `c904299` | feat(tui): reader-reducer awaiting state gains suggestion fields |
| 3 | `8556f03` | feat(tui): suggestion list view + driver gate split for navigation keys |
| 4 | `e0051ff` | docs(openspec): add SDD trail + verify report for palette-suggestions |
| 5 | `9d5b4df` | fix(tui): use onInput for per-keystroke palette updates |

---

## Verification Summary

**Initial SDD verify verdict**: PASS WITH WARNINGS (16/16 REQs satisfied)
**Tests**: 178/178 pass
**tsc --noEmit**: exits 0
**Critical**: 0 | **Warning**: 2 (test count gap vs target — non-blocking; no driver/view automated tests — by design) | **Suggestion**: 2 (cosmetic)

### Post-verify sanctioned fix (commit 5)

Manual PTY smoke revealed suggestions never appeared while typing. Root cause: OpenTUI's `<input>` follows DOM semantics — `onChange` fires on commit/blur, NOT per keystroke. The reader was wired to `onChange`, so `state.query` only updated when `onSubmit` fired on Enter (which races just in time for `QuerySubmitted` to read the final value). During typing, `state.query` was perpetually empty, so `suggestBooks` returned `[]` every render.

One-line fix: `onChange` → `onInput`. `onInput` is OpenTUI's per-keystroke event. `QueryTyped` now dispatches on every character, suggestions populate live, list renders correctly.

This bug was latent in PR #10 and #11 as well — the reader still worked because `onChange` fires just before `onSubmit` on Enter, so the final value landed in `state.query` in time. But during typing, state lagged. This PR is the first that actually depends on per-keystroke updates.

---

## Residual Manual Step — DONE

User confirmed PTY smoke after the fix:
- Type `john` → suggestion list shows John, 1 John, 2 John, 3 John in accent-styled rows
- `↓` highlights top result
- `Tab` rewrites input to `John ` and clears list
- Type `3:16` → Enter → loads John 3:16

---

## Out-of-Scope Follow-Ups

Captured as engram backlog (`next-step/palette-chapter-picker`, observation #298):

1. **Chapter picker after book pick** — user surfaced during smoke. Backlog: not on the v1 roadmap (`docs/roadmap.md` line 15). Discipline decision: ship roadmap capabilities (translations, favorites, last-position, `--format`, mouse input) before more palette UX polish.
2. **Suggestion column alignment** — USFM codes currently render right after display name with 2 spaces; a fixed-column right-alignment would scan better. Cosmetic.
3. **Recent references / history** — out of scope for this PR; could plug into the palette as a default list when query is empty.
4. **Chapter-level suggestions** within a book ("John 3" → chapter options) — covered by the chapter-picker backlog item above.

---

## Engram Observations (Traceability)

| Topic | ID | Type | Notes |
|---|---|---|---|
| `sdd/palette-suggestions/explore` | 291 | architecture | Subsequence algorithm, state machine sketch, key-handling analysis |
| `sdd/palette-suggestions/proposal` | 292 | architecture | Locked decisions |
| `sdd/palette-suggestions/spec` | 293 | architecture | 16 REQs |
| `sdd/palette-suggestions/tasks` | 294 | architecture | 10 tasks across 3 commits |
| `sdd/palette-suggestions/apply-progress` | 295 | architecture | 156 → 178 tests; Tab confirmed not consumed by `<input>` |
| `sdd/palette-suggestions/verify-report` | 296 | architecture | PASS WITH WARNINGS |
| `sdd/palette-suggestions/archive-report` | 297 | architecture | This report |
| `next-step/palette-chapter-picker` | 298 | decision | Backlog item, deferred to favor roadmap capabilities |

Plus a process discovery from the smoke:
- OpenTUI `<input>` uses DOM semantics for `onChange` (commit only) vs `onInput` (per-keystroke). Always use `onInput` for live-update integration with reducers. This will save a debug cycle on every future controlled-input wiring.

---

## SDD Cycle Complete

The `palette-suggestions` change has been:

- Proposed, specified, designed (via explore), tasked
- Applied (3 commits)
- Verified (PASS WITH WARNINGS)
- Smoke-tested (1 post-verify fix: `onInput` instead of `onChange`)
- Archived (this report)

Cycle closed. No blockers for merge.
138 changes: 138 additions & 0 deletions openspec/changes/archive/palette-suggestions/explore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Exploration: palette-suggestions

## Current State (post PR #11 tui-reader-paging)

- `reader-reducer.ts` — `awaiting` variant: `{ kind: "awaiting"; query: string; parseError: ParseError | null }`. No suggestion state.
- `reader-screen.tsx` — awaiting branch renders: hint text, `<input>` (focused), optional parse-error text. No suggestion list.
- `tui-driver.tsx` — `useKeyboard` gates ALL reader keybinds behind `if (readerState.kind === "awaiting") return`. Arrow keys and Tab are completely suppressed while input is focused.
- `BOOK_ALIASES` in `src/domain/reference.ts` — ~130 entries. Keyed by lower-case alias → USFM code. No pretty display names. Aliases are the canonical names (e.g. `"john"`, `"genesis"`) plus abbreviations.
- No `BOOK_DISPLAY_NAMES` table exists. Display names must be derived.
- OpenTUI tab key name confirmed: `keyEvent.name === "tab"`.

## 1. Match Algorithm

| Approach | Pros | Cons |
|----------|------|------|
| A — Substring (case-insensitive) | Trivial | Misses "jhn" → "john" |
| **B — Subsequence + light scoring (recommended)** | Handles abbreviations naturally; no external dep; 130 entries is trivial | Slightly more code than substring |
| C — Fuzzy/Levenshtein | Handles typos | Over-engineered; surprising results |

**Recommendation: B.** Score = prefix bonus (50) + exact-match bonus (100) + density bonus (`q.length / alias.length * 30`).

## 2. Suggestion Shape

```ts
export type BookSuggestion = {
alias: string; // matched alias key (e.g. "john")
canonical: string; // USFM code (e.g. "JHN")
displayName: string; // pretty name (e.g. "John")
};
```

**Display name source: derive from longest alias per USFM code at module load.** Zero-maintenance — inherits any future alias additions. Numbered books (`"1samuel"` → `"1 Samuel"`) handled by a simple regex transform.

## 3. Pure Suggester Location

**`src/domain/book-suggestions.ts`** — pure, no IO, no deps. Imports `BOOK_ALIASES` from `reference.ts` (must be exported — see Risks).

```ts
export function suggestBooks(query: string, limit = 5): BookSuggestion[];
```

## 4. State Machine Extension

New `awaiting`:
```ts
| {
kind: "awaiting";
query: string;
parseError: ParseError | null;
suggestions: BookSuggestion[];
selectedIndex: number; // -1 = no selection
}
```

New actions:
- `SuggestionMovedUp` / `SuggestionMovedDown` — clamp at edges (0 and length-1; do not wrap)
- `SuggestionAccepted` — rewrite `query` to `displayName + " "` (trailing space, ready for chapter), reset `suggestions: []`, `selectedIndex: -1`

`QueryTyped` recomputes `suggestions` and resets `selectedIndex` to -1. `PaletteReopened` initializes the new fields. `initialReaderState` adds `suggestions: [], selectedIndex: -1`.

**Enter vs Tab**: Enter ALWAYS submits (via `<input> onSubmit` → `QuerySubmitted`). Tab ALWAYS accepts the selected suggestion (via `useKeyboard`). No ambiguity.

## 5. View Extension

Below the input, when `suggestions.length > 0`:

```tsx
{state.suggestions.map((s, i) => (
<text key={s.canonical}>
<span fg={i === state.selectedIndex ? ACCENT_HEX : undefined}>
{i === state.selectedIndex ? " ▶ " : " "}
</span>
<span fg={i === state.selectedIndex ? ACCENT_HEX : undefined}>{s.displayName}</span>
<span attributes={DIM}>{` ${s.canonical}`}</span>
</text>
))}
```

When `suggestions.length === 0`: hide the list entirely.

`bottomTitleFor` for awaiting: `" Tab complete • ↑↓ suggest • Enter open • q quit "`.

## 6. Driver-Level Keybind Changes

Split the existing `awaiting`-state gate to allow specific keys through:

```ts
if (readerState.kind === "awaiting") {
if (keyEvent.name === "down") { dispatch({ type: "SuggestionMovedDown" }); return; }
if (keyEvent.name === "up") { dispatch({ type: "SuggestionMovedUp" }); return; }
if (keyEvent.name === "tab") { dispatch({ type: "SuggestionAccepted" }); return; }
return;
}
```

`q`/`Q` quit must remain ABOVE this block.

## 7. Constraint: `BOOK_ALIASES` export

Currently `const` (module-private) in `reference.ts`. Must be exported. Recommend single-line `export const BOOK_ALIASES = { ... }`.

## 8. Slicing

One PR, ~246 lines:

| File | Estimate |
|------|----------|
| `src/domain/reference.ts` | +1 (export) |
| `src/domain/book-suggestions.ts` (new) | ~55 |
| `src/domain/book-suggestions.test.ts` (new) | ~70 |
| `src/tui/reader/reader-reducer.ts` | ~35 |
| `src/tui/reader/reader-reducer.test.ts` | ~50 |
| `src/tui/reader/reader-screen.tsx` | ~25 |
| `src/tui/tui-driver.tsx` | ~10 |
| **Total** | **~246** |

Under the 400-line budget.

## Risks

1. **`BOOK_ALIASES` not exported** — trivial fix, but easy to forget.
2. **`initialReaderState` shape change** — ~12 existing reducer tests that assert the awaiting shape will need `suggestions: [], selectedIndex: -1` added. Mechanical but volume.
3. **Tab consumed by `<input>` before `useKeyboard`** — needs runtime verification at apply time. If consumed, fall back to a different accept key (Ctrl+Space, Esc-then-Enter, or a `keyEvent`-listener on the input itself).
4. **No driver-level test infrastructure** — Tab/arrow behavior in awaiting state verifiable only via manual PTY smoke.

## Open Questions Resolved

| Question | Decision |
|----------|----------|
| Display name source | Derived from longest alias per USFM code at module load |
| Match algorithm | Subsequence + light scoring |
| Tab vs Enter to accept | Tab = accept. Enter = submit. |
| 0 suggestions behavior | Hide list entirely |
| selectedIndex edge behavior | Clamp (no wrap) |

## Ready for Proposal

Yes. Primary risk (Tab consumption) is a verification step for apply, not a blocker for proposal.
Loading
Loading