rv-bible-cli 0.1.5 → 0.1.7

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
@@ -114,6 +114,7 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
114
114
  |-----|--------|
115
115
  | `j` / `k` / arrows | scroll up/down |
116
116
  | `Space` / `b` | page down/up |
117
+ | `Tab` / `Shift+Tab` | scroll down / up by a quarter of the chapter |
117
118
  | `g` / `G` | jump to top / bottom |
118
119
  | left / right | prev / next chapter (even across books) |
119
120
  | `f` | toggle footnotes on/off |
@@ -133,19 +134,23 @@ When you read a chapter in a terminal, rvb opens a full-screen interactive reade
133
134
 
134
135
  Deep-dive into footnotes and cross-references. Press `d` while reading any chapter.
135
136
 
136
- - A cursor highlights the current verse
137
- - The bottom panel shows all footnotes for that verse with numbered cross-references
137
+ - A cursor highlights the current verse — the chapter view auto-scrolls to keep it vertically centered as you move
138
+ - 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
138
139
  - **Type a number** to follow a cross-reference — you'll jump to that chapter and land on the exact verse
139
140
  - Press `[` to go back to where you were
140
141
  - Study mode stays active as you navigate — it's designed for chaining through references
142
+ - If a verse has very long notes, the panel caps at half your terminal height — use `,` / `.` to scroll within it
141
143
 
142
144
  | Key | Action |
143
145
  |-----|--------|
144
- | up / down | move between verses |
146
+ | up / down | move cursor up / down one verse |
145
147
  | left / right | prev / next chapter |
146
148
  | type number | follow a cross-reference |
147
149
  | `v` then up/down then `c` | select a range and copy |
148
150
  | `c` | copy current verse |
151
+ | `Tab` / `Shift+Tab` | jump cursor by a quarter of the chapter |
152
+ | `g` / `G` | cursor to first / last verse |
153
+ | `,` / `.` | scroll the footnote panel up / down (when it overflows) |
149
154
  | `f` / `o` | toggle footnotes / outline |
150
155
  | `/` | find text |
151
156
  | `:` | jump to a book |
package/dist/format.d.ts CHANGED
@@ -8,6 +8,8 @@ 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;
12
+ export declare function wrapWords(text: string, maxWidth: number, ansi?: boolean): string[];
11
13
  export declare function renderFootnoteBlock(footnotes: Footnote[]): string;
12
14
  export declare function renderNoteDisplayAll(verse: Verse, footnotes: Footnote[], bookName: string): string;
13
15
  export declare function renderNoteDisplay(verse: Verse, fn: 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).
@@ -118,7 +121,7 @@ function visibleLength(s) {
118
121
  // ── Word-wrap helper ─────────────────────────────────────────────────────────
119
122
  // Wraps plain `text` to `maxWidth` columns. Returns array of line strings.
120
123
  // Use visibleLength=true when words may contain ANSI escape codes.
121
- function wrapWords(text, maxWidth, ansi = false) {
124
+ export function wrapWords(text, maxWidth, ansi = false) {
122
125
  if (maxWidth < 20)
123
126
  return [text];
124
127
  const words = text.split(' ');
package/dist/index.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { program } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import clipboard from 'clipboardy';
5
+ import { readFileSync } from 'node:fs';
6
+ import path from 'node:path';
5
7
  import { parseRefList, resolveBook } from './parser.js';
6
8
  import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getFootnote, getVerse, getBookInfo, getAllBooks, isInConcordance, getTopicVerses, searchFTS, } from './db.js';
7
9
  import { renderVerses, renderVerseInline, renderFootnoteBlock, renderNoteDisplay, renderNoteDisplayAll, stripMarkers, highlightTerms, } from './format.js';
@@ -10,10 +12,12 @@ import { getLastRead, saveLastRead } from './state.js';
10
12
  function stripAnsi(s) {
11
13
  return s.replace(/\x1b\[[0-9;]*m/g, '');
12
14
  }
15
+ // Read version from package.json (single source of truth — avoids drift)
16
+ const pkg = JSON.parse(readFileSync(path.join(import.meta.dirname, '../package.json'), 'utf-8'));
13
17
  program
14
18
  .name('rv')
15
19
  .description('Recovery Version Bible CLI')
16
- .version('0.1.0')
20
+ .version(pkg.version)
17
21
  .enablePositionalOptions();
18
22
  // Formats a ParsedRef + book name into a display label: "John 3", "John 3:16", "John 3:16–18"
19
23
  function formatRefLabel(ref, bookName) {
@@ -393,9 +397,7 @@ program
393
397
  const bookName = getBookInfo(v.book)?.full_name ?? v.book;
394
398
  const ref = `${bookName} ${v.chapter}:${v.verse}`;
395
399
  const cleanText = stripMarkers(v.text);
396
- // Truncate to ~150 chars for scanability
397
- const truncated = cleanText.length > 150 ? cleanText.substring(0, 147) + '...' : cleanText;
398
- const highlighted = highlightTerms(truncated, terms);
400
+ const highlighted = highlightTerms(cleanText, terms);
399
401
  // Wrap the text at ~76 chars with 4-space indent
400
402
  const maxW = Math.min((process.stdout.columns ?? 80) - 4, 76);
401
403
  const words = highlighted.split(' ');
package/dist/ui/Pager.js CHANGED
@@ -4,7 +4,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
4
4
  import chalk from 'chalk';
5
5
  import clipboard from 'clipboardy';
6
6
  import { getVersesByRef, getSectionHeaders, getFootnotesForChapter, getFootnotesForVerses, getCrossRefsForVerse, getBookInfo, getAllBooks, } from '../db.js';
7
- import { renderVerses, stripMarkers, highlightTerms, toSuperscript, zoomIn, zoomOut, zoomReset, getZoom } from '../format.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.
@@ -144,6 +158,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
144
158
  const [studyCursorIdx, setStudyCursorIdx] = useState(0);
145
159
  const [studySelStart, setStudySelStart] = useState(null);
146
160
  const [studyRefInput, setStudyRefInput] = useState(''); // multi-digit ref number
161
+ const [panelScrollOffset, setPanelScrollOffset] = useState(0); // scroll within study panel when notes overflow cap
147
162
  const studyActiveRef = useRef(false); // persists across navigate so study mode survives ref follows
148
163
  const pendingVerseRef = useRef(null); // target verse to jump to after navigate
149
164
  // Flash message
@@ -215,10 +230,13 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
215
230
  const panelLines = [];
216
231
  const verseLabel = `${bookName} ${chapter}:${v.verse}`;
217
232
  panelLines.push(` ${chalk.bold(verseLabel)} ${chalk.dim(`— ${fns.length} note${fns.length === 1 ? '' : 's'}`)}`);
233
+ const BODY_INDENT = ' '; // 5 spaces — visually under the word, after marker
234
+ const bodyWidth = Math.max(20, cols - BODY_INDENT.length);
218
235
  for (const fn of fns) {
219
236
  // Extract the word the marker attaches to (first word after "Book ch:v word" in footnote text)
220
- const fnTextParts = fn.text.split(/\s+/);
237
+ const fnTextParts = fn.text.trim().split(/\s+/);
221
238
  const word = fnTextParts[2] ?? ''; // "Joh 3:16 loved ..." → "loved"
239
+ const noteBody = fnTextParts.slice(3).join(' ').trim(); // everything after "Book ch:v word"
222
240
  const markerDisp = toSuperscript(fn.marker);
223
241
  const refs = refsByMarker.get(fn.marker) ?? [];
224
242
  // Deduplicate refs by target
@@ -230,42 +248,85 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
230
248
  seen.add(key);
231
249
  return true;
232
250
  });
233
- if (uniqueRefs.length === 0) {
234
- // Study note with no cross-refs — show truncated text
235
- const notePreview = fn.text.length > 60 ? fn.text.substring(0, 57) + '...' : fn.text;
236
- panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)} ${chalk.dim(notePreview)}`);
251
+ // Build numbered ref labels (mutates studyRefs)
252
+ const refLabels = [];
253
+ for (const r of uniqueRefs) {
254
+ const refNum = studyRefs.length + 1;
255
+ const tgtBookName = getBookInfo(r.tgt_book)?.abbr ?? r.tgt_book;
256
+ const label = `${tgtBookName} ${r.tgt_chapter}:${r.tgt_verse}`;
257
+ studyRefs.push({
258
+ num: refNum, marker: fn.marker,
259
+ tgt_book: r.tgt_book, tgt_chapter: r.tgt_chapter, tgt_verse: r.tgt_verse, label,
260
+ });
261
+ refLabels.push(`${chalk.yellow(`[${refNum}]`)} ${chalk.dim(label)}`);
237
262
  }
238
- else {
239
- // Build numbered ref labels
240
- const refLabels = [];
241
- for (const r of uniqueRefs) {
242
- const refNum = studyRefs.length + 1;
243
- const tgtBookName = getBookInfo(r.tgt_book)?.abbr ?? r.tgt_book;
244
- const label = `${tgtBookName} ${r.tgt_chapter}:${r.tgt_verse}`;
245
- studyRefs.push({
246
- num: refNum, marker: fn.marker,
247
- tgt_book: r.tgt_book, tgt_chapter: r.tgt_chapter, tgt_verse: r.tgt_verse, label,
248
- });
249
- refLabels.push(`${chalk.yellow(`[${refNum}]`)} ${chalk.dim(label)}`);
263
+ // Header line: marker + word + refs (refs may be empty)
264
+ const refsPart = refLabels.length > 0 ? ` ${refLabels.join(' ')}` : '';
265
+ panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)}${refsPart}`);
266
+ // Body: wrap full note text under hanging indent, dim style. No truncation.
267
+ if (noteBody) {
268
+ const wrapped = wrapWords(noteBody, bodyWidth);
269
+ for (const wl of wrapped) {
270
+ panelLines.push(`${BODY_INDENT}${chalk.dim(wl)}`);
250
271
  }
251
- // Compact: marker + word + refs on one line (wrap if needed)
252
- const hasStudyNote = fn.text.length > 80;
253
- const noteHint = hasStudyNote ? chalk.dim(' (note)') : '';
254
- panelLines.push(` ${chalk.cyan(markerDisp)} ${chalk.dim(word)}${noteHint} ${refLabels.join(' ')}`);
255
272
  }
256
273
  }
257
274
  return { lines: panelLines, refs: studyRefs };
258
- }, [mode, studyCursorIdx, verseMap, verses, book, chapter, bookName]);
275
+ }, [mode, studyCursorIdx, verseMap, verses, book, chapter, bookName, cols]);
276
+ // ── Study panel sizing & scroll (cap at 50% of screen, scrollable on overflow) ──
277
+ const panelLinesAll = studyPanel.lines;
278
+ const panelMaxH = Math.max(3, Math.floor(rows * 0.5));
279
+ const panelOverflow = panelLinesAll.length > panelMaxH;
280
+ const panelContentSlots = panelOverflow ? panelMaxH - 1 : panelLinesAll.length; // reserve 1 line for indicator
281
+ const maxPanelScroll = Math.max(0, panelLinesAll.length - panelContentSlots);
282
+ const panelScrollClamped = Math.max(0, Math.min(panelScrollOffset, maxPanelScroll));
283
+ const panelVisibleLines = panelLinesAll.slice(panelScrollClamped, panelScrollClamped + panelContentSlots);
284
+ const panelDisplayH = panelLinesAll.length > 0 ? panelVisibleLines.length + (panelOverflow ? 1 : 0) : 0;
259
285
  // ── Viewport height (shrinks when study panel is visible) ────────────
260
- const studyPanelH = studyPanel.lines.length > 0 ? studyPanel.lines.length + 1 : 0; // +1 for separator
286
+ const studyPanelH = panelDisplayH > 0 ? panelDisplayH + 1 : 0; // +1 for separator above panel
261
287
  const hasFooter2 = mode === 'normal' || mode === 'study';
262
288
  const chromeH = 3 + (hasFooter2 ? 2 : 1) + studyPanelH; // header + sep + footer(s) + sep + panel
263
289
  const viewportH = Math.max(1, rows - chromeH);
264
- // Clamp scroll when content or viewport changes
290
+ // Reset panel scroll when the verse cursor changes or when leaving study mode
291
+ useEffect(() => {
292
+ setPanelScrollOffset(0);
293
+ }, [studyCursorIdx, book, chapter, mode]);
294
+ // Clamp scroll when content or viewport changes. Normal mode uses the strict
295
+ // maxScroll clamp (can't scroll past content). Study mode allows overscroll so
296
+ // the last verse can still center — the visible slice is padded with empty rows
297
+ // when scrollOffset > maxScroll, keeping the panel anchored to the bottom.
265
298
  const maxScroll = Math.max(0, lines.length - viewportH);
299
+ const overscrollLimit = Math.max(0, lines.length - 1);
266
300
  useEffect(() => {
267
- setScrollOffset(prev => Math.min(prev, maxScroll));
268
- }, [maxScroll]);
301
+ if (mode !== 'study') {
302
+ setScrollOffset(prev => Math.min(prev, maxScroll));
303
+ }
304
+ else {
305
+ setScrollOffset(prev => Math.min(prev, overscrollLimit));
306
+ }
307
+ }, [maxScroll, overscrollLimit, mode]);
308
+ // Center the cursor verse in study mode. If the verse fits in the viewport,
309
+ // place its midpoint at viewportH/2. If the verse is taller than the viewport
310
+ // (rare — long verse with markers wrapping), pin its start to the top so the
311
+ // beginning is always visible. Triggers on cursor move and viewport resize.
312
+ useEffect(() => {
313
+ if (mode !== 'study' || verseMap.length === 0)
314
+ return;
315
+ const entry = verseMap[studyCursorIdx];
316
+ if (!entry)
317
+ return;
318
+ const verseH = entry.endLine - entry.startLine + 1;
319
+ let target;
320
+ if (verseH >= viewportH) {
321
+ target = entry.startLine;
322
+ }
323
+ else {
324
+ const verseMid = entry.startLine + Math.floor(verseH / 2);
325
+ target = verseMid - Math.floor(viewportH / 2);
326
+ }
327
+ target = Math.max(0, Math.min(target, overscrollLimit));
328
+ setScrollOffset(target);
329
+ }, [studyCursorIdx, viewportH, mode, verseMap, overscrollLimit]);
269
330
  // ── Search highlights ────────────────────────────────────────────────────
270
331
  const displayLines = useMemo(() => {
271
332
  if (!searchSubmitted || searchMatches.length === 0)
@@ -288,6 +349,20 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
288
349
  setStudyCursorIdx(0);
289
350
  }, []);
290
351
  const navigate = useCallback((newBook, newChapter, pushHistory = true) => {
352
+ 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).
355
+ if (sameChapter && pendingVerseRef.current) {
356
+ const target = pendingVerseRef.current;
357
+ pendingVerseRef.current = null;
358
+ let idx = verseMap.findIndex(e => e.verse === target);
359
+ if (idx < 0)
360
+ idx = verseMap.findIndex(e => e.verse.startsWith(target));
361
+ if (idx >= 0)
362
+ setStudyCursorIdx(idx);
363
+ saveLastRead(newBook, newChapter);
364
+ return;
365
+ }
291
366
  if (pushHistory) {
292
367
  historyRef.current = [...historyRef.current, { book: bookRef.current, chapter: chapterRef.current, scroll: scrollRef.current }];
293
368
  forwardRef.current = [];
@@ -297,7 +372,7 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
297
372
  setScrollOffset(0);
298
373
  restoreMode();
299
374
  saveLastRead(newBook, newChapter);
300
- }, [restoreMode]);
375
+ }, [restoreMode, verseMap]);
301
376
  const goBack = useCallback(() => {
302
377
  const hist = historyRef.current;
303
378
  if (hist.length === 0)
@@ -342,6 +417,11 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
342
417
  const scrollUp = useCallback(() => scrollTo(scrollOffset - 1), [scrollOffset, scrollTo]);
343
418
  const pageDown = useCallback(() => scrollTo(scrollOffset + viewportH - 2), [scrollOffset, viewportH, scrollTo]);
344
419
  const pageUp = useCallback(() => scrollTo(scrollOffset - viewportH + 2), [scrollOffset, viewportH, scrollTo]);
420
+ // Quarter-of-chapter slider — 4 presses spans top to bottom regardless of length
421
+ const scrollQuarter = useCallback((dir) => {
422
+ const q = Math.max(1, Math.floor(lines.length * 0.25));
423
+ scrollTo(scrollOffset + dir * q);
424
+ }, [lines.length, scrollOffset, scrollTo]);
345
425
  // ── Copy helpers ─────────────────────────────────────────────────────────
346
426
  const doCopy = useCallback(async () => {
347
427
  const lo = selStart !== null ? Math.min(selStart, cursorIdx) : cursorIdx;
@@ -598,26 +678,23 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
598
678
  return;
599
679
  }
600
680
  if (input === 'j' || key.downArrow) {
601
- setStudyCursorIdx(prev => {
602
- const next = Math.min(prev + 1, verseMap.length - 1);
603
- const entry = verseMap[next];
604
- if (entry && entry.startLine >= scrollOffset + viewportH) {
605
- scrollTo(entry.startLine - viewportH + 3);
606
- }
607
- return next;
608
- });
681
+ setStudyCursorIdx(prev => Math.min(prev + 1, verseMap.length - 1));
609
682
  setStudyRefInput('');
610
683
  return;
611
684
  }
612
685
  if (input === 'k' || key.upArrow) {
613
- setStudyCursorIdx(prev => {
614
- const next = Math.max(prev - 1, 0);
615
- const entry = verseMap[next];
616
- if (entry && entry.startLine < scrollOffset) {
617
- scrollTo(entry.startLine);
618
- }
619
- return next;
620
- });
686
+ setStudyCursorIdx(prev => Math.max(prev - 1, 0));
687
+ setStudyRefInput('');
688
+ return;
689
+ }
690
+ // Cursor to first / last verse (cursor-visible effect handles scroll)
691
+ if (input === 'g') {
692
+ setStudyCursorIdx(0);
693
+ setStudyRefInput('');
694
+ return;
695
+ }
696
+ if (input === 'G') {
697
+ setStudyCursorIdx(Math.max(0, verseMap.length - 1));
621
698
  setStudyRefInput('');
622
699
  return;
623
700
  }
@@ -632,6 +709,23 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
632
709
  setZoomState(getZoom());
633
710
  return;
634
711
  }
712
+ // Quarter-of-chapter cursor jump (cursor-visible effect handles scroll)
713
+ if (key.tab) {
714
+ const step = Math.max(1, Math.floor(verseMap.length / 4));
715
+ const dir = key.shift ? -1 : 1;
716
+ setStudyCursorIdx(prev => Math.max(0, Math.min(verseMap.length - 1, prev + dir * step)));
717
+ setStudyRefInput('');
718
+ return;
719
+ }
720
+ // Panel scroll (only meaningful when notes panel overflows)
721
+ if (input === ',') {
722
+ setPanelScrollOffset(prev => Math.max(0, prev - 1));
723
+ return;
724
+ }
725
+ if (input === '.') {
726
+ setPanelScrollOffset(prev => Math.min(maxPanelScroll, prev + 1));
727
+ return;
728
+ }
635
729
  // Number keys: build ref number, then follow on Enter or after short delay
636
730
  if (input && /^[0-9]$/.test(input)) {
637
731
  const newInput = studyRefInput + input;
@@ -786,6 +880,14 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
786
880
  scrollTo(maxScroll);
787
881
  return;
788
882
  }
883
+ if (key.tab && key.shift) {
884
+ scrollQuarter(-1);
885
+ return;
886
+ }
887
+ if (key.tab) {
888
+ scrollQuarter(1);
889
+ return;
890
+ }
789
891
  if (input === 'n' || key.rightArrow) {
790
892
  if (searchSubmitted && searchMatches.length > 0) {
791
893
  nextMatch();
@@ -926,18 +1028,34 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
926
1028
  const homeContent = homeLines.join('\n');
927
1029
  let homeFooter;
928
1030
  if (mode === 'home-chapter') {
929
- homeFooter = ` ${chalk.dim('←/→ or type number · Enter go · Esc back')}`;
1031
+ homeFooter = ` ${[
1032
+ kb('←/→', 'ch'),
1033
+ kb('0-9', 'jump'),
1034
+ kb('Enter', 'go'),
1035
+ kb('Esc', 'back'),
1036
+ ].join(FOOTER_SEP)}`;
930
1037
  }
931
1038
  else {
932
1039
  const lastRead = getLastRead();
933
- const continueHint = lastRead ? 'Space continue · ' : '';
934
- homeFooter = ` ${chalk.dim(`↑↓←→ navigate · Enter select · ${continueHint}type letter to jump · q quit`)}`;
1040
+ const parts = [
1041
+ kb('↑↓←→', 'navigate'),
1042
+ kb('Enter', 'select'),
1043
+ ];
1044
+ if (lastRead)
1045
+ parts.push(kb('Space', 'continue'));
1046
+ parts.push(kb('a-z', 'jump'));
1047
+ parts.push(kb('q', 'quit'));
1048
+ homeFooter = ` ${parts.join(FOOTER_SEP)}`;
935
1049
  }
936
1050
  const homeSep = chalk.dim('─'.repeat(cols));
937
1051
  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 })] }));
938
1052
  }
939
1053
  // ── Chapter view: build visible content ──────────────────────────────────
940
1054
  const visibleSlice = displayLines.slice(scrollOffset, scrollOffset + viewportH);
1055
+ // Pad to exactly viewportH rows so the panel stays anchored to the bottom even
1056
+ // when scrollOffset > maxScroll (study-mode overscroll for centering).
1057
+ while (visibleSlice.length < viewportH)
1058
+ visibleSlice.push('');
941
1059
  // Apply cursor highlight for copy mode and study mode
942
1060
  const contentLines = (mode === 'copy' || mode === 'study') ? visibleSlice.map((line, viewIdx) => {
943
1061
  const absLine = scrollOffset + viewIdx;
@@ -955,9 +1073,12 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
955
1073
  ? chalk.inverse(`› ${stripAnsi(line)}`)
956
1074
  : chalk.inverse(` ${stripAnsi(line)}`);
957
1075
  }
958
- // Study mode: subtle — just a › marker on first line, no bg change
1076
+ // Study mode: subtle — just a › marker on first line, no bg change.
1077
+ // Place cursor 3 columns left of the verse number so the gap stays
1078
+ // constant regardless of margin (zoom level).
959
1079
  if (absLine === entry.startLine) {
960
- return `${chalk.cyan('›')}${line.substring(1)}`;
1080
+ const cursorCol = Math.max(0, getMargin() - 3);
1081
+ return `${line.slice(0, cursorCol)}${chalk.cyan('›')}${line.slice(cursorCol + 1)}`;
961
1082
  }
962
1083
  return line;
963
1084
  }
@@ -986,6 +1107,8 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
986
1107
  const headerPad = Math.max(1, cols - stripAnsi(titleLeft).length - position.length - 2);
987
1108
  const header = `${titleLeft}${' '.repeat(headerPad)}${chalk.dim(position)}`;
988
1109
  // ── Footer (two lines for normal/study, one line for modal modes) ──────
1110
+ // Toggle indicator: cyan when active (state cue), magenta key + dim desc when off.
1111
+ const toggleHint = (on, key, desc) => on ? chalk.cyan(`${key} ${desc}`) : kb(key, desc);
989
1112
  let footer1;
990
1113
  let footer2 = null;
991
1114
  if (flash) {
@@ -996,38 +1119,81 @@ function Pager({ initialBook, initialChapter, initialNotes, initialOutline, star
996
1119
  const countLabel = searchMatches.length === 0
997
1120
  ? 'no matches'
998
1121
  : `match ${matchIdx + 1}/${searchMatches.length}`;
999
- footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${chalk.dim('n/N cycle · Esc clear')}`;
1122
+ footer1 = ` / ${searchQuery} ${chalk.dim(countLabel)} ${[kb('n/N', 'cycle'), kb('Esc', 'clear')].join(FOOTER_SEP)}`;
1000
1123
  }
1001
1124
  else {
1002
- footer1 = ` / ${searchQuery}${chalk.dim('_')} ${chalk.dim('Enter search · Esc cancel')}`;
1125
+ footer1 = ` / ${searchQuery}${chalk.dim('_')} ${[kb('Enter', 'search'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1003
1126
  }
1004
1127
  }
1005
1128
  else if (mode === 'goto') {
1006
- footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8 · Enter go · Esc cancel')}`;
1129
+ footer1 = ` : ${gotoInput}${chalk.dim('_')} ${chalk.dim('e.g. rom 8')}${FOOTER_SEP}${[kb('Enter', 'go'), kb('Esc', 'cancel')].join(FOOTER_SEP)}`;
1007
1130
  }
1008
1131
  else if (mode === 'study') {
1009
1132
  const inputHint = studyRefInput ? ` ${chalk.yellow(studyRefInput)}${chalk.dim('_')}` : '';
1010
1133
  const rangeHint = studySelStart !== null ? chalk.dim(' (range)') : '';
1011
- const sNoteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
1012
- const sOutlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
1013
- footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${chalk.dim('# ref · v range · c copy ·')} ${sNoteFlag} ${chalk.dim('·')} ${sOutlineFlag} ${chalk.dim('· / find')}`;
1014
- footer2 = ` ${chalk.dim('↑↓ verse · ←→ ch · : goto · [/] back/fwd · d exit')}`;
1134
+ footer1 = ` ${chalk.cyan('STUDY')}${inputHint}${rangeHint} ${[
1135
+ kb('#', 'ref'),
1136
+ kb('v', 'range'),
1137
+ kb('c', 'copy'),
1138
+ toggleHint(notes, 'f', 'notes'),
1139
+ toggleHint(outline, 'o', 'outline'),
1140
+ kb('/', 'find'),
1141
+ ].join(FOOTER_SEP)}`;
1142
+ const f2Parts = [
1143
+ kb('↑↓', 'verse'),
1144
+ kb('Tab/⇧Tab', 'quarter'),
1145
+ kb('g/G', 'top/bot'),
1146
+ kb('←→', 'ch'),
1147
+ kb(':', 'goto'),
1148
+ kb('[/]', 'back/fwd'),
1149
+ kb('d', 'exit'),
1150
+ ];
1151
+ if (panelOverflow)
1152
+ f2Parts.push(kb(',/.', 'note'));
1153
+ footer2 = ` ${f2Parts.join(FOOTER_SEP)}`;
1015
1154
  }
1016
1155
  else if (mode === 'copy') {
1017
1156
  const rangeHint = selStart !== null ? chalk.dim(' (range active)') : '';
1018
- footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${chalk.dim('↑↓ move · v range · Enter copy · Esc cancel')}`;
1157
+ footer1 = ` ${chalk.cyan('COPY')}${rangeHint} ${[
1158
+ kb('↑↓', 'move'),
1159
+ kb('v', 'range'),
1160
+ kb('Enter', 'copy'),
1161
+ kb('Esc', 'cancel'),
1162
+ ].join(FOOTER_SEP)}`;
1019
1163
  }
1020
1164
  else {
1021
- const noteFlag = notes ? chalk.cyan('f notes') : chalk.dim('f notes');
1022
- const outlineFlag = outline ? chalk.cyan('o outline') : chalk.dim('o outline');
1023
- footer1 = ` ${noteFlag} ${chalk.dim('·')} ${outlineFlag} ${chalk.dim('· d study · c copy · / find')}`;
1024
- footer2 = ` ${chalk.dim('↑↓ scroll · ←→ ch · +/- width · : goto · H home · [/] back/fwd · q quit')}`;
1165
+ footer1 = ` ${[
1166
+ toggleHint(notes, 'f', 'notes'),
1167
+ toggleHint(outline, 'o', 'outline'),
1168
+ kb('d', 'study'),
1169
+ kb('c', 'copy'),
1170
+ kb('/', 'find'),
1171
+ ].join(FOOTER_SEP)}`;
1172
+ footer2 = ` ${[
1173
+ kb('↑↓', 'scroll'),
1174
+ kb('Tab/⇧Tab', 'quarter'),
1175
+ kb('g/G', 'top/bot'),
1176
+ kb('←→', 'ch'),
1177
+ kb('+/-', 'width'),
1178
+ kb(':', 'goto'),
1179
+ kb('H', 'home'),
1180
+ kb('[/]', 'back/fwd'),
1181
+ kb('q', 'quit'),
1182
+ ].join(FOOTER_SEP)}`;
1025
1183
  }
1026
1184
  const separator = chalk.dim('· '.repeat(Math.floor(cols / 2)));
1027
1185
  // ── Render ───────────────────────────────────────────────────────────────
1028
- const studyPanelContent = studyPanel.lines.length > 0
1029
- ? studyPanel.lines.join('\n')
1030
- : null;
1186
+ let studyPanelContent = null;
1187
+ if (panelLinesAll.length > 0) {
1188
+ const parts = [panelVisibleLines.join('\n')];
1189
+ if (panelOverflow) {
1190
+ const lo = panelScrollClamped + 1;
1191
+ const hi = panelScrollClamped + panelVisibleLines.length;
1192
+ const total = panelLinesAll.length;
1193
+ parts.push(` ${chalk.dim(`── ${lo}–${hi} of ${total} ·`)} ${kb(',/.', 'scroll')} ${chalk.dim('──')}`);
1194
+ }
1195
+ studyPanelContent = parts.join('\n');
1196
+ }
1031
1197
  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 })] }));
1032
1198
  }
1033
1199
  // ── Launcher ─────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rv-bible-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",