@carlesandres/house 0.4.7 → 0.4.9

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
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,8 +52,12 @@ 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
@@ -96,18 +102,6 @@ const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(rea
96
102
 
97
103
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
98
104
 
99
- /** Bindings the help overlay lets through. Single source of truth for both
100
- * the keyboard early-return and the footer hint filter. `palette.open`
101
- * passes through so users can jump from help into the palette in one
102
- * keystroke — `openPalette` closes help on its way in. */
103
- const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
104
- "help.toggle",
105
- "theme.next",
106
- "theme.prev",
107
- "theme.toneToggle",
108
- "palette.open",
109
- ])
110
-
111
105
  let nextReaderEmptyStateTipRotation = 0
112
106
 
113
107
  export const resetReaderEmptyStateTipRotationForTests = () => {
@@ -121,10 +115,80 @@ export const setReaderEmptyStateTipRotationForTests = (next: number) => {
121
115
  const FILTER_DEBOUNCE_MS = 50
122
116
  const RENDERED_PATH_DEBOUNCE_MS = 80
123
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
+
124
186
  export const Browser = ({
125
187
  files,
126
188
  initialIndex = 0,
189
+ initialQuery = "",
127
190
  maxWidth = null,
191
+ emptyRootLabel = "current root",
128
192
  discoveryStatus = null,
129
193
  discoverySpinnerIntervalMs,
130
194
  discoverySpinnerInitialFrameIndex,
@@ -187,18 +251,29 @@ export const Browser = ({
187
251
  shown || initialFocus === "sidebar" ? "sidebar" : "reader",
188
252
  )
189
253
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
190
- const [helpVisible, setHelpVisible] = useState<boolean>(false)
191
254
  const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
192
- const [filterInput, setFilterInput] = useState<string>("")
193
- const [filterApplied, setFilterApplied] = useState<string>("")
194
- 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
195
271
  const [paletteQuery, setPaletteQuery] = useState<string>("")
196
272
  const [paletteIndex, setPaletteIndex] = useState<number>(0)
197
273
  // Synchronous mirrors for the keyboard handler — same reason filterOpenRef
198
274
  // exists. Modal input can arrive in one React batch (e.g. ctrl+p, Down,
199
275
  // Return), so every palette field read by later keys must update its ref
200
276
  // before React state commits.
201
- const paletteOpenRef = useRef(false)
202
277
  const paletteQueryRef = useRef("")
203
278
  const paletteIndexRef = useRef(0)
204
279
  const [readerEmptyStateTipRotation, setReaderEmptyStateTipRotation] = useState(
@@ -210,8 +285,8 @@ export const Browser = ({
210
285
  // first key opens the filter; subsequent keys in the same tick would
211
286
  // otherwise still observe filterOpen=false through closure).
212
287
  const filterOpenRef = useRef(startInFilter)
213
- const filterInputRef = useRef("")
214
- const filterAppliedRef = useRef("")
288
+ const filterInputRef = useRef(initialQuery)
289
+ const filterAppliedRef = useRef(initialQuery)
215
290
  const autoSelectForAppliedFilterRef = useRef(true)
216
291
  const focusRef = useRef<"sidebar" | "reader">(focus)
217
292
  const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
@@ -246,11 +321,25 @@ export const Browser = ({
246
321
  // the other's display window.
247
322
  useEffect(() => {
248
323
  if (footerNotice === null) return
324
+ closeFloatingOverlay()
249
325
  if (disableFooterNoticeAutoClear) return
250
326
  const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
251
327
  return () => clearTimeout(timer)
252
328
  }, [disableFooterNoticeAutoClear, footerNotice])
253
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])
342
+
254
343
  // Push the update-available nudge once, when it arrives from the parent
255
344
  // (the registry probe resolves asynchronously after boot). 10s gives the
256
345
  // user time to read it before it auto-clears; the quit-time stderr print
@@ -385,7 +474,6 @@ export const Browser = ({
385
474
  hasSelected: selected != null,
386
475
  focus,
387
476
  sidebarShown: shown,
388
- helpVisible,
389
477
  filterOpen,
390
478
  restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
391
479
  filterQuery: filterInput,
@@ -425,8 +513,8 @@ export const Browser = ({
425
513
  if (focus === "reader") setFocus("sidebar")
426
514
  }
427
515
  },
428
- setHelpVisible,
429
516
  openFilter: () => {
517
+ closeFloatingOverlay()
430
518
  // Focus the sidebar so the filter input has a home. In wide,
431
519
  // §7.1's visibility rule (`shown || focus === "sidebar"`) brings
432
520
  // the inline sidebar back on screen if it was hidden. In narrow,
@@ -439,6 +527,7 @@ export const Browser = ({
439
527
  setFilterOpen(true)
440
528
  },
441
529
  clearAndOpenFilter: () => {
530
+ closeFloatingOverlay()
442
531
  // Reset both the ref and the state so the freshly-opened modal
443
532
  // shows an empty input and selection lands on the first file in
444
533
  // the (now unfiltered) list.
@@ -454,16 +543,13 @@ export const Browser = ({
454
543
  setFilterOpen(true)
455
544
  },
456
545
  openPalette: () => {
457
- // Close help if it was open — palette is the active modal now.
458
546
  // Reset query/index so each open starts fresh (no stale state from
459
547
  // the previous session).
460
- if (helpVisible) setHelpVisible(() => false)
461
548
  paletteQueryRef.current = ""
462
549
  setPaletteQuery("")
463
550
  paletteIndexRef.current = 0
464
551
  setPaletteIndex(0)
465
- paletteOpenRef.current = true
466
- setPaletteOpen(true)
552
+ dispatchFloatingOverlay({ type: "open-command-palette" })
467
553
  },
468
554
  cycleTheme,
469
555
  toggleTone,
@@ -577,6 +663,68 @@ export const Browser = ({
577
663
  }
578
664
 
579
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
+
580
728
  // Filter modal: capture keystrokes for the input. Esc closes,
581
729
  // leaving the typed query applied as the active filter; Return
582
730
  // closes and focuses the reader (open the match); Ctrl+\ clears
@@ -631,6 +779,10 @@ export const Browser = ({
631
779
  setFocus("reader")
632
780
  return
633
781
  }
782
+ if (key.ctrl && !key.meta && key.name === "p") {
783
+ ctx.openPalette()
784
+ return
785
+ }
634
786
  if (key.ctrl && key.name === "\\") {
635
787
  // Same action as the `filter.clearOrOpen` binding fires from
636
788
  // outside the modal: clear the query, reset selection. The
@@ -676,95 +828,6 @@ export const Browser = ({
676
828
  }
677
829
  return
678
830
  }
679
- // Command palette modal: capture keystrokes for the query input and
680
- // list navigation. Esc closes (single press, regardless of query —
681
- // #70 Q7a). Return runs the selected command. Up/Down navigate.
682
- // Backspace edits the query and is a no-op on empty (#70 Q7b —
683
- // intentionally diverges from the filter modal, which closes on
684
- // empty-backspace, because accidental close feels worse in the
685
- // palette). Printable characters extend the query and snap selection
686
- // to 0 (#70 Q7c). Ctrl/Meta-modified keys are swallowed except
687
- // ctrl+p, which toggles the palette closed (matches help-toggle's
688
- // re-press-to-close behavior).
689
- if (paletteOpenRef.current) {
690
- const closePalette = () => {
691
- paletteOpenRef.current = false
692
- paletteQueryRef.current = ""
693
- paletteIndexRef.current = 0
694
- setPaletteOpen(false)
695
- setPaletteQuery("")
696
- setPaletteIndex(0)
697
- }
698
- const setPaletteIndexSync = (next: number) => {
699
- paletteIndexRef.current = next
700
- setPaletteIndex(next)
701
- }
702
- const allCommands = buildCommands(ctx)
703
- const filtered = filterCommands(allCommands, paletteQueryRef.current)
704
- if (key.name === "escape") {
705
- closePalette()
706
- return
707
- }
708
- if (key.name === "return") {
709
- const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
710
- closePalette()
711
- picked?.run()
712
- return
713
- }
714
- if (key.name === "up") {
715
- setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
716
- return
717
- }
718
- if (key.name === "down") {
719
- setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
720
- return
721
- }
722
- if (key.name === "backspace" || key.name === "delete") {
723
- if (paletteQueryRef.current.length === 0) return
724
- paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
725
- setPaletteQuery(paletteQueryRef.current)
726
- setPaletteIndexSync(0)
727
- return
728
- }
729
- // ctrl+p again closes — matches help-toggle behavior.
730
- if (key.ctrl && !key.meta && key.name === "p") {
731
- closePalette()
732
- return
733
- }
734
- if (key.ctrl || key.meta) return
735
- let char: string | null = null
736
- if (key.name === "space") char = " "
737
- else if (typeof key.name === "string" && key.name.length === 1) {
738
- char = key.shift ? key.name.toUpperCase() : key.name
739
- }
740
- if (char !== null) {
741
- paletteQueryRef.current = paletteQueryRef.current + char
742
- setPaletteQuery(paletteQueryRef.current)
743
- setPaletteIndexSync(0)
744
- }
745
- return
746
- }
747
- // While help is open, swallow most keys: only ? (toggle), esc
748
- // (close), and the theme bindings pass through. Theme keys stay live
749
- // so users can preview palette changes against the overlay itself —
750
- // it is the largest theme-painted surface in the app. Everything else
751
- // is suppressed so the user can read without driving the UI behind.
752
- // This is the one place we step outside the data-driven keymap; the
753
- // alternative — adding `when: !c.helpVisible` to every other binding
754
- // — would clutter the array. See DESIGN.md §12 (keymap composition).
755
- if (helpVisible) {
756
- if (key.name === "escape") {
757
- setHelpVisible(() => false)
758
- return
759
- }
760
- const allowed = browserBindings.filter((b) => HELP_ALLOWED_IDS.has(b.id))
761
- // Defensive: stub quit even though no allowed binding currently
762
- // calls it. Keeps the invariant local to this branch instead of
763
- // relying on a future maintainer remembering not to add quit-ish
764
- // bindings to HELP_ALLOWED_IDS.
765
- dispatch(allowed, { ...ctx, quit: () => {} }, key)
766
- return
767
- }
768
831
  dispatch(browserBindings, ctx, key)
769
832
  })
770
833
 
@@ -787,6 +850,7 @@ export const Browser = ({
787
850
  // per-pane border title that used to carry this information).
788
851
  const currentFile = selected?.relativePath ?? null
789
852
  const content = loaded?.path === renderedPath ? loaded.content : ""
853
+ const parsedContent = useMemo(() => parseFrontmatter(content), [content])
790
854
  const readerEmptyStateTitle = filterHasNoMatches
791
855
  ? `No files match: ${filterInput}`
792
856
  : `${BRAND} ${BRAND_NAME}`
@@ -840,31 +904,26 @@ export const Browser = ({
840
904
  [sidebarTextWidth],
841
905
  )
842
906
 
843
- // While help is open, the `?` key closes the overlay — relabel its hint
844
- // so the footer accurately describes what pressing the key will do.
845
- // Memoized: `helpVisible` changes rarely; `browserBindings` and
846
- // `HELP_ALLOWED_IDS` are module-level constants.
847
- const footerBindings = useMemo(
848
- () =>
849
- helpVisible
850
- ? browserBindings
851
- .filter((b) => HELP_ALLOWED_IDS.has(b.id))
852
- .map((b) => (b.id === "help.toggle" ? { ...b, hint: "close" } : b))
853
- : browserBindings,
854
- [helpVisible],
855
- )
856
907
  const footerProps = {
857
- bindings: footerBindings,
908
+ bindings: browserBindings,
858
909
  ctx,
859
910
  width,
860
911
  notice: footerNotice?.text ?? null,
861
912
  discoveryStatus,
862
- filterQuery: !filterOpen && filterInput.length > 0 ? filterInput : null,
863
913
  ...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
864
914
  ...(discoverySpinnerInitialFrameIndex === undefined
865
915
  ? {}
866
916
  : { discoverySpinnerInitialFrameIndex }),
867
917
  ...(discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick }),
918
+ ...(discoveryWarningStatus === null
919
+ ? {}
920
+ : {
921
+ onDiscoveryWarningToggle: () =>
922
+ dispatchFloatingOverlay({
923
+ type: "toggle-status-popover",
924
+ content: discoveryWarningStatus,
925
+ }),
926
+ }),
868
927
  } satisfies FooterProps<BrowserCtx>
869
928
  const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
870
929
  const readerEmptyStateTip = useMemo(
@@ -885,33 +944,25 @@ export const Browser = ({
885
944
  />
886
945
  )}
887
946
  {displayedFiles.length === 0 ? (
888
- <text
889
- content={
947
+ <SidebarEmptyMessage
948
+ withTopSpacer={filterRowVisible}
949
+ width={sidebarTextWidth}
950
+ label={
890
951
  files.length === 0
891
952
  ? discoveryActive
892
- ? "(scanning…)"
893
- : "(no markdown files)"
894
- : "(no matches)"
953
+ ? "Scanning"
954
+ : "No markdown files in"
955
+ : "No files match"
895
956
  }
896
- style={{ fg: colors.textMuted }}
957
+ value={files.length === 0 ? (discoveryActive ? "…" : emptyRootLabel) : filterApplied}
897
958
  />
898
959
  ) : (
899
960
  visibleFiles.map((file, idx) => {
900
961
  const realIdx = desiredScroll + idx
901
962
  const isSelected = realIdx === selectedIndex
902
963
  const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
903
- const selectedFg =
904
- colors.selectedListItemText === colors.background
905
- ? colors.primary
906
- : colors.selectedListItemText
907
- const basenameFg = isSelected
908
- ? sidebarActive
909
- ? selectedFg
910
- : colors.primary
911
- : colors.text
912
- const rowStyle = isSelected
913
- ? { bg: sidebarActive ? colors.backgroundElement : colors.borderSubtle }
914
- : {}
964
+ const basenameFg = isSelected ? colors.selectedListItemText : colors.text
965
+ const rowStyle = isSelected ? { bg: colors.backgroundElement } : {}
915
966
  return (
916
967
  <text key={file.path} wrapMode="none" style={rowStyle}>
917
968
  <span style={{ fg: basenameFg }}>{basename}</span>
@@ -946,6 +997,7 @@ export const Browser = ({
946
997
  rightT: "┤",
947
998
  cross: "┼",
948
999
  } as const
1000
+ const INACTIVE_PANE_OPACITY = 0.62
949
1001
 
950
1002
  return (
951
1003
  <box
@@ -966,7 +1018,7 @@ export const Browser = ({
966
1018
  // Narrow mode runs single-pane: the sidebar fills the area
967
1019
  // and drops its right divider (no neighbour to abut).
968
1020
  border: isNarrow ? readerBorderSides : sidebarBorderSides,
969
- borderColor: colors.textMuted,
1021
+ borderColor: colors.border,
970
1022
  ...(isNarrow
971
1023
  ? { flexGrow: 1, flexShrink: 1 }
972
1024
  : { width: sidebarWidth, flexShrink: 0 }),
@@ -985,6 +1037,7 @@ export const Browser = ({
985
1037
  flexDirection: "column",
986
1038
  paddingLeft: 1,
987
1039
  backgroundColor: sidebarActive ? colors.background : colors.backgroundPanel,
1040
+ opacity: sidebarActive ? 1 : INACTIVE_PANE_OPACITY,
988
1041
  }}
989
1042
  >
990
1043
  {sidebarBody}
@@ -995,7 +1048,7 @@ export const Browser = ({
995
1048
  <box
996
1049
  style={{
997
1050
  border: readerBorderSides,
998
- borderColor: colors.textMuted,
1051
+ borderColor: colors.border,
999
1052
  flexGrow: 1,
1000
1053
  flexShrink: 1,
1001
1054
  flexDirection: "column",
@@ -1010,6 +1063,7 @@ export const Browser = ({
1010
1063
  flexDirection: "column",
1011
1064
  padding: 1,
1012
1065
  backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
1066
+ opacity: readerActive ? 1 : INACTIVE_PANE_OPACITY,
1013
1067
  }}
1014
1068
  >
1015
1069
  {error ? (
@@ -1053,17 +1107,38 @@ export const Browser = ({
1053
1107
  }}
1054
1108
  // opentui's scrollbox consumes arrow keys at the focused-element
1055
1109
  // level *before* useKeyboard fires, so a modal that handles
1056
- // arrow keys itself (palette nav, help dismissal) would still
1110
+ // arrow keys itself (palette nav) would still
1057
1111
  // see the reader scroll alongside its own action. Unfocus the
1058
1112
  // scrollbox while any blocking modal is up — useKeyboard's
1059
1113
  // modal branches own the keys in that state. Filter is not
1060
1114
  // listed because it force-focuses the sidebar (readerActive
1061
1115
  // is already false).
1062
- focused={readerActive && !paletteOpen && !helpVisible}
1116
+ focused={readerActive && !paletteOpen}
1063
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
+ )}
1064
1139
  <markdown
1065
1140
  key={renderedPath ?? "empty"}
1066
- content={content}
1141
+ content={parsedContent.body}
1067
1142
  syntaxStyle={syntaxStyle}
1068
1143
  fg={colors.text}
1069
1144
  bg={readerActive ? colors.background : colors.backgroundPanel}
@@ -1077,18 +1152,18 @@ export const Browser = ({
1077
1152
  )}
1078
1153
  </box>
1079
1154
  <Footer {...footerProps} />
1080
- {helpVisible && (
1081
- <HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
1082
- )}
1083
1155
  {paletteOpen && (
1084
1156
  <CommandPalette
1085
- commands={filterCommands(buildCommands(ctx), paletteQuery)}
1157
+ commands={orderCommandsForPalette(filterCommands(buildCommands(ctx), paletteQuery))}
1086
1158
  query={paletteQuery}
1087
1159
  selectedIndex={paletteIndex}
1088
1160
  viewportWidth={width}
1089
1161
  viewportHeight={height}
1090
1162
  />
1091
1163
  )}
1164
+ {activeStatusPopover && (
1165
+ <StatusPopoverPanel content={activeStatusPopover.content} variant="warning" />
1166
+ )}
1092
1167
  </box>
1093
1168
  )
1094
1169
  }