rv-bible-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,999 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
3
+ import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
4
+ import chalk from 'chalk';
5
+ import clipboard from 'clipboardy';
6
+ import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, } from '../db.js';
7
+ import { renderVerses, stripMarkers, highlightTerms, toSuperscript } from '../format.js';
8
+ import { saveLastRead, getLastRead } from '../state.js';
9
+ import { resolveBook } from '../parser.js';
10
+ import { getNextChapter, getPrevChapter } from './nav.js';
11
+ // ── Helpers ──────────────────────────────────────────────────────────────────
12
+ function stripAnsi(s) {
13
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
14
+ }
15
+ // Detect verse-number lines by checking for chalk.dim() ANSI code (\x1b[2m) before
16
+ // a superscript character. This distinguishes verse numbers (dim) from inline footnote
17
+ // markers (cyan) that could appear at the start of word-wrapped continuation lines.
18
+ // Fallback regex (no ANSI) for edge cases where chalk colors are disabled.
19
+ const VERSE_NUM_ANSI = /^\s+\x1b\[2m[\u2070-\u209F\u00B9\u00B2\u00B3]/;
20
+ const VERSE_NUM_PLAIN = /^\s{2,4}[\u2070-\u209F\u00B9\u00B2\u00B3]/;
21
+ function buildVerseLineMap(lines, verses) {
22
+ // Pick regex: check if first lines contain ANSI codes at all
23
+ const hasAnsi = lines.some(l => /\x1b\[/.test(l));
24
+ const re = hasAnsi ? VERSE_NUM_ANSI : VERSE_NUM_PLAIN;
25
+ const map = [];
26
+ let vIdx = 0;
27
+ for (let i = 0; i < lines.length; i++) {
28
+ if (re.test(lines[i]) && vIdx < verses.length) {
29
+ if (map.length > 0)
30
+ map[map.length - 1].endLine = i - 1;
31
+ map.push({ verse: verses[vIdx].verse, startLine: i, endLine: i });
32
+ vIdx++;
33
+ }
34
+ }
35
+ if (map.length > 0)
36
+ map[map.length - 1].endLine = lines.length - 1;
37
+ return map;
38
+ }
39
+ function verseAtLine(map, line) {
40
+ for (let i = map.length - 1; i >= 0; i--) {
41
+ if (map[i].startLine <= line)
42
+ return i;
43
+ }
44
+ return 0;
45
+ }
46
+ // ── Home screen helpers ──────────────────────────────────────────────────────
47
+ const GRID_COLS = 12;
48
+ function getBookGrid() {
49
+ const all = getAllBooks();
50
+ return { ot: all.filter(b => b.testament === 'OT'), nt: all.filter(b => b.testament === 'NT') };
51
+ }
52
+ function flatBookList() {
53
+ const { ot, nt } = getBookGrid();
54
+ return [...ot, ...nt];
55
+ }
56
+ const OT_COUNT = 39;
57
+ const TOTAL_BOOKS = 66;
58
+ const OT_ROWS = Math.ceil(OT_COUNT / GRID_COLS);
59
+ const NT_ROWS = Math.ceil((TOTAL_BOOKS - OT_COUNT) / GRID_COLS);
60
+ // Navigate the home grid: handles OT↔NT boundary, row/col aware
61
+ function gridMove(idx, dir) {
62
+ if (dir === 'left')
63
+ return Math.max(0, idx - 1);
64
+ if (dir === 'right')
65
+ return Math.min(TOTAL_BOOKS - 1, idx + 1);
66
+ const inOT = idx < OT_COUNT;
67
+ const sectionStart = inOT ? 0 : OT_COUNT;
68
+ const localIdx = idx - sectionStart;
69
+ const row = Math.floor(localIdx / GRID_COLS);
70
+ const col = localIdx % GRID_COLS;
71
+ if (dir === 'down') {
72
+ if (inOT) {
73
+ const nextRow = row + 1;
74
+ if (nextRow < OT_ROWS) {
75
+ // Stay in OT, clamp to last book in that row
76
+ return Math.min(sectionStart + nextRow * GRID_COLS + col, OT_COUNT - 1);
77
+ }
78
+ // Jump to NT row 0, same column
79
+ return Math.min(OT_COUNT + col, TOTAL_BOOKS - 1);
80
+ }
81
+ else {
82
+ const nextRow = row + 1;
83
+ if (nextRow < NT_ROWS) {
84
+ return Math.min(sectionStart + nextRow * GRID_COLS + col, TOTAL_BOOKS - 1);
85
+ }
86
+ return idx; // already at bottom
87
+ }
88
+ }
89
+ // dir === 'up'
90
+ if (inOT) {
91
+ if (row > 0) {
92
+ return sectionStart + (row - 1) * GRID_COLS + col;
93
+ }
94
+ return idx; // already at top
95
+ }
96
+ else {
97
+ if (row > 0) {
98
+ return Math.min(sectionStart + (row - 1) * GRID_COLS + col, TOTAL_BOOKS - 1);
99
+ }
100
+ // Jump to OT last row, same column
101
+ const otLastRowStart = (OT_ROWS - 1) * GRID_COLS;
102
+ return Math.min(otLastRowStart + col, OT_COUNT - 1);
103
+ }
104
+ }
105
+ // ── Pager Component ──────────────────────────────────────────────────────────
106
+ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, startHome }) {
107
+ const { exit } = useApp();
108
+ const { stdout } = useStdout();
109
+ // Chapter position (state + refs for useInput access)
110
+ const [book, setBook] = useState(initialBook ?? 'Gen');
111
+ const [chapter, setChapter] = useState(initialChapter ?? 1);
112
+ const bookRef = useRef(book);
113
+ const chapterRef = useRef(chapter);
114
+ useEffect(() => { bookRef.current = book; }, [book]);
115
+ useEffect(() => { chapterRef.current = chapter; }, [chapter]);
116
+ // Display toggles
117
+ const [notes, setNotes] = useState(initialNotes);
118
+ const [outline, setOutline] = useState(initialOutline);
119
+ // Scroll
120
+ const [scrollOffset, setScrollOffset] = useState(0);
121
+ const scrollRef = useRef(scrollOffset);
122
+ useEffect(() => { scrollRef.current = scrollOffset; }, [scrollOffset]);
123
+ // Mode
124
+ const [mode, setMode] = useState(startHome ? 'home' : 'normal');
125
+ // Copy mode state
126
+ const [cursorIdx, setCursorIdx] = useState(0);
127
+ const [selStart, setSelStart] = useState(null);
128
+ // Search mode state
129
+ const [searchQuery, setSearchQuery] = useState('');
130
+ const [searchSubmitted, setSearchSubmitted] = useState(false);
131
+ const [searchMatches, setSearchMatches] = useState([]);
132
+ const [matchIdx, setMatchIdx] = useState(0);
133
+ // Goto mode state
134
+ const [gotoInput, setGotoInput] = useState('');
135
+ // Navigation history: back/forward stacks (refs so useInput always sees latest)
136
+ const historyRef = useRef([]);
137
+ const forwardRef = useRef([]);
138
+ // Home mode state
139
+ const [homeIdx, setHomeIdx] = useState(0); // index into flat book list
140
+ const [homeChapter, setHomeChapter] = useState(1); // chapter selector
141
+ const allBooks = useMemo(() => flatBookList(), []);
142
+ const lastLetterRef = useRef(''); // tracks last typed letter for cycling
143
+ // Study mode state
144
+ const [studyCursorIdx, setStudyCursorIdx] = useState(0);
145
+ const [studySelStart, setStudySelStart] = useState(null);
146
+ const [studyRefInput, setStudyRefInput] = useState(''); // multi-digit ref number
147
+ const studyActiveRef = useRef(false); // persists across navigate so study mode survives ref follows
148
+ const pendingVerseRef = useRef(null); // target verse to jump to after navigate
149
+ // Flash message
150
+ const [flash, setFlash] = useState('');
151
+ // Terminal dimensions
152
+ const rows = stdout?.rows ?? 24;
153
+ const cols = stdout?.columns ?? 80;
154
+ // ── Chapter data (derived) ───────────────────────────────────────────────
155
+ const { verses, lines, verseMap, bookName, chapterCount } = useMemo(() => {
156
+ if (mode === 'home' || mode === 'home-chapter') {
157
+ return { verses: [], lines: [], verseMap: [], bookName: '', chapterCount: 0 };
158
+ }
159
+ const ref = { book, chapter };
160
+ const v = getVersesByRef(ref);
161
+ const info = getBookInfo(book);
162
+ const bName = info?.full_name ?? book;
163
+ const cCount = info?.chapter_count ?? 1;
164
+ const headers = outline ? getSectionHeaders(book) : [];
165
+ // In study mode: show inline markers but skip the footnote block (panel handles it)
166
+ const showBlock = notes && !studyActiveRef.current;
167
+ const footnotes = showBlock ? getFootnotesForChapter(book, chapter) : [];
168
+ const title = `${bName} ${chapter}`;
169
+ const rendered = renderVerses(v, headers, footnotes, { notes, title });
170
+ const ls = rendered.split('\n');
171
+ const vm = buildVerseLineMap(ls, v);
172
+ return { verses: v, lines: ls, verseMap: vm, bookName: bName, chapterCount: cCount };
173
+ }, [book, chapter, notes, outline, cols, mode]);
174
+ // ── Jump to pending target verse after navigate ───────────────────────
175
+ useEffect(() => {
176
+ const target = pendingVerseRef.current;
177
+ if (!target || verseMap.length === 0)
178
+ return;
179
+ pendingVerseRef.current = null;
180
+ // Find the verse in the map (handle sub-verses: '8' matches '8', '8a', '8b')
181
+ let idx = verseMap.findIndex(e => e.verse === target);
182
+ if (idx < 0)
183
+ idx = verseMap.findIndex(e => e.verse.startsWith(target));
184
+ if (idx >= 0) {
185
+ setStudyCursorIdx(idx);
186
+ const entry = verseMap[idx];
187
+ setScrollOffset(Math.max(0, entry.startLine - 2)); // show a couple lines of context above
188
+ }
189
+ }, [verseMap]);
190
+ const studyPanel = useMemo(() => {
191
+ if (mode !== 'study' || verseMap.length === 0)
192
+ return { lines: [], refs: [] };
193
+ const entry = verseMap[studyCursorIdx];
194
+ if (!entry)
195
+ return { lines: [], refs: [] };
196
+ const v = verses.find(vv => vv.verse === entry.verse);
197
+ if (!v)
198
+ return { lines: [], refs: [] };
199
+ // Get footnotes for this verse (only markers that appear inline)
200
+ const allFootnotes = getFootnotesForVerses(book, chapter, [v.verse]);
201
+ const inlineMarkers = new Set([...v.text.matchAll(/\[([^\]]+)\]/g)].map(m => m[1]));
202
+ const fns = allFootnotes.filter(f => inlineMarkers.has(f.marker));
203
+ // Get cross-refs for this verse, grouped by marker
204
+ const crossRefs = getCrossRefsForVerse(book, chapter, v.verse);
205
+ const refsByMarker = new Map();
206
+ for (const cr of crossRefs) {
207
+ const list = refsByMarker.get(cr.src_marker) ?? [];
208
+ list.push(cr);
209
+ refsByMarker.set(cr.src_marker, list);
210
+ }
211
+ // Build numbered refs and display lines
212
+ const studyRefs = [];
213
+ const panelLines = [];
214
+ const verseLabel = `${bookName} ${chapter}:${v.verse}`;
215
+ panelLines.push(` ${chalk.bold(verseLabel)} ${chalk.dim(`— ${fns.length} note${fns.length === 1 ? '' : 's'}`)}`);
216
+ for (const fn of fns) {
217
+ // Extract the word the marker attaches to (first word after "Book ch:v word" in footnote text)
218
+ const fnTextParts = fn.text.split(/\s+/);
219
+ const word = fnTextParts[2] ?? ''; // "Joh 3:16 loved ..." → "loved"
220
+ const markerDisp = toSuperscript(fn.marker);
221
+ const refs = refsByMarker.get(fn.marker) ?? [];
222
+ // Deduplicate refs by target
223
+ const seen = new Set();
224
+ const uniqueRefs = refs.filter(r => {
225
+ const key = `${r.tgt_book}:${r.tgt_chapter}:${r.tgt_verse}`;
226
+ if (seen.has(key))
227
+ return false;
228
+ seen.add(key);
229
+ return true;
230
+ });
231
+ if (uniqueRefs.length === 0) {
232
+ // Study note with no cross-refs — show truncated text
233
+ const notePreview = fn.text.length > 60 ? fn.text.substring(0, 57) + '...' : fn.text;
234
+ panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)} ${chalk.dim(notePreview)}`);
235
+ }
236
+ else {
237
+ // Build numbered ref labels
238
+ const refLabels = [];
239
+ for (const r of uniqueRefs) {
240
+ const refNum = studyRefs.length + 1;
241
+ const tgtBookName = getBookInfo(r.tgt_book)?.abbr ?? r.tgt_book;
242
+ const label = `${tgtBookName} ${r.tgt_chapter}:${r.tgt_verse}`;
243
+ studyRefs.push({
244
+ num: refNum, marker: fn.marker,
245
+ tgt_book: r.tgt_book, tgt_chapter: r.tgt_chapter, tgt_verse: r.tgt_verse, label,
246
+ });
247
+ refLabels.push(`${chalk.yellow(`[${refNum}]`)} ${chalk.dim(label)}`);
248
+ }
249
+ // Compact: marker + word + refs on one line (wrap if needed)
250
+ const hasStudyNote = fn.text.length > 80;
251
+ const noteHint = hasStudyNote ? chalk.dim(' (note)') : '';
252
+ panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)}${noteHint} ${refLabels.join(' ')}`);
253
+ }
254
+ }
255
+ return { lines: panelLines, refs: studyRefs };
256
+ }, [mode, studyCursorIdx, verseMap, verses, book, chapter, bookName]);
257
+ // ── Viewport height (shrinks when study panel is visible) ────────────
258
+ const studyPanelH = studyPanel.lines.length > 0 ? studyPanel.lines.length + 1 : 0; // +1 for separator
259
+ const hasFooter2 = mode === 'normal' || mode === 'study';
260
+ const chromeH = 3 + (hasFooter2 ? 2 : 1) + studyPanelH; // header + sep + footer(s) + sep + panel
261
+ const viewportH = Math.max(1, rows - chromeH);
262
+ // Clamp scroll when content or viewport changes
263
+ const maxScroll = Math.max(0, lines.length - viewportH);
264
+ useEffect(() => {
265
+ setScrollOffset(prev => Math.min(prev, maxScroll));
266
+ }, [maxScroll]);
267
+ // ── Search highlights ────────────────────────────────────────────────────
268
+ const displayLines = useMemo(() => {
269
+ if (!searchSubmitted || searchMatches.length === 0)
270
+ return lines;
271
+ const q = searchQuery.toLowerCase();
272
+ return lines.map((line) => {
273
+ const stripped = stripAnsi(line);
274
+ if (!stripped.toLowerCase().includes(q))
275
+ return line;
276
+ return highlightTerms(stripped, [searchQuery]);
277
+ });
278
+ }, [lines, searchSubmitted, searchMatches, searchQuery]);
279
+ // ── Navigation helpers ───────────────────────────────────────────────────
280
+ const restoreMode = useCallback(() => {
281
+ setMode(studyActiveRef.current ? 'study' : 'normal');
282
+ setSearchSubmitted(false);
283
+ setSearchQuery('');
284
+ setSearchMatches([]);
285
+ setStudyRefInput('');
286
+ setStudyCursorIdx(0);
287
+ }, []);
288
+ const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
289
+ if (pushHistory) {
290
+ historyRef.current = [...historyRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
291
+ forwardRef.current = [];
292
+ }
293
+ setBook(newBook);
294
+ setChapter(newChapter);
295
+ setScrollOffset(0);
296
+ restoreMode();
297
+ saveLastRead(newBook, newChapter);
298
+ }, [restoreMode]);
299
+ const goBack = useCallback(() => {
300
+ const hist = historyRef.current;
301
+ if (hist.length === 0)
302
+ return;
303
+ const last = hist[hist.length - 1];
304
+ forwardRef.current = [...forwardRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
305
+ historyRef.current = hist.slice(0, -1);
306
+ setBook(last.book);
307
+ setChapter(last.chapter);
308
+ setScrollOffset(last.scroll);
309
+ restoreMode();
310
+ saveLastRead(last.book, last.chapter);
311
+ }, [restoreMode]);
312
+ const goForward = useCallback(() => {
313
+ const fwd = forwardRef.current;
314
+ if (fwd.length === 0)
315
+ return;
316
+ const next = fwd[fwd.length - 1];
317
+ historyRef.current = [...historyRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
318
+ forwardRef.current = fwd.slice(0, -1);
319
+ setBook(next.book);
320
+ setChapter(next.chapter);
321
+ setScrollOffset(next.scroll);
322
+ restoreMode();
323
+ saveLastRead(next.book, next.chapter);
324
+ }, [restoreMode]);
325
+ const goNext = useCallback(() => {
326
+ const next = getNextChapter(book, chapter);
327
+ if (next)
328
+ navigate(next.book, next.chapter);
329
+ }, [book, chapter, navigate]);
330
+ const goPrev = useCallback(() => {
331
+ const prev = getPrevChapter(book, chapter);
332
+ if (prev)
333
+ navigate(prev.book, prev.chapter);
334
+ }, [book, chapter, navigate]);
335
+ // ── Scroll helpers ───────────────────────────────────────────────────────
336
+ const scrollTo = useCallback((n) => {
337
+ setScrollOffset(Math.max(0, Math.min(n, maxScroll)));
338
+ }, [maxScroll]);
339
+ const scrollDown = useCallback(() => scrollTo(scrollOffset + 1), [scrollOffset, scrollTo]);
340
+ const scrollUp = useCallback(() => scrollTo(scrollOffset - 1), [scrollOffset, scrollTo]);
341
+ const pageDown = useCallback(() => scrollTo(scrollOffset + viewportH - 2), [scrollOffset, viewportH, scrollTo]);
342
+ const pageUp = useCallback(() => scrollTo(scrollOffset - viewportH + 2), [scrollOffset, viewportH, scrollTo]);
343
+ // ── Copy helpers ─────────────────────────────────────────────────────────
344
+ const doCopy = useCallback(async () => {
345
+ const lo = selStart !== null ? Math.min(selStart, cursorIdx) : cursorIdx;
346
+ const hi = selStart !== null ? Math.max(selStart, cursorIdx) : cursorIdx;
347
+ const selected = verses.slice(lo, hi + 1);
348
+ if (selected.length === 0)
349
+ return;
350
+ const text = selected.map(v => `${bookName} ${v.chapter}:${v.verse} ${stripMarkers(v.text)}`).join('\n');
351
+ await clipboard.write(text);
352
+ const refLabel = selected.length === 1
353
+ ? `${bookName} ${chapter}:${selected[0].verse}`
354
+ : `${bookName} ${chapter}:${selected[0].verse}–${selected[selected.length - 1].verse}`;
355
+ setFlash(`Copied ${refLabel}`);
356
+ setTimeout(() => setFlash(''), 2000);
357
+ setMode('normal');
358
+ setSelStart(null);
359
+ }, [selStart, cursorIdx, verses, bookName, chapter]);
360
+ // ── Search helpers ───────────────────────────────────────────────────────
361
+ const submitSearch = useCallback(() => {
362
+ if (!searchQuery)
363
+ return;
364
+ const q = searchQuery.toLowerCase();
365
+ const matches = [];
366
+ for (let i = 0; i < lines.length; i++) {
367
+ if (stripAnsi(lines[i]).toLowerCase().includes(q))
368
+ matches.push(i);
369
+ }
370
+ setSearchMatches(matches);
371
+ setSearchSubmitted(true);
372
+ setMatchIdx(0);
373
+ if (matches.length > 0)
374
+ scrollTo(matches[0]);
375
+ }, [searchQuery, lines, scrollTo]);
376
+ const nextMatch = useCallback(() => {
377
+ if (searchMatches.length === 0)
378
+ return;
379
+ const next = (matchIdx + 1) % searchMatches.length;
380
+ setMatchIdx(next);
381
+ scrollTo(searchMatches[next]);
382
+ }, [matchIdx, searchMatches, scrollTo]);
383
+ const prevMatch = useCallback(() => {
384
+ if (searchMatches.length === 0)
385
+ return;
386
+ const prev = (matchIdx - 1 + searchMatches.length) % searchMatches.length;
387
+ setMatchIdx(prev);
388
+ scrollTo(searchMatches[prev]);
389
+ }, [matchIdx, searchMatches, scrollTo]);
390
+ // ── Goto helpers ─────────────────────────────────────────────────────────
391
+ const submitGoto = useCallback(() => {
392
+ const trimmed = gotoInput.trim();
393
+ if (!trimmed) {
394
+ setMode(studyActiveRef.current ? 'study' : 'normal');
395
+ return;
396
+ }
397
+ // Try parsing as "book chapter" or just "book"
398
+ const parts = trimmed.split(/\s+/);
399
+ // Try longest book match first (e.g. "song of songs 1")
400
+ for (let nameLen = Math.min(parts.length, 4); nameLen >= 1; nameLen--) {
401
+ const bookCandidate = parts.slice(0, nameLen).join(' ');
402
+ const resolved = resolveBook(bookCandidate);
403
+ if (resolved) {
404
+ const chNum = parts[nameLen] ? parseInt(parts[nameLen], 10) : 1;
405
+ const info = getBookInfo(resolved);
406
+ const validCh = Math.max(1, Math.min(chNum || 1, info?.chapter_count ?? 1));
407
+ navigate(resolved, validCh);
408
+ setGotoInput('');
409
+ return;
410
+ }
411
+ }
412
+ // No match — flash error
413
+ setFlash(`Unknown: "${trimmed}"`);
414
+ setTimeout(() => setFlash(''), 2000);
415
+ setMode(studyActiveRef.current ? 'study' : 'normal');
416
+ setGotoInput('');
417
+ }, [gotoInput, navigate]);
418
+ // ── Keyboard input ───────────────────────────────────────────────────────
419
+ useInput((input, key) => {
420
+ // ── Home mode: book grid browser ─────────────────────────────────────
421
+ if (mode === 'home') {
422
+ if (key.escape || input === 'q') {
423
+ if (initialBook) {
424
+ setMode('normal');
425
+ }
426
+ else {
427
+ exit();
428
+ }
429
+ return;
430
+ }
431
+ // Space = continue reading last-read chapter
432
+ if (input === ' ') {
433
+ const last = getLastRead();
434
+ if (last)
435
+ navigate(last.book, last.chapter);
436
+ return;
437
+ }
438
+ if (key.return) {
439
+ const selectedBook = allBooks[homeIdx];
440
+ if (selectedBook) {
441
+ const info = getBookInfo(selectedBook.abbr);
442
+ if (info && info.chapter_count === 1) {
443
+ navigate(selectedBook.abbr, 1);
444
+ }
445
+ else {
446
+ setHomeChapter(1);
447
+ setMode('home-chapter');
448
+ }
449
+ }
450
+ return;
451
+ }
452
+ // Arrow keys only for grid navigation (no j/k/h/l — those are type-ahead)
453
+ if (key.rightArrow) {
454
+ setHomeIdx(prev => gridMove(prev, 'right'));
455
+ return;
456
+ }
457
+ if (key.leftArrow) {
458
+ setHomeIdx(prev => gridMove(prev, 'left'));
459
+ return;
460
+ }
461
+ if (key.downArrow) {
462
+ setHomeIdx(prev => gridMove(prev, 'down'));
463
+ return;
464
+ }
465
+ if (key.upArrow) {
466
+ setHomeIdx(prev => gridMove(prev, 'up'));
467
+ return;
468
+ }
469
+ // All letter/number keys → type-ahead jump; repeat same letter cycles matches
470
+ if (input && /^[a-zA-Z0-9]$/.test(input)) {
471
+ const lower = input.toLowerCase();
472
+ const matches = allBooks
473
+ .map((b, i) => ({ i, b }))
474
+ .filter(({ b }) => b.abbr.toLowerCase().startsWith(lower) || b.full_name.toLowerCase().startsWith(lower));
475
+ if (matches.length > 0) {
476
+ if (lower === lastLetterRef.current) {
477
+ // Same letter again: cycle to next match after current position
478
+ const nextMatch = matches.find(m => m.i > homeIdx) ?? matches[0];
479
+ setHomeIdx(nextMatch.i);
480
+ }
481
+ else {
482
+ setHomeIdx(matches[0].i);
483
+ }
484
+ lastLetterRef.current = lower;
485
+ }
486
+ return;
487
+ }
488
+ return;
489
+ }
490
+ // ── Home-chapter mode: chapter number selector ───────────────────────
491
+ if (mode === 'home-chapter') {
492
+ const selectedBook = allBooks[homeIdx];
493
+ const maxCh = selectedBook ? (getBookInfo(selectedBook.abbr)?.chapter_count ?? 1) : 1;
494
+ if (key.escape) {
495
+ setMode('home');
496
+ return;
497
+ }
498
+ if (key.return) {
499
+ if (selectedBook)
500
+ navigate(selectedBook.abbr, homeChapter);
501
+ return;
502
+ }
503
+ if (key.rightArrow || input === 'l') {
504
+ setHomeChapter(prev => Math.min(prev + 1, maxCh));
505
+ return;
506
+ }
507
+ if (key.leftArrow || input === 'h') {
508
+ setHomeChapter(prev => Math.max(prev - 1, 1));
509
+ return;
510
+ }
511
+ if (key.downArrow || input === 'j') {
512
+ setHomeChapter(prev => Math.min(prev + 10, maxCh));
513
+ return;
514
+ }
515
+ if (key.upArrow || input === 'k') {
516
+ setHomeChapter(prev => Math.max(prev - 10, 1));
517
+ return;
518
+ }
519
+ // Number keys: build chapter number
520
+ if (input && /^[0-9]$/.test(input)) {
521
+ setHomeChapter(prev => {
522
+ const next = prev * 10 + parseInt(input, 10);
523
+ return next > maxCh ? parseInt(input, 10) : next;
524
+ });
525
+ return;
526
+ }
527
+ return;
528
+ }
529
+ // ── Search mode ──────────────────────────────────────────────────────
530
+ if (mode === 'search') {
531
+ const returnMode = studyActiveRef.current ? 'study' : 'normal';
532
+ if (key.escape) {
533
+ setMode(returnMode);
534
+ setSearchQuery('');
535
+ setSearchSubmitted(false);
536
+ setSearchMatches([]);
537
+ return;
538
+ }
539
+ if (key.return) {
540
+ submitSearch();
541
+ setMode(returnMode);
542
+ return;
543
+ }
544
+ if (key.backspace || key.delete) {
545
+ setSearchQuery(prev => prev.slice(0, -1));
546
+ return;
547
+ }
548
+ if (searchSubmitted && input === 'n') {
549
+ nextMatch();
550
+ return;
551
+ }
552
+ if (searchSubmitted && input === 'N') {
553
+ prevMatch();
554
+ return;
555
+ }
556
+ if (input && !key.ctrl && !key.meta) {
557
+ setSearchQuery(prev => prev + input);
558
+ }
559
+ return;
560
+ }
561
+ // ── Goto mode ────────────────────────────────────────────────────────
562
+ if (mode === 'goto') {
563
+ if (key.escape) {
564
+ setMode(studyActiveRef.current ? 'study' : 'normal');
565
+ setGotoInput('');
566
+ return;
567
+ }
568
+ if (key.return) {
569
+ submitGoto();
570
+ return;
571
+ }
572
+ if (key.backspace || key.delete) {
573
+ setGotoInput(prev => prev.slice(0, -1));
574
+ return;
575
+ }
576
+ if (input && !key.ctrl && !key.meta) {
577
+ setGotoInput(prev => prev + input);
578
+ }
579
+ return;
580
+ }
581
+ // ── Study mode ───────────────────────────────────────────────────────
582
+ if (mode === 'study') {
583
+ if (key.escape || input === 'd') {
584
+ setMode('normal');
585
+ studyActiveRef.current = false;
586
+ setStudyRefInput('');
587
+ setStudySelStart(null);
588
+ return;
589
+ }
590
+ if (input === '[') {
591
+ goBack();
592
+ return;
593
+ }
594
+ if (input === ']') {
595
+ goForward();
596
+ return;
597
+ }
598
+ if (input === 'j' || key.downArrow) {
599
+ setStudyCursorIdx(prev => {
600
+ const next = Math.min(prev + 1, verseMap.length - 1);
601
+ const entry = verseMap[next];
602
+ if (entry && entry.startLine >= scrollOffset + viewportH) {
603
+ scrollTo(entry.startLine - viewportH + 3);
604
+ }
605
+ return next;
606
+ });
607
+ setStudyRefInput('');
608
+ return;
609
+ }
610
+ if (input === 'k' || key.upArrow) {
611
+ setStudyCursorIdx(prev => {
612
+ const next = Math.max(prev - 1, 0);
613
+ const entry = verseMap[next];
614
+ if (entry && entry.startLine < scrollOffset) {
615
+ scrollTo(entry.startLine);
616
+ }
617
+ return next;
618
+ });
619
+ setStudyRefInput('');
620
+ return;
621
+ }
622
+ // Number keys: build ref number, then follow on Enter or after short delay
623
+ if (input && /^[0-9]$/.test(input)) {
624
+ const newInput = studyRefInput + input;
625
+ setStudyRefInput(newInput);
626
+ const num = parseInt(newInput, 10);
627
+ // Auto-follow if the number can't grow to match any ref
628
+ const maxRef = studyPanel.refs.length;
629
+ if (num > 0 && num <= maxRef && num * 10 > maxRef) {
630
+ // Unambiguous — follow immediately
631
+ const ref = studyPanel.refs[num - 1];
632
+ setStudyRefInput('');
633
+ pendingVerseRef.current = ref.tgt_verse;
634
+ navigate(ref.tgt_book, ref.tgt_chapter);
635
+ }
636
+ return;
637
+ }
638
+ if (key.return && studyRefInput) {
639
+ const num = parseInt(studyRefInput, 10);
640
+ if (num > 0 && num <= studyPanel.refs.length) {
641
+ const ref = studyPanel.refs[num - 1];
642
+ setStudyRefInput('');
643
+ pendingVerseRef.current = ref.tgt_verse;
644
+ navigate(ref.tgt_book, ref.tgt_chapter);
645
+ }
646
+ else {
647
+ setStudyRefInput('');
648
+ }
649
+ return;
650
+ }
651
+ // Chapter nav
652
+ if (input === 'n' || key.rightArrow) {
653
+ goNext();
654
+ return;
655
+ }
656
+ if (input === 'p' || key.leftArrow) {
657
+ goPrev();
658
+ return;
659
+ }
660
+ // Range selection
661
+ if (input === 'v') {
662
+ setStudySelStart(studyCursorIdx);
663
+ return;
664
+ }
665
+ // Copy cursor verse or selected range
666
+ if (input === 'c') {
667
+ const lo = studySelStart !== null ? Math.min(studySelStart, studyCursorIdx) : studyCursorIdx;
668
+ const hi = studySelStart !== null ? Math.max(studySelStart, studyCursorIdx) : studyCursorIdx;
669
+ const selected = verses.slice(lo, hi + 1);
670
+ if (selected.length > 0) {
671
+ const text = selected.map(v => `${bookName} ${v.chapter}:${v.verse} ${stripMarkers(v.text)}`).join('\n');
672
+ const refLabel = selected.length === 1
673
+ ? `${bookName} ${chapter}:${selected[0].verse}`
674
+ : `${bookName} ${chapter}:${selected[0].verse}–${selected[selected.length - 1].verse}`;
675
+ clipboard.write(text).then(() => {
676
+ setFlash(`Copied ${refLabel}`);
677
+ setTimeout(() => setFlash(''), 2000);
678
+ });
679
+ setStudySelStart(null);
680
+ }
681
+ return;
682
+ }
683
+ // Goto
684
+ if (input === ':') {
685
+ setMode('goto');
686
+ setGotoInput('');
687
+ return;
688
+ }
689
+ // Search within chapter
690
+ if (input === '/') {
691
+ setMode('search');
692
+ setSearchQuery('');
693
+ setSearchSubmitted(false);
694
+ setSearchMatches([]);
695
+ return;
696
+ }
697
+ return;
698
+ }
699
+ // ── Copy mode ────────────────────────────────────────────────────────
700
+ if (mode === 'copy') {
701
+ if (key.escape) {
702
+ setMode('normal');
703
+ setSelStart(null);
704
+ return;
705
+ }
706
+ if (input === 'j' || key.downArrow) {
707
+ setCursorIdx(prev => {
708
+ const next = Math.min(prev + 1, verseMap.length - 1);
709
+ const entry = verseMap[next];
710
+ if (entry && entry.startLine >= scrollOffset + viewportH) {
711
+ scrollTo(entry.startLine - viewportH + 3);
712
+ }
713
+ return next;
714
+ });
715
+ return;
716
+ }
717
+ if (input === 'k' || key.upArrow) {
718
+ setCursorIdx(prev => {
719
+ const next = Math.max(prev - 1, 0);
720
+ const entry = verseMap[next];
721
+ if (entry && entry.startLine < scrollOffset) {
722
+ scrollTo(entry.startLine);
723
+ }
724
+ return next;
725
+ });
726
+ return;
727
+ }
728
+ if (input === 'v') {
729
+ setSelStart(cursorIdx);
730
+ return;
731
+ }
732
+ if (key.return || input === 'c') {
733
+ doCopy();
734
+ return;
735
+ }
736
+ return;
737
+ }
738
+ // ── Normal mode ──────────────────────────────────────────────────────
739
+ if (input === 'q' || key.escape) {
740
+ exit();
741
+ return;
742
+ }
743
+ if (input === 'j' || key.downArrow) {
744
+ scrollDown();
745
+ return;
746
+ }
747
+ if (input === 'k' || key.upArrow) {
748
+ scrollUp();
749
+ return;
750
+ }
751
+ if (input === ' ') {
752
+ pageDown();
753
+ return;
754
+ }
755
+ if (input === 'b') {
756
+ pageUp();
757
+ return;
758
+ }
759
+ if (input === 'g') {
760
+ scrollTo(0);
761
+ return;
762
+ }
763
+ if (input === 'G') {
764
+ scrollTo(maxScroll);
765
+ return;
766
+ }
767
+ if (input === 'n' || key.rightArrow) {
768
+ if (searchSubmitted && searchMatches.length > 0) {
769
+ nextMatch();
770
+ }
771
+ else {
772
+ goNext();
773
+ }
774
+ return;
775
+ }
776
+ if (input === 'N') {
777
+ prevMatch();
778
+ return;
779
+ }
780
+ if (input === 'p' || key.leftArrow) {
781
+ goPrev();
782
+ return;
783
+ }
784
+ if (input === 'f') {
785
+ setNotes(prev => !prev);
786
+ return;
787
+ }
788
+ if (input === 'o') {
789
+ setOutline(prev => !prev);
790
+ return;
791
+ }
792
+ if (input === '/') {
793
+ setMode('search');
794
+ setSearchQuery('');
795
+ setSearchSubmitted(false);
796
+ setSearchMatches([]);
797
+ return;
798
+ }
799
+ if (input === 'c') {
800
+ setMode('copy');
801
+ setCursorIdx(verseAtLine(verseMap, scrollOffset));
802
+ setSelStart(null);
803
+ return;
804
+ }
805
+ if (input === ':') {
806
+ setMode('goto');
807
+ setGotoInput('');
808
+ return;
809
+ }
810
+ if (input === 'd') {
811
+ setMode('study');
812
+ studyActiveRef.current = true;
813
+ if (!notes)
814
+ setNotes(true);
815
+ setStudyCursorIdx(verseAtLine(verseMap, scrollOffset));
816
+ setStudyRefInput('');
817
+ return;
818
+ }
819
+ if (input === 'H') {
820
+ setMode('home');
821
+ return;
822
+ }
823
+ if (input === '[') {
824
+ goBack();
825
+ return;
826
+ }
827
+ if (input === ']') {
828
+ goForward();
829
+ return;
830
+ }
831
+ });
832
+ // ── Home screen render ───────────────────────────────────────────────────
833
+ if (mode === 'home' || mode === 'home-chapter') {
834
+ const { ot, nt } = getBookGrid();
835
+ const last = getLastRead();
836
+ const selectedBook = allBooks[homeIdx];
837
+ const homeLines = [];
838
+ homeLines.push('');
839
+ homeLines.push(chalk.bold(' Recovery Version Bible'));
840
+ homeLines.push('');
841
+ if (last) {
842
+ const lastBookName = getBookInfo(last.book)?.full_name ?? last.book;
843
+ homeLines.push(` Last read: ${chalk.bold(`${lastBookName} ${last.chapter}`)} ${chalk.dim('— press Space to continue')}`);
844
+ }
845
+ else {
846
+ homeLines.push(chalk.dim(' Select a book to start reading'));
847
+ }
848
+ homeLines.push('');
849
+ // Render book grid with cursor highlight
850
+ function renderGridRow(books, startIdx) {
851
+ return ' ' + books.map((b, i) => {
852
+ const flatIdx = startIdx + i;
853
+ const abbr = b.abbr.padEnd(5);
854
+ if (flatIdx === homeIdx)
855
+ return chalk.inverse.bold(abbr);
856
+ return abbr;
857
+ }).join('');
858
+ }
859
+ homeLines.push(chalk.dim(' OLD TESTAMENT'));
860
+ for (let i = 0; i < ot.length; i += GRID_COLS) {
861
+ const row = ot.slice(i, i + GRID_COLS);
862
+ homeLines.push(renderGridRow(row, i));
863
+ }
864
+ homeLines.push('');
865
+ homeLines.push(chalk.dim(' NEW TESTAMENT'));
866
+ const otLen = ot.length;
867
+ for (let i = 0; i < nt.length; i += GRID_COLS) {
868
+ const row = nt.slice(i, i + GRID_COLS);
869
+ homeLines.push(renderGridRow(row, otLen + i));
870
+ }
871
+ if (mode === 'home-chapter' && selectedBook) {
872
+ const info = getBookInfo(selectedBook.abbr);
873
+ const maxCh = info?.chapter_count ?? 1;
874
+ homeLines.push('');
875
+ homeLines.push(` ${chalk.bold(selectedBook.full_name)} — select chapter:`);
876
+ // Render chapter numbers in rows of 10
877
+ const chNums = [];
878
+ for (let c = 1; c <= maxCh; c++) {
879
+ const label = String(c).padStart(3);
880
+ chNums.push(c === homeChapter ? chalk.inverse.bold(label) : chalk.dim(label));
881
+ }
882
+ for (let i = 0; i < chNums.length; i += 10) {
883
+ homeLines.push(' ' + chNums.slice(i, i + 10).join(' '));
884
+ }
885
+ }
886
+ const homeContent = homeLines.join('\n');
887
+ let homeFooter;
888
+ if (mode === 'home-chapter') {
889
+ homeFooter = ` ${chalk.dim('←/→ or type number · Enter go · Esc back')}`;
890
+ }
891
+ else {
892
+ const lastRead = getLastRead();
893
+ const continueHint = lastRead ? 'Space continue · ' : '';
894
+ homeFooter = ` ${chalk.dim(`↑↓←→ navigate · Enter select · ${continueHint}type letter to jump · q quit`)}`;
895
+ }
896
+ const homeSep = chalk.dim('─'.repeat(cols));
897
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { children: chalk.bold(' rv') }), _jsx(Text, { children: homeSep }), _jsx(Box, { flexGrow: 1, overflow: "hidden", children: _jsx(Text, { children: homeContent }) }), _jsx(Text, { children: homeSep }), _jsx(Text, { children: homeFooter })] }));
898
+ }
899
+ // ── Chapter view: build visible content ──────────────────────────────────
900
+ const visibleSlice = displayLines.slice(scrollOffset, scrollOffset + viewportH);
901
+ // Apply cursor highlight for copy mode and study mode
902
+ const contentLines = (mode === 'copy' || mode === 'study') ? visibleSlice.map((line, viewIdx) => {
903
+ const absLine = scrollOffset + viewIdx;
904
+ const isCopy = mode === 'copy';
905
+ const cursor = isCopy ? cursorIdx : studyCursorIdx;
906
+ const sel = isCopy ? selStart : studySelStart;
907
+ const lo = sel !== null ? Math.min(sel, cursor) : cursor;
908
+ const hi = sel !== null ? Math.max(sel, cursor) : cursor;
909
+ for (let v = lo; v <= hi; v++) {
910
+ const entry = verseMap[v];
911
+ if (entry && absLine >= entry.startLine && absLine <= entry.endLine) {
912
+ if (isCopy) {
913
+ // Copy mode: full inverse highlight
914
+ return absLine === entry.startLine
915
+ ? chalk.inverse(`› ${stripAnsi(line)}`)
916
+ : chalk.inverse(` ${stripAnsi(line)}`);
917
+ }
918
+ // Study mode: subtle — just a › marker on first line, no bg change
919
+ if (absLine === entry.startLine) {
920
+ return `${chalk.cyan('›')}${line.substring(1)}`;
921
+ }
922
+ return line;
923
+ }
924
+ }
925
+ return line;
926
+ }) : visibleSlice;
927
+ const content = contentLines.join('\n');
928
+ // ── Header ───────────────────────────────────────────────────────────────
929
+ const CATEGORY_LABELS = {
930
+ Moses: 'Books of Moses', Israel: 'History of Israel', Poetry: 'Poetry & Wisdom',
931
+ MajorProphets: 'Major Prophets', MinorProphets: 'Minor Prophets',
932
+ Gospels: 'Gospels', Paul: 'Epistles of Paul', Others: 'General Epistles & Prophecy',
933
+ };
934
+ const bookInfo = getBookInfo(book);
935
+ const categoryLabel = CATEGORY_LABELS[bookInfo?.category ?? ''] ?? '';
936
+ const topVerseIdx = verseAtLine(verseMap, scrollOffset) + 1;
937
+ const title = `${bookName} ${chapter}`;
938
+ const position = `${topVerseIdx}/${verses.length}`;
939
+ const titleLeft = categoryLabel
940
+ ? ` ${title} ${chalk.dim('·')} ${chalk.dim(categoryLabel)}`
941
+ : ` ${title}`;
942
+ const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
943
+ const header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
944
+ // ── Footer (two lines for normal/study, one line for modal modes) ──────
945
+ let footer1;
946
+ let footer2 = null;
947
+ if (flash) {
948
+ footer1 = ` ${chalk.green(`✓ ${flash}`)}`;
949
+ }
950
+ else if (mode === 'search') {
951
+ if (searchSubmitted) {
952
+ const countLabel = searchMatches.length === 0
953
+ ? 'no matches'
954
+ : `match ${matchIdx + 1}/${searchMatches.length}`;
955
+ footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${chalk.dim('n/N cycle · Esc clear')}`;
956
+ }
957
+ else {
958
+ footer1 = ` / ${searchQuery}${chalk.dim('_')} ${chalk.dim('Enter search · Esc cancel')}`;
959
+ }
960
+ }
961
+ else if (mode === 'goto') {
962
+ footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8 · Enter go · Esc cancel')}`;
963
+ }
964
+ else if (mode === 'study') {
965
+ const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
966
+ const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
967
+ footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${chalk.dim('# follow ref · v range · c copy · / find')}`;
968
+ footer2 = ` ${chalk.dim('↑↓ verse · ←→ ch · : goto · [/] back/fwd · d exit')}`;
969
+ }
970
+ else if (mode === 'copy') {
971
+ const rangeHint = selStart !== null ? chalk.dim(' (range active)') : '';
972
+ footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${chalk.dim('↑↓ move · v range · Enter copy · Esc cancel')}`;
973
+ }
974
+ else {
975
+ const noteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
976
+ const outlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
977
+ footer1 = ` ${noteFlag} ${chalk.dim('·')} ${outlineFlag} ${chalk.dim('· d study · c copy · / find')}`;
978
+ footer2 = ` ${chalk.dim('↑↓ scroll · ←→ ch · : goto · H home · [/] back/fwd · q quit')}`;
979
+ }
980
+ const separator = chalk.dim('· '.repeat(Math.floor(cols / 2)));
981
+ // ── Render ───────────────────────────────────────────────────────────────
982
+ const studyPanelContent = studyPanel.lines.length > 0
983
+ ? studyPanel.lines.join('\n')
984
+ : null;
985
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { children: header }), _jsx(Text, { children: separator }), _jsx(Box, { flexGrow: 1, overflow: "hidden", children: _jsx(Text, { children: content }) }), _jsx(Text, { children: separator }), studyPanelContent && _jsx(Text, { children: studyPanelContent }), studyPanelContent && _jsx(Text, { children: separator }), _jsx(Text, { children: footer1 }), footer2 && _jsx(Text, { children: footer2 })] }));
986
+ }
987
+ // ── Launcher ─────────────────────────────────────────────────────────────────
988
+ export async function launchPager(book, chapter, opts) {
989
+ process.stdout.write('\x1b[?1049h');
990
+ const instance = render(_jsx(Pager, { initialBook: book, initialChapter: chapter, initialNotes: opts.notes, initialOutline: opts.outline }));
991
+ await instance.waitUntilExit();
992
+ process.stdout.write('\x1b[?1049l');
993
+ }
994
+ export async function launchPagerHome() {
995
+ process.stdout.write('\x1b[?1049h');
996
+ const instance = render(_jsx(Pager, { initialNotes: false, initialOutline: false, startHome: true }));
997
+ await instance.waitUntilExit();
998
+ process.stdout.write('\x1b[?1049l');
999
+ }