@carlesandres/house 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Browser.tsx CHANGED
@@ -14,7 +14,10 @@ import { SyntaxStyle } from "@opentui/core"
14
14
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
15
15
  import { useAtomValue, useAtomSet } from "@effect/atom-react"
16
16
  import { Effect } from "effect"
17
- import { useEffect, useMemo, useRef, useState } from "react"
17
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
18
+ import { buildCommands } from "./commands/buildCommands.ts"
19
+ import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
20
+ import { CommandPalette } from "./CommandPalette.tsx"
18
21
  import { filterFiles } from "./discovery/filter.ts"
19
22
  import { type FileEntry } from "./discovery/walk.ts"
20
23
  import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
@@ -22,18 +25,32 @@ import { HelpOverlay } from "./HelpOverlay.tsx"
22
25
  import { readFileText } from "./io/readFile.ts"
23
26
  import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
24
27
  import { dispatch } from "./keymap/keymap.ts"
28
+ import {
29
+ canFitInline,
30
+ defaultPreferredWidth,
31
+ initialShownForAuto,
32
+ resolveSidebarWidth,
33
+ } from "./layout/resolve.ts"
25
34
  import { openInBrowser } from "./serve/openBrowser.ts"
26
35
  import { startServer, type ServerHandle } from "./serve/server.ts"
27
36
  import { colors, setActiveTheme } from "./theme/colors.ts"
28
37
  import { themeAtom } from "./theme/atom.ts"
29
38
  import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
30
39
 
40
+ export type SidebarMode = "auto" | "on" | "off"
41
+
31
42
  export interface BrowserProps {
32
43
  readonly files: readonly FileEntry[]
33
44
  readonly title?: string
34
45
  readonly initialIndex?: number
35
46
  /** Cap the rendered markdown's width at N columns. Null = fill the pane. */
36
47
  readonly maxWidth?: number | null
48
+ /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
49
+ * when discovery has finished; the indicator clears. */
50
+ readonly discoveryStatus?: string | null
51
+ /** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
52
+ * launch viewport bucket once; subsequent visibility goes through `s`. */
53
+ readonly sidebarMode?: SidebarMode
37
54
  readonly onQuit?: () => void
38
55
  /** Test seam: replaces the file reader. */
39
56
  readonly readFile?: (path: string) => Promise<string>
@@ -44,12 +61,15 @@ const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(rea
44
61
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
45
62
 
46
63
  /** Bindings the help overlay lets through. Single source of truth for both
47
- * the keyboard early-return and the footer hint filter. */
64
+ * the keyboard early-return and the footer hint filter. `palette.open`
65
+ * passes through so users can jump from help into the palette in one
66
+ * keystroke — `openPalette` closes help on its way in. */
48
67
  const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
49
68
  "help.toggle",
50
69
  "theme.next",
51
70
  "theme.prev",
52
71
  "theme.toneToggle",
72
+ "palette.open",
53
73
  ])
54
74
 
55
75
  export const Browser = ({
@@ -57,6 +77,8 @@ export const Browser = ({
57
77
  title = "house",
58
78
  initialIndex = 0,
59
79
  maxWidth = null,
80
+ discoveryStatus = null,
81
+ sidebarMode = "auto",
60
82
  onQuit,
61
83
  readFile = defaultReadFile,
62
84
  }: BrowserProps) => {
@@ -69,20 +91,49 @@ export const Browser = ({
69
91
  const [selectedIndex, setSelectedIndex] = useState(() =>
70
92
  clamp(initialIndex, 0, Math.max(0, files.length - 1)),
71
93
  )
72
- const [content, setContent] = useState<string>("")
94
+ const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
73
95
  const [error, setError] = useState<string | null>(null)
74
- const [focus, setFocus] = useState<"sidebar" | "reader">("sidebar")
75
- const [sidebarVisible, setSidebarVisible] = useState<boolean>(true)
96
+ // `shown` is the user's sticky preference. Visibility is derived:
97
+ // `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
98
+ //
99
+ // Launch consults the viewport bucket once for `--sidebar=auto`. The
100
+ // useState initializer pins this to the first render — buckets are
101
+ // launch-only by design, so resize must NOT re-evaluate.
102
+ const [shown, setShown] = useState<boolean>(() => {
103
+ switch (sidebarMode) {
104
+ case "on":
105
+ return true
106
+ case "off":
107
+ return false
108
+ case "auto":
109
+ return initialShownForAuto(width)
110
+ }
111
+ })
112
+ const [focus, setFocus] = useState<"sidebar" | "reader">(() => (shown ? "sidebar" : "reader"))
76
113
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
77
114
  const [helpVisible, setHelpVisible] = useState<boolean>(false)
78
115
  const [filterOpen, setFilterOpen] = useState<boolean>(false)
79
116
  const [filterQuery, setFilterQuery] = useState<string>("")
117
+ const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
118
+ const [paletteQuery, setPaletteQuery] = useState<string>("")
119
+ const [paletteIndex, setPaletteIndex] = useState<number>(0)
120
+ // Synchronous mirrors for the keyboard handler — same reason filterOpenRef
121
+ // exists. Modal input can arrive in one React batch (e.g. ctrl+p, Down,
122
+ // Return), so every palette field read by later keys must update its ref
123
+ // before React state commits.
124
+ const paletteOpenRef = useRef(false)
125
+ const paletteQueryRef = useRef("")
126
+ const paletteIndexRef = useRef(0)
80
127
  // Mirror filter state into refs so the keyboard handler sees synchronous
81
128
  // updates even when multiple keys arrive in a single React batch (the
82
129
  // first key opens the filter; subsequent keys in the same tick would
83
130
  // otherwise still observe filterOpen=false through closure).
84
131
  const filterOpenRef = useRef(false)
85
132
  const filterQueryRef = useRef("")
133
+ // Snapshot the query at filter-open so Esc reverts edits but commit (Return)
134
+ // keeps them. Layout snapshots are no longer needed — focus drives drawer
135
+ // dismissal under the §7.1 visibility rule.
136
+ const priorFilterQueryRef = useRef("")
86
137
  const [footerNotice, setFooterNotice] = useState<string | null>(null)
87
138
  const serverRef = useRef<ServerHandle | null>(null)
88
139
 
@@ -121,7 +172,6 @@ export const Browser = ({
121
172
  }
122
173
 
123
174
  const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
124
-
125
175
  // When the filtered list shrinks, keep selectedIndex valid. The reset to 0
126
176
  // on every query change happens in the keystroke handler, not here, so a
127
177
  // no-op rerender doesn't snap the cursor back to the top.
@@ -145,24 +195,24 @@ export const Browser = ({
145
195
  if (target === renderedPath) return
146
196
  const timer = setTimeout(() => setRenderedPath(target), 80)
147
197
  return () => clearTimeout(timer)
148
- }, [selected, renderedPath])
198
+ }, [selected?.path, renderedPath])
149
199
 
150
200
  useEffect(() => {
151
201
  if (!renderedPath) {
152
- setContent("")
202
+ setLoaded(null)
153
203
  return
154
204
  }
155
205
  let cancelled = false
156
206
  readFile(renderedPath).then(
157
207
  (text) => {
158
208
  if (!cancelled) {
159
- setContent(text)
209
+ setLoaded({ path: renderedPath, content: text })
160
210
  setError(null)
161
211
  }
162
212
  },
163
213
  (err: unknown) => {
164
214
  if (!cancelled) {
165
- setContent("")
215
+ setLoaded(null)
166
216
  setError(`Cannot read ${renderedPath}: ${String(err)}`)
167
217
  }
168
218
  },
@@ -182,17 +232,51 @@ export const Browser = ({
182
232
  const ctx: BrowserCtx = {
183
233
  files: displayedFiles,
184
234
  focus,
185
- sidebarVisible,
235
+ sidebarShown: shown,
186
236
  helpVisible,
187
237
  filterOpen,
238
+ paletteOpen,
188
239
  setFocus,
189
240
  setSelectedIndex,
190
- setSidebarVisible,
241
+ toggleShown: () => {
242
+ // Per DESIGN.md §7.1 s-behavior table:
243
+ // shown=true, focus=sidebar → shown=false, focus=reader
244
+ // (otherwise the drawer would
245
+ // immediately re-appear)
246
+ // shown=true, focus=reader → shown=false, focus=reader
247
+ // shown=false, focus=reader → shown=true, focus=sidebar
248
+ // shown=false, focus=sidebar → shown=true, focus=sidebar
249
+ if (shown) {
250
+ setShown(false)
251
+ if (focus === "sidebar") setFocus("reader")
252
+ } else {
253
+ setShown(true)
254
+ if (focus === "reader") setFocus("sidebar")
255
+ }
256
+ },
191
257
  setHelpVisible,
192
258
  openFilter: () => {
259
+ // Focus the sidebar so the filter input has a home. Under §7.1's
260
+ // `visible = shown || focus==="sidebar"` rule, focus alone makes
261
+ // the sidebar visible (as a drawer when `shown=false`), so we no
262
+ // longer need to mutate `shown` here.
263
+ priorFilterQueryRef.current = filterQueryRef.current
264
+ if (focus !== "sidebar") setFocus("sidebar")
193
265
  filterOpenRef.current = true
194
266
  setFilterOpen(true)
195
267
  },
268
+ openPalette: () => {
269
+ // Close help if it was open — palette is the active modal now.
270
+ // Reset query/index so each open starts fresh (no stale state from
271
+ // the previous session).
272
+ if (helpVisible) setHelpVisible(() => false)
273
+ paletteQueryRef.current = ""
274
+ setPaletteQuery("")
275
+ paletteIndexRef.current = 0
276
+ setPaletteIndex(0)
277
+ paletteOpenRef.current = true
278
+ setPaletteOpen(true)
279
+ },
196
280
  cycleTheme,
197
281
  toggleTone,
198
282
  serveCurrent: () => {
@@ -250,17 +334,46 @@ export const Browser = ({
250
334
  //
251
335
  // Centralized so the dual filterOpenRef / filterOpen invariant
252
336
  // only has to be maintained in one place (plus `openFilter`).
253
- const closeFilter = (focusReader: boolean) => {
337
+ const closeFilter = (commit: boolean) => {
254
338
  const picked = displayedFiles[selectedIndex] ?? null
339
+ // Return on a zero-match list has nothing to commit. Treat it
340
+ // as Esc so the user isn't stranded in an "applied filter with
341
+ // no visible files" state they'd have to back out of manually.
342
+ const effectiveCommit = commit && picked !== null
255
343
  filterOpenRef.current = false
256
- filterQueryRef.current = ""
257
344
  setFilterOpen(false)
258
- setFilterQuery("")
259
- if (picked) {
260
- const fullIdx = files.findIndex((f) => f.path === picked.path)
261
- if (fullIdx >= 0) setSelectedIndex(() => fullIdx)
345
+ if (effectiveCommit) {
346
+ // Return keeps the query. selectedIndex is already a valid
347
+ // position in the (still-filtered) displayedFiles list, so
348
+ // no translation is needed.
349
+ } else {
350
+ // Esc reverts the query to its pre-session value. After the
351
+ // revert, displayedFiles may change shape — translate the
352
+ // cursor by path so it stays on whatever the user was
353
+ // looking at, instead of snapping to a numerically-equivalent
354
+ // row in the restored list.
355
+ const before = priorFilterQueryRef.current
356
+ filterQueryRef.current = before
357
+ setFilterQuery(before)
358
+ if (picked) {
359
+ const restored = before === "" ? files : filterFiles(files, before)
360
+ const idx = restored.findIndex((f) => f.path === picked.path)
361
+ if (idx >= 0) setSelectedIndex(() => idx)
362
+ }
363
+ }
364
+ // Where focus lands depends on layout intent (DESIGN.md §7.1):
365
+ // commit (Return on a real pick) → reader, always. The user
366
+ // asked to open the match; show them what they picked.
367
+ // cancel (Esc, or Return with no pick) → if the sidebar is
368
+ // inline (shown && fits), keep focus there so j/k keeps
369
+ // walking the list; if the sidebar was only up as a drawer
370
+ // (shown=false), dismiss focus to the reader so the drawer
371
+ // disappears under the §7.1 visibility rule.
372
+ if (effectiveCommit) {
373
+ setFocus("reader")
374
+ } else {
375
+ setFocus(shown && canFitInline(width) ? "sidebar" : "reader")
262
376
  }
263
- if (focusReader && picked) setFocus("reader")
264
377
  }
265
378
  if (key.name === "escape") {
266
379
  closeFilter(false)
@@ -270,7 +383,15 @@ export const Browser = ({
270
383
  closeFilter(true)
271
384
  return
272
385
  }
273
- if (key.name === "backspace") {
386
+ if (key.name === "backspace" || key.name === "delete") {
387
+ // Pressing backspace/delete with no query left removes the
388
+ // leading `/` — i.e. closes the modal. Equivalent to Esc:
389
+ // reverts to the pre-session query (so an applied filter
390
+ // survives a "I changed my mind" tap).
391
+ if (filterQueryRef.current.length === 0) {
392
+ closeFilter(false)
393
+ return
394
+ }
274
395
  filterQueryRef.current = filterQueryRef.current.slice(0, -1)
275
396
  setFilterQuery(filterQueryRef.current)
276
397
  setSelectedIndex(() => 0)
@@ -297,6 +418,74 @@ export const Browser = ({
297
418
  }
298
419
  return
299
420
  }
421
+ // Command palette modal: capture keystrokes for the query input and
422
+ // list navigation. Esc closes (single press, regardless of query —
423
+ // #70 Q7a). Return runs the selected command. Up/Down navigate.
424
+ // Backspace edits the query and is a no-op on empty (#70 Q7b —
425
+ // intentionally diverges from the filter modal, which closes on
426
+ // empty-backspace, because accidental close feels worse in the
427
+ // palette). Printable characters extend the query and snap selection
428
+ // to 0 (#70 Q7c). Ctrl/Meta-modified keys are swallowed except
429
+ // ctrl+p, which toggles the palette closed (matches help-toggle's
430
+ // re-press-to-close behavior).
431
+ if (paletteOpenRef.current) {
432
+ const closePalette = () => {
433
+ paletteOpenRef.current = false
434
+ paletteQueryRef.current = ""
435
+ paletteIndexRef.current = 0
436
+ setPaletteOpen(false)
437
+ setPaletteQuery("")
438
+ setPaletteIndex(0)
439
+ }
440
+ const setPaletteIndexSync = (next: number) => {
441
+ paletteIndexRef.current = next
442
+ setPaletteIndex(next)
443
+ }
444
+ const allCommands = buildCommands(ctx)
445
+ const filtered = filterCommands(allCommands, paletteQueryRef.current)
446
+ if (key.name === "escape") {
447
+ closePalette()
448
+ return
449
+ }
450
+ if (key.name === "return") {
451
+ const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
452
+ closePalette()
453
+ picked?.run()
454
+ return
455
+ }
456
+ if (key.name === "up") {
457
+ setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
458
+ return
459
+ }
460
+ if (key.name === "down") {
461
+ setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
462
+ return
463
+ }
464
+ if (key.name === "backspace" || key.name === "delete") {
465
+ if (paletteQueryRef.current.length === 0) return
466
+ paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
467
+ setPaletteQuery(paletteQueryRef.current)
468
+ setPaletteIndexSync(0)
469
+ return
470
+ }
471
+ // ctrl+p again closes — matches help-toggle behavior.
472
+ if (key.ctrl && !key.meta && key.name === "p") {
473
+ closePalette()
474
+ return
475
+ }
476
+ if (key.ctrl || key.meta) return
477
+ let char: string | null = null
478
+ if (key.name === "space") char = " "
479
+ else if (typeof key.name === "string" && key.name.length === 1) {
480
+ char = key.shift ? key.name.toUpperCase() : key.name
481
+ }
482
+ if (char !== null) {
483
+ paletteQueryRef.current = paletteQueryRef.current + char
484
+ setPaletteQuery(paletteQueryRef.current)
485
+ setPaletteIndexSync(0)
486
+ }
487
+ return
488
+ }
300
489
  // While help is open, swallow most keys: only ? (toggle), esc
301
490
  // (close), and the theme bindings pass through. Theme keys stay live
302
491
  // so users can preview palette changes against the overlay itself —
@@ -321,22 +510,49 @@ export const Browser = ({
321
510
  dispatch(browserBindings, ctx, key)
322
511
  })
323
512
 
324
- // Min/max-clamped percentage of viewport: narrow terminals stay readable,
325
- // wide terminals get more room without wasting space at extremes.
326
- // User-configurable width is deferred — see DESIGN.md §12.
327
- const sidebarWidth = Math.max(28, Math.min(60, Math.floor(width * 0.25)))
513
+ // Sidebar width is a pure function of viewport (DESIGN.md §7.1). Until
514
+ // persistent config (#13) lands, `preferred` is derived from viewport,
515
+ // matching the pre-#22 inline math.
516
+ const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
328
517
  const sidebarActive = focus === "sidebar"
329
518
  const readerActive = focus === "reader"
519
+ // Visibility = shown OR sidebar-focused. When visible-because-focused
520
+ // only, render as a drawer (absolute) on top of the reader. We also
521
+ // fall back to drawer rendering when the viewport is too narrow for
522
+ // the inline two-pane layout even with `shown=true` (Q2 in DESIGN.md
523
+ // §7.1) — preserves the user's preference without squeezing the reader
524
+ // below READER_MIN_WIDTH.
525
+ const sidebarVisible = shown || sidebarActive
526
+ const sidebarAsDrawer = sidebarVisible && (!shown || !canFitInline(width))
527
+ const sidebarInline = sidebarVisible && !sidebarAsDrawer
528
+ // Drawer is offset 1 row from the top so the reader's title stays visible.
529
+ // That row comes off the drawer's own height, so the body has one fewer
530
+ // usable row than the inline sidebar. Tracked here so the virtualization
531
+ // slice matches the wrapper that actually paints it.
532
+ const drawerTopOffset = 1
330
533
  const sidebarTitle = sidebarActive ? " ▸ files " : " files "
331
534
  const readerLabel = selected?.relativePath ?? title
332
535
  const readerTitle = readerActive ? ` ▸ ${readerLabel} ` : ` ${readerLabel} `
536
+ const content = loaded?.path === renderedPath ? loaded.content : ""
333
537
 
334
538
  // Sidebar virtualization: render only the visible window. Without this,
335
539
  // every keystroke re-renders all N file rows even though only the bg of
336
540
  // two of them changed (old + new selected). On a 195-file vault that
337
541
  // dominates the per-keystroke cost.
338
- // Sidebar box adds top/bottom borders (2); footer eats FOOTER_HEIGHT.
339
- const sidebarBodyHeight = Math.max(1, height - 2 - FOOTER_HEIGHT)
542
+ // Sidebar box adds top/bottom borders (2); footer eats FOOTER_HEIGHT;
543
+ // the filter row eats one more cell when files are present *or* while
544
+ // discovery is in flight (allocates the row up front so it doesn't pop
545
+ // in when the first file arrives).
546
+ const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
547
+ const filterRowVisible = files.length > 0 || discoveryActive
548
+ const sidebarBodyHeight = Math.max(
549
+ 1,
550
+ height -
551
+ 2 -
552
+ FOOTER_HEIGHT -
553
+ (filterRowVisible ? 1 : 0) -
554
+ (sidebarAsDrawer ? drawerTopOffset : 0),
555
+ )
340
556
  const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
341
557
  const desiredScroll = (() => {
342
558
  let s = sidebarScroll
@@ -352,16 +568,92 @@ export const Browser = ({
352
568
  const sidebarTextWidth = Math.max(4, sidebarWidth - 2)
353
569
  // Right-anchored truncation: keep the filename visible, lose the prefix
354
570
  // with a leading ellipsis when the path is too long.
355
- const truncatePath = (s: string): string =>
356
- s.length <= sidebarTextWidth ? s : "…" + s.slice(s.length - sidebarTextWidth + 1)
571
+ const truncatePath = useCallback(
572
+ (s: string): string =>
573
+ s.length <= sidebarTextWidth ? s : "…" + s.slice(s.length - sidebarTextWidth + 1),
574
+ [sidebarTextWidth],
575
+ )
576
+
577
+ // Filter row content + color. Three reachable states:
578
+ // editing — filterOpen=true → /<query>▏ in textStrong
579
+ // applied — !filterOpen && query !== "" → /<query> in text
580
+ // idle — !filterOpen && query === "" → "/ filter…" in textMuted
581
+ const filterRowFg = filterOpen
582
+ ? colors.textStrong
583
+ : filterQuery.length > 0
584
+ ? colors.text
585
+ : colors.textMuted
586
+ const filterRowRaw = filterOpen
587
+ ? `/${filterQuery}▏`
588
+ : filterQuery.length > 0
589
+ ? `/${filterQuery}`
590
+ : "/ filter…"
591
+ // Editing keeps the cursor visible — anchor the right edge with a leading
592
+ // ellipsis when the query overflows. Applied/idle anchor the left edge
593
+ // (lose the tail) so the leading `/` always reads as a filter marker.
594
+ const filterRowContent =
595
+ filterRowRaw.length <= sidebarTextWidth
596
+ ? filterRowRaw
597
+ : filterOpen
598
+ ? "…" + filterRowRaw.slice(filterRowRaw.length - sidebarTextWidth + 1)
599
+ : filterRowRaw.slice(0, sidebarTextWidth - 1) + "…"
357
600
 
358
601
  // While help is open, the `?` key closes the overlay — relabel its hint
359
602
  // so the footer accurately describes what pressing the key will do.
360
- const footerBindings = helpVisible
361
- ? browserBindings
362
- .filter((b) => HELP_ALLOWED_IDS.has(b.id))
363
- .map((b) => (b.id === "help.toggle" ? { ...b, hint: "close" } : b))
364
- : browserBindings
603
+ // Memoized: `helpVisible` changes rarely; `browserBindings` and
604
+ // `HELP_ALLOWED_IDS` are module-level constants.
605
+ const footerBindings = useMemo(
606
+ () =>
607
+ helpVisible
608
+ ? browserBindings
609
+ .filter((b) => HELP_ALLOWED_IDS.has(b.id))
610
+ .map((b) => (b.id === "help.toggle" ? { ...b, hint: "close" } : b))
611
+ : browserBindings,
612
+ [helpVisible],
613
+ )
614
+
615
+ // One sidebar element is reused for inline and drawer rendering; only
616
+ // the wrapper differs (flex sibling vs absolute-positioned). The body is
617
+ // identical so file rows / filter row don't drift between modes.
618
+ const sidebarBody = (
619
+ <>
620
+ {filterRowVisible && (
621
+ <text content={filterRowContent} wrapMode="none" style={{ fg: filterRowFg }} />
622
+ )}
623
+ {displayedFiles.length === 0 ? (
624
+ <text
625
+ content={
626
+ files.length === 0
627
+ ? discoveryActive
628
+ ? "(scanning…)"
629
+ : "(no markdown files)"
630
+ : "(no matches)"
631
+ }
632
+ style={{ fg: colors.textMuted }}
633
+ />
634
+ ) : (
635
+ visibleFiles.map((file, idx) => {
636
+ const realIdx = desiredScroll + idx
637
+ const isSelected = realIdx === selectedIndex
638
+ const display = truncatePath(file.relativePath)
639
+ if (!isSelected) {
640
+ return (
641
+ <text key={file.path} content={display} wrapMode="none" style={{ fg: colors.text }} />
642
+ )
643
+ }
644
+ const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
645
+ return (
646
+ <text
647
+ key={file.path}
648
+ content={display}
649
+ wrapMode="none"
650
+ style={{ fg: colors.textStrong, bg }}
651
+ />
652
+ )
653
+ })
654
+ )}
655
+ </>
656
+ )
365
657
 
366
658
  return (
367
659
  <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
@@ -373,7 +665,7 @@ export const Browser = ({
373
665
  backgroundColor: colors.background,
374
666
  }}
375
667
  >
376
- {sidebarVisible && (
668
+ {sidebarInline && (
377
669
  <box
378
670
  title={sidebarTitle}
379
671
  titleAlignment="left"
@@ -386,37 +678,7 @@ export const Browser = ({
386
678
  backgroundColor: colors.surface,
387
679
  }}
388
680
  >
389
- {displayedFiles.length === 0 ? (
390
- <text
391
- content={files.length === 0 ? "(no markdown files)" : "(no matches)"}
392
- style={{ fg: colors.textMuted }}
393
- />
394
- ) : (
395
- visibleFiles.map((file, idx) => {
396
- const realIdx = desiredScroll + idx
397
- const isSelected = realIdx === selectedIndex
398
- const display = truncatePath(file.relativePath)
399
- if (!isSelected) {
400
- return (
401
- <text
402
- key={file.path}
403
- content={display}
404
- wrapMode="none"
405
- style={{ fg: colors.text }}
406
- />
407
- )
408
- }
409
- const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
410
- return (
411
- <text
412
- key={file.path}
413
- content={display}
414
- wrapMode="none"
415
- style={{ fg: colors.textStrong, bg }}
416
- />
417
- )
418
- })
419
- )}
681
+ {sidebarBody}
420
682
  </box>
421
683
  )}
422
684
  <box
@@ -442,7 +704,15 @@ export const Browser = ({
442
704
  flexShrink: 1,
443
705
  backgroundColor: colors.background,
444
706
  }}
445
- focused={readerActive}
707
+ // opentui's scrollbox consumes arrow keys at the focused-element
708
+ // level *before* useKeyboard fires, so a modal that handles
709
+ // arrow keys itself (palette nav, help dismissal) would still
710
+ // see the reader scroll alongside its own action. Unfocus the
711
+ // scrollbox while any blocking modal is up — useKeyboard's
712
+ // modal branches own the keys in that state. Filter is not
713
+ // listed because it force-focuses the sidebar (readerActive
714
+ // is already false).
715
+ focused={readerActive && !paletteOpen && !helpVisible}
446
716
  >
447
717
  <markdown
448
718
  key={renderedPath ?? "empty"}
@@ -457,16 +727,50 @@ export const Browser = ({
457
727
  )}
458
728
  </box>
459
729
  </box>
730
+ {sidebarAsDrawer && (
731
+ // Offset by 1 row so the reader pane's top border (which carries
732
+ // the current file name) stays visible above the drawer. Without
733
+ // this, the user loses the only on-screen indicator of which
734
+ // file they're reading whenever the drawer is up.
735
+ <box
736
+ position="absolute"
737
+ left={0}
738
+ top={drawerTopOffset}
739
+ width={sidebarWidth}
740
+ height={Math.max(1, height - FOOTER_HEIGHT - drawerTopOffset)}
741
+ zIndex={5}
742
+ title={sidebarTitle}
743
+ titleAlignment="left"
744
+ style={{
745
+ border: true,
746
+ borderColor: sidebarActive ? colors.borderActive : colors.border,
747
+ flexDirection: "column",
748
+ backgroundColor: colors.surface,
749
+ }}
750
+ >
751
+ {sidebarBody}
752
+ </box>
753
+ )}
460
754
  <Footer
461
755
  bindings={footerBindings}
462
756
  ctx={ctx}
463
757
  width={width}
464
758
  notice={footerNotice}
465
- filter={filterOpen ? { query: filterQuery } : null}
759
+ discoveryStatus={discoveryStatus}
760
+ filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
466
761
  />
467
762
  {helpVisible && (
468
763
  <HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
469
764
  )}
765
+ {paletteOpen && (
766
+ <CommandPalette
767
+ commands={filterCommands(buildCommands(ctx), paletteQuery)}
768
+ query={paletteQuery}
769
+ selectedIndex={paletteIndex}
770
+ viewportWidth={width}
771
+ viewportHeight={height}
772
+ />
773
+ )}
470
774
  </box>
471
775
  )
472
776
  }