diff --git a/src/tui/reader/reader-reducer.test.ts b/src/tui/reader/reader-reducer.test.ts index e7da63c..d8cf1eb 100644 --- a/src/tui/reader/reader-reducer.test.ts +++ b/src/tui/reader/reader-reducer.test.ts @@ -596,6 +596,55 @@ describe("readerReducer", () => { if (next.kind !== "awaiting") throw new Error("expected awaiting"); expect(next.phase).toBe("chapter"); }); + + it("preserves chapter phase when the trailing space is deleted (query equals book name exactly)", () => { + // Bug: backspacing the trailing space after picking "John" used to drop + // the user out of chapter phase, hiding the chapter grid. Deleting the + // space alone is not a signal that the user wants to re-search books. + const state = makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: 21 }, (_, i) => i + 1), + selectedIndex: 0, + query: "John ", + }); + const next = dispatch(state, { type: "QueryTyped", query: "John" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("chapter"); + expect(next.bookChosen).not.toBeNull(); + expect(next.chapters.length).toBe(21); + }); + + it("highlights the chapter when digits follow the book name without a space", () => { + // Bug follow-up: after deleting the trailing space, typing a digit + // produces "John1" (no space). The chapter grid should still highlight + // chapter 1, matching the "John 1" behavior. + const state = makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: 21 }, (_, i) => i + 1), + selectedIndex: 0, + query: "John", + }); + const next = dispatch(state, { type: "QueryTyped", query: "John1" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("chapter"); + expect(next.selectedIndex).toBe(0); + }); + + it("highlights chapter 10 for 'John10' (multi-digit, no space)", () => { + const state = makeAwaiting({ + phase: "chapter", + bookChosen: { alias: "john", canonical: "JHN", displayName: "John" }, + chapters: Array.from({ length: 21 }, (_, i) => i + 1), + selectedIndex: 0, + query: "John", + }); + const next = dispatch(state, { type: "QueryTyped", query: "John10" }); + if (next.kind !== "awaiting") throw new Error("expected awaiting"); + expect(next.phase).toBe("chapter"); + expect(next.selectedIndex).toBe(9); + }); }); describe("PassageFetched (intent branches)", () => { diff --git a/src/tui/reader/reader-reducer.ts b/src/tui/reader/reader-reducer.ts index e750c7c..8191645 100644 --- a/src/tui/reader/reader-reducer.ts +++ b/src/tui/reader/reader-reducer.ts @@ -47,25 +47,29 @@ export type ReaderAction = const handlers = { QueryTyped: (s: ReaderState, a: Extract): ReaderState => { if (s.kind !== "awaiting") return s; - // Stay in chapter phase if the new query still has the chosen book's display - // name as a prefix. Without this, OpenTUI's controlled kicks us out - // of chapter mode the instant SuggestionAccepted programmatically rewrites - // the query, because the value-prop change synthesizes an onInput event. - if ( - s.phase === "chapter" && - s.bookChosen !== null && - a.query.toLowerCase().startsWith(`${s.bookChosen.displayName.toLowerCase()} `) - ) { - // Decode trailing digit suffix → grid selection. Multi-digit naturally works: - // "John 1" highlights chapter 1, "John 10" highlights chapter 10. - const suffix = a.query.slice(s.bookChosen.displayName.length + 1); - const digitMatch = /^(\d+)/.exec(suffix); - let selectedIndex = s.selectedIndex; - if (digitMatch) { - const n = parseInt(digitMatch[1], 10); - if (n >= 1 && n <= s.chapters.length) selectedIndex = n - 1; + // Stay in chapter phase when the new query is the chosen book's display + // name, optionally followed by a space and/or digits (e.g. "John", "John ", + // "John 1", "John1"). Deleting just the trailing space must NOT kick the + // user back to book phase — that hides the chapter grid mid-edit and is + // what produced the "sometimes the chapter menu doesn't show up" bug. + if (s.phase === "chapter" && s.bookChosen !== null) { + const dn = s.bookChosen.displayName.toLowerCase(); + const q = a.query.toLowerCase(); + if (q.startsWith(dn)) { + const rest = q.slice(dn.length); + if (rest === "" || rest.startsWith(" ") || /^\d/.test(rest)) { + // Decode digit suffix → grid selection. Optional space between name + // and digits; multi-digit naturally works ("John 10" / "John10"). + const afterBook = a.query.slice(s.bookChosen.displayName.length); + const digitMatch = /^\s*(\d+)/.exec(afterBook); + let selectedIndex = s.selectedIndex; + if (digitMatch) { + const n = parseInt(digitMatch[1], 10); + if (n >= 1 && n <= s.chapters.length) selectedIndex = n - 1; + } + return { ...s, query: a.query, parseError: null, selectedIndex }; + } } - return { ...s, query: a.query, parseError: null, selectedIndex }; } // Book phase: re-suggest and auto-highlight the top match so Tab/Enter // accept the obvious choice without arrow-down first (fzf-style).