rv-bible-cli 0.1.7 → 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 |
package/dist/ui/Pager.js CHANGED
@@ -3,7 +3,7 @@ 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';
6
+ import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from '../db.js';
7
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';
@@ -139,11 +139,17 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
139
139
  // Copy mode state
140
140
  const [cursorIdx, setCursorIdx] = useState(0);
141
141
  const [selStart, setSelStart] = useState(null);
142
- // Search mode state
143
- const [searchQuery, setSearchQuery] = useState('');
144
- const [searchSubmitted, setSearchSubmitted] = useState(false);
145
- const [searchMatches, setSearchMatches] = useState([]);
146
- 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);
147
153
  // Goto mode state
148
154
  const [gotoInput, setGotoInput] = useState('');
149
155
  // Navigation history: back/forward stacks (refs so useInput always sees latest)
@@ -328,38 +334,69 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
328
334
  setScrollOffset(target);
329
335
  }, [studyCursorIdx, viewportH, mode, verseMap, overscrollLimit]);
330
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.
331
340
  const displayLines = useMemo(() => {
332
- if (!searchSubmitted || searchMatches.length === 0)
341
+ if (!findSubmitted || findMatches.length === 0)
333
342
  return lines;
334
- 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());
335
347
  return lines.map((line) => {
336
348
  const stripped = stripAnsi(line);
337
- if (!stripped.toLowerCase().includes(q))
349
+ const lower = stripped.toLowerCase();
350
+ if (!lowers.some(l => lower.includes(l)))
338
351
  return line;
339
- return highlightTerms(stripped, [searchQuery]);
352
+ return highlightTerms(stripped, queryTerms);
340
353
  });
341
- }, [lines, searchSubmitted, searchMatches, searchQuery]);
354
+ }, [lines, findSubmitted, findMatches, findQuery]);
342
355
  // ── Navigation helpers ───────────────────────────────────────────────────
343
356
  const restoreMode = useCallback(() => {
344
357
  setMode(studyActiveRef.current ? 'study' : 'normal');
345
- setSearchSubmitted(false);
346
- setSearchQuery('');
347
- setSearchMatches([]);
358
+ setFindSubmitted(false);
359
+ setFindQuery('');
360
+ setFindMatches([]);
348
361
  setStudyRefInput('');
349
362
  setStudyCursorIdx(0);
350
363
  }, []);
351
364
  const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
352
365
  const sameChapter = newBook === bookRef.current && newChapter === chapterRef.current;
353
- // Same-chapter cross-ref follow: jump cursor directly. Don't reset state via
354
- // 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.
355
368
  if (sameChapter && pendingVerseRef.current) {
356
369
  const target = pendingVerseRef.current;
357
370
  pendingVerseRef.current = null;
358
371
  let idx = verseMap.findIndex(e => e.verse === target);
359
372
  if (idx < 0)
360
373
  idx = verseMap.findIndex(e => e.verse.startsWith(target));
361
- if (idx >= 0)
362
- 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');
363
400
  saveLastRead(newBook, newChapter);
364
401
  return;
365
402
  }
@@ -372,7 +409,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
372
409
  setScrollOffset(0);
373
410
  restoreMode();
374
411
  saveLastRead(newBook, newChapter);
375
- }, [restoreMode, verseMap]);
412
+ }, [restoreMode, verseMap, lines]);
376
413
  const goBack = useCallback(() => {
377
414
  const hist = historyRef.current;
378
415
  if (hist.length === 0)
@@ -440,35 +477,149 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
440
477
  setSelStart(null);
441
478
  }, [selStart, cursorIdx, verses, bookName, chapter]);
442
479
  // ── Search helpers ───────────────────────────────────────────────────────
443
- const submitSearch = useCallback(() => {
444
- if (!searchQuery)
480
+ const submitFind = useCallback(() => {
481
+ if (!findQuery)
482
+ return;
483
+ const queryTerms = findQuery.split(/\s+/).filter(Boolean);
484
+ if (queryTerms.length === 0)
445
485
  return;
446
- const q = searchQuery.toLowerCase();
486
+ const lowers = queryTerms.map(t => t.toLowerCase());
447
487
  const matches = [];
448
488
  for (let i = 0; i < lines.length; i++) {
449
- if (stripAnsi(lines[i]).toLowerCase().includes(q))
489
+ const lower = stripAnsi(lines[i]).toLowerCase();
490
+ if (lowers.some(l => lower.includes(l)))
450
491
  matches.push(i);
451
492
  }
452
- setSearchMatches(matches);
453
- setSearchSubmitted(true);
454
- setMatchIdx(0);
493
+ setFindMatches(matches);
494
+ setFindSubmitted(true);
495
+ setFindIdx(0);
455
496
  if (matches.length > 0)
456
497
  scrollTo(matches[0]);
457
- }, [searchQuery, lines, scrollTo]);
458
- const nextMatch = useCallback(() => {
459
- if (searchMatches.length === 0)
498
+ }, [findQuery, lines, scrollTo]);
499
+ const nextFindMatch = useCallback(() => {
500
+ if (findMatches.length === 0)
460
501
  return;
461
- const next = (matchIdx + 1) % searchMatches.length;
462
- setMatchIdx(next);
463
- scrollTo(searchMatches[next]);
464
- }, [matchIdx, searchMatches, scrollTo]);
465
- const prevMatch = useCallback(() => {
466
- if (searchMatches.length === 0)
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)
467
508
  return;
468
- const prev = (matchIdx - 1 + searchMatches.length) % searchMatches.length;
469
- setMatchIdx(prev);
470
- scrollTo(searchMatches[prev]);
471
- }, [matchIdx, searchMatches, scrollTo]);
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)
609
+ return;
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]);
472
623
  // ── Goto helpers ─────────────────────────────────────────────────────────
473
624
  const submitGoto = useCallback(() => {
474
625
  const trimmed = gotoInput.trim();
@@ -557,8 +708,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
557
708
  if (matches.length > 0) {
558
709
  if (lower === lastLetterRef.current) {
559
710
  // Same letter again: cycle to next match after current position
560
- const nextMatch = matches.find(m => m.i > homeIdx) ?? matches[0];
561
- setHomeIdx(nextMatch.i);
711
+ const nextLetterMatch = matches.find(m => m.i > homeIdx) ?? matches[0];
712
+ setHomeIdx(nextLetterMatch.i);
562
713
  }
563
714
  else {
564
715
  setHomeIdx(matches[0].i);
@@ -608,35 +759,145 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
608
759
  }
609
760
  return;
610
761
  }
611
- // ── Search mode ──────────────────────────────────────────────────────
612
- if (mode === 'search') {
762
+ // ── Find-in-chapter mode (`/` modal) ────────────────────────────────
763
+ if (mode === 'find') {
613
764
  const returnMode = studyActiveRef.current ? 'study' : 'normal';
614
765
  if (key.escape) {
615
766
  setMode(returnMode);
616
- setSearchQuery('');
617
- setSearchSubmitted(false);
618
- setSearchMatches([]);
767
+ setFindQuery('');
768
+ setFindSubmitted(false);
769
+ setFindMatches([]);
619
770
  return;
620
771
  }
621
772
  if (key.return) {
622
- submitSearch();
773
+ submitFind();
623
774
  setMode(returnMode);
624
775
  return;
625
776
  }
626
777
  if (key.backspace || key.delete) {
627
- setSearchQuery(prev => prev.slice(0, -1));
778
+ setFindQuery(prev => prev.slice(0, -1));
628
779
  return;
629
780
  }
630
- if (searchSubmitted && input === 'n') {
631
- nextMatch();
781
+ if (findSubmitted && input === 'n') {
782
+ nextFindMatch();
632
783
  return;
633
784
  }
634
- if (searchSubmitted && input === 'N') {
635
- prevMatch();
785
+ if (findSubmitted && input === 'N') {
786
+ prevFindMatch();
636
787
  return;
637
788
  }
638
789
  if (input && !key.ctrl && !key.meta) {
639
- setSearchQuery(prev => prev + input);
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));
827
+ return;
828
+ }
829
+ if (input && !key.ctrl && !key.meta) {
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;
640
901
  }
641
902
  return;
642
903
  }
@@ -802,12 +1063,18 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
802
1063
  setGotoInput('');
803
1064
  return;
804
1065
  }
805
- // Search within chapter
1066
+ // Find within chapter
806
1067
  if (input === '/') {
807
- setMode('search');
808
- setSearchQuery('');
809
- setSearchSubmitted(false);
810
- 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 ?? '');
811
1078
  return;
812
1079
  }
813
1080
  return;
@@ -889,8 +1156,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
889
1156
  return;
890
1157
  }
891
1158
  if (input === 'n' || key.rightArrow) {
892
- if (searchSubmitted && searchMatches.length > 0) {
893
- nextMatch();
1159
+ if (findSubmitted && findMatches.length > 0) {
1160
+ nextFindMatch();
894
1161
  }
895
1162
  else {
896
1163
  goNext();
@@ -898,7 +1165,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
898
1165
  return;
899
1166
  }
900
1167
  if (input === 'N') {
901
- prevMatch();
1168
+ prevFindMatch();
902
1169
  return;
903
1170
  }
904
1171
  if (input === 'p' || key.leftArrow) {
@@ -914,10 +1181,15 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
914
1181
  return;
915
1182
  }
916
1183
  if (input === '/') {
917
- setMode('search');
918
- setSearchQuery('');
919
- setSearchSubmitted(false);
920
- 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 ?? '');
921
1193
  return;
922
1194
  }
923
1195
  if (input === 'c') {
@@ -1085,7 +1357,31 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1085
1357
  }
1086
1358
  return line;
1087
1359
  }) : visibleSlice;
1088
- 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
+ }
1089
1385
  // ── Header ───────────────────────────────────────────────────────────────
1090
1386
  const CATEGORY_LABELS = {
1091
1387
  Moses: 'Books of Moses', Israel: 'History of Israel', Poetry: 'Poetry & Wisdom',
@@ -1105,7 +1401,24 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1105
1401
  ? ` ${title} ${chalk.dim('·')} ${chalk.dim(categoryLabel)}${zoomLabel}`
1106
1402
  : ` ${title}${zoomLabel}`;
1107
1403
  const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
1108
- 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
+ }
1109
1422
  // ── Footer (two lines for normal/study, one line for modal modes) ──────
1110
1423
  // Toggle indicator: cyan when active (state cue), magenta key + dim desc when off.
1111
1424
  const toggleHint = (on, key, desc) => on ? chalk.cyan(`${key} ${desc}`) : kb(key, desc);
@@ -1114,20 +1427,35 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1114
1427
  if (flash) {
1115
1428
  footer1 = ` ${chalk.green(`✓ ${flash}`)}`;
1116
1429
  }
1117
- else if (mode === 'search') {
1118
- if (searchSubmitted) {
1119
- const countLabel = searchMatches.length === 0
1430
+ else if (mode === 'find') {
1431
+ if (findSubmitted) {
1432
+ const countLabel = findMatches.length === 0
1120
1433
  ? 'no matches'
1121
- : `match ${matchIdx + 1}/${searchMatches.length}`;
1122
- footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${[kb('n/N', 'cycle'), kb('Esc', 'clear')].join(FOOTER_SEP)}`;
1434
+ : `match ${findIdx + 1}/${findMatches.length}`;
1435
+ footer1 = ` / ${findQuery} ${chalk.dim(countLabel)} ${[kb('n/N', 'cycle'), kb('Esc', 'clear')].join(FOOTER_SEP)}`;
1123
1436
  }
1124
1437
  else {
1125
- footer1 = ` / ${searchQuery}${chalk.dim('_')} ${[kb('Enter', 'search'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1438
+ footer1 = ` / ${findQuery}${chalk.dim('_')} ${[kb('Enter', 'find'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1126
1439
  }
1127
1440
  }
1128
1441
  else if (mode === 'goto') {
1129
1442
  footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8')}${FOOTER_SEP}${[kb('Enter', 'go'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1130
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)}`;
1458
+ }
1131
1459
  else if (mode === 'study') {
1132
1460
  const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
1133
1461
  const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
@@ -1138,6 +1466,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1138
1466
  toggleHint(notes, 'f', 'notes'),
1139
1467
  toggleHint(outline, 'o', 'outline'),
1140
1468
  kb('/', 'find'),
1469
+ kb('?', 'search'),
1141
1470
  ].join(FOOTER_SEP)}`;
1142
1471
  const f2Parts = [
1143
1472
  kb('↑↓', 'verse'),
@@ -1168,6 +1497,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
1168
1497
  kb('d', 'study'),
1169
1498
  kb('c', 'copy'),
1170
1499
  kb('/', 'find'),
1500
+ kb('?', 'search'),
1171
1501
  ].join(FOOTER_SEP)}`;
1172
1502
  footer2 = ` ${[
1173
1503
  kb('↑↓', 'scroll'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rv-bible-cli",
3
- "version": "0.1.7",
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",