rv-bible-cli 0.1.6 → 0.1.8

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.
package/README.md CHANGED
@@ -123,6 +123,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
123
123
  | `d` | enter study mode |
124
124
  | `c` | enter copy mode |
125
125
  | `/` | find text in current chapter |
126
+ | `?` | search the whole Bible — type a query, browse results, Enter opens |
126
127
  | `:` | jump to any book — type `rom 8`, `jn 3`, etc. |
127
128
  | `[` / `]` | go back / forward (like browser history) |
128
129
  | `H` | go to home screen |
@@ -134,7 +135,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
134
135
 
135
136
  Deep-dive into footnotes and cross-references. Press `d` while reading any chapter.
136
137
 
137
- - A cursor highlights the current verse
138
+ - A cursor highlights the current verse — the chapter view auto-scrolls to keep it vertically centered as you move
138
139
  - The bottom panel shows the **full** footnote text for that verse — header line with marker + word + numbered cross-references, then the wrapped commentary body underneath
139
140
  - **Type a number** to follow a cross-reference — you'll jump to that chapter and land on the exact verse
140
141
  - Press `[` to go back to where you were
package/dist/format.d.ts CHANGED
@@ -8,6 +8,7 @@ export declare function getZoom(): number;
8
8
  export declare function zoomIn(): void;
9
9
  export declare function zoomOut(): void;
10
10
  export declare function zoomReset(): void;
11
+ export declare function getMargin(): number;
11
12
  export declare function wrapWords(text: string, maxWidth: number, ansi?: boolean): string[];
12
13
  export declare function renderFootnoteBlock(footnotes: Footnote[]): string;
13
14
  export declare function renderNoteDisplayAll(verse: Verse, footnotes: Footnote[], bookName: string): string;
package/dist/format.js CHANGED
@@ -109,6 +109,9 @@ function contentWidth() {
109
109
  function margin() {
110
110
  return ' '.repeat(ZOOM_LEVELS[currentZoom].margin);
111
111
  }
112
+ export function getMargin() {
113
+ return ZOOM_LEVELS[currentZoom].margin;
114
+ }
112
115
  // ── Visible-length helper ────────────────────────────────────────────────────
113
116
  // Strip ANSI escape codes before measuring — needed for word-wrap width checks
114
117
  // on text that may contain chalk styling (e.g. highlighted markers in notes mode).
package/dist/index.js CHANGED
@@ -397,9 +397,7 @@ program
397
397
  const bookName = getBookInfo(v.book)?.full_name ?? v.book;
398
398
  const ref = `${bookName} ${v.chapter}:${v.verse}`;
399
399
  const cleanText = stripMarkers(v.text);
400
- // Truncate to ~150 chars for scanability
401
- const truncated = cleanText.length > 150 ? cleanText.substring(0, 147) + '...' : cleanText;
402
- const highlighted = highlightTerms(truncated, terms);
400
+ const highlighted = highlightTerms(cleanText, terms);
403
401
  // Wrap the text at ~76 chars with 4-space indent
404
402
  const maxW = Math.min((process.stdout.columns ?? 80) - 4, 76);
405
403
  const words = highlighted.split(' ');
package/dist/ui/Pager.js CHANGED
@@ -3,8 +3,8 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
3
3
  import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
4
4
  import chalk from 'chalk';
5
5
  import clipboard from 'clipboardy';
6
- import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, } from '../db.js';
7
- import { renderVerses, stripMarkers, highlightTerms, toSuperscript, wrapWords, zoomIn, zoomOut, zoomReset, getZoom } from '../format.js';
6
+ import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from '../db.js';
7
+ import { renderVerses, stripMarkers, highlightTerms, toSuperscript, wrapWords, zoomIn, zoomOut, zoomReset, getZoom, getMargin } from '../format.js';
8
8
  import { saveLastRead, getLastRead } from '../state.js';
9
9
  import { resolveBook } from '../parser.js';
10
10
  import { getNextChapter, getPrevChapter } from './nav.js';
@@ -12,6 +12,20 @@ import { getNextChapter, getPrevChapter } from './nav.js';
12
12
  function stripAnsi(s) {
13
13
  return s.replace(/\x1b\[[0-9;]*m/g, '');
14
14
  }
15
+ // Footer keybind helpers: magenta for the key, dim for the description,
16
+ // dim middle dot as separator. Keeps "this is a key you can press" visually
17
+ // distinct from the prose around it. When `/` separates two keys in a
18
+ // compound (e.g. `[/]`, `g/G`, `Tab/⇧Tab`), the slash itself drops to dim so
19
+ // the keys stand out. A lone `/` (e.g. the find command) stays magenta.
20
+ function kb(key, desc) {
21
+ const parts = key.split('/');
22
+ const isCompound = parts.length > 1 && parts.every(p => p.length > 0);
23
+ const styledKey = isCompound
24
+ ? parts.map(k => chalk.magenta(k)).join(chalk.dim('/'))
25
+ : chalk.magenta(key);
26
+ return `${styledKey} ${chalk.dim(desc)}`;
27
+ }
28
+ const FOOTER_SEP = chalk.dim(' · ');
15
29
  // Detect verse-number lines by checking for chalk.dim() ANSI code (\x1b[2m) before
16
30
  // a superscript character. This distinguishes verse numbers (dim) from inline footnote
17
31
  // markers (cyan) that could appear at the start of word-wrapped continuation lines.
@@ -125,11 +139,17 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
125
139
  // Copy mode state
126
140
  const [cursorIdx, setCursorIdx] = useState(0);
127
141
  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);
142
+ // Find-in-chapter mode state (the `/` modal)
143
+ const [findQuery, setFindQuery] = useState('');
144
+ const [findSubmitted, setFindSubmitted] = useState(false);
145
+ const [findMatches, setFindMatches] = useState([]);
146
+ const [findIdx, setFindIdx] = useState(0);
147
+ // Global search session state (the `?` modal — full Bible search)
148
+ const [gsearchInput, setGsearchInput] = useState('');
149
+ const [gsearchSession, setGsearchSession] = useState(null);
150
+ // Holds the search terms to highlight in the next-rendered chapter (consumed by
151
+ // a useEffect once `lines` rebuilds). Array so multi-term queries highlight all words.
152
+ const pendingFindTermsRef = useRef(null);
133
153
  // Goto mode state
134
154
  const [gotoInput, setGotoInput] = useState('');
135
155
  // Navigation history: back/forward stacks (refs so useInput always sees latest)
@@ -277,64 +297,106 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
277
297
  useEffect(() => {
278
298
  setPanelScrollOffset(0);
279
299
  }, [studyCursorIdx, book, chapter, mode]);
280
- // Clamp scroll when content or viewport changes
300
+ // Clamp scroll when content or viewport changes. Normal mode uses the strict
301
+ // maxScroll clamp (can't scroll past content). Study mode allows overscroll so
302
+ // the last verse can still center — the visible slice is padded with empty rows
303
+ // when scrollOffset > maxScroll, keeping the panel anchored to the bottom.
281
304
  const maxScroll = Math.max(0, lines.length - viewportH);
305
+ const overscrollLimit = Math.max(0, lines.length - 1);
282
306
  useEffect(() => {
283
- setScrollOffset(prev => Math.min(prev, maxScroll));
284
- }, [maxScroll]);
285
- // Keep the study-mode cursor verse fully visible above the footnote panel.
286
- // Triggers when cursor moves OR viewport shrinks (panel grew with longer notes).
307
+ if (mode !== 'study') {
308
+ setScrollOffset(prev => Math.min(prev, maxScroll));
309
+ }
310
+ else {
311
+ setScrollOffset(prev => Math.min(prev, overscrollLimit));
312
+ }
313
+ }, [maxScroll, overscrollLimit, mode]);
314
+ // Center the cursor verse in study mode. If the verse fits in the viewport,
315
+ // place its midpoint at viewportH/2. If the verse is taller than the viewport
316
+ // (rare — long verse with markers wrapping), pin its start to the top so the
317
+ // beginning is always visible. Triggers on cursor move and viewport resize.
287
318
  useEffect(() => {
288
319
  if (mode !== 'study' || verseMap.length === 0)
289
320
  return;
290
321
  const entry = verseMap[studyCursorIdx];
291
322
  if (!entry)
292
323
  return;
293
- setScrollOffset(prev => {
294
- // Cursor verse extends below the visible viewport — pull it in from the bottom
295
- if (entry.endLine >= prev + viewportH) {
296
- return Math.max(0, entry.endLine - viewportH + 1);
297
- }
298
- // Cursor verse starts above the visible viewport — pull it in from the top
299
- if (entry.startLine < prev) {
300
- return entry.startLine;
301
- }
302
- return prev;
303
- });
304
- }, [studyCursorIdx, viewportH, mode, verseMap]);
324
+ const verseH = entry.endLine - entry.startLine + 1;
325
+ let target;
326
+ if (verseH >= viewportH) {
327
+ target = entry.startLine;
328
+ }
329
+ else {
330
+ const verseMid = entry.startLine + Math.floor(verseH / 2);
331
+ target = verseMid - Math.floor(viewportH / 2);
332
+ }
333
+ target = Math.max(0, Math.min(target, overscrollLimit));
334
+ setScrollOffset(target);
335
+ }, [studyCursorIdx, viewportH, mode, verseMap, overscrollLimit]);
305
336
  // ── Search highlights ────────────────────────────────────────────────────
337
+ // Highlights every space-separated token in findQuery (so multi-term gsearch
338
+ // results like "only begotten" light up both words). For pure single-word find,
339
+ // this collapses to the original single-term behavior.
306
340
  const displayLines = useMemo(() => {
307
- if (!searchSubmitted || searchMatches.length === 0)
341
+ if (!findSubmitted || findMatches.length === 0)
308
342
  return lines;
309
- const q = searchQuery.toLowerCase();
343
+ const queryTerms = findQuery.split(/\s+/).filter(Boolean);
344
+ if (queryTerms.length === 0)
345
+ return lines;
346
+ const lowers = queryTerms.map(t => t.toLowerCase());
310
347
  return lines.map((line) => {
311
348
  const stripped = stripAnsi(line);
312
- if (!stripped.toLowerCase().includes(q))
349
+ const lower = stripped.toLowerCase();
350
+ if (!lowers.some(l => lower.includes(l)))
313
351
  return line;
314
- return highlightTerms(stripped, [searchQuery]);
352
+ return highlightTerms(stripped, queryTerms);
315
353
  });
316
- }, [lines, searchSubmitted, searchMatches, searchQuery]);
354
+ }, [lines, findSubmitted, findMatches, findQuery]);
317
355
  // ── Navigation helpers ───────────────────────────────────────────────────
318
356
  const restoreMode = useCallback(() => {
319
357
  setMode(studyActiveRef.current ? 'study' : 'normal');
320
- setSearchSubmitted(false);
321
- setSearchQuery('');
322
- setSearchMatches([]);
358
+ setFindSubmitted(false);
359
+ setFindQuery('');
360
+ setFindMatches([]);
323
361
  setStudyRefInput('');
324
362
  setStudyCursorIdx(0);
325
363
  }, []);
326
364
  const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
327
365
  const sameChapter = newBook === bookRef.current && newChapter === chapterRef.current;
328
- // Same-chapter cross-ref follow: jump cursor directly. Don't reset state via
329
- // restoreMode (would clobber cursor to 0), don't push to history (cursor isn't tracked there).
366
+ // Same-chapter target: jump cursor (study) or scroll (normal) directly without
367
+ // re-rendering the chapter. Exits any modal mode (gsearch-list, etc.) via restoreMode.
330
368
  if (sameChapter && pendingVerseRef.current) {
331
369
  const target = pendingVerseRef.current;
332
370
  pendingVerseRef.current = null;
333
371
  let idx = verseMap.findIndex(e => e.verse === target);
334
372
  if (idx < 0)
335
373
  idx = verseMap.findIndex(e => e.verse.startsWith(target));
336
- if (idx >= 0)
337
- setStudyCursorIdx(idx);
374
+ if (idx >= 0) {
375
+ const entry = verseMap[idx];
376
+ if (studyActiveRef.current) {
377
+ setStudyCursorIdx(idx);
378
+ }
379
+ else {
380
+ setScrollOffset(Math.max(0, entry.startLine - 2));
381
+ }
382
+ }
383
+ // Highlight pending terms in current chapter (no new `lines` to trigger the effect).
384
+ const terms = pendingFindTermsRef.current;
385
+ if (terms && terms.length > 0) {
386
+ pendingFindTermsRef.current = null;
387
+ const lowers = terms.map(t => t.toLowerCase());
388
+ const matches = [];
389
+ for (let i = 0; i < lines.length; i++) {
390
+ const lower = stripAnsi(lines[i]).toLowerCase();
391
+ if (lowers.some(l => lower.includes(l)))
392
+ matches.push(i);
393
+ }
394
+ setFindQuery(terms.join(' '));
395
+ setFindMatches(matches);
396
+ setFindSubmitted(true);
397
+ setFindIdx(0);
398
+ }
399
+ setMode(studyActiveRef.current ? 'study' : 'normal');
338
400
  saveLastRead(newBook, newChapter);
339
401
  return;
340
402
  }
@@ -347,7 +409,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
347
409
  setScrollOffset(0);
348
410
  restoreMode();
349
411
  saveLastRead(newBook, newChapter);
350
- }, [restoreMode, verseMap]);
412
+ }, [restoreMode, verseMap, lines]);
351
413
  const goBack = useCallback(() => {
352
414
  const hist = historyRef.current;
353
415
  if (hist.length === 0)
@@ -415,35 +477,149 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
415
477
  setSelStart(null);
416
478
  }, [selStart, cursorIdx, verses, bookName, chapter]);
417
479
  // ── Search helpers ───────────────────────────────────────────────────────
418
- const submitSearch = useCallback(() => {
419
- if (!searchQuery)
480
+ const submitFind = useCallback(() => {
481
+ if (!findQuery)
482
+ return;
483
+ const queryTerms = findQuery.split(/\s+/).filter(Boolean);
484
+ if (queryTerms.length === 0)
420
485
  return;
421
- const q = searchQuery.toLowerCase();
486
+ const lowers = queryTerms.map(t => t.toLowerCase());
422
487
  const matches = [];
423
488
  for (let i = 0; i < lines.length; i++) {
424
- if (stripAnsi(lines[i]).toLowerCase().includes(q))
489
+ const lower = stripAnsi(lines[i]).toLowerCase();
490
+ if (lowers.some(l => lower.includes(l)))
425
491
  matches.push(i);
426
492
  }
427
- setSearchMatches(matches);
428
- setSearchSubmitted(true);
429
- setMatchIdx(0);
493
+ setFindMatches(matches);
494
+ setFindSubmitted(true);
495
+ setFindIdx(0);
430
496
  if (matches.length > 0)
431
497
  scrollTo(matches[0]);
432
- }, [searchQuery, lines, scrollTo]);
433
- const nextMatch = useCallback(() => {
434
- if (searchMatches.length === 0)
498
+ }, [findQuery, lines, scrollTo]);
499
+ const nextFindMatch = useCallback(() => {
500
+ if (findMatches.length === 0)
501
+ return;
502
+ const next = (findIdx + 1) % findMatches.length;
503
+ setFindIdx(next);
504
+ scrollTo(findMatches[next]);
505
+ }, [findIdx, findMatches, scrollTo]);
506
+ const prevFindMatch = useCallback(() => {
507
+ if (findMatches.length === 0)
435
508
  return;
436
- const next = (matchIdx + 1) % searchMatches.length;
437
- setMatchIdx(next);
438
- scrollTo(searchMatches[next]);
439
- }, [matchIdx, searchMatches, scrollTo]);
440
- const prevMatch = useCallback(() => {
441
- if (searchMatches.length === 0)
509
+ const prev = (findIdx - 1 + findMatches.length) % findMatches.length;
510
+ setFindIdx(prev);
511
+ scrollTo(findMatches[prev]);
512
+ }, [findIdx, findMatches, scrollTo]);
513
+ // ── Global search helpers (the `?` modal — full Bible search) ─────────────
514
+ // Page size for paginating results in the gsearch-list view (←/→ to page).
515
+ const GSEARCH_PAGE_SIZE = 100;
516
+ // Parse "in <book>" scope from tokens (mirrors CLI parseScope behavior)
517
+ const parseGsearchScope = useCallback((tokens) => {
518
+ for (let nameLen = 3; nameLen >= 1; nameLen--) {
519
+ const inIdx = tokens.length - nameLen - 1;
520
+ if (inIdx < 1)
521
+ continue;
522
+ if (tokens[inIdx]?.toLowerCase() !== 'in')
523
+ continue;
524
+ const resolved = resolveBook(tokens.slice(inIdx + 1, inIdx + 1 + nameLen).join(' '));
525
+ if (resolved)
526
+ return { queryTokens: tokens.slice(0, inIdx), book: resolved };
527
+ }
528
+ return { queryTokens: tokens, book: undefined };
529
+ }, []);
530
+ // Run the search. Single known concordance word → concordance, else FTS LIKE.
531
+ // Returns ALL results — pagination happens at render time (GSEARCH_PAGE_SIZE per page).
532
+ const executeGsearch = useCallback((rawInput) => {
533
+ const trimmed = rawInput.trim();
534
+ if (!trimmed)
535
+ return null;
536
+ const tokens = trimmed.split(/\s+/);
537
+ const { queryTokens, book: scopeBook } = parseGsearchScope(tokens);
538
+ if (queryTokens.length === 0)
539
+ return null;
540
+ const word = queryTokens.join(' ');
541
+ let results;
542
+ let terms;
543
+ if (queryTokens.length === 1 && isInConcordance(word)) {
544
+ results = getTopicVerses(word, scopeBook);
545
+ terms = [word];
546
+ }
547
+ else {
548
+ const ftsQuery = queryTokens.length === 1
549
+ ? `"${queryTokens[0].replace(/"/g, '')}"`
550
+ : queryTokens.map(t => `"${t.replace(/"/g, '')}"`).join(' AND ');
551
+ results = searchFTS(ftsQuery, scopeBook);
552
+ terms = queryTokens.map(t => t.replace(/"/g, '').trim()).filter(Boolean);
553
+ }
554
+ return { query: trimmed, results, terms, cursor: 0, pageIdx: 0 };
555
+ }, [parseGsearchScope]);
556
+ // Build the rendered result list lines + per-result line ranges (for cursor scroll).
557
+ // Renders only the current page (GSEARCH_PAGE_SIZE results); the cursor index in
558
+ // the session is page-local, so itemMap aligns directly.
559
+ const gsearchListData = useMemo(() => {
560
+ if (!gsearchSession)
561
+ return null;
562
+ const { results, terms, cursor, pageIdx } = gsearchSession;
563
+ const start = pageIdx * GSEARCH_PAGE_SIZE;
564
+ const pageResults = results.slice(start, start + GSEARCH_PAGE_SIZE);
565
+ const renderLines = [];
566
+ const itemMap = [];
567
+ const wrapWidth = Math.max(40, Math.min(cols - 10, 90));
568
+ pageResults.forEach((v, i) => {
569
+ const startLine = renderLines.length;
570
+ const bn = getBookInfo(v.book)?.full_name ?? v.book;
571
+ const ref = `${bn} ${v.chapter}:${v.verse}`;
572
+ const cleanText = stripMarkers(v.text);
573
+ const highlighted = highlightTerms(cleanText, terms);
574
+ const wrapped = wrapWords(highlighted, wrapWidth, true);
575
+ const mark = i === cursor ? chalk.magenta('›') : ' ';
576
+ renderLines.push(` ${mark} ${chalk.bold(ref)}`);
577
+ for (const wl of wrapped)
578
+ renderLines.push(` ${wl}`);
579
+ const endLine = renderLines.length - 1;
580
+ renderLines.push(''); // blank separator
581
+ itemMap.push({ startLine, endLine });
582
+ });
583
+ return { lines: renderLines, itemMap, pageResults };
584
+ }, [gsearchSession, cols]);
585
+ // Center the cursor result in the list viewport (same pattern as study mode).
586
+ const gsearchScroll = useMemo(() => {
587
+ if (mode !== 'gsearch-list' || !gsearchSession || !gsearchListData)
588
+ return 0;
589
+ const item = gsearchListData.itemMap[gsearchSession.cursor];
590
+ if (!item)
591
+ return 0;
592
+ const itemH = item.endLine - item.startLine + 1;
593
+ let target;
594
+ if (itemH >= viewportH) {
595
+ target = item.startLine;
596
+ }
597
+ else {
598
+ const itemMid = item.startLine + Math.floor(itemH / 2);
599
+ target = itemMid - Math.floor(viewportH / 2);
600
+ }
601
+ return Math.max(0, Math.min(target, Math.max(0, gsearchListData.lines.length - 1)));
602
+ }, [mode, gsearchSession, gsearchListData, viewportH]);
603
+ // After navigate from a search result, highlight ALL search terms in the new chapter
604
+ // (piggybacks on the chapter-find code path so n/N cycling works too). Doesn't
605
+ // scroll — the pendingVerseRef effect already positioned the cursor on the picked verse.
606
+ useEffect(() => {
607
+ const terms = pendingFindTermsRef.current;
608
+ if (!terms || terms.length === 0 || lines.length === 0)
442
609
  return;
443
- const prev = (matchIdx - 1 + searchMatches.length) % searchMatches.length;
444
- setMatchIdx(prev);
445
- scrollTo(searchMatches[prev]);
446
- }, [matchIdx, searchMatches, scrollTo]);
610
+ pendingFindTermsRef.current = null;
611
+ const lowers = terms.map(t => t.toLowerCase());
612
+ const matches = [];
613
+ for (let i = 0; i < lines.length; i++) {
614
+ const lower = stripAnsi(lines[i]).toLowerCase();
615
+ if (lowers.some(l => lower.includes(l)))
616
+ matches.push(i);
617
+ }
618
+ setFindQuery(terms.join(' '));
619
+ setFindMatches(matches);
620
+ setFindSubmitted(true);
621
+ setFindIdx(0);
622
+ }, [lines]);
447
623
  // ── Goto helpers ─────────────────────────────────────────────────────────
448
624
  const submitGoto = useCallback(() => {
449
625
  const trimmed = gotoInput.trim();
@@ -532,8 +708,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
532
708
  if (matches.length > 0) {
533
709
  if (lower === lastLetterRef.current) {
534
710
  // Same letter again: cycle to next match after current position
535
- const nextMatch = matches.find(m => m.i > homeIdx) ?? matches[0];
536
- setHomeIdx(nextMatch.i);
711
+ const nextLetterMatch = matches.find(m => m.i > homeIdx) ?? matches[0];
712
+ setHomeIdx(nextLetterMatch.i);
537
713
  }
538
714
  else {
539
715
  setHomeIdx(matches[0].i);
@@ -583,35 +759,145 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
583
759
  }
584
760
  return;
585
761
  }
586
- // ── Search mode ──────────────────────────────────────────────────────
587
- if (mode === 'search') {
762
+ // ── Find-in-chapter mode (`/` modal) ────────────────────────────────
763
+ if (mode === 'find') {
588
764
  const returnMode = studyActiveRef.current ? 'study' : 'normal';
589
765
  if (key.escape) {
590
766
  setMode(returnMode);
591
- setSearchQuery('');
592
- setSearchSubmitted(false);
593
- setSearchMatches([]);
767
+ setFindQuery('');
768
+ setFindSubmitted(false);
769
+ setFindMatches([]);
594
770
  return;
595
771
  }
596
772
  if (key.return) {
597
- submitSearch();
773
+ submitFind();
598
774
  setMode(returnMode);
599
775
  return;
600
776
  }
601
777
  if (key.backspace || key.delete) {
602
- setSearchQuery(prev => prev.slice(0, -1));
778
+ setFindQuery(prev => prev.slice(0, -1));
779
+ return;
780
+ }
781
+ if (findSubmitted && input === 'n') {
782
+ nextFindMatch();
603
783
  return;
604
784
  }
605
- if (searchSubmitted && input === 'n') {
606
- nextMatch();
785
+ if (findSubmitted && input === 'N') {
786
+ prevFindMatch();
607
787
  return;
608
788
  }
609
- if (searchSubmitted && input === 'N') {
610
- prevMatch();
789
+ if (input && !key.ctrl && !key.meta) {
790
+ setFindQuery(prev => prev + input);
791
+ }
792
+ return;
793
+ }
794
+ // ── Global-search input mode (`?` modal — type query) ───────────────
795
+ if (mode === 'gsearch-input') {
796
+ const returnMode = studyActiveRef.current ? 'study' : 'normal';
797
+ if (key.escape) {
798
+ setMode(returnMode);
799
+ setGsearchInput('');
800
+ return;
801
+ }
802
+ if (key.return) {
803
+ const trimmed = gsearchInput.trim();
804
+ if (!trimmed)
805
+ return;
806
+ // Same query as the live session → restore the cached list at last cursor.
807
+ if (gsearchSession && gsearchSession.query === trimmed) {
808
+ setMode('gsearch-list');
809
+ setGsearchInput('');
810
+ return;
811
+ }
812
+ const session = executeGsearch(gsearchInput);
813
+ if (session) {
814
+ setGsearchSession(session);
815
+ setMode('gsearch-list');
816
+ }
817
+ else {
818
+ // Empty results case still gets a session so the list shows "0 results".
819
+ setGsearchSession({ query: trimmed, results: [], terms: trimmed.split(/\s+/), cursor: 0, pageIdx: 0 });
820
+ setMode('gsearch-list');
821
+ }
822
+ setGsearchInput('');
823
+ return;
824
+ }
825
+ if (key.backspace || key.delete) {
826
+ setGsearchInput(prev => prev.slice(0, -1));
611
827
  return;
612
828
  }
613
829
  if (input && !key.ctrl && !key.meta) {
614
- setSearchQuery(prev => prev + input);
830
+ setGsearchInput(prev => prev + input);
831
+ }
832
+ return;
833
+ }
834
+ // ── Global-search list mode (`?` modal — browse results) ────────────
835
+ if (mode === 'gsearch-list') {
836
+ if (!gsearchSession) {
837
+ setMode('normal');
838
+ return;
839
+ }
840
+ const totalResults = gsearchSession.results.length;
841
+ const pageCount = Math.max(1, Math.ceil(totalResults / GSEARCH_PAGE_SIZE));
842
+ const pageStart = gsearchSession.pageIdx * GSEARCH_PAGE_SIZE;
843
+ const pageEnd = Math.min(pageStart + GSEARCH_PAGE_SIZE, totalResults);
844
+ const pageLen = pageEnd - pageStart;
845
+ const returnMode = studyActiveRef.current ? 'study' : 'normal';
846
+ if (key.escape || input === 'q') {
847
+ setMode(returnMode);
848
+ return;
849
+ }
850
+ if (input === '?') {
851
+ setMode('gsearch-input');
852
+ setGsearchInput(gsearchSession.query);
853
+ return;
854
+ }
855
+ if (key.return) {
856
+ const v = gsearchSession.results[pageStart + gsearchSession.cursor];
857
+ if (v) {
858
+ pendingVerseRef.current = v.verse;
859
+ pendingFindTermsRef.current = gsearchSession.terms.length > 0 ? gsearchSession.terms : null;
860
+ // navigate() calls restoreMode() which puts us back in normal/study.
861
+ navigate(v.book, v.chapter);
862
+ }
863
+ return;
864
+ }
865
+ if (totalResults === 0)
866
+ return;
867
+ // Page navigation: ← prev page, → next page (cursor resets to top of new page).
868
+ if (key.leftArrow || input === 'h') {
869
+ if (gsearchSession.pageIdx > 0) {
870
+ setGsearchSession(s => s ? { ...s, pageIdx: s.pageIdx - 1, cursor: 0 } : s);
871
+ }
872
+ return;
873
+ }
874
+ if (key.rightArrow || input === 'l') {
875
+ if (gsearchSession.pageIdx < pageCount - 1) {
876
+ setGsearchSession(s => s ? { ...s, pageIdx: s.pageIdx + 1, cursor: 0 } : s);
877
+ }
878
+ return;
879
+ }
880
+ if (key.upArrow || input === 'k') {
881
+ setGsearchSession(s => s ? { ...s, cursor: Math.max(0, s.cursor - 1) } : s);
882
+ return;
883
+ }
884
+ if (key.downArrow || input === 'j') {
885
+ setGsearchSession(s => s ? { ...s, cursor: Math.min(pageLen - 1, s.cursor + 1) } : s);
886
+ return;
887
+ }
888
+ if (input === 'g') {
889
+ setGsearchSession(s => s ? { ...s, cursor: 0 } : s);
890
+ return;
891
+ }
892
+ if (input === 'G') {
893
+ setGsearchSession(s => s ? { ...s, cursor: Math.max(0, pageLen - 1) } : s);
894
+ return;
895
+ }
896
+ if (key.tab) {
897
+ const step = Math.max(1, Math.floor(pageLen / 4));
898
+ const dir = key.shift ? -1 : 1;
899
+ setGsearchSession(s => s ? { ...s, cursor: Math.max(0, Math.min(pageLen - 1, s.cursor + dir * step)) } : s);
900
+ return;
615
901
  }
616
902
  return;
617
903
  }
@@ -777,12 +1063,18 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
777
1063
  setGotoInput('');
778
1064
  return;
779
1065
  }
780
- // Search within chapter
1066
+ // Find within chapter
781
1067
  if (input === '/') {
782
- setMode('search');
783
- setSearchQuery('');
784
- setSearchSubmitted(false);
785
- setSearchMatches([]);
1068
+ setMode('find');
1069
+ setFindQuery('');
1070
+ setFindSubmitted(false);
1071
+ setFindMatches([]);
1072
+ return;
1073
+ }
1074
+ // Global search (full Bible)
1075
+ if (input === '?') {
1076
+ setMode('gsearch-input');
1077
+ setGsearchInput(gsearchSession?.query ?? '');
786
1078
  return;
787
1079
  }
788
1080
  return;
@@ -864,8 +1156,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
864
1156
  return;
865
1157
  }
866
1158
  if (input === 'n' || key.rightArrow) {
867
- if (searchSubmitted && searchMatches.length > 0) {
868
- nextMatch();
1159
+ if (findSubmitted && findMatches.length > 0) {
1160
+ nextFindMatch();
869
1161
  }
870
1162
  else {
871
1163
  goNext();
@@ -873,7 +1165,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
873
1165
  return;
874
1166
  }
875
1167
  if (input === 'N') {
876
- prevMatch();
1168
+ prevFindMatch();
877
1169
  return;
878
1170
  }
879
1171
  if (input === 'p' || key.leftArrow) {
@@ -889,10 +1181,15 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
889
1181
  return;
890
1182
  }
891
1183
  if (input === '/') {
892
- setMode('search');
893
- setSearchQuery('');
894
- setSearchSubmitted(false);
895
- setSearchMatches([]);
1184
+ setMode('find');
1185
+ setFindQuery('');
1186
+ setFindSubmitted(false);
1187
+ setFindMatches([]);
1188
+ return;
1189
+ }
1190
+ if (input === '?') {
1191
+ setMode('gsearch-input');
1192
+ setGsearchInput(gsearchSession?.query ?? '');
896
1193
  return;
897
1194
  }
898
1195
  if (input === 'c') {
@@ -1003,18 +1300,34 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1003
1300
  const homeContent = homeLines.join('\n');
1004
1301
  let homeFooter;
1005
1302
  if (mode === 'home-chapter') {
1006
- homeFooter = ` ${chalk.dim('←/→ or type number · Enter go · Esc back')}`;
1303
+ homeFooter = ` ${[
1304
+ kb('←/→', 'ch'),
1305
+ kb('0-9', 'jump'),
1306
+ kb('Enter', 'go'),
1307
+ kb('Esc', 'back'),
1308
+ ].join(FOOTER_SEP)}`;
1007
1309
  }
1008
1310
  else {
1009
1311
  const lastRead = getLastRead();
1010
- const continueHint = lastRead ? 'Space continue · ' : '';
1011
- homeFooter = ` ${chalk.dim(`↑↓←→ navigate · Enter select · ${continueHint}type letter to jump · q quit`)}`;
1312
+ const parts = [
1313
+ kb('↑↓←→', 'navigate'),
1314
+ kb('Enter', 'select'),
1315
+ ];
1316
+ if (lastRead)
1317
+ parts.push(kb('Space', 'continue'));
1318
+ parts.push(kb('a-z', 'jump'));
1319
+ parts.push(kb('q', 'quit'));
1320
+ homeFooter = ` ${parts.join(FOOTER_SEP)}`;
1012
1321
  }
1013
1322
  const homeSep = chalk.dim('─'.repeat(cols));
1014
1323
  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 })] }));
1015
1324
  }
1016
1325
  // ── Chapter view: build visible content ──────────────────────────────────
1017
1326
  const visibleSlice = displayLines.slice(scrollOffset, scrollOffset + viewportH);
1327
+ // Pad to exactly viewportH rows so the panel stays anchored to the bottom even
1328
+ // when scrollOffset > maxScroll (study-mode overscroll for centering).
1329
+ while (visibleSlice.length < viewportH)
1330
+ visibleSlice.push('');
1018
1331
  // Apply cursor highlight for copy mode and study mode
1019
1332
  const contentLines = (mode === 'copy' || mode === 'study') ? visibleSlice.map((line, viewIdx) => {
1020
1333
  const absLine = scrollOffset + viewIdx;
@@ -1032,16 +1345,43 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1032
1345
  ? chalk.inverse(`› ${stripAnsi(line)}`)
1033
1346
  : chalk.inverse(` ${stripAnsi(line)}`);
1034
1347
  }
1035
- // Study mode: subtle — just a › marker on first line, no bg change
1348
+ // Study mode: subtle — just a › marker on first line, no bg change.
1349
+ // Place cursor 3 columns left of the verse number so the gap stays
1350
+ // constant regardless of margin (zoom level).
1036
1351
  if (absLine === entry.startLine) {
1037
- return `${chalk.cyan('›')}${line.substring(1)}`;
1352
+ const cursorCol = Math.max(0, getMargin() - 3);
1353
+ return `${line.slice(0, cursorCol)}${chalk.cyan('›')}${line.slice(cursorCol + 1)}`;
1038
1354
  }
1039
1355
  return line;
1040
1356
  }
1041
1357
  }
1042
1358
  return line;
1043
1359
  }) : visibleSlice;
1044
- const content = contentLines.join('\n');
1360
+ // Global-search list view replaces the chapter content slot.
1361
+ let content;
1362
+ if (mode === 'gsearch-list' && gsearchSession && gsearchListData) {
1363
+ const slice = gsearchListData.lines.slice(gsearchScroll, gsearchScroll + viewportH);
1364
+ while (slice.length < viewportH)
1365
+ slice.push('');
1366
+ if (gsearchSession.results.length === 0) {
1367
+ // Empty-state: center "no results" text in the viewport
1368
+ const msg = chalk.dim(` No results for "${gsearchSession.query}"`);
1369
+ const padTop = Math.max(0, Math.floor(viewportH / 2) - 1);
1370
+ const out = [];
1371
+ for (let i = 0; i < padTop; i++)
1372
+ out.push('');
1373
+ out.push(msg);
1374
+ while (out.length < viewportH)
1375
+ out.push('');
1376
+ content = out.join('\n');
1377
+ }
1378
+ else {
1379
+ content = slice.join('\n');
1380
+ }
1381
+ }
1382
+ else {
1383
+ content = contentLines.join('\n');
1384
+ }
1045
1385
  // ── Header ───────────────────────────────────────────────────────────────
1046
1386
  const CATEGORY_LABELS = {
1047
1387
  Moses: 'Books of Moses', Israel: 'History of Israel', Poetry: 'Poetry & Wisdom',
@@ -1061,45 +1401,115 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1061
1401
  ? ` ${title} ${chalk.dim('·')} ${chalk.dim(categoryLabel)}${zoomLabel}`
1062
1402
  : ` ${title}${zoomLabel}`;
1063
1403
  const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
1064
- const header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
1404
+ let header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
1405
+ // Override header for the global-search list view
1406
+ if (mode === 'gsearch-list' && gsearchSession) {
1407
+ const totalResults = gsearchSession.results.length;
1408
+ const pageCount = Math.max(1, Math.ceil(totalResults / GSEARCH_PAGE_SIZE));
1409
+ const pageStart = gsearchSession.pageIdx * GSEARCH_PAGE_SIZE;
1410
+ const pageEnd = Math.min(pageStart + GSEARCH_PAGE_SIZE, totalResults);
1411
+ const pageLen = pageEnd - pageStart;
1412
+ const countLabel = totalResults === 0
1413
+ ? 'no results'
1414
+ : pageCount > 1
1415
+ ? `page ${gsearchSession.pageIdx + 1}/${pageCount} · ${pageStart + 1}–${pageEnd} of ${totalResults}`
1416
+ : `${totalResults} result${totalResults === 1 ? '' : 's'}`;
1417
+ const gsTitleLeft = ` ${chalk.bold(`Search: "${gsearchSession.query}"`)} ${chalk.dim('·')} ${chalk.dim(countLabel)}`;
1418
+ const gsPosition = pageLen > 0 ? `${gsearchSession.cursor + 1}/${pageLen}` : '';
1419
+ const gsPad = Math.max(1, cols - stripAnsi(gsTitleLeft).length - gsPosition.length - 2);
1420
+ header = `${gsTitleLeft}${' '.repeat(gsPad)}${chalk.dim(gsPosition)}`;
1421
+ }
1065
1422
  // ── Footer (two lines for normal/study, one line for modal modes) ──────
1423
+ // Toggle indicator: cyan when active (state cue), magenta key + dim desc when off.
1424
+ const toggleHint = (on, key, desc) => on ? chalk.cyan(`${key} ${desc}`) : kb(key, desc);
1066
1425
  let footer1;
1067
1426
  let footer2 = null;
1068
1427
  if (flash) {
1069
1428
  footer1 = ` ${chalk.green(`✓ ${flash}`)}`;
1070
1429
  }
1071
- else if (mode === 'search') {
1072
- if (searchSubmitted) {
1073
- const countLabel = searchMatches.length === 0
1430
+ else if (mode === 'find') {
1431
+ if (findSubmitted) {
1432
+ const countLabel = findMatches.length === 0
1074
1433
  ? 'no matches'
1075
- : `match ${matchIdx + 1}/${searchMatches.length}`;
1076
- footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${chalk.dim('n/N cycle · Esc clear')}`;
1434
+ : `match ${findIdx + 1}/${findMatches.length}`;
1435
+ footer1 = ` / ${findQuery} ${chalk.dim(countLabel)} ${[kb('n/N', 'cycle'), kb('Esc', 'clear')].join(FOOTER_SEP)}`;
1077
1436
  }
1078
1437
  else {
1079
- footer1 = ` / ${searchQuery}${chalk.dim('_')} ${chalk.dim('Enter search · Esc cancel')}`;
1438
+ footer1 = ` / ${findQuery}${chalk.dim('_')} ${[kb('Enter', 'find'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1080
1439
  }
1081
1440
  }
1082
1441
  else if (mode === 'goto') {
1083
- footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8 · Enter go · Esc cancel')}`;
1442
+ footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8')}${FOOTER_SEP}${[kb('Enter', 'go'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1443
+ }
1444
+ else if (mode === 'gsearch-input') {
1445
+ footer1 = ` ? ${gsearchInput}${chalk.dim('_')} ${chalk.dim('try grace · "only begotten" · grace in romans')}${FOOTER_SEP}${[kb('Enter', 'search'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1446
+ }
1447
+ else if (mode === 'gsearch-list') {
1448
+ const showPageHint = gsearchSession ? gsearchSession.results.length > GSEARCH_PAGE_SIZE : false;
1449
+ const listKeys = [
1450
+ kb('↑↓', 'select'),
1451
+ kb('Tab/⇧Tab', 'quarter'),
1452
+ kb('g/G', 'first/last'),
1453
+ ];
1454
+ if (showPageHint)
1455
+ listKeys.push(kb('←→', 'page'));
1456
+ listKeys.push(kb('Enter', 'open'), kb('?', 'edit'), kb('Esc', 'back'));
1457
+ footer1 = ` ${listKeys.join(FOOTER_SEP)}`;
1084
1458
  }
1085
1459
  else if (mode === 'study') {
1086
1460
  const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
1087
1461
  const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
1088
- const sNoteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
1089
- const sOutlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
1090
- footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${chalk.dim('# ref · v range · c copy ·')} ${sNoteFlag} ${chalk.dim('·')} ${sOutlineFlag} ${chalk.dim('· / find')}`;
1091
- const panelHint = panelOverflow ? chalk.dim(' · ,/. note') : '';
1092
- footer2 = ` ${chalk.dim('↑↓ verse · Tab/⇧Tab quarter · g/G top/bot · ←→ ch · : goto · [/] back/fwd · d exit')}${panelHint}`;
1462
+ footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${[
1463
+ kb('#', 'ref'),
1464
+ kb('v', 'range'),
1465
+ kb('c', 'copy'),
1466
+ toggleHint(notes, 'f', 'notes'),
1467
+ toggleHint(outline, 'o', 'outline'),
1468
+ kb('/', 'find'),
1469
+ kb('?', 'search'),
1470
+ ].join(FOOTER_SEP)}`;
1471
+ const f2Parts = [
1472
+ kb('↑↓', 'verse'),
1473
+ kb('Tab/⇧Tab', 'quarter'),
1474
+ kb('g/G', 'top/bot'),
1475
+ kb('←→', 'ch'),
1476
+ kb(':', 'goto'),
1477
+ kb('[/]', 'back/fwd'),
1478
+ kb('d', 'exit'),
1479
+ ];
1480
+ if (panelOverflow)
1481
+ f2Parts.push(kb(',/.', 'note'));
1482
+ footer2 = ` ${f2Parts.join(FOOTER_SEP)}`;
1093
1483
  }
1094
1484
  else if (mode === 'copy') {
1095
1485
  const rangeHint = selStart !== null ? chalk.dim(' (range active)') : '';
1096
- footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${chalk.dim('↑↓ move · v range · Enter copy · Esc cancel')}`;
1486
+ footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${[
1487
+ kb('↑↓', 'move'),
1488
+ kb('v', 'range'),
1489
+ kb('Enter', 'copy'),
1490
+ kb('Esc', 'cancel'),
1491
+ ].join(FOOTER_SEP)}`;
1097
1492
  }
1098
1493
  else {
1099
- const noteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
1100
- const outlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
1101
- footer1 = ` ${noteFlag} ${chalk.dim('·')} ${outlineFlag} ${chalk.dim('· d study · c copy · / find')}`;
1102
- footer2 = ` ${chalk.dim('↑↓ scroll · Tab/⇧Tab quarter · g/G top/bot · ←→ ch · +/- width · : goto · H home · [/] back/fwd · q quit')}`;
1494
+ footer1 = ` ${[
1495
+ toggleHint(notes, 'f', 'notes'),
1496
+ toggleHint(outline, 'o', 'outline'),
1497
+ kb('d', 'study'),
1498
+ kb('c', 'copy'),
1499
+ kb('/', 'find'),
1500
+ kb('?', 'search'),
1501
+ ].join(FOOTER_SEP)}`;
1502
+ footer2 = ` ${[
1503
+ kb('↑↓', 'scroll'),
1504
+ kb('Tab/⇧Tab', 'quarter'),
1505
+ kb('g/G', 'top/bot'),
1506
+ kb('←→', 'ch'),
1507
+ kb('+/-', 'width'),
1508
+ kb(':', 'goto'),
1509
+ kb('H', 'home'),
1510
+ kb('[/]', 'back/fwd'),
1511
+ kb('q', 'quit'),
1512
+ ].join(FOOTER_SEP)}`;
1103
1513
  }
1104
1514
  const separator = chalk.dim('· '.repeat(Math.floor(cols / 2)));
1105
1515
  // ── Render ───────────────────────────────────────────────────────────────
@@ -1110,7 +1520,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1110
1520
  const lo = panelScrollClamped + 1;
1111
1521
  const hi = panelScrollClamped + panelVisibleLines.length;
1112
1522
  const total = panelLinesAll.length;
1113
- parts.push(chalk.dim(` ── ${lo}–${hi} of ${total} · ,/. scroll ──`));
1523
+ parts.push(` ${chalk.dim(`── ${lo}–${hi} of ${total} ·`)} ${kb(',/.', 'scroll')} ${chalk.dim('──')}`);
1114
1524
  }
1115
1525
  studyPanelContent = parts.join('\n');
1116
1526
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rv-bible-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Fast, offline terminal Bible reader for the Recovery Version. Footnotes, cross-references, concordance search, interactive pager.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",