@carlesandres/house 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.7] — 2026-05-27
10
+
11
+ ### Added
12
+
13
+ - `--root <path>` and TOML `defaultRoot` let launches default to a configured browsing root while preserving explicit path arguments.
14
+ - Discovery/indexing status now shows a spinner in the chrome instead of only static text.
15
+
16
+ ### Changed
17
+
18
+ - File actions now use uppercase `E` / `O` instead of lowercase `e` / `o`, adding a small amount of friction to edit and open-in-browser actions.
19
+ - Sidebar filtering now applies after a short debounce to reduce churn while typing.
20
+ - Filtered browsing now keeps the first match sticky/selected as results update.
21
+
22
+ ### Fixed
23
+
24
+ - PTY typing coverage and footer hint tests now match the shifted file-action bindings.
25
+ - CI typechecking now satisfies exact optional prop typing in the release branch.
26
+
27
+ ### Docs
28
+
29
+ - Removed the obsolete `CONTEXT.md` glossary and inlined the beta-term explanation into `DESIGN.md` with cleaned-up cross-references.
30
+ - ROADMAP shipped items were pruned after the covered work landed.
31
+
32
+ ### Tests
33
+
34
+ - Browser test noise from React act warnings was reduced around the new filter behavior coverage.
35
+
9
36
  ## [0.4.6] — 2026-05-25
10
37
 
11
38
  ### Added
@@ -73,7 +100,7 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
73
100
 
74
101
  ### Added
75
102
 
76
- - Editor hand-off: press `e` to open the selected file in `$EDITOR`/`$VISUAL` (`#19`).
103
+ - Editor hand-off: press `E` to open the selected file in `$EDITOR`/`$VISUAL` (`#19`).
77
104
  - Sidebar rows now render basename-first with segment-aware parent-path elision, improving scanability in deep trees.
78
105
  - `ctrl+\\` clears an active sidebar filter query without leaving filter mode.
79
106
 
@@ -207,7 +234,7 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
207
234
  ### Added — keymap
208
235
 
209
236
  - `KeyBinding[]` with `id` / `description` / `keys` / `group` / optional `when` / `run`. Single source for both `useKeyboard` dispatch and the help overlay.
210
- - Bindings: `j`/`k` + arrows, shift-jump, page/half-page, `g`/`G`, `return`/`l`/`→`, `escape`/`h`/`←`, `[`/`]`, `tab`, `\`, `?`, `q`/`ctrl+c`. Reserved (not bound): `/`, `e`, `o`, `r`.
237
+ - Bindings: `j`/`k` + arrows, shift-jump, page/half-page, `g`/`G`, `return`/`l`/`→`, `escape`/`h`/`←`, `[`/`]`, `tab`, `\`, `?`, `q`/`ctrl+c`. Reserved (not bound): `/`, `r`.
211
238
 
212
239
  ### Added — release infra
213
240
 
@@ -230,7 +257,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
230
257
 
231
258
  Search, stdin, URL fetching, cross-file link following, `$EDITOR` hand-off, syntax highlighting, persistent config, OS-appearance auto-detect, single-binary distribution (issue [#2](https://github.com/carlesandres/openmdr/issues/2)), Homebrew tap. All tracked.
232
259
 
233
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.6...HEAD
260
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.7...HEAD
261
+ [0.4.7]: https://github.com/carlesandres/house/compare/v0.4.6...v0.4.7
234
262
  [0.4.6]: https://github.com/carlesandres/house/compare/v0.4.5...v0.4.6
235
263
  [0.4.5]: https://github.com/carlesandres/house/compare/v0.4.4...v0.4.5
236
264
  [0.4.4]: https://github.com/carlesandres/house/compare/v0.4.3...v0.4.4
package/README.md CHANGED
@@ -111,8 +111,8 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
111
111
  | `s` | Toggle sidebar visibility |
112
112
  | `?` | Show / dismiss help overlay |
113
113
  | `ctrl+p` | Command palette |
114
- | `o` | Open current file in browser as HTML |
115
- | `e` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
114
+ | `O` | Open current file in browser as HTML |
115
+ | `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
116
116
  | `t` | Next theme |
117
117
  | `T` | Previous theme |
118
118
  | `L` | Toggle dark / light tone |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/Browser.tsx CHANGED
@@ -22,7 +22,7 @@ import { CommandPalette } from "./CommandPalette.tsx"
22
22
  import { filterFiles } from "./discovery/filter.ts"
23
23
  import { type FileEntry } from "./discovery/walk.ts"
24
24
  import { BRAND, BRAND_NAME } from "./brand.ts"
25
- import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
25
+ import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
26
26
  import { Header, HEADER_HEIGHT } from "./Header.tsx"
27
27
  import { HelpOverlay } from "./HelpOverlay.tsx"
28
28
  import { openInEditor, resolveEditor } from "./io/editor.ts"
@@ -55,6 +55,17 @@ export interface BrowserProps {
55
55
  /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
56
56
  * when discovery has finished; the indicator clears. */
57
57
  readonly discoveryStatus?: string | null
58
+ /** Test seam: override the footer discovery spinner speed. */
59
+ readonly discoverySpinnerIntervalMs?: number
60
+ readonly discoverySpinnerInitialFrameIndex?: number
61
+ /** Test seam: deterministic footer spinner driver. */
62
+ readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
63
+ /** Test seam: override filter debounce timing. */
64
+ readonly filterDebounceMs?: number
65
+ /** Test seam: override rendered-path debounce timing. */
66
+ readonly renderedPathDebounceMs?: number
67
+ /** Test seam: disable reader-empty-state tip rotation effect. */
68
+ readonly disableReaderEmptyStateRotation?: boolean
58
69
  /** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
59
70
  * launch viewport bucket once; subsequent visibility goes through `s`. */
60
71
  readonly sidebarMode?: SidebarMode
@@ -69,6 +80,8 @@ export interface BrowserProps {
69
80
  /** TTL (ms) for the update-notice toast. Exposed so tests can use a small
70
81
  * value instead of sleeping for the production 10s window. */
71
82
  readonly updateNoticeTtlMs?: number
83
+ /** Test seam: disable footer-notice auto-clear timers. */
84
+ readonly disableFooterNoticeAutoClear?: boolean
72
85
  /** Flip the parent's discovery vocabulary (#145). Browser doesn't need
73
86
  * to know which categories are currently on — the toggle is opaque
74
87
  * from this side; we just snapshot the selected path so it can be
@@ -105,16 +118,26 @@ export const setReaderEmptyStateTipRotationForTests = (next: number) => {
105
118
  nextReaderEmptyStateTipRotation = next
106
119
  }
107
120
 
121
+ const FILTER_DEBOUNCE_MS = 50
122
+ const RENDERED_PATH_DEBOUNCE_MS = 80
123
+
108
124
  export const Browser = ({
109
125
  files,
110
126
  initialIndex = 0,
111
127
  maxWidth = null,
112
128
  discoveryStatus = null,
129
+ discoverySpinnerIntervalMs,
130
+ discoverySpinnerInitialFrameIndex,
131
+ discoverySpinnerRegisterTick = null,
132
+ filterDebounceMs = FILTER_DEBOUNCE_MS,
133
+ renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
134
+ disableReaderEmptyStateRotation = false,
113
135
  sidebarMode = "auto",
114
136
  onQuit,
115
137
  readFile = defaultReadFile,
116
138
  updateNotice = null,
117
139
  updateNoticeTtlMs = 10000,
140
+ disableFooterNoticeAutoClear = false,
118
141
  onToggleAll,
119
142
  startupFocus = null,
120
143
  }: BrowserProps) => {
@@ -166,7 +189,8 @@ export const Browser = ({
166
189
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
167
190
  const [helpVisible, setHelpVisible] = useState<boolean>(false)
168
191
  const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
169
- const [filterQuery, setFilterQuery] = useState<string>("")
192
+ const [filterInput, setFilterInput] = useState<string>("")
193
+ const [filterApplied, setFilterApplied] = useState<string>("")
170
194
  const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
171
195
  const [paletteQuery, setPaletteQuery] = useState<string>("")
172
196
  const [paletteIndex, setPaletteIndex] = useState<number>(0)
@@ -186,7 +210,9 @@ export const Browser = ({
186
210
  // first key opens the filter; subsequent keys in the same tick would
187
211
  // otherwise still observe filterOpen=false through closure).
188
212
  const filterOpenRef = useRef(startInFilter)
189
- const filterQueryRef = useRef("")
213
+ const filterInputRef = useRef("")
214
+ const filterAppliedRef = useRef("")
215
+ const autoSelectForAppliedFilterRef = useRef(true)
190
216
  const focusRef = useRef<"sidebar" | "reader">(focus)
191
217
  const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
192
218
  const [footerNotice, setFooterNoticeState] = useState<{
@@ -220,9 +246,10 @@ export const Browser = ({
220
246
  // the other's display window.
221
247
  useEffect(() => {
222
248
  if (footerNotice === null) return
249
+ if (disableFooterNoticeAutoClear) return
223
250
  const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
224
251
  return () => clearTimeout(timer)
225
- }, [footerNotice])
252
+ }, [disableFooterNoticeAutoClear, footerNotice])
226
253
 
227
254
  // Push the update-available nudge once, when it arrives from the parent
228
255
  // (the registry probe resolves asynchronously after boot). 10s gives the
@@ -257,8 +284,29 @@ export const Browser = ({
257
284
  pushFooterNotice(`tone: ${nextTone}`)
258
285
  }
259
286
 
260
- const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
261
- const filterHasNoMatches = filterQuery.length > 0 && displayedFiles.length === 0
287
+ useEffect(() => {
288
+ if (filterInput === filterApplied) return
289
+ const timer = setTimeout(() => {
290
+ filterAppliedRef.current = filterInput
291
+ setFilterApplied(filterInput)
292
+ }, filterDebounceMs)
293
+ return () => clearTimeout(timer)
294
+ }, [filterApplied, filterDebounceMs, filterInput])
295
+
296
+ useEffect(() => {
297
+ autoSelectForAppliedFilterRef.current = true
298
+ }, [filterApplied])
299
+
300
+ const displayedFiles = useMemo(() => filterFiles(files, filterApplied), [files, filterApplied])
301
+ const filterHasNoMatches = filterInput.length > 0 && displayedFiles.length === 0
302
+
303
+ useEffect(() => {
304
+ if (filterApplied.length === 0) return
305
+ if (!autoSelectForAppliedFilterRef.current) return
306
+ if (displayedFiles.length === 0) return
307
+ setSelectedIndex(0)
308
+ autoSelectForAppliedFilterRef.current = false
309
+ }, [displayedFiles, filterApplied])
262
310
  // When the filtered list shrinks, keep selectedIndex valid. The reset to 0
263
311
  // on every query change happens in the keystroke handler, not here, so a
264
312
  // no-op rerender doesn't snap the cursor back to the top.
@@ -296,9 +344,9 @@ export const Browser = ({
296
344
  useEffect(() => {
297
345
  const target = selected?.path ?? null
298
346
  if (target === renderedPath) return
299
- const timer = setTimeout(() => setRenderedPath(target), 80)
347
+ const timer = setTimeout(() => setRenderedPath(target), renderedPathDebounceMs)
300
348
  return () => clearTimeout(timer)
301
- }, [selected?.path, renderedPath])
349
+ }, [selected?.path, renderedPath, renderedPathDebounceMs])
302
350
 
303
351
  useEffect(() => {
304
352
  if (!renderedPath) {
@@ -340,7 +388,7 @@ export const Browser = ({
340
388
  helpVisible,
341
389
  filterOpen,
342
390
  restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
343
- filterQuery,
391
+ filterQuery: filterInput,
344
392
  paletteOpen,
345
393
  setFocus,
346
394
  // Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
@@ -350,6 +398,7 @@ export const Browser = ({
350
398
  // itself) deliberately use the raw `setSelectedIndex` setter.
351
399
  setSelectedIndex: (updater) => {
352
400
  pendingSelectionPathRef.current = null
401
+ autoSelectForAppliedFilterRef.current = false
353
402
  setSelectedIndex(updater)
354
403
  },
355
404
  toggleShown: () => {
@@ -393,8 +442,10 @@ export const Browser = ({
393
442
  // Reset both the ref and the state so the freshly-opened modal
394
443
  // shows an empty input and selection lands on the first file in
395
444
  // the (now unfiltered) list.
396
- filterQueryRef.current = ""
397
- setFilterQuery("")
445
+ filterInputRef.current = ""
446
+ filterAppliedRef.current = ""
447
+ setFilterInput("")
448
+ setFilterApplied("")
398
449
  setSelectedIndex(() => 0)
399
450
  focusRef.current = "sidebar"
400
451
  if (focus !== "sidebar") setFocus("sidebar")
@@ -467,7 +518,7 @@ export const Browser = ({
467
518
  if (!file) return
468
519
  const editor = resolveEditor(process.env)
469
520
  if (!editor) {
470
- pushFooterNotice("set $EDITOR or $VISUAL to use e")
521
+ pushFooterNotice("set $EDITOR or $VISUAL to use E")
471
522
  return
472
523
  }
473
524
  if (!renderer) {
@@ -543,6 +594,8 @@ export const Browser = ({
543
594
  // the Return semantic (open the match in the reader); false is
544
595
  // Esc (stop typing, keep the applied filter, stay in sidebar).
545
596
  const closeFilter = (commit: boolean) => {
597
+ filterAppliedRef.current = filterInputRef.current
598
+ setFilterApplied(filterInputRef.current)
546
599
  const picked = displayedFiles[selectedIndex] ?? null
547
600
  const effectiveCommit = commit && picked !== null
548
601
  restoreFilterOnSidebarFocusRef.current = false
@@ -583,20 +636,22 @@ export const Browser = ({
583
636
  // outside the modal: clear the query, reset selection. The
584
637
  // keymap doesn't see keys in filter mode, so this branch is
585
638
  // the in-modal half of that single chord.
586
- filterQueryRef.current = ""
587
- setFilterQuery("")
639
+ filterInputRef.current = ""
640
+ filterAppliedRef.current = ""
641
+ setFilterInput("")
642
+ setFilterApplied("")
588
643
  setSelectedIndex(() => 0)
589
644
  return
590
645
  }
591
646
  if (key.name === "backspace" || key.name === "delete") {
592
647
  // Backspace on empty input closes the modal — the leading `/`
593
648
  // chevron is the last thing left to "delete."
594
- if (filterQueryRef.current.length === 0) {
649
+ if (filterInputRef.current.length === 0) {
595
650
  closeFilter(false)
596
651
  return
597
652
  }
598
- filterQueryRef.current = filterQueryRef.current.slice(0, -1)
599
- setFilterQuery(filterQueryRef.current)
653
+ filterInputRef.current = filterInputRef.current.slice(0, -1)
654
+ setFilterInput(filterInputRef.current)
600
655
  setSelectedIndex(() => 0)
601
656
  return
602
657
  }
@@ -615,8 +670,8 @@ export const Browser = ({
615
670
  char = key.shift ? key.name.toUpperCase() : key.name
616
671
  }
617
672
  if (char !== null) {
618
- filterQueryRef.current = filterQueryRef.current + char
619
- setFilterQuery(filterQueryRef.current)
673
+ filterInputRef.current = filterInputRef.current + char
674
+ setFilterInput(filterInputRef.current)
620
675
  setSelectedIndex(() => 0)
621
676
  }
622
677
  return
@@ -733,11 +788,12 @@ export const Browser = ({
733
788
  const currentFile = selected?.relativePath ?? null
734
789
  const content = loaded?.path === renderedPath ? loaded.content : ""
735
790
  const readerEmptyStateTitle = filterHasNoMatches
736
- ? `No files match: ${filterQuery}`
791
+ ? `No files match: ${filterInput}`
737
792
  : `${BRAND} ${BRAND_NAME}`
738
793
  const readerEmptyStateVisible = error == null && renderedPath == null
739
794
 
740
795
  useEffect(() => {
796
+ if (disableReaderEmptyStateRotation) return
741
797
  if (readerEmptyStateVisible) {
742
798
  if (!readerEmptyStateVisibleRef.current) {
743
799
  readerEmptyStateVisibleRef.current = true
@@ -746,7 +802,7 @@ export const Browser = ({
746
802
  return
747
803
  }
748
804
  readerEmptyStateVisibleRef.current = false
749
- }, [readerEmptyStateVisible])
805
+ }, [disableReaderEmptyStateRotation, readerEmptyStateVisible])
750
806
 
751
807
  // Sidebar virtualization: render only the visible window. Without this,
752
808
  // every keystroke re-renders all N file rows even though only the bg of
@@ -797,6 +853,19 @@ export const Browser = ({
797
853
  : browserBindings,
798
854
  [helpVisible],
799
855
  )
856
+ const footerProps = {
857
+ bindings: footerBindings,
858
+ ctx,
859
+ width,
860
+ notice: footerNotice?.text ?? null,
861
+ discoveryStatus,
862
+ filterQuery: !filterOpen && filterInput.length > 0 ? filterInput : null,
863
+ ...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
864
+ ...(discoverySpinnerInitialFrameIndex === undefined
865
+ ? {}
866
+ : { discoverySpinnerInitialFrameIndex }),
867
+ ...(discoverySpinnerRegisterTick === undefined ? {} : { discoverySpinnerRegisterTick }),
868
+ } satisfies FooterProps<BrowserCtx>
800
869
  const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
801
870
  const readerEmptyStateTip = useMemo(
802
871
  () => pickTipByRotation(readerEmptyStateTips, readerEmptyStateTipRotation),
@@ -809,7 +878,7 @@ export const Browser = ({
809
878
  <>
810
879
  {filterRowVisible && (
811
880
  <PromptRow
812
- query={filterQuery}
881
+ query={filterInput}
813
882
  editing={filterOpen}
814
883
  placeholder="/ to filter…"
815
884
  width={sidebarTextWidth}
@@ -1007,14 +1076,7 @@ export const Browser = ({
1007
1076
  </box>
1008
1077
  )}
1009
1078
  </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
- />
1079
+ <Footer {...footerProps} />
1018
1080
  {helpVisible && (
1019
1081
  <HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
1020
1082
  )}
package/src/Footer.tsx CHANGED
@@ -21,8 +21,10 @@
21
21
  * require a real cell-width counter (e.g. East Asian Width).
22
22
  */
23
23
 
24
+ import type React from "react"
24
25
  import type { KeyBinding } from "./keymap/keymap.ts"
25
26
  import { displayKey } from "./keymap/displayKey.ts"
27
+ import { Spinner } from "./Spinner.tsx"
26
28
  import { colors } from "./theme/colors.ts"
27
29
 
28
30
  /** Rows the Footer occupies. Importers use it for layout math so a future
@@ -43,6 +45,12 @@ export interface FooterProps<C> {
43
45
  * while the filter input is open (the sidebar already shows the query) or
44
46
  * when no filter is applied. */
45
47
  readonly filterQuery?: string | null
48
+ /** Test seam: override spinner tick speed so tests don't sleep on the full
49
+ * production interval. Ignored when discoveryStatus is null. */
50
+ readonly discoverySpinnerIntervalMs?: number
51
+ readonly discoverySpinnerInitialFrameIndex?: number
52
+ /** Test seam: deterministic footer spinner driver. */
53
+ readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
46
54
  }
47
55
 
48
56
  const HINT_SEPARATOR = " "
@@ -92,6 +100,9 @@ export const Footer = <C,>({
92
100
  notice,
93
101
  discoveryStatus,
94
102
  filterQuery,
103
+ discoverySpinnerIntervalMs,
104
+ discoverySpinnerInitialFrameIndex,
105
+ discoverySpinnerRegisterTick,
95
106
  }: FooterProps<C>) => {
96
107
  const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
97
108
 
@@ -185,8 +196,23 @@ export const Footer = <C,>({
185
196
  }
186
197
 
187
198
  if (status !== null) {
199
+ const spinnerProps = {
200
+ fg: colors.secondary,
201
+ ...(discoverySpinnerIntervalMs === undefined
202
+ ? {}
203
+ : { intervalMs: discoverySpinnerIntervalMs }),
204
+ ...(discoverySpinnerInitialFrameIndex === undefined
205
+ ? {}
206
+ : { initialFrameIndex: discoverySpinnerInitialFrameIndex }),
207
+ ...(discoverySpinnerRegisterTick === undefined
208
+ ? {}
209
+ : { registerTick: discoverySpinnerRegisterTick }),
210
+ } satisfies React.ComponentProps<typeof Spinner>
211
+
188
212
  return (
189
213
  <box style={rowStyle}>
214
+ <Spinner {...spinnerProps} />
215
+ <text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
190
216
  <text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
191
217
  <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
192
218
  {renderHints()}
@@ -0,0 +1,39 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+ import { colors } from "./theme/colors.ts"
3
+
4
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
5
+ const FRAME_COUNT = FRAMES.length
6
+ const normalizeFrameIndex = (index: number) => ((index % FRAME_COUNT) + FRAME_COUNT) % FRAME_COUNT
7
+
8
+ export interface SpinnerProps {
9
+ readonly fg?: string
10
+ readonly intervalMs?: number
11
+ readonly initialFrameIndex?: number
12
+ /** Test seam: deterministic driver for frame advancement. When present,
13
+ * Spinner registers its tick callback here instead of starting an interval. */
14
+ readonly registerTick?: ((tick: () => void) => void) | null
15
+ }
16
+
17
+ export const Spinner = ({
18
+ fg = colors.textMuted,
19
+ intervalMs = 100,
20
+ initialFrameIndex = 0,
21
+ registerTick = null,
22
+ }: SpinnerProps) => {
23
+ const [index, setIndex] = useState(() => normalizeFrameIndex(initialFrameIndex))
24
+ const tickRef = useRef<() => void>(() => undefined)
25
+ tickRef.current = () => setIndex((prev) => (prev + 1) % FRAME_COUNT)
26
+
27
+ useEffect(() => {
28
+ if (registerTick) {
29
+ registerTick(() => tickRef.current())
30
+ return
31
+ }
32
+ const id = setInterval(() => {
33
+ tickRef.current()
34
+ }, intervalMs)
35
+ return () => clearInterval(id)
36
+ }, [intervalMs, registerTick])
37
+
38
+ return <text content={FRAMES[index] ?? FRAMES[0]} wrapMode="none" style={{ fg }} />
39
+ }
package/src/cli/argv.ts CHANGED
@@ -4,6 +4,8 @@ import { themeDefinitions } from "../theme/registry.ts"
4
4
  export interface ParsedArgs {
5
5
  /** First positional argument, or null if none was given. */
6
6
  readonly path: string | null
7
+ /** Value of `--root <dir>`, or null. */
8
+ readonly root: string | null
7
9
  /** Value of `--theme <id>`, or null. Validated by the boot layer against the registry. */
8
10
  readonly theme: string | null
9
11
  /** Value of `--tone dark|light`, or null. Validated by the boot layer. */
@@ -58,6 +60,7 @@ const createProgram = () =>
58
60
  .option("--no-mdx")
59
61
  .option("--focus [mode]")
60
62
  .option("--show [list]")
63
+ .option("--root [dir]")
61
64
  .option("-h, --help")
62
65
  .option("-v, --version")
63
66
  .argument("[path]")
@@ -71,6 +74,7 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
71
74
  "--sidebar",
72
75
  "--focus",
73
76
  "--show",
77
+ "--root",
74
78
  ])
75
79
 
76
80
  const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
@@ -115,6 +119,7 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
115
119
 
116
120
  return {
117
121
  path: typeof pathArg === "string" ? pathArg : null,
122
+ root: stringOrNull(opts["root"]),
118
123
  theme: stringOrNull(opts["theme"]),
119
124
  tone: stringOrNull(opts["tone"]),
120
125
  width: stringOrNull(opts["width"]),
@@ -144,6 +149,7 @@ options:
144
149
  --width <N> cap rendered markdown width at N columns
145
150
  --show <list> reveal normally-skipped entries; comma-separated subset of:
146
151
  hidden, gitignored. Use --show "" to clear.
152
+ --root <dir> discovery root to walk (overrides defaultRoot config)
147
153
  --sort <mode> sidebar order: dirs-first (default) or files-first
148
154
  --sidebar <m> initial sidebar visibility: auto (default), on, or off
149
155
  --focus <m> startup focus: sidebar, reader, or filter (default: filter)
@@ -157,6 +163,6 @@ options:
157
163
 
158
164
  configuration:
159
165
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
160
- keys: theme, tone, mdx, show, focus
161
- env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS
166
+ keys: theme, tone, mdx, show, focus, defaultRoot
167
+ env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
162
168
  precedence (high → low): flags → env → file → defaults`
@@ -20,6 +20,8 @@ export interface HouseConfig {
20
20
  readonly theme: string
21
21
  readonly tone: "dark" | "light"
22
22
  readonly mdx: boolean
23
+ /** Default discovery-root strategy when no explicit `--root` flag is passed. */
24
+ readonly defaultRoot: "cwd" | "git"
23
25
  /** Categories of normally-skipped entries to opt into. See
24
26
  * `src/discovery/show.ts` for the vocabulary. Empty array (the
25
27
  * default) yields the conservative discovery set. */
@@ -43,6 +45,7 @@ export interface CliOverrides {
43
45
  const DEFAULT_THEME = "opencode"
44
46
  const DEFAULT_TONE: "dark" | "light" = "dark"
45
47
  const DEFAULT_MDX = true
48
+ const DEFAULT_ROOT: "cwd" | "git" = "cwd"
46
49
  const DEFAULT_SHOW = ""
47
50
  const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
48
51
 
@@ -54,11 +57,19 @@ const themeIds = themeDefinitions.map((t) => t.id)
54
57
  * Used by `fileProvider` to warn about unrecognized keys (with a
55
58
  * did-you-mean hint when one is close) while still loading the rest.
56
59
  */
57
- const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx", "show", "focus"])
60
+ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
61
+ "theme",
62
+ "tone",
63
+ "mdx",
64
+ "show",
65
+ "focus",
66
+ "defaultRoot",
67
+ ])
58
68
 
59
69
  const schema = Config.all({
60
70
  theme: Config.schema(Schema.Literals(themeIds), "theme"),
61
71
  tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
72
+ defaultRoot: Config.schema(Schema.String, "defaultRoot"),
62
73
  // Boolean stored as string literal because providers stringify values
63
74
  // (TOML bools, env vars, CLI flags all flow through as text). Mapped to
64
75
  // a real boolean in `loadConfig` below.
@@ -75,6 +86,7 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
75
86
  ConfigProvider.fromUnknown({
76
87
  theme: DEFAULT_THEME,
77
88
  tone: DEFAULT_TONE,
89
+ defaultRoot: DEFAULT_ROOT,
78
90
  mdx: String(DEFAULT_MDX),
79
91
  show: DEFAULT_SHOW,
80
92
  focus: DEFAULT_FOCUS,
@@ -192,11 +204,13 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
192
204
  const entries: Array<[string, string]> = []
193
205
  const theme = env["HOUSE_THEME"]
194
206
  const tone = env["HOUSE_TONE"]
207
+ const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
195
208
  const mdx = env["HOUSE_MDX"]
196
209
  const show = env["HOUSE_SHOW"]
197
210
  const focus = env["HOUSE_FOCUS"]
198
211
  if (theme !== undefined) entries.push(["theme", theme])
199
212
  if (tone !== undefined) entries.push(["tone", tone])
213
+ if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
200
214
  if (mdx !== undefined) entries.push(["mdx", mdx])
201
215
  if (show !== undefined) entries.push(["show", show])
202
216
  if (focus !== undefined) entries.push(["focus", focus])
@@ -262,6 +276,13 @@ export const loadConfig = (
262
276
  )
263
277
  return schema.parse(provider).pipe(
264
278
  Effect.flatMap((raw) => {
279
+ const defaultRoot =
280
+ raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
281
+ if (raw.defaultRoot !== defaultRoot) {
282
+ onWarning(
283
+ `house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
284
+ )
285
+ }
265
286
  const parsed = parseShowList(raw.show)
266
287
  if (!parsed.ok) {
267
288
  // Effect's `Config.ConfigError` requires a `SchemaError` or
@@ -278,6 +299,7 @@ export const loadConfig = (
278
299
  return Effect.succeed({
279
300
  theme: raw.theme,
280
301
  tone: raw.tone,
302
+ defaultRoot,
281
303
  mdx: raw.mdx === "true",
282
304
  show: parsed.value,
283
305
  focus: raw.focus,
package/src/index.tsx CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { stat } from "node:fs/promises"
12
+ import { dirname, resolve } from "node:path"
12
13
  import { createCliRenderer, SyntaxStyle } from "@opentui/core"
13
14
  import type { BorderSides } from "@opentui/core"
14
15
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
@@ -58,6 +59,39 @@ export interface AppProps {
58
59
  */
59
60
  export type SidebarMode = "auto" | "on" | "off"
60
61
 
62
+ const pathIsDirectory = async (path: string): Promise<boolean> => {
63
+ try {
64
+ return (await stat(path)).isDirectory()
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ const findGitRoot = async (cwd: string): Promise<string> => {
71
+ const start = resolve(cwd)
72
+ let current = start
73
+ for (;;) {
74
+ if (await pathIsDirectory(resolve(current, ".git"))) return current
75
+ const parent = dirname(current)
76
+ if (parent === current) return start
77
+ current = parent
78
+ }
79
+ }
80
+
81
+ export const resolveDiscoveryRoot = async ({
82
+ cliRoot,
83
+ defaultRoot,
84
+ cwd,
85
+ }: {
86
+ readonly cliRoot: string | null
87
+ readonly defaultRoot: "cwd" | "git"
88
+ readonly cwd: string
89
+ }): Promise<string> => {
90
+ if (cliRoot !== null) return cliRoot
91
+ if (defaultRoot === "git") return findGitRoot(cwd)
92
+ return cwd
93
+ }
94
+
61
95
  interface DiscoverShellProps {
62
96
  readonly target: string
63
97
  /** Resolved discovery vocabulary from the config layer. The shift+a
@@ -277,7 +311,7 @@ if (import.meta.main) {
277
311
  console.error(`house: ${formatConfigError(err)}`)
278
312
  process.exit(2)
279
313
  })
280
- const { theme: themeId, tone, mdx, show, focus: startupFocus } = config
314
+ const { theme: themeId, tone, mdx, show, focus: startupFocus, defaultRoot } = config
281
315
  const themeDef = getThemeDefinition(themeId)
282
316
  if (themeDef === undefined) {
283
317
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -296,7 +330,9 @@ if (import.meta.main) {
296
330
  maxWidth = n
297
331
  }
298
332
 
333
+ const cwd = process.cwd()
299
334
  const target = args.path ?? "."
335
+ const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
300
336
 
301
337
  if (args.serve) {
302
338
  let stats: Awaited<ReturnType<typeof stat>>
@@ -357,6 +393,7 @@ if (import.meta.main) {
357
393
  }
358
394
  await runTui({
359
395
  target,
396
+ discoveryRoot,
360
397
  themeId,
361
398
  tone,
362
399
  maxWidth,
@@ -372,6 +409,7 @@ if (import.meta.main) {
372
409
 
373
410
  interface TuiBootOptions {
374
411
  readonly target: string
412
+ readonly discoveryRoot: string
375
413
  readonly themeId: string
376
414
  readonly tone: "dark" | "light"
377
415
  readonly maxWidth: number | null
@@ -387,6 +425,7 @@ interface TuiBootOptions {
387
425
 
388
426
  async function runTui({
389
427
  target,
428
+ discoveryRoot,
390
429
  themeId,
391
430
  tone,
392
431
  maxWidth,
@@ -430,7 +469,7 @@ async function runTui({
430
469
  createRoot(renderer).render(
431
470
  <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
432
471
  <DiscoverShell
433
- target={target}
472
+ target={discoveryRoot}
434
473
  initialShow={show}
435
474
  sort={sort}
436
475
  mdx={mdx}
@@ -11,7 +11,7 @@ export type BrowserFocus = "sidebar" | "reader"
11
11
  export interface BrowserCtx {
12
12
  readonly files: readonly FileEntry[]
13
13
  /** True iff `files[selectedIndex]` resolves to an entry. The honest
14
- * predicate for File-group actions (`o`, `e`, `[`, `]`): with debounced
14
+ * predicate for File-group actions (`O`, `E`, `[`, `]`): with debounced
15
15
  * filter and sticky auto-select, `files.length > 0` can be true while
16
16
  * `selectedIndex` is invalid for the displayed list. See #115. */
17
17
  readonly hasSelected: boolean
@@ -278,7 +278,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
278
278
  group: "File",
279
279
  description: "Open current file in browser as HTML",
280
280
  hint: "html",
281
- keys: ["o"],
281
+ keys: ["shift+o"],
282
282
  when: hasSelected,
283
283
  run: (c) => c.serveCurrent(),
284
284
  },
@@ -287,7 +287,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
287
287
  group: "File",
288
288
  description: "Open current file in $EDITOR",
289
289
  hint: "edit",
290
- keys: ["e"],
290
+ keys: ["shift+e"],
291
291
  when: hasSelected,
292
292
  run: (c) => c.editCurrent(),
293
293
  },
@@ -2,7 +2,7 @@
2
2
  * Local HTML preview server for a single markdown file.
3
3
  *
4
4
  * One long-lived `Bun.serve` instance. The served file path is swappable
5
- * via `setTarget(path)` — used by the TUI's `o` binding so pressing it on a
5
+ * via `setTarget(path)` — used by the TUI's `O` binding so pressing it on a
6
6
  * new file retargets the existing server (live-reload fires) instead of
7
7
  * spawning a second one.
8
8
  *