@carlesandres/house 0.3.1 → 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/CHANGELOG.md CHANGED
@@ -12,6 +12,25 @@ file is the curated, narrative version.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.4.0] — 2026-05-21
16
+
17
+ ### Added
18
+
19
+ - Command palette v1: `ctrl+p` opens a modal palette with visible browser
20
+ commands derived from the keymap, including quit, sidebar toggle, help,
21
+ filter, open-in-browser, and theme controls.
22
+ - Palette search uses a small tiered scorer so empty queries stay in keymap
23
+ order while typed queries rank stronger title and word-boundary matches.
24
+
25
+ ### Fixed
26
+
27
+ - Reader scroll focus is suspended while overlays are open, preventing palette
28
+ arrow navigation from scrolling the markdown pane behind the modal.
29
+
30
+ ### Tests
31
+
32
+ - Added command-palette interaction coverage plus scroll regression checks.
33
+
15
34
  ## [0.3.1] — 2026-05-17
16
35
 
17
36
  Beta release gates (DESIGN §10.2) closed.
@@ -204,7 +223,9 @@ auto-detect, single-binary distribution (issue
204
223
  [#2](https://github.com/carlesandres/openmdr/issues/2)),
205
224
  Homebrew tap. All tracked.
206
225
 
207
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.3.0...HEAD
226
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.0...HEAD
227
+ [0.4.0]: https://github.com/carlesandres/house/compare/v0.3.1...v0.4.0
228
+ [0.3.1]: https://github.com/carlesandres/house/compare/v0.3.0...v0.3.1
208
229
  [0.3.0]: https://github.com/carlesandres/house/compare/v0.2.1...v0.3.0
209
230
  [0.2.1]: https://github.com/carlesandres/house/compare/v0.2.0...v0.2.1
210
231
  [0.2.0]: https://github.com/carlesandres/house/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -37,6 +37,34 @@ house [options] <path>
37
37
  |------|---------|-------------|
38
38
  | `--theme <name>` | `opencode` | Starting theme (see list below) |
39
39
  | `--tone dark\|light` | `dark` | Starting tone |
40
+ | `--config-path` | — | Print the resolved config-file path and exit |
41
+
42
+ ## Configuration
43
+
44
+ house reads optional defaults from a TOML file:
45
+
46
+ ```
47
+ $XDG_CONFIG_HOME/house/config.toml (defaults to ~/.config/house/config.toml)
48
+ ```
49
+
50
+ Run `house --config-path` to print the exact location.
51
+
52
+ ```toml
53
+ # ~/.config/house/config.toml
54
+ theme = "tokyonight"
55
+ tone = "dark"
56
+ ```
57
+
58
+ Precedence, highest to lowest:
59
+
60
+ 1. CLI flags (`--theme`, `--tone`)
61
+ 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`)
62
+ 3. Config file
63
+ 4. Built-in defaults (`opencode` / `dark`)
64
+
65
+ The file is optional — a missing file is fine. Invalid keys, unknown themes,
66
+ or malformed TOML fail loudly with a one-line error. Per-project config
67
+ (`.house/config.toml`) and additional keys are deferred.
40
68
 
41
69
  ## Keys
42
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,8 @@
45
45
  "format:check": "oxfmt --check src/",
46
46
  "test": "bun test",
47
47
  "build:themes": "bun run dev/build-themes.ts",
48
- "repro:pty-codeblocks": "bun run dev/repro-pty-codeblocks.ts"
48
+ "repro:pty-codeblocks": "bun run dev/repro-pty-codeblocks.ts",
49
+ "prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
49
50
  },
50
51
  "dependencies": {
51
52
  "@effect/atom-react": "4.0.0-beta.59",
package/src/Browser.tsx CHANGED
@@ -15,6 +15,9 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
15
15
  import { useAtomValue, useAtomSet } from "@effect/atom-react"
16
16
  import { Effect } from "effect"
17
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) => {
@@ -71,18 +93,47 @@ export const Browser = ({
71
93
  )
72
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
 
@@ -181,17 +232,51 @@ export const Browser = ({
181
232
  const ctx: BrowserCtx = {
182
233
  files: displayedFiles,
183
234
  focus,
184
- sidebarVisible,
235
+ sidebarShown: shown,
185
236
  helpVisible,
186
237
  filterOpen,
238
+ paletteOpen,
187
239
  setFocus,
188
240
  setSelectedIndex,
189
- 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
+ },
190
257
  setHelpVisible,
191
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")
192
265
  filterOpenRef.current = true
193
266
  setFilterOpen(true)
194
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
+ },
195
280
  cycleTheme,
196
281
  toggleTone,
197
282
  serveCurrent: () => {
@@ -249,17 +334,46 @@ export const Browser = ({
249
334
  //
250
335
  // Centralized so the dual filterOpenRef / filterOpen invariant
251
336
  // only has to be maintained in one place (plus `openFilter`).
252
- const closeFilter = (focusReader: boolean) => {
337
+ const closeFilter = (commit: boolean) => {
253
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
254
343
  filterOpenRef.current = false
255
- filterQueryRef.current = ""
256
344
  setFilterOpen(false)
257
- setFilterQuery("")
258
- if (picked) {
259
- const fullIdx = files.findIndex((f) => f.path === picked.path)
260
- 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")
261
376
  }
262
- if (focusReader && picked) setFocus("reader")
263
377
  }
264
378
  if (key.name === "escape") {
265
379
  closeFilter(false)
@@ -269,7 +383,15 @@ export const Browser = ({
269
383
  closeFilter(true)
270
384
  return
271
385
  }
272
- 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
+ }
273
395
  filterQueryRef.current = filterQueryRef.current.slice(0, -1)
274
396
  setFilterQuery(filterQueryRef.current)
275
397
  setSelectedIndex(() => 0)
@@ -296,6 +418,74 @@ export const Browser = ({
296
418
  }
297
419
  return
298
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
+ }
299
489
  // While help is open, swallow most keys: only ? (toggle), esc
300
490
  // (close), and the theme bindings pass through. Theme keys stay live
301
491
  // so users can preview palette changes against the overlay itself —
@@ -320,12 +510,26 @@ export const Browser = ({
320
510
  dispatch(browserBindings, ctx, key)
321
511
  })
322
512
 
323
- // Min/max-clamped percentage of viewport: narrow terminals stay readable,
324
- // wide terminals get more room without wasting space at extremes.
325
- // User-configurable width is deferred — see DESIGN.md §12.
326
- 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))
327
517
  const sidebarActive = focus === "sidebar"
328
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
329
533
  const sidebarTitle = sidebarActive ? " ▸ files " : " files "
330
534
  const readerLabel = selected?.relativePath ?? title
331
535
  const readerTitle = readerActive ? ` ▸ ${readerLabel} ` : ` ${readerLabel} `
@@ -335,8 +539,20 @@ export const Browser = ({
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
@@ -358,6 +574,30 @@ export const Browser = ({
358
574
  [sidebarTextWidth],
359
575
  )
360
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) + "…"
600
+
361
601
  // While help is open, the `?` key closes the overlay — relabel its hint
362
602
  // so the footer accurately describes what pressing the key will do.
363
603
  // Memoized: `helpVisible` changes rarely; `browserBindings` and
@@ -372,6 +612,49 @@ export const Browser = ({
372
612
  [helpVisible],
373
613
  )
374
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
+ )
657
+
375
658
  return (
376
659
  <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
377
660
  <box
@@ -382,7 +665,7 @@ export const Browser = ({
382
665
  backgroundColor: colors.background,
383
666
  }}
384
667
  >
385
- {sidebarVisible && (
668
+ {sidebarInline && (
386
669
  <box
387
670
  title={sidebarTitle}
388
671
  titleAlignment="left"
@@ -395,37 +678,7 @@ export const Browser = ({
395
678
  backgroundColor: colors.surface,
396
679
  }}
397
680
  >
398
- {displayedFiles.length === 0 ? (
399
- <text
400
- content={files.length === 0 ? "(no markdown files)" : "(no matches)"}
401
- style={{ fg: colors.textMuted }}
402
- />
403
- ) : (
404
- visibleFiles.map((file, idx) => {
405
- const realIdx = desiredScroll + idx
406
- const isSelected = realIdx === selectedIndex
407
- const display = truncatePath(file.relativePath)
408
- if (!isSelected) {
409
- return (
410
- <text
411
- key={file.path}
412
- content={display}
413
- wrapMode="none"
414
- style={{ fg: colors.text }}
415
- />
416
- )
417
- }
418
- const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
419
- return (
420
- <text
421
- key={file.path}
422
- content={display}
423
- wrapMode="none"
424
- style={{ fg: colors.textStrong, bg }}
425
- />
426
- )
427
- })
428
- )}
681
+ {sidebarBody}
429
682
  </box>
430
683
  )}
431
684
  <box
@@ -451,7 +704,15 @@ export const Browser = ({
451
704
  flexShrink: 1,
452
705
  backgroundColor: colors.background,
453
706
  }}
454
- 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}
455
716
  >
456
717
  <markdown
457
718
  key={renderedPath ?? "empty"}
@@ -466,16 +727,50 @@ export const Browser = ({
466
727
  )}
467
728
  </box>
468
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
+ )}
469
754
  <Footer
470
755
  bindings={footerBindings}
471
756
  ctx={ctx}
472
757
  width={width}
473
758
  notice={footerNotice}
474
- filter={filterOpen ? { query: filterQuery } : null}
759
+ discoveryStatus={discoveryStatus}
760
+ filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
475
761
  />
476
762
  {helpVisible && (
477
763
  <HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
478
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
+ )}
479
774
  </box>
480
775
  )
481
776
  }