@carlesandres/house 0.4.6 → 0.4.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/src/Browser.tsx CHANGED
@@ -7,7 +7,7 @@
7
7
  * - q / ctrl+c quit.
8
8
  *
9
9
  * Deferred to next iteration: focus model, reader scrolling via j/k,
10
- * sidebar collapse with `\`, help overlay.
10
+ * sidebar collapse with `\`.
11
11
  */
12
12
 
13
13
  import { SyntaxStyle } from "@opentui/core"
@@ -15,16 +15,16 @@ import type { BorderSides } from "@opentui/core"
15
15
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
16
16
  import { useAtomValue, useAtomSet } from "@effect/atom-react"
17
17
  import { Effect } from "effect"
18
- import { useCallback, useEffect, useMemo, useRef, useState } from "react"
18
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"
19
19
  import { buildCommands } from "./commands/buildCommands.ts"
20
20
  import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
21
- import { CommandPalette } from "./CommandPalette.tsx"
21
+ import { CommandPalette, orderCommandsForPalette } from "./CommandPalette.tsx"
22
22
  import { filterFiles } from "./discovery/filter.ts"
23
23
  import { type FileEntry } from "./discovery/walk.ts"
24
+ import { parseFrontmatter } from "./markdown/frontmatter.ts"
24
25
  import { BRAND, BRAND_NAME } from "./brand.ts"
25
- import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
26
+ import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
26
27
  import { Header, HEADER_HEIGHT } from "./Header.tsx"
27
- import { HelpOverlay } from "./HelpOverlay.tsx"
28
28
  import { openInEditor, resolveEditor } from "./io/editor.ts"
29
29
  import { readFileText } from "./io/readFile.ts"
30
30
  import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
@@ -35,8 +35,10 @@ import {
35
35
  initialShownForAuto,
36
36
  resolveSidebarWidth,
37
37
  } from "./layout/resolve.ts"
38
+ import { fitSidebarEmptyValue } from "./layout/sidebarEmptyState.ts"
38
39
  import { formatSidebarRow } from "./layout/sidebarRow.ts"
39
40
  import { PromptRow } from "./PromptRow.tsx"
41
+ import { StatusPopoverPanel } from "./StatusPopover.tsx"
40
42
  import { buildReaderEmptyStateTips, pickTipByRotation } from "./tips.ts"
41
43
  import { openInBrowser } from "./serve/openBrowser.ts"
42
44
  import { startServer, type ServerHandle } from "./serve/server.ts"
@@ -50,11 +52,26 @@ export type StartupFocus = "sidebar" | "reader" | "filter"
50
52
  export interface BrowserProps {
51
53
  readonly files: readonly FileEntry[]
52
54
  readonly initialIndex?: number
55
+ /** Initial applied filter query seeded from the CLI positional. */
56
+ readonly initialQuery?: string
53
57
  /** Cap the rendered markdown's width at N columns. Null = fill the pane. */
54
58
  readonly maxWidth?: number | null
59
+ /** Discovery root label used in the post-discovery empty-vault sidebar row. */
60
+ readonly emptyRootLabel?: string
55
61
  /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
56
62
  * when discovery has finished; the indicator clears. */
57
63
  readonly discoveryStatus?: string | null
64
+ /** Test seam: override the footer discovery spinner speed. */
65
+ readonly discoverySpinnerIntervalMs?: number
66
+ readonly discoverySpinnerInitialFrameIndex?: number
67
+ /** Test seam: deterministic footer spinner driver. */
68
+ readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
69
+ /** Test seam: override filter debounce timing. */
70
+ readonly filterDebounceMs?: number
71
+ /** Test seam: override rendered-path debounce timing. */
72
+ readonly renderedPathDebounceMs?: number
73
+ /** Test seam: disable reader-empty-state tip rotation effect. */
74
+ readonly disableReaderEmptyStateRotation?: boolean
58
75
  /** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
59
76
  * launch viewport bucket once; subsequent visibility goes through `s`. */
60
77
  readonly sidebarMode?: SidebarMode
@@ -69,6 +86,8 @@ export interface BrowserProps {
69
86
  /** TTL (ms) for the update-notice toast. Exposed so tests can use a small
70
87
  * value instead of sleeping for the production 10s window. */
71
88
  readonly updateNoticeTtlMs?: number
89
+ /** Test seam: disable footer-notice auto-clear timers. */
90
+ readonly disableFooterNoticeAutoClear?: boolean
72
91
  /** Flip the parent's discovery vocabulary (#145). Browser doesn't need
73
92
  * to know which categories are currently on — the toggle is opaque
74
93
  * from this side; we just snapshot the selected path so it can be
@@ -83,18 +102,6 @@ const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(rea
83
102
 
84
103
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
85
104
 
86
- /** Bindings the help overlay lets through. Single source of truth for both
87
- * the keyboard early-return and the footer hint filter. `palette.open`
88
- * passes through so users can jump from help into the palette in one
89
- * keystroke — `openPalette` closes help on its way in. */
90
- const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
91
- "help.toggle",
92
- "theme.next",
93
- "theme.prev",
94
- "theme.toneToggle",
95
- "palette.open",
96
- ])
97
-
98
105
  let nextReaderEmptyStateTipRotation = 0
99
106
 
100
107
  export const resetReaderEmptyStateTipRotationForTests = () => {
@@ -105,16 +112,96 @@ export const setReaderEmptyStateTipRotationForTests = (next: number) => {
105
112
  nextReaderEmptyStateTipRotation = next
106
113
  }
107
114
 
115
+ const FILTER_DEBOUNCE_MS = 50
116
+ const RENDERED_PATH_DEBOUNCE_MS = 80
117
+
118
+ const isPartialDiscoveryWarning = (status: string | null | undefined): boolean =>
119
+ status?.trimStart().startsWith("scan incomplete:") ?? false
120
+
121
+ type FloatingOverlay =
122
+ | { readonly kind: "none" }
123
+ | { readonly kind: "command-palette" }
124
+ | { readonly kind: "status-popover"; readonly content: string }
125
+
126
+ type FloatingOverlayAction =
127
+ | { readonly type: "close" }
128
+ | { readonly type: "open-command-palette" }
129
+ | { readonly type: "toggle-status-popover"; readonly content: string }
130
+ | { readonly type: "update-status-popover"; readonly content: string }
131
+
132
+ const noFloatingOverlay: FloatingOverlay = { kind: "none" }
133
+
134
+ const floatingOverlayReducer = (
135
+ state: FloatingOverlay,
136
+ action: FloatingOverlayAction,
137
+ ): FloatingOverlay => {
138
+ switch (action.type) {
139
+ case "close":
140
+ return noFloatingOverlay
141
+ case "open-command-palette":
142
+ return { kind: "command-palette" }
143
+ case "toggle-status-popover":
144
+ return state.kind === "status-popover"
145
+ ? noFloatingOverlay
146
+ : { kind: "status-popover", content: action.content }
147
+ case "update-status-popover":
148
+ return state.kind === "status-popover"
149
+ ? { kind: "status-popover", content: action.content }
150
+ : state
151
+ }
152
+ }
153
+
154
+ const SidebarEmptyMessage = ({
155
+ label,
156
+ value,
157
+ width,
158
+ withTopSpacer,
159
+ }: {
160
+ readonly label: string
161
+ readonly value: string
162
+ readonly width: number
163
+ readonly withTopSpacer: boolean
164
+ }) => (
165
+ <>
166
+ {withTopSpacer && <text content="" />}
167
+ <box
168
+ style={{
169
+ width,
170
+ flexDirection: "row",
171
+ flexWrap: "wrap",
172
+ justifyContent: "center",
173
+ gap: 1,
174
+ }}
175
+ >
176
+ <text wrapMode="none">
177
+ <span style={{ fg: colors.textMuted }}>{label}</span>
178
+ </text>
179
+ <text wrapMode="none">
180
+ <span style={{ fg: colors.textMuted }}>{`"${fitSidebarEmptyValue(value, width)}"`}</span>
181
+ </text>
182
+ </box>
183
+ </>
184
+ )
185
+
108
186
  export const Browser = ({
109
187
  files,
110
188
  initialIndex = 0,
189
+ initialQuery = "",
111
190
  maxWidth = null,
191
+ emptyRootLabel = "current root",
112
192
  discoveryStatus = null,
193
+ discoverySpinnerIntervalMs,
194
+ discoverySpinnerInitialFrameIndex,
195
+ discoverySpinnerRegisterTick = null,
196
+ filterDebounceMs = FILTER_DEBOUNCE_MS,
197
+ renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
198
+ disableReaderEmptyStateRotation = false,
113
199
  sidebarMode = "auto",
114
200
  onQuit,
115
201
  readFile = defaultReadFile,
116
202
  updateNotice = null,
117
203
  updateNoticeTtlMs = 10000,
204
+ disableFooterNoticeAutoClear = false,
118
205
  onToggleAll,
119
206
  startupFocus = null,
120
207
  }: BrowserProps) => {
@@ -164,17 +251,29 @@ export const Browser = ({
164
251
  shown || initialFocus === "sidebar" ? "sidebar" : "reader",
165
252
  )
166
253
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
167
- const [helpVisible, setHelpVisible] = useState<boolean>(false)
168
254
  const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
169
- const [filterQuery, setFilterQuery] = useState<string>("")
170
- const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
255
+ const [filterInput, setFilterInput] = useState<string>(initialQuery)
256
+ const [filterApplied, setFilterApplied] = useState<string>(initialQuery)
257
+ const [floatingOverlay, dispatchFloatingOverlayState] = useReducer(
258
+ floatingOverlayReducer,
259
+ noFloatingOverlay,
260
+ )
261
+ const floatingOverlayRef = useRef<FloatingOverlay>(noFloatingOverlay)
262
+ const dispatchFloatingOverlay = (action: FloatingOverlayAction): void => {
263
+ const next = floatingOverlayReducer(floatingOverlayRef.current, action)
264
+ floatingOverlayRef.current = next
265
+ dispatchFloatingOverlayState(action)
266
+ }
267
+ const closeFloatingOverlay = (): void => dispatchFloatingOverlay({ type: "close" })
268
+ const paletteOpen = floatingOverlay.kind === "command-palette"
269
+ const activeStatusPopover = floatingOverlay.kind === "status-popover" ? floatingOverlay : null
270
+ const discoveryWarningStatus = isPartialDiscoveryWarning(discoveryStatus) ? discoveryStatus : null
171
271
  const [paletteQuery, setPaletteQuery] = useState<string>("")
172
272
  const [paletteIndex, setPaletteIndex] = useState<number>(0)
173
273
  // Synchronous mirrors for the keyboard handler — same reason filterOpenRef
174
274
  // exists. Modal input can arrive in one React batch (e.g. ctrl+p, Down,
175
275
  // Return), so every palette field read by later keys must update its ref
176
276
  // before React state commits.
177
- const paletteOpenRef = useRef(false)
178
277
  const paletteQueryRef = useRef("")
179
278
  const paletteIndexRef = useRef(0)
180
279
  const [readerEmptyStateTipRotation, setReaderEmptyStateTipRotation] = useState(
@@ -186,7 +285,9 @@ export const Browser = ({
186
285
  // first key opens the filter; subsequent keys in the same tick would
187
286
  // otherwise still observe filterOpen=false through closure).
188
287
  const filterOpenRef = useRef(startInFilter)
189
- const filterQueryRef = useRef("")
288
+ const filterInputRef = useRef(initialQuery)
289
+ const filterAppliedRef = useRef(initialQuery)
290
+ const autoSelectForAppliedFilterRef = useRef(true)
190
291
  const focusRef = useRef<"sidebar" | "reader">(focus)
191
292
  const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
192
293
  const [footerNotice, setFooterNoticeState] = useState<{
@@ -220,9 +321,24 @@ export const Browser = ({
220
321
  // the other's display window.
221
322
  useEffect(() => {
222
323
  if (footerNotice === null) return
324
+ closeFloatingOverlay()
325
+ if (disableFooterNoticeAutoClear) return
223
326
  const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
224
327
  return () => clearTimeout(timer)
225
- }, [footerNotice])
328
+ }, [disableFooterNoticeAutoClear, footerNotice])
329
+
330
+ useEffect(() => {
331
+ if (discoveryWarningStatus === null) {
332
+ if (floatingOverlay.kind === "status-popover") closeFloatingOverlay()
333
+ return
334
+ }
335
+ if (
336
+ floatingOverlay.kind === "status-popover" &&
337
+ floatingOverlay.content !== discoveryWarningStatus
338
+ ) {
339
+ dispatchFloatingOverlay({ type: "update-status-popover", content: discoveryWarningStatus })
340
+ }
341
+ }, [discoveryWarningStatus, floatingOverlay])
226
342
 
227
343
  // Push the update-available nudge once, when it arrives from the parent
228
344
  // (the registry probe resolves asynchronously after boot). 10s gives the
@@ -257,8 +373,29 @@ export const Browser = ({
257
373
  pushFooterNotice(`tone: ${nextTone}`)
258
374
  }
259
375
 
260
- const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
261
- const filterHasNoMatches = filterQuery.length > 0 && displayedFiles.length === 0
376
+ useEffect(() => {
377
+ if (filterInput === filterApplied) return
378
+ const timer = setTimeout(() => {
379
+ filterAppliedRef.current = filterInput
380
+ setFilterApplied(filterInput)
381
+ }, filterDebounceMs)
382
+ return () => clearTimeout(timer)
383
+ }, [filterApplied, filterDebounceMs, filterInput])
384
+
385
+ useEffect(() => {
386
+ autoSelectForAppliedFilterRef.current = true
387
+ }, [filterApplied])
388
+
389
+ const displayedFiles = useMemo(() => filterFiles(files, filterApplied), [files, filterApplied])
390
+ const filterHasNoMatches = filterInput.length > 0 && displayedFiles.length === 0
391
+
392
+ useEffect(() => {
393
+ if (filterApplied.length === 0) return
394
+ if (!autoSelectForAppliedFilterRef.current) return
395
+ if (displayedFiles.length === 0) return
396
+ setSelectedIndex(0)
397
+ autoSelectForAppliedFilterRef.current = false
398
+ }, [displayedFiles, filterApplied])
262
399
  // When the filtered list shrinks, keep selectedIndex valid. The reset to 0
263
400
  // on every query change happens in the keystroke handler, not here, so a
264
401
  // no-op rerender doesn't snap the cursor back to the top.
@@ -296,9 +433,9 @@ export const Browser = ({
296
433
  useEffect(() => {
297
434
  const target = selected?.path ?? null
298
435
  if (target === renderedPath) return
299
- const timer = setTimeout(() => setRenderedPath(target), 80)
436
+ const timer = setTimeout(() => setRenderedPath(target), renderedPathDebounceMs)
300
437
  return () => clearTimeout(timer)
301
- }, [selected?.path, renderedPath])
438
+ }, [selected?.path, renderedPath, renderedPathDebounceMs])
302
439
 
303
440
  useEffect(() => {
304
441
  if (!renderedPath) {
@@ -337,10 +474,9 @@ export const Browser = ({
337
474
  hasSelected: selected != null,
338
475
  focus,
339
476
  sidebarShown: shown,
340
- helpVisible,
341
477
  filterOpen,
342
478
  restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
343
- filterQuery,
479
+ filterQuery: filterInput,
344
480
  paletteOpen,
345
481
  setFocus,
346
482
  // Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
@@ -350,6 +486,7 @@ export const Browser = ({
350
486
  // itself) deliberately use the raw `setSelectedIndex` setter.
351
487
  setSelectedIndex: (updater) => {
352
488
  pendingSelectionPathRef.current = null
489
+ autoSelectForAppliedFilterRef.current = false
353
490
  setSelectedIndex(updater)
354
491
  },
355
492
  toggleShown: () => {
@@ -376,8 +513,8 @@ export const Browser = ({
376
513
  if (focus === "reader") setFocus("sidebar")
377
514
  }
378
515
  },
379
- setHelpVisible,
380
516
  openFilter: () => {
517
+ closeFloatingOverlay()
381
518
  // Focus the sidebar so the filter input has a home. In wide,
382
519
  // §7.1's visibility rule (`shown || focus === "sidebar"`) brings
383
520
  // the inline sidebar back on screen if it was hidden. In narrow,
@@ -390,11 +527,14 @@ export const Browser = ({
390
527
  setFilterOpen(true)
391
528
  },
392
529
  clearAndOpenFilter: () => {
530
+ closeFloatingOverlay()
393
531
  // Reset both the ref and the state so the freshly-opened modal
394
532
  // shows an empty input and selection lands on the first file in
395
533
  // the (now unfiltered) list.
396
- filterQueryRef.current = ""
397
- setFilterQuery("")
534
+ filterInputRef.current = ""
535
+ filterAppliedRef.current = ""
536
+ setFilterInput("")
537
+ setFilterApplied("")
398
538
  setSelectedIndex(() => 0)
399
539
  focusRef.current = "sidebar"
400
540
  if (focus !== "sidebar") setFocus("sidebar")
@@ -403,16 +543,13 @@ export const Browser = ({
403
543
  setFilterOpen(true)
404
544
  },
405
545
  openPalette: () => {
406
- // Close help if it was open — palette is the active modal now.
407
546
  // Reset query/index so each open starts fresh (no stale state from
408
547
  // the previous session).
409
- if (helpVisible) setHelpVisible(() => false)
410
548
  paletteQueryRef.current = ""
411
549
  setPaletteQuery("")
412
550
  paletteIndexRef.current = 0
413
551
  setPaletteIndex(0)
414
- paletteOpenRef.current = true
415
- setPaletteOpen(true)
552
+ dispatchFloatingOverlay({ type: "open-command-palette" })
416
553
  },
417
554
  cycleTheme,
418
555
  toggleTone,
@@ -467,7 +604,7 @@ export const Browser = ({
467
604
  if (!file) return
468
605
  const editor = resolveEditor(process.env)
469
606
  if (!editor) {
470
- pushFooterNotice("set $EDITOR or $VISUAL to use e")
607
+ pushFooterNotice("set $EDITOR or $VISUAL to use E")
471
608
  return
472
609
  }
473
610
  if (!renderer) {
@@ -526,6 +663,68 @@ export const Browser = ({
526
663
  }
527
664
 
528
665
  useKeyboard((key) => {
666
+ // Command palette modal: capture keystrokes for the query input and
667
+ // list navigation. This branch intentionally runs before the filter
668
+ // branch so ctrl+p can open the palette from filter mode while the
669
+ // palette still owns Esc/arrows/Return until it closes.
670
+ if (floatingOverlayRef.current.kind === "command-palette") {
671
+ const closePalette = () => {
672
+ paletteQueryRef.current = ""
673
+ paletteIndexRef.current = 0
674
+ closeFloatingOverlay()
675
+ setPaletteQuery("")
676
+ setPaletteIndex(0)
677
+ }
678
+ const setPaletteIndexSync = (next: number) => {
679
+ paletteIndexRef.current = next
680
+ setPaletteIndex(next)
681
+ }
682
+ const allCommands = buildCommands(ctx)
683
+ const filtered = orderCommandsForPalette(filterCommands(allCommands, paletteQueryRef.current))
684
+ if (key.name === "escape") {
685
+ closePalette()
686
+ return
687
+ }
688
+ if (key.name === "return") {
689
+ const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
690
+ closePalette()
691
+ picked?.run()
692
+ return
693
+ }
694
+ if (key.name === "up") {
695
+ setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
696
+ return
697
+ }
698
+ if (key.name === "down") {
699
+ setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
700
+ return
701
+ }
702
+ if (key.name === "backspace" || key.name === "delete") {
703
+ if (paletteQueryRef.current.length === 0) return
704
+ paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
705
+ setPaletteQuery(paletteQueryRef.current)
706
+ setPaletteIndexSync(0)
707
+ return
708
+ }
709
+ // ctrl+p again closes.
710
+ if (key.ctrl && !key.meta && key.name === "p") {
711
+ closePalette()
712
+ return
713
+ }
714
+ if (key.ctrl || key.meta) return
715
+ let char: string | null = null
716
+ if (key.name === "space") char = " "
717
+ else if (typeof key.name === "string" && key.name.length === 1) {
718
+ char = key.shift ? key.name.toUpperCase() : key.name
719
+ }
720
+ if (char !== null) {
721
+ paletteQueryRef.current = paletteQueryRef.current + char
722
+ setPaletteQuery(paletteQueryRef.current)
723
+ setPaletteIndexSync(0)
724
+ }
725
+ return
726
+ }
727
+
529
728
  // Filter modal: capture keystrokes for the input. Esc closes,
530
729
  // leaving the typed query applied as the active filter; Return
531
730
  // closes and focuses the reader (open the match); Ctrl+\ clears
@@ -543,6 +742,8 @@ export const Browser = ({
543
742
  // the Return semantic (open the match in the reader); false is
544
743
  // Esc (stop typing, keep the applied filter, stay in sidebar).
545
744
  const closeFilter = (commit: boolean) => {
745
+ filterAppliedRef.current = filterInputRef.current
746
+ setFilterApplied(filterInputRef.current)
546
747
  const picked = displayedFiles[selectedIndex] ?? null
547
748
  const effectiveCommit = commit && picked !== null
548
749
  restoreFilterOnSidebarFocusRef.current = false
@@ -578,25 +779,31 @@ export const Browser = ({
578
779
  setFocus("reader")
579
780
  return
580
781
  }
782
+ if (key.ctrl && !key.meta && key.name === "p") {
783
+ ctx.openPalette()
784
+ return
785
+ }
581
786
  if (key.ctrl && key.name === "\\") {
582
787
  // Same action as the `filter.clearOrOpen` binding fires from
583
788
  // outside the modal: clear the query, reset selection. The
584
789
  // keymap doesn't see keys in filter mode, so this branch is
585
790
  // the in-modal half of that single chord.
586
- filterQueryRef.current = ""
587
- setFilterQuery("")
791
+ filterInputRef.current = ""
792
+ filterAppliedRef.current = ""
793
+ setFilterInput("")
794
+ setFilterApplied("")
588
795
  setSelectedIndex(() => 0)
589
796
  return
590
797
  }
591
798
  if (key.name === "backspace" || key.name === "delete") {
592
799
  // Backspace on empty input closes the modal — the leading `/`
593
800
  // chevron is the last thing left to "delete."
594
- if (filterQueryRef.current.length === 0) {
801
+ if (filterInputRef.current.length === 0) {
595
802
  closeFilter(false)
596
803
  return
597
804
  }
598
- filterQueryRef.current = filterQueryRef.current.slice(0, -1)
599
- setFilterQuery(filterQueryRef.current)
805
+ filterInputRef.current = filterInputRef.current.slice(0, -1)
806
+ setFilterInput(filterInputRef.current)
600
807
  setSelectedIndex(() => 0)
601
808
  return
602
809
  }
@@ -615,101 +822,12 @@ export const Browser = ({
615
822
  char = key.shift ? key.name.toUpperCase() : key.name
616
823
  }
617
824
  if (char !== null) {
618
- filterQueryRef.current = filterQueryRef.current + char
619
- setFilterQuery(filterQueryRef.current)
825
+ filterInputRef.current = filterInputRef.current + char
826
+ setFilterInput(filterInputRef.current)
620
827
  setSelectedIndex(() => 0)
621
828
  }
622
829
  return
623
830
  }
624
- // Command palette modal: capture keystrokes for the query input and
625
- // list navigation. Esc closes (single press, regardless of query —
626
- // #70 Q7a). Return runs the selected command. Up/Down navigate.
627
- // Backspace edits the query and is a no-op on empty (#70 Q7b —
628
- // intentionally diverges from the filter modal, which closes on
629
- // empty-backspace, because accidental close feels worse in the
630
- // palette). Printable characters extend the query and snap selection
631
- // to 0 (#70 Q7c). Ctrl/Meta-modified keys are swallowed except
632
- // ctrl+p, which toggles the palette closed (matches help-toggle's
633
- // re-press-to-close behavior).
634
- if (paletteOpenRef.current) {
635
- const closePalette = () => {
636
- paletteOpenRef.current = false
637
- paletteQueryRef.current = ""
638
- paletteIndexRef.current = 0
639
- setPaletteOpen(false)
640
- setPaletteQuery("")
641
- setPaletteIndex(0)
642
- }
643
- const setPaletteIndexSync = (next: number) => {
644
- paletteIndexRef.current = next
645
- setPaletteIndex(next)
646
- }
647
- const allCommands = buildCommands(ctx)
648
- const filtered = filterCommands(allCommands, paletteQueryRef.current)
649
- if (key.name === "escape") {
650
- closePalette()
651
- return
652
- }
653
- if (key.name === "return") {
654
- const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
655
- closePalette()
656
- picked?.run()
657
- return
658
- }
659
- if (key.name === "up") {
660
- setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
661
- return
662
- }
663
- if (key.name === "down") {
664
- setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
665
- return
666
- }
667
- if (key.name === "backspace" || key.name === "delete") {
668
- if (paletteQueryRef.current.length === 0) return
669
- paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
670
- setPaletteQuery(paletteQueryRef.current)
671
- setPaletteIndexSync(0)
672
- return
673
- }
674
- // ctrl+p again closes — matches help-toggle behavior.
675
- if (key.ctrl && !key.meta && key.name === "p") {
676
- closePalette()
677
- return
678
- }
679
- if (key.ctrl || key.meta) return
680
- let char: string | null = null
681
- if (key.name === "space") char = " "
682
- else if (typeof key.name === "string" && key.name.length === 1) {
683
- char = key.shift ? key.name.toUpperCase() : key.name
684
- }
685
- if (char !== null) {
686
- paletteQueryRef.current = paletteQueryRef.current + char
687
- setPaletteQuery(paletteQueryRef.current)
688
- setPaletteIndexSync(0)
689
- }
690
- return
691
- }
692
- // While help is open, swallow most keys: only ? (toggle), esc
693
- // (close), and the theme bindings pass through. Theme keys stay live
694
- // so users can preview palette changes against the overlay itself —
695
- // it is the largest theme-painted surface in the app. Everything else
696
- // is suppressed so the user can read without driving the UI behind.
697
- // This is the one place we step outside the data-driven keymap; the
698
- // alternative — adding `when: !c.helpVisible` to every other binding
699
- // — would clutter the array. See DESIGN.md §12 (keymap composition).
700
- if (helpVisible) {
701
- if (key.name === "escape") {
702
- setHelpVisible(() => false)
703
- return
704
- }
705
- const allowed = browserBindings.filter((b) => HELP_ALLOWED_IDS.has(b.id))
706
- // Defensive: stub quit even though no allowed binding currently
707
- // calls it. Keeps the invariant local to this branch instead of
708
- // relying on a future maintainer remembering not to add quit-ish
709
- // bindings to HELP_ALLOWED_IDS.
710
- dispatch(allowed, { ...ctx, quit: () => {} }, key)
711
- return
712
- }
713
831
  dispatch(browserBindings, ctx, key)
714
832
  })
715
833
 
@@ -732,12 +850,14 @@ export const Browser = ({
732
850
  // per-pane border title that used to carry this information).
733
851
  const currentFile = selected?.relativePath ?? null
734
852
  const content = loaded?.path === renderedPath ? loaded.content : ""
853
+ const parsedContent = useMemo(() => parseFrontmatter(content), [content])
735
854
  const readerEmptyStateTitle = filterHasNoMatches
736
- ? `No files match: ${filterQuery}`
855
+ ? `No files match: ${filterInput}`
737
856
  : `${BRAND} ${BRAND_NAME}`
738
857
  const readerEmptyStateVisible = error == null && renderedPath == null
739
858
 
740
859
  useEffect(() => {
860
+ if (disableReaderEmptyStateRotation) return
741
861
  if (readerEmptyStateVisible) {
742
862
  if (!readerEmptyStateVisibleRef.current) {
743
863
  readerEmptyStateVisibleRef.current = true
@@ -746,7 +866,7 @@ export const Browser = ({
746
866
  return
747
867
  }
748
868
  readerEmptyStateVisibleRef.current = false
749
- }, [readerEmptyStateVisible])
869
+ }, [disableReaderEmptyStateRotation, readerEmptyStateVisible])
750
870
 
751
871
  // Sidebar virtualization: render only the visible window. Without this,
752
872
  // every keystroke re-renders all N file rows even though only the bg of
@@ -784,19 +904,27 @@ export const Browser = ({
784
904
  [sidebarTextWidth],
785
905
  )
786
906
 
787
- // While help is open, the `?` key closes the overlay — relabel its hint
788
- // so the footer accurately describes what pressing the key will do.
789
- // Memoized: `helpVisible` changes rarely; `browserBindings` and
790
- // `HELP_ALLOWED_IDS` are module-level constants.
791
- const footerBindings = useMemo(
792
- () =>
793
- helpVisible
794
- ? browserBindings
795
- .filter((b) => HELP_ALLOWED_IDS.has(b.id))
796
- .map((b) => (b.id === "help.toggle" ? { ...b, hint: "close" } : b))
797
- : browserBindings,
798
- [helpVisible],
799
- )
907
+ const footerProps = {
908
+ bindings: browserBindings,
909
+ ctx,
910
+ width,
911
+ notice: footerNotice?.text ?? null,
912
+ discoveryStatus,
913
+ ...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
914
+ ...(discoverySpinnerInitialFrameIndex === undefined
915
+ ? {}
916
+ : { discoverySpinnerInitialFrameIndex }),
917
+ ...(discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick }),
918
+ ...(discoveryWarningStatus === null
919
+ ? {}
920
+ : {
921
+ onDiscoveryWarningToggle: () =>
922
+ dispatchFloatingOverlay({
923
+ type: "toggle-status-popover",
924
+ content: discoveryWarningStatus,
925
+ }),
926
+ }),
927
+ } satisfies FooterProps<BrowserCtx>
800
928
  const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
801
929
  const readerEmptyStateTip = useMemo(
802
930
  () => pickTipByRotation(readerEmptyStateTips, readerEmptyStateTipRotation),
@@ -809,40 +937,32 @@ export const Browser = ({
809
937
  <>
810
938
  {filterRowVisible && (
811
939
  <PromptRow
812
- query={filterQuery}
940
+ query={filterInput}
813
941
  editing={filterOpen}
814
942
  placeholder="/ to filter…"
815
943
  width={sidebarTextWidth}
816
944
  />
817
945
  )}
818
946
  {displayedFiles.length === 0 ? (
819
- <text
820
- content={
947
+ <SidebarEmptyMessage
948
+ withTopSpacer={filterRowVisible}
949
+ width={sidebarTextWidth}
950
+ label={
821
951
  files.length === 0
822
952
  ? discoveryActive
823
- ? "(scanning…)"
824
- : "(no markdown files)"
825
- : "(no matches)"
953
+ ? "Scanning"
954
+ : "No markdown files in"
955
+ : "No files match"
826
956
  }
827
- style={{ fg: colors.textMuted }}
957
+ value={files.length === 0 ? (discoveryActive ? "…" : emptyRootLabel) : filterApplied}
828
958
  />
829
959
  ) : (
830
960
  visibleFiles.map((file, idx) => {
831
961
  const realIdx = desiredScroll + idx
832
962
  const isSelected = realIdx === selectedIndex
833
963
  const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
834
- const selectedFg =
835
- colors.selectedListItemText === colors.background
836
- ? colors.primary
837
- : colors.selectedListItemText
838
- const basenameFg = isSelected
839
- ? sidebarActive
840
- ? selectedFg
841
- : colors.primary
842
- : colors.text
843
- const rowStyle = isSelected
844
- ? { bg: sidebarActive ? colors.backgroundElement : colors.borderSubtle }
845
- : {}
964
+ const basenameFg = isSelected ? colors.selectedListItemText : colors.text
965
+ const rowStyle = isSelected ? { bg: colors.backgroundElement } : {}
846
966
  return (
847
967
  <text key={file.path} wrapMode="none" style={rowStyle}>
848
968
  <span style={{ fg: basenameFg }}>{basename}</span>
@@ -877,6 +997,7 @@ export const Browser = ({
877
997
  rightT: "┤",
878
998
  cross: "┼",
879
999
  } as const
1000
+ const INACTIVE_PANE_OPACITY = 0.62
880
1001
 
881
1002
  return (
882
1003
  <box
@@ -897,7 +1018,7 @@ export const Browser = ({
897
1018
  // Narrow mode runs single-pane: the sidebar fills the area
898
1019
  // and drops its right divider (no neighbour to abut).
899
1020
  border: isNarrow ? readerBorderSides : sidebarBorderSides,
900
- borderColor: colors.textMuted,
1021
+ borderColor: colors.border,
901
1022
  ...(isNarrow
902
1023
  ? { flexGrow: 1, flexShrink: 1 }
903
1024
  : { width: sidebarWidth, flexShrink: 0 }),
@@ -916,6 +1037,7 @@ export const Browser = ({
916
1037
  flexDirection: "column",
917
1038
  paddingLeft: 1,
918
1039
  backgroundColor: sidebarActive ? colors.background : colors.backgroundPanel,
1040
+ opacity: sidebarActive ? 1 : INACTIVE_PANE_OPACITY,
919
1041
  }}
920
1042
  >
921
1043
  {sidebarBody}
@@ -926,7 +1048,7 @@ export const Browser = ({
926
1048
  <box
927
1049
  style={{
928
1050
  border: readerBorderSides,
929
- borderColor: colors.textMuted,
1051
+ borderColor: colors.border,
930
1052
  flexGrow: 1,
931
1053
  flexShrink: 1,
932
1054
  flexDirection: "column",
@@ -941,6 +1063,7 @@ export const Browser = ({
941
1063
  flexDirection: "column",
942
1064
  padding: 1,
943
1065
  backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
1066
+ opacity: readerActive ? 1 : INACTIVE_PANE_OPACITY,
944
1067
  }}
945
1068
  >
946
1069
  {error ? (
@@ -984,17 +1107,38 @@ export const Browser = ({
984
1107
  }}
985
1108
  // opentui's scrollbox consumes arrow keys at the focused-element
986
1109
  // level *before* useKeyboard fires, so a modal that handles
987
- // arrow keys itself (palette nav, help dismissal) would still
1110
+ // arrow keys itself (palette nav) would still
988
1111
  // see the reader scroll alongside its own action. Unfocus the
989
1112
  // scrollbox while any blocking modal is up — useKeyboard's
990
1113
  // modal branches own the keys in that state. Filter is not
991
1114
  // listed because it force-focuses the sidebar (readerActive
992
1115
  // is already false).
993
- focused={readerActive && !paletteOpen && !helpVisible}
1116
+ focused={readerActive && !paletteOpen}
994
1117
  >
1118
+ {parsedContent.fields.length > 0 && (
1119
+ <box style={{ flexDirection: "column", marginBottom: 1 }}>
1120
+ {parsedContent.fields.map((field) => (
1121
+ <box
1122
+ key={field.key}
1123
+ style={{ flexDirection: "row", gap: 1, flexWrap: "wrap" }}
1124
+ >
1125
+ <text
1126
+ content={`${field.key}:`}
1127
+ wrapMode="word"
1128
+ style={{ fg: colors.secondary }}
1129
+ />
1130
+ <text
1131
+ content={field.value}
1132
+ wrapMode="word"
1133
+ style={{ fg: colors.textMuted }}
1134
+ />
1135
+ </box>
1136
+ ))}
1137
+ </box>
1138
+ )}
995
1139
  <markdown
996
1140
  key={renderedPath ?? "empty"}
997
- content={content}
1141
+ content={parsedContent.body}
998
1142
  syntaxStyle={syntaxStyle}
999
1143
  fg={colors.text}
1000
1144
  bg={readerActive ? colors.background : colors.backgroundPanel}
@@ -1007,26 +1151,19 @@ export const Browser = ({
1007
1151
  </box>
1008
1152
  )}
1009
1153
  </box>
1010
- <Footer
1011
- bindings={footerBindings}
1012
- ctx={ctx}
1013
- width={width}
1014
- notice={footerNotice?.text ?? null}
1015
- discoveryStatus={discoveryStatus}
1016
- filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
1017
- />
1018
- {helpVisible && (
1019
- <HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
1020
- )}
1154
+ <Footer {...footerProps} />
1021
1155
  {paletteOpen && (
1022
1156
  <CommandPalette
1023
- commands={filterCommands(buildCommands(ctx), paletteQuery)}
1157
+ commands={orderCommandsForPalette(filterCommands(buildCommands(ctx), paletteQuery))}
1024
1158
  query={paletteQuery}
1025
1159
  selectedIndex={paletteIndex}
1026
1160
  viewportWidth={width}
1027
1161
  viewportHeight={height}
1028
1162
  />
1029
1163
  )}
1164
+ {activeStatusPopover && (
1165
+ <StatusPopoverPanel content={activeStatusPopover.content} variant="warning" />
1166
+ )}
1030
1167
  </box>
1031
1168
  )
1032
1169
  }