@carlesandres/house 0.4.8 → 0.4.10

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,31 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.10] — 2026-06-14
10
+
11
+ ### Added
12
+
13
+ - Active theme selection is now persisted globally, with hardened config saving that preserves symlinked config files.
14
+
15
+ ### Changed
16
+
17
+ - Sidebar parent paths now middle-truncate in deep trees to keep filenames scannable.
18
+ - Markdown discovery now uses an extension allow-list for supported markdown file types.
19
+
20
+ ## [0.4.9] — 2026-06-09
21
+
22
+ ### Fixed
23
+
24
+ - Sidebar rows now keep a tighter single-space filename/path gap, and footer hint width calculation better handles wide glyphs.
25
+
26
+ ### Changed
27
+
28
+ - Removed the experimental `--sort` option. The sidebar now consistently lists files before directories, so current-directory files appear before nested subtrees in the base list. This makes less-nested files naturally rank higher even without a search term. When a filter query is active the existing `filterFiles` / `rankFile` scoring (filename bias + strengthened depth penalties) still applies on top.
29
+
30
+ ### Docs
31
+
32
+ - Demo recording now lives in `recordings/record-demo.sh`, with docs and recorded assets updated to match the new capture flow.
33
+
9
34
  ## [0.4.8] — 2026-06-02
10
35
 
11
36
  ### Fixed
@@ -18,7 +43,7 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
18
43
  ### Docs
19
44
 
20
45
  - Corrected usage docs and `--help` text: directory browsing is controlled by `--root` / `defaultRoot`; the positional path now seeds the initial browser filter query.
21
- - Refreshed unified-browser docs, roadmap state, and demo tapes after the single Browser model landed.
46
+ - Refreshed unified-browser docs, pruned shipped roadmap items, and updated demo tapes after the single Browser model landed.
22
47
  - Added ADR 0001 documenting the decision to keep the current `marked`-based browser preview instead of adopting Streamdown for now.
23
48
 
24
49
  ### Changed
@@ -184,7 +209,7 @@ Beta release gates (DESIGN §10.2) closed.
184
209
 
185
210
  ### Docs
186
211
 
187
- - README embeds a VHS-generated demo gif; `tape/` holds the source scripts.
212
+ - README embeds the recorded demo asset; `recordings/` holds the capture workflow.
188
213
  - `CONTRIBUTING.md` documents the `captureSpans()` / `MockTreeSitterClient` / `TestRecorder` testing patterns and the "before blaming `<markdown>`" stale-watcher debugging checklist.
189
214
 
190
215
  ## [0.3.0] — 2026-05-16
@@ -245,7 +270,7 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
245
270
 
246
271
  ### Added — discovery
247
272
 
248
- - Recursive walk from the path argument (or cwd), `.md` / `.markdown` / `.mdx` only.
273
+ - Recursive walk from the path argument (or cwd), limited to markdown extensions.
249
274
  - Honors `.gitignore` (root + nested).
250
275
  - Hard-skips `node_modules`, `.git`, `.venv` (always, even with `--all`).
251
276
  - Does not follow symlinks.
@@ -280,7 +305,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
280
305
 
281
306
  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.
282
307
 
283
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.8...HEAD
308
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.10...HEAD
309
+ [0.4.10]: https://github.com/carlesandres/house/compare/v0.4.9...v0.4.10
310
+ [0.4.9]: https://github.com/carlesandres/house/compare/v0.4.8...v0.4.9
284
311
  [0.4.8]: https://github.com/carlesandres/house/compare/v0.4.7...v0.4.8
285
312
  [0.4.7]: https://github.com/carlesandres/house/compare/v0.4.6...v0.4.7
286
313
  [0.4.6]: https://github.com/carlesandres/house/compare/v0.4.5...v0.4.6
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A terminal markdown reader and navigator — themable and configurable, with a keyboard-driven modern UI. Browse a directory of `.md` files without leaving the terminal.
4
4
 
5
- ![house demo](tape/house.gif)
5
+ <video src="recordings/house-demo.mp4" controls muted loop playsinline></video>
6
6
 
7
7
  ## Features
8
8
 
@@ -60,23 +60,22 @@ house --serve README.md
60
60
 
61
61
  ### Options
62
62
 
63
- | Flag | Default | Description |
64
- |------|---------|-------------|
65
- | `--theme <name>` | `opencode` | Starting theme (see list below) |
66
- | `--tone dark\|light` | `dark` | Starting tone |
67
- | `--width <N>` | — | Cap rendered markdown width at N columns |
68
- | `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
69
- | `--root <dir>` | current directory | Discovery root to walk; overrides `defaultRoot` config/env |
70
- | `--sort <mode>` | `dirs-first` | Sidebar order: `dirs-first` or `files-first` |
71
- | `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
72
- | `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
73
- | `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
74
- | `--port <N>` | OS-assigned | Port for `--serve` |
75
- | `--no-mdx` | off | Exclude `.mdx` files from discovery |
76
- | `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
77
- | `--config-path` | — | Print the resolved config-file path and exit |
78
- | `-h`, `--help` | — | Show help and exit |
79
- | `-v`, `--version` | — | Print version and exit |
63
+ | Flag | Default | Description |
64
+ | -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
65
+ | `--theme <name>` | `opencode` | Starting theme (see list below) |
66
+ | `--tone dark\|light` | `dark` | Starting tone |
67
+ | `--width <N>` | — | Cap rendered markdown width at N columns |
68
+ | `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
69
+ | `--root <dir>` | current directory | Discovery root to walk; overrides `defaultRoot` config/env |
70
+ | `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
71
+ | `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
72
+ | `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
73
+ | `--port <N>` | OS-assigned | Port for `--serve` |
74
+ | `--ext <list>` | none | Include extra file extensions (comma-separated) |
75
+ | `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
76
+ | `--config-path` | | Print the resolved config-file path and exit |
77
+ | `-h`, `--help` | — | Show help and exit |
78
+ | `-v`, `--version` | — | Print version and exit |
80
79
 
81
80
  ## Configuration
82
81
 
@@ -92,22 +91,22 @@ Run `house --config-path` to print the exact location.
92
91
  # ~/.config/house/config.toml
93
92
  theme = "tokyonight"
94
93
  tone = "dark"
95
- mdx = true
94
+ extensions = []
96
95
  show = ["hidden", "gitignored"]
97
96
  focus = "filter"
98
97
  defaultRoot = "cwd" # or "git"
99
98
  ```
100
99
 
101
- Supported keys: `theme`, `tone`, `mdx`, `show`, `focus`, `defaultRoot`.
100
+ Supported keys: `theme`, `tone`, `extensions`, `show`, `focus`, `defaultRoot`.
102
101
 
103
102
  `show` is a list of normally-skipped categories to opt into. Known categories: `hidden` (dot-prefixed entries), `gitignored` (entries matched by a `.gitignore`). Default is the empty list. Hard skips (`node_modules`, `.git`, `.venv`) always apply.
104
103
 
105
104
  Precedence, highest to lowest:
106
105
 
107
- 1. CLI flags (`--theme`, `--tone`, `--no-mdx`, `--show`, `--focus`, `--root`)
108
- 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
106
+ 1. CLI flags (`--theme`, `--tone`, `--ext`, `--show`, `--focus`, `--root`)
107
+ 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
109
108
  3. Config file
110
- 4. Built-in defaults (`opencode` / `dark` / `mdx = true` / `show = []` / `focus = "filter"` / `defaultRoot = "cwd"`)
109
+ 4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `show = []` / `focus = "filter"` / `defaultRoot = "cwd"`)
111
110
 
112
111
  `HOUSE_SHOW` takes a comma-separated list (`HOUSE_SHOW=hidden,gitignored`). For `show` specifically, each source completely replaces the next — categories don't merge across layers. Press `shift+a` in the TUI to round-trip between the configured set and the full vocabulary without editing config.
113
112
 
@@ -117,41 +116,41 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
117
116
 
118
117
  ### Global
119
118
 
120
- | Key | Action |
121
- |-----|--------|
122
- | `q` / `ctrl+c` | Quit |
123
- | `tab` | Toggle focus (sidebar ↔ reader) |
124
- | `s` | Toggle sidebar visibility |
125
- | `ctrl+p` | Command palette |
126
- | `O` | Open current file in browser as HTML |
127
- | `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
128
- | `t` | Next theme |
129
- | `T` | Previous theme |
130
- | `L` | Toggle dark / light tone |
119
+ | Key | Action |
120
+ | -------------- | ----------------------------------------------------------- |
121
+ | `q` / `ctrl+c` | Quit |
122
+ | `tab` | Toggle focus (sidebar ↔ reader) |
123
+ | `s` | Toggle sidebar visibility |
124
+ | `ctrl+p` | Command palette |
125
+ | `O` | Open current file in browser as HTML |
126
+ | `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
127
+ | `t` | Next theme |
128
+ | `T` | Previous theme |
129
+ | `L` | Toggle dark / light tone |
131
130
 
132
131
  ### Sidebar
133
132
 
134
- | Key | Action |
135
- |-----|--------|
136
- | `j` / `↓` | Move selection down |
137
- | `k` / `↑` | Move selection up |
138
- | `J` | Jump down 8 |
139
- | `K` | Jump up 8 |
140
- | `space` / `pagedown` / `ctrl+d` | Page down |
141
- | `b` / `pageup` / `ctrl+u` | Page up |
142
- | `g` | First file |
143
- | `G` | Last file |
144
- | `/` | Filter files (fuzzy match on path) |
145
- | `A` | Toggle hidden + gitignored entries (session-only; round-trips with the configured `show`) |
146
- | `↵` / `→` / `l` | Open file (focus reader) |
133
+ | Key | Action |
134
+ | ------------------------------- | ----------------------------------------------------------------------------------------- |
135
+ | `j` / `↓` | Move selection down |
136
+ | `k` / `↑` | Move selection up |
137
+ | `J` | Jump down 8 |
138
+ | `K` | Jump up 8 |
139
+ | `space` / `pagedown` / `ctrl+d` | Page down |
140
+ | `b` / `pageup` / `ctrl+u` | Page up |
141
+ | `g` | First file |
142
+ | `G` | Last file |
143
+ | `/` | Filter files (fuzzy match on path) |
144
+ | `A` | Toggle hidden + gitignored entries (session-only; round-trips with the configured `show`) |
145
+ | `↵` / `→` / `l` | Open file (focus reader) |
147
146
 
148
147
  ### Reader
149
148
 
150
- | Key | Action |
151
- |-----|--------|
149
+ | Key | Action |
150
+ | ----------------- | --------------- |
152
151
  | `esc` / `←` / `h` | Back to sidebar |
153
- | `[` | Previous file |
154
- | `]` | Next file |
152
+ | `[` | Previous file |
153
+ | `]` | Next file |
155
154
 
156
155
  ## Themes
157
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "scripts": {
43
43
  "dev": "bun --watch src/index.tsx",
44
44
  "start": "bun run src/index.tsx",
45
+ "record-demo": "bash recordings/record-demo.sh",
45
46
  "typecheck": "tsc --noEmit",
46
47
  "lint": "oxlint --tsconfig tsconfig.json src/",
47
48
  "format": "oxfmt src/",
package/src/Browser.tsx CHANGED
@@ -45,6 +45,8 @@ import { startServer, type ServerHandle } from "./serve/server.ts"
45
45
  import { colors, setActiveTheme } from "./theme/colors.ts"
46
46
  import { themeAtom } from "./theme/atom.ts"
47
47
  import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
48
+ import { saveThemePreference } from "./config/save.ts"
49
+ import { middleTruncate } from "./ui/middleTruncate.ts"
48
50
 
49
51
  export type SidebarMode = "auto" | "on" | "off"
50
52
  export type StartupFocus = "sidebar" | "reader" | "filter"
@@ -362,6 +364,12 @@ export const Browser = ({
362
364
  if (!next) return
363
365
  setActiveTheme(next, theme.tone)
364
366
  setTheme({ id: next.id, tone: theme.tone })
367
+ void saveThemePreference({ theme: next.id, tone: theme.tone }).catch((err) => {
368
+ pushFooterNotice("theme not saved")
369
+ process.stderr.write(
370
+ `house: failed to save theme preference: ${err instanceof Error ? err.message : String(err)}\n`,
371
+ )
372
+ })
365
373
  pushFooterNotice(`theme: ${next.name}`)
366
374
  }
367
375
 
@@ -370,6 +378,12 @@ export const Browser = ({
370
378
  const def = getThemeDefinition(theme.id)
371
379
  if (def) setActiveTheme(def, nextTone)
372
380
  setTheme({ id: theme.id, tone: nextTone })
381
+ void saveThemePreference({ theme: theme.id, tone: nextTone }).catch((err) => {
382
+ pushFooterNotice("theme not saved")
383
+ process.stderr.write(
384
+ `house: failed to save theme preference: ${err instanceof Error ? err.message : String(err)}\n`,
385
+ )
386
+ })
373
387
  pushFooterNotice(`tone: ${nextTone}`)
374
388
  }
375
389
 
@@ -967,7 +981,12 @@ export const Browser = ({
967
981
  <text key={file.path} wrapMode="none" style={rowStyle}>
968
982
  <span style={{ fg: basenameFg }}>{basename}</span>
969
983
  {parent !== "" && (
970
- <span style={{ fg: colors.textMuted }}>{`${separator}${parent}`}</span>
984
+ <span style={{ fg: colors.textMuted }}>
985
+ {middleTruncate(
986
+ `${separator}${parent}`,
987
+ Math.max(0, sidebarTextWidth - basename.length),
988
+ )}
989
+ </span>
971
990
  )}
972
991
  </text>
973
992
  )
package/src/Footer.tsx CHANGED
@@ -15,12 +15,11 @@
15
15
  * sidebar (see Browser.tsx). The pattern mirrors ghui's PR list, where the
16
16
  * filter is part of the list it filters.
17
17
  *
18
- * Width math assumes hint labels are ASCII plus a small set of single-cell
19
- * BMP glyphs (see `displayKey`). `fitHints` and notice clipping use string
20
- * length as a proxy for cell count; introducing a CJK or emoji label would
21
- * require a real cell-width counter (e.g. East Asian Width).
18
+ * Width math uses terminal-style string width instead of raw string length so
19
+ * ambiguous glyphs such as `↵` don't steal spacing from the label beside them.
22
20
  */
23
21
 
22
+ import stringWidth from "string-width"
24
23
  import type React from "react"
25
24
  import type { KeyBinding } from "./keymap/keymap.ts"
26
25
  import { displayKey } from "./keymap/displayKey.ts"
@@ -59,7 +58,7 @@ interface Hint {
59
58
  readonly label: string
60
59
  }
61
60
 
62
- const hintWidth = (h: Hint): number => h.key.length + 1 + h.label.length // key + " " + label
61
+ const hintWidth = (h: Hint): number => stringWidth(h.key) + 1 + stringWidth(h.label) // key + " " + label
63
62
 
64
63
  const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
65
64
  if (!b.hint) return null
package/src/cli/argv.ts CHANGED
@@ -12,8 +12,6 @@ export interface ParsedArgs {
12
12
  readonly tone: string | null
13
13
  /** Value of `--width <N>`, or null. Validated by the boot layer (must be a positive integer). */
14
14
  readonly width: string | null
15
- /** Value of `--sort <mode>` (`dirs-first` or `files-first`), or null. Validated by the boot layer. */
16
- readonly sort: string | null
17
15
  /** True when `--serve` was passed: serve the positional path as HTML, skip TUI. */
18
16
  readonly serve: boolean
19
17
  /** Value of `--port <N>`, or null. Validated by the boot layer. */
@@ -30,8 +28,8 @@ export interface ParsedArgs {
30
28
  * probe and the "update available" notice. Mirrors the
31
29
  * `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
32
30
  readonly noUpdateCheck: boolean
33
- /** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
34
- readonly noMdx: boolean
31
+ /** Raw value of `--ext [list]`, or null if absent. Comma-separated list. */
32
+ readonly extensions: string | null
35
33
  /** Value of `--focus <mode>` (`sidebar`, `reader`, `filter`), or null.
36
34
  * Validated by the boot layer. */
37
35
  readonly focus: string | null
@@ -51,13 +49,12 @@ const createProgram = () =>
51
49
  .option("--theme [id]")
52
50
  .option("--tone [mode]")
53
51
  .option("--width [N]")
54
- .option("--sort [mode]")
55
52
  .option("--serve")
56
53
  .option("--port [N]")
57
54
  .option("--config-path")
58
55
  .option("--sidebar [mode]")
59
56
  .option("--no-update-check")
60
- .option("--no-mdx")
57
+ .option("--ext [list]")
61
58
  .option("--focus [mode]")
62
59
  .option("--show [list]")
63
60
  .option("--root [dir]")
@@ -69,19 +66,20 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
69
66
  "--theme",
70
67
  "--tone",
71
68
  "--width",
72
- "--sort",
73
69
  "--port",
74
70
  "--sidebar",
75
71
  "--focus",
76
72
  "--show",
77
73
  "--root",
74
+ "--ext",
78
75
  ])
79
76
 
77
+ const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort"])
78
+
80
79
  const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
81
80
  "--serve",
82
81
  "--config-path",
83
82
  "--no-update-check",
84
- "--no-mdx",
85
83
  "--help",
86
84
  "-h",
87
85
  "--version",
@@ -91,7 +89,7 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
91
89
  const findPathArg = (argv: readonly string[]): string | null => {
92
90
  for (let i = 0; i < argv.length; i++) {
93
91
  const arg = argv[i]!
94
- if (VALUE_FLAGS.has(arg)) {
92
+ if (VALUE_FLAGS.has(arg) || REMOVED_VALUE_FLAGS.has(arg)) {
95
93
  const next = argv[i + 1]
96
94
  if (next !== undefined && !next.startsWith("-")) i++
97
95
  continue
@@ -123,7 +121,6 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
123
121
  theme: stringOrNull(opts["theme"]),
124
122
  tone: stringOrNull(opts["tone"]),
125
123
  width: stringOrNull(opts["width"]),
126
- sort: stringOrNull(opts["sort"]),
127
124
  serve: opts["serve"] === true,
128
125
  port: stringOrNull(opts["port"]),
129
126
  help: opts["help"] === true,
@@ -131,7 +128,7 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
131
128
  configPath: opts["configPath"] === true,
132
129
  sidebar: stringOrNull(opts["sidebar"]),
133
130
  noUpdateCheck: opts["noUpdateCheck"] === true,
134
- noMdx: opts["mdx"] === false,
131
+ extensions: stringOrNull(opts["ext"]),
135
132
  show: stringOrNull(opts["show"]),
136
133
  focus: stringOrNull(opts["focus"]),
137
134
  }
@@ -152,7 +149,6 @@ options:
152
149
  --show <list> reveal normally-skipped entries; comma-separated subset of:
153
150
  hidden, gitignored. Use --show "" to clear.
154
151
  --root <dir> discovery root to walk (overrides defaultRoot config/env)
155
- --sort <mode> sidebar order: dirs-first (default) or files-first
156
152
  --sidebar <m> initial sidebar visibility: auto (default), on, or off
157
153
  --focus <m> startup focus: sidebar, reader, or filter (default: filter)
158
154
  --serve serve the positional path as HTML in the browser (skips TUI)
@@ -161,7 +157,7 @@ options:
161
157
  -v, --version print version and exit
162
158
  --config-path print path to the config file and exit
163
159
  --no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
164
- --no-mdx exclude .mdx files from discovery (default: included)
160
+ --ext <list> include extra file extensions (comma-separated)
165
161
 
166
162
  examples:
167
163
  house README.md
@@ -170,6 +166,6 @@ examples:
170
166
 
171
167
  configuration:
172
168
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
173
- keys: theme, tone, mdx, show, focus, defaultRoot
174
- env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
169
+ keys: theme, tone, extensions, show, focus, defaultRoot
170
+ env: HOUSE_THEME, HOUSE_TONE, HOUSE_EXTENSIONS, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
175
171
  precedence (high → low): flags → env → file → defaults`
@@ -19,7 +19,7 @@ import { themeDefinitions } from "../theme/registry.ts"
19
19
  export interface HouseConfig {
20
20
  readonly theme: string
21
21
  readonly tone: "dark" | "light"
22
- readonly mdx: boolean
22
+ readonly extensions: readonly string[]
23
23
  /** Default discovery-root strategy when no explicit `--root` flag is passed. */
24
24
  readonly defaultRoot: "cwd" | "git"
25
25
  /** Categories of normally-skipped entries to opt into. See
@@ -34,7 +34,7 @@ export interface HouseConfig {
34
34
  export interface CliOverrides {
35
35
  readonly theme: string | null
36
36
  readonly tone: string | null
37
- readonly mdx: boolean | null
37
+ readonly extensions: readonly string[] | null
38
38
  /** When non-null, the parsed `--show` list completely replaces env/file
39
39
  * (no per-category merging — sets compose by replacement, like every
40
40
  * other CLI override here). `--show ""` sets the empty set. */
@@ -44,7 +44,7 @@ export interface CliOverrides {
44
44
 
45
45
  const DEFAULT_THEME = "opencode"
46
46
  const DEFAULT_TONE: "dark" | "light" = "dark"
47
- const DEFAULT_MDX = true
47
+ const DEFAULT_EXTENSIONS: readonly string[] = []
48
48
  const DEFAULT_ROOT: "cwd" | "git" = "cwd"
49
49
  const DEFAULT_SHOW = ""
50
50
  const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
@@ -60,7 +60,7 @@ const themeIds = themeDefinitions.map((t) => t.id)
60
60
  const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
61
61
  "theme",
62
62
  "tone",
63
- "mdx",
63
+ "extensions",
64
64
  "show",
65
65
  "focus",
66
66
  "defaultRoot",
@@ -70,10 +70,8 @@ const schema = Config.all({
70
70
  theme: Config.schema(Schema.Literals(themeIds), "theme"),
71
71
  tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
72
72
  defaultRoot: Config.schema(Schema.String, "defaultRoot"),
73
- // Boolean stored as string literal because providers stringify values
74
- // (TOML bools, env vars, CLI flags all flow through as text). Mapped to
75
- // a real boolean in `loadConfig` below.
76
- mdx: Config.schema(Schema.Literals(["true", "false"] as const), "mdx"),
73
+ // Comma-separated extension list. Empty string means no extra extensions.
74
+ extensions: Config.schema(Schema.String, "extensions"),
77
75
  // `show` arrives as a comma-separated string from every provider
78
76
  // (`fileProvider` coerces TOML arrays via `String()`, which produces
79
77
  // `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
@@ -87,7 +85,7 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
87
85
  theme: DEFAULT_THEME,
88
86
  tone: DEFAULT_TONE,
89
87
  defaultRoot: DEFAULT_ROOT,
90
- mdx: String(DEFAULT_MDX),
88
+ extensions: DEFAULT_EXTENSIONS.join(","),
91
89
  show: DEFAULT_SHOW,
92
90
  focus: DEFAULT_FOCUS,
93
91
  })
@@ -205,13 +203,13 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
205
203
  const theme = env["HOUSE_THEME"]
206
204
  const tone = env["HOUSE_TONE"]
207
205
  const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
208
- const mdx = env["HOUSE_MDX"]
206
+ const extensions = env["HOUSE_EXTENSIONS"]
209
207
  const show = env["HOUSE_SHOW"]
210
208
  const focus = env["HOUSE_FOCUS"]
211
209
  if (theme !== undefined) entries.push(["theme", theme])
212
210
  if (tone !== undefined) entries.push(["tone", tone])
213
211
  if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
214
- if (mdx !== undefined) entries.push(["mdx", mdx])
212
+ if (extensions !== undefined) entries.push(["extensions", extensions])
215
213
  if (show !== undefined) entries.push(["show", show])
216
214
  if (focus !== undefined) entries.push(["focus", focus])
217
215
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
@@ -221,7 +219,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
221
219
  const entries: Array<[string, string]> = []
222
220
  if (overrides.theme !== null) entries.push(["theme", overrides.theme])
223
221
  if (overrides.tone !== null) entries.push(["tone", overrides.tone])
224
- if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
222
+ if (overrides.extensions !== null) entries.push(["extensions", overrides.extensions.join(",")])
225
223
  if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
226
224
  if (overrides.focus !== null) entries.push(["focus", overrides.focus])
227
225
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
@@ -264,7 +262,7 @@ export const loadConfig = (
264
262
  const cli = options.cli ?? {
265
263
  theme: null,
266
264
  tone: null,
267
- mdx: null,
265
+ extensions: null,
268
266
  show: null,
269
267
  focus: null,
270
268
  }
@@ -300,7 +298,13 @@ export const loadConfig = (
300
298
  theme: raw.theme,
301
299
  tone: raw.tone,
302
300
  defaultRoot,
303
- mdx: raw.mdx === "true",
301
+ extensions:
302
+ raw.extensions === ""
303
+ ? []
304
+ : raw.extensions
305
+ .split(",")
306
+ .map((s) => s.trim())
307
+ .filter(Boolean),
304
308
  show: parsed.value,
305
309
  focus: raw.focus,
306
310
  })
@@ -0,0 +1,95 @@
1
+ import { dirname } from "node:path"
2
+ import { lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises"
3
+ import { defaultConfigPath } from "./load.ts"
4
+
5
+ export interface ThemePreference {
6
+ readonly theme: string
7
+ readonly tone: "dark" | "light"
8
+ }
9
+
10
+ let saveQueue: Promise<void> = Promise.resolve()
11
+
12
+ const encodeTomlString = (value: string): string => JSON.stringify(value)
13
+
14
+ const upsertTopLevelString = (raw: string, key: keyof ThemePreference, value: string): string => {
15
+ const encoded = encodeTomlString(value)
16
+ const lines = raw.split("\n")
17
+ const keyPattern = new RegExp(`^(\\s*)${key}\\s*=.*$`)
18
+ let inTopLevel = true
19
+ let insertAt = lines.length
20
+
21
+ for (let i = 0; i < lines.length; i++) {
22
+ const line = lines[i]!
23
+ if (/^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(line)) {
24
+ inTopLevel = false
25
+ insertAt = Math.min(insertAt, i)
26
+ }
27
+ if (!inTopLevel) continue
28
+ const match = keyPattern.exec(line)
29
+ if (match) {
30
+ lines[i] = `${match[1]}${key} = ${encoded}`
31
+ return lines.join("\n")
32
+ }
33
+ }
34
+
35
+ const insertion = `${key} = ${encoded}`
36
+ if (insertAt === lines.length) {
37
+ if (lines.length === 0 || lines[lines.length - 1] !== "") return `${raw}\n${insertion}\n`
38
+ lines.splice(lines.length - 1, 0, insertion)
39
+ return lines.join("\n")
40
+ }
41
+
42
+ lines.splice(insertAt, 0, insertion)
43
+ return lines.join("\n")
44
+ }
45
+
46
+ const updateThemePreferenceToml = (raw: string, record: ThemePreference): string => {
47
+ // Validate the existing file before preserving and editing its text. If the
48
+ // user has malformed TOML, fail loudly rather than replacing it wholesale.
49
+ Bun.TOML.parse(raw)
50
+ return upsertTopLevelString(upsertTopLevelString(raw, "theme", record.theme), "tone", record.tone)
51
+ }
52
+
53
+ const resolveWritableConfigPath = async (path: string): Promise<string> => {
54
+ try {
55
+ const stat = await lstat(path)
56
+ if (stat.isSymbolicLink()) return await realpath(path)
57
+ } catch (err) {
58
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
59
+ }
60
+ return path
61
+ }
62
+
63
+ const writeThemePreference = async (record: ThemePreference, path: string): Promise<void> => {
64
+ const targetPath = await resolveWritableConfigPath(path)
65
+ path = targetPath
66
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
67
+ try {
68
+ await mkdir(dirname(path), { recursive: true })
69
+ let next = `theme = ${encodeTomlString(record.theme)}\ntone = ${encodeTomlString(record.tone)}\n`
70
+ try {
71
+ const raw = await readFile(path, "utf8")
72
+ next = updateThemePreferenceToml(raw, record)
73
+ } catch (err) {
74
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
75
+ }
76
+ await writeFile(tmp, next, "utf8")
77
+ await rename(tmp, path)
78
+ } catch (err) {
79
+ try {
80
+ await unlink(tmp)
81
+ } catch {
82
+ // best-effort cleanup
83
+ }
84
+ throw err
85
+ }
86
+ }
87
+
88
+ export const saveThemePreference = async (
89
+ record: ThemePreference,
90
+ path = defaultConfigPath(),
91
+ ): Promise<void> => {
92
+ const run = saveQueue.catch(() => {}).then(() => writeThemePreference(record, path))
93
+ saveQueue = run.catch(() => {})
94
+ return run
95
+ }
@@ -3,12 +3,26 @@
3
3
  *
4
4
  * Matching stays intentionally small and pure: case-insensitive subsequence on
5
5
  * the filename and full relative path. Ranking prefers what users usually mean
6
- * in a sidebar:
7
- * - filename matches above folder-only matches
8
- * - files in the current folder above equally good nested matches
9
- * - shallower paths above deeper ones as a soft tie-break
6
+ * in a sidebar (no configuration, no user knobs):
7
+ * - filename matches above folder-only matches (zf/fzf-inspired strong bias)
8
+ * - shallower / current-folder paths win over deep nested ones
9
+ * - incidental matches purely from deep directory names are heavily demoted
10
+ * so "nested folders bubbling up" does not happen for typical queries
10
11
  *
11
12
  * Empty query preserves discovery/tree order.
13
+ *
14
+ * The low-level fuzzyScore uses a "best alignment" scan (try every possible
15
+ * start position for the first query char, complete greedily, take the highest
16
+ * scoring match). This avoids the "early spurious char traps" a pure left-to-right
17
+ * greedy can hit on paths (e.g. "src/r.../readme" for query "readme" must prefer
18
+ * the boundary "r" in the filename, not the "r" in "src"). This draws from fzf's
19
+ * v1 "find occurrence then look for better" idea and the general principle that
20
+ * boundary+consecutive bonuses should win when they exist later in the string.
21
+ * We deliberately do not depend on fuzzysort/fuse/fzf here: the ranking *policy*
22
+ * (heavy filename bias, exact/stem bonuses, depth soft penalty) is application
23
+ * specific to "what makes a sidebar nice" and must be owned so end users get the
24
+ * good experience with zero configuration or thought. See DESIGN.md §7.4 and the
25
+ * filter tests.
12
26
  */
13
27
 
14
28
  import type { FileEntry } from "./walk.ts"
@@ -17,19 +31,41 @@ export const fuzzyScore = (query: string, target: string): number | null => {
17
31
  if (query.length === 0) return 0
18
32
  const q = query.toLowerCase()
19
33
  const t = target.toLowerCase()
20
- let qi = 0
21
- let score = 0
22
- let lastMatch = -2
23
- for (let i = 0; i < t.length && qi < q.length; i++) {
24
- if (t[i] !== q[qi]) continue
25
- const isWordStart = i === 0 || t[i - 1] === "/"
26
- score += isWordStart ? 10 : 1
27
- if (lastMatch === i - 1) score += 5
28
- lastMatch = i
29
- qi++
34
+ if (q.length > t.length) return null
35
+
36
+ let best: number | null = null
37
+
38
+ // Best-alignment: try every viable start for q[0], then complete the rest
39
+ // greedily from there. Take the alignment that accumulates the most bonus
40
+ // points (word-start after /, consecutives). This is what gives good OOB
41
+ // results on tree paths without the caller doing extra work.
42
+ for (let start = 0; start < t.length; start++) {
43
+ if (t[start] !== q[0]) continue
44
+
45
+ let qi = 1
46
+ let score = 0
47
+ let lastMatch = start
48
+
49
+ // score first char
50
+ const isWordStart0 = start === 0 || t[start - 1] === "/"
51
+ score += isWordStart0 ? 10 : 1
52
+
53
+ let i = start + 1
54
+ for (; i < t.length && qi < q.length; i++) {
55
+ if (t[i] !== q[qi]) continue
56
+ const isWordStart = i === 0 || t[i - 1] === "/"
57
+ score += isWordStart ? 10 : 1
58
+ if (lastMatch === i - 1) score += 5
59
+ lastMatch = i
60
+ qi++
61
+ }
62
+
63
+ if (qi === q.length) {
64
+ if (best === null || score > best) best = score
65
+ }
30
66
  }
31
- if (qi < q.length) return null
32
- return score
67
+
68
+ return best
33
69
  }
34
70
 
35
71
  const splitPath = (relativePath: string): { fileName: string; depth: number } => {
@@ -55,15 +91,31 @@ const rankFile = (query: string, file: FileEntry): number | null => {
55
91
  const stem = fileStem(fileName).toLowerCase()
56
92
  const nameScore = fuzzyScore(query, fileName) ?? 0
57
93
 
58
- let score = pathScore * 10
59
- score += nameScore * 100
94
+ let score = pathScore * 4
95
+ score += nameScore * 150
60
96
 
97
+ // Filename (and stem) priority — the heart of good sidebar UX.
98
+ // Users almost always mean "the file whose name contains this", not
99
+ // "some directory component that happens to have these letters".
61
100
  if (name === q || stem === q) score += 5_000
62
101
  else if (name.startsWith(q) || stem.startsWith(q)) score += 2_000
63
102
  else if (name.includes(q)) score += 1_000
64
103
 
65
- if (depth === 0) score += 300
66
- score -= depth * 10
104
+ // Strong depth bias so less-nested files appear near the top.
105
+ // We want shallow files to win over deep "nested folders" even when
106
+ // the deep file has a decent path match (common with short queries or
107
+ // dir names). The penalty is now large enough to matter vs. the
108
+ // match bonuses. Root gets a big kick. This is the main lever for
109
+ // "position in the tree structure" without any user config.
110
+ if (depth === 0) score += 800
111
+ score -= depth * 120
112
+
113
+ // Extra penalty when the match is weak or absent in the *basename*.
114
+ // This aggressively demotes results where the hit is only because the
115
+ // file lives under a matching deep directory ("nested folders bubbling up").
116
+ if (nameScore == null || nameScore < 8) {
117
+ score -= depth * 80
118
+ }
67
119
 
68
120
  return score
69
121
  }
@@ -73,16 +125,24 @@ const rankFile = (query: string, file: FileEntry): number | null => {
73
125
  * unchanged (preserves the discovery sort order). Non-empty query keeps
74
126
  * matches only, sorted by score desc; ties fall back to the input order so
75
127
  * the discovery sort still leaks through.
128
+ *
129
+ * All the "make this feel right for users exploring a tree" logic lives here
130
+ * (and in rankFile) so callers (Browser) and end users never have to think
131
+ * about or configure ranking.
76
132
  */
77
133
  export const filterFiles = (files: readonly FileEntry[], query: string): readonly FileEntry[] => {
78
134
  if (query.length === 0) return files
79
- const scored: { file: FileEntry; score: number; index: number }[] = []
135
+ const scored: { file: FileEntry; score: number; depth: number; index: number }[] = []
80
136
  for (let i = 0; i < files.length; i++) {
81
137
  const file = files[i]!
82
138
  const score = rankFile(query, file)
83
139
  if (score === null) continue
84
- scored.push({ file, score, index: i })
140
+ const depth = (file.relativePath.match(/\//g) || []).length
141
+ scored.push({ file, score, depth, index: i })
85
142
  }
86
- scored.sort((a, b) => b.score - a.score || a.index - b.index)
143
+ // Primary: match quality (fuzzy + name bonuses - depth penalties already in score)
144
+ // Secondary: shallower depth wins (less nested near top)
145
+ // Tertiary: stable original discovery order
146
+ scored.sort((a, b) => b.score - a.score || a.depth - b.depth || a.index - b.index)
87
147
  return scored.map((s) => s.file)
88
148
  }
@@ -18,18 +18,15 @@ export interface DiscoveryWarning {
18
18
  readonly cause: unknown
19
19
  }
20
20
 
21
- export type SortOrder = "dirs-first" | "files-first"
22
-
23
21
  export interface WalkOptions {
24
22
  /** Categories of normally-skipped entries to opt into. Empty (the
25
23
  * default) yields the conservative set: no dotfiles, no gitignored
26
24
  * entries. Order is irrelevant — semantics are set membership. Hard
27
25
  * skips (`node_modules`, `.git`, `.venv`) always apply. */
28
26
  readonly show?: Iterable<ShowCategory>
29
- /** Group order within each directory. Default `dirs-first`. */
30
- readonly sort?: SortOrder
31
- /** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
32
- readonly mdx?: boolean
27
+ /** Additional file extensions to include alongside `.md` / `.markdown`.
28
+ * Values may include or omit the leading dot. */
29
+ readonly extensions?: Iterable<string>
33
30
  /** Non-fatal subtree read errors. Root-level failures still error the walk. */
34
31
  readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
35
32
  }
@@ -39,10 +36,14 @@ export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
39
36
  readonly cause: unknown
40
37
  }> {}
41
38
 
42
- const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"])
43
- const MARKDOWN_EXTENSIONS_NO_MDX = new Set([".md", ".markdown"])
39
+ const BASE_EXTENSIONS = new Set([".md", ".markdown"])
44
40
  const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
45
41
 
42
+ const normalizeExtension = (ext: string): string => {
43
+ const trimmed = ext.trim().toLowerCase()
44
+ return trimmed.startsWith(".") ? trimmed : `.${trimmed}`
45
+ }
46
+
46
47
  interface IgnoreLevel {
47
48
  readonly dir: string
48
49
  readonly ig: Ignore
@@ -73,14 +74,12 @@ const tryLoadGitignore = async (dir: string): Promise<Ignore | null> => {
73
74
 
74
75
  const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
75
76
  entries: readonly T[],
76
- order: SortOrder,
77
77
  ): T[] =>
78
78
  [...entries].sort((a, b) => {
79
79
  const aDir = a.isDirectory()
80
80
  const bDir = b.isDirectory()
81
81
  if (aDir !== bDir) {
82
- if (order === "files-first") return aDir ? 1 : -1
83
- return aDir ? -1 : 1
82
+ return aDir ? 1 : -1
84
83
  }
85
84
  return a.name.localeCompare(b.name)
86
85
  })
@@ -88,7 +87,7 @@ const sortEntries = <T extends { name: string; isDirectory: () => boolean }>(
88
87
  /**
89
88
  * DFS generator. Yields each markdown FileEntry as it is discovered, before
90
89
  * descending further. Per-directory sort still happens before yielding so
91
- * arrival order within a directory matches the configured sort.
90
+ * arrival order is files-first and alphabetical within each group.
92
91
  *
93
92
  * Cancellation: `signal.aborted` is checked between syscalls. Node's
94
93
  * `readdir` doesn't accept an AbortSignal, so a single in-flight `readdir`
@@ -99,7 +98,7 @@ async function* walkDirGen(
99
98
  dirPath: string,
100
99
  rootPath: string,
101
100
  parentLevels: readonly IgnoreLevel[],
102
- opts: { showHidden: boolean; showGitignored: boolean; sort: SortOrder; mdx: boolean },
101
+ opts: { showHidden: boolean; showGitignored: boolean; extensions: ReadonlySet<string> },
103
102
  onWarning: ((warning: DiscoveryWarning) => void) | null,
104
103
  signal: AbortSignal,
105
104
  ): AsyncGenerator<FileEntry, void, void> {
@@ -123,7 +122,7 @@ async function* walkDirGen(
123
122
  }
124
123
  if (signal.aborted) return
125
124
 
126
- for (const entry of sortEntries(raw, opts.sort)) {
125
+ for (const entry of sortEntries(raw)) {
127
126
  if (signal.aborted) return
128
127
 
129
128
  // Never follow symlinks — cycle hazard, and a markdown reader doesn't
@@ -142,7 +141,7 @@ async function* walkDirGen(
142
141
 
143
142
  if (!entry.isFile()) continue
144
143
  if (!opts.showHidden && entry.name.startsWith(".")) continue
145
- const allowed = opts.mdx ? MARKDOWN_EXTENSIONS : MARKDOWN_EXTENSIONS_NO_MDX
144
+ const allowed = opts.extensions
146
145
  if (!allowed.has(extname(entry.name).toLowerCase())) continue
147
146
  if (!opts.showGitignored && isIgnored(entryPath, false, levels)) continue
148
147
 
@@ -161,13 +160,12 @@ async function* walkDirGen(
161
160
  * at its next `signal.aborted` check.
162
161
  *
163
162
  * Rules (see DESIGN.md §6):
164
- * - Extensions: `.md`, `.markdown`, and `.mdx` (unless `mdx: false`).
163
+ * - Extensions: `.md`, `.markdown`, plus configured extras.
165
164
  * - Hard skips (always): `node_modules`, `.git`, `.venv`.
166
165
  * - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
167
166
  * - `.gitignore` honored, including nested `.gitignore` files.
168
167
  * - Symlinks not followed.
169
- * - Sort: alphabetical within each group; directories before files
170
- * (`dirs-first`, default) or files before directories (`files-first`).
168
+ * - Sort: files before directories, alphabetical within each group.
171
169
  */
172
170
  export const walk = (
173
171
  root: string,
@@ -175,11 +173,12 @@ export const walk = (
175
173
  ): Stream.Stream<FileEntry, DiscoveryError> => {
176
174
  const absRoot = resolve(root)
177
175
  const show = new Set<ShowCategory>(options.show ?? [])
176
+ const extensions = new Set(BASE_EXTENSIONS)
177
+ for (const ext of options.extensions ?? []) extensions.add(normalizeExtension(ext))
178
178
  const opts = {
179
179
  showHidden: show.has("hidden"),
180
180
  showGitignored: show.has("gitignored"),
181
- sort: options.sort ?? ("dirs-first" as SortOrder),
182
- mdx: options.mdx ?? true,
181
+ extensions,
183
182
  }
184
183
  const onWarning = options.onWarning ?? null
185
184
  const controller = new AbortController()
package/src/index.tsx CHANGED
@@ -13,7 +13,7 @@ import { Browser, type StartupFocus } from "./Browser.tsx"
13
13
  import { parseArgv, usage } from "./cli/argv.ts"
14
14
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
15
15
  import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
16
- import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
16
+ import { walk, type FileEntry } from "./discovery/walk.ts"
17
17
  import { openInBrowser } from "./serve/openBrowser.ts"
18
18
  import { startServer } from "./serve/server.ts"
19
19
  import { setActiveTheme } from "./theme/colors.ts"
@@ -106,8 +106,7 @@ interface DiscoverShellProps {
106
106
  * and the full vocabulary; the underlying categories remain
107
107
  * independent everywhere else. */
108
108
  readonly initialShow: readonly ShowCategory[]
109
- readonly sort: SortOrder
110
- readonly mdx: boolean
109
+ readonly extensions: readonly string[]
111
110
  readonly maxWidth: number | null
112
111
  readonly sidebarMode: SidebarMode
113
112
  readonly startupFocus: StartupFocus
@@ -117,8 +116,7 @@ export const DiscoverShell = ({
117
116
  target,
118
117
  initialQuery,
119
118
  initialShow,
120
- sort,
121
- mdx,
119
+ extensions,
122
120
  maxWidth,
123
121
  sidebarMode,
124
122
  startupFocus,
@@ -147,8 +145,7 @@ export const DiscoverShell = ({
147
145
  countRef.current = 0
148
146
  const warnedProgram = walk(target, {
149
147
  show,
150
- sort,
151
- mdx,
148
+ extensions,
152
149
  onWarning: ({ path }) => {
153
150
  const relativePath = relative(resolve(target), path)
154
151
  setSkippedDirCount((prev) => prev + 1)
@@ -182,7 +179,7 @@ export const DiscoverShell = ({
182
179
  return () => {
183
180
  Effect.runFork(Fiber.interrupt(fiber))
184
181
  }
185
- }, [target, show, sort, mdx])
182
+ }, [target, show, extensions])
186
183
 
187
184
  const discoveryStatus =
188
185
  scanError ??
@@ -271,9 +268,13 @@ if (import.meta.main) {
271
268
  cli: {
272
269
  theme: args.theme,
273
270
  tone: args.tone,
274
- // --no-mdx is a one-way override: present means "off". When
275
- // absent, fall through to env/file/default.
276
- mdx: args.noMdx ? false : null,
271
+ extensions:
272
+ args.extensions === null
273
+ ? null
274
+ : args.extensions
275
+ .split(",")
276
+ .map((s) => s.trim())
277
+ .filter(Boolean),
277
278
  // `--show` replaces env/file when present (set semantics —
278
279
  // no per-category merge across sources). `null` falls through.
279
280
  show: cliShow,
@@ -287,7 +288,7 @@ if (import.meta.main) {
287
288
  console.error(`house: ${formatConfigError(err)}`)
288
289
  process.exit(2)
289
290
  })
290
- const { theme: themeId, tone, mdx, show, focus: startupFocus, defaultRoot } = config
291
+ const { theme: themeId, tone, extensions, show, focus: startupFocus, defaultRoot } = config
291
292
  const themeDef = getThemeDefinition(themeId)
292
293
  if (themeDef === undefined) {
293
294
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -348,14 +349,6 @@ if (import.meta.main) {
348
349
  process.on("SIGTERM", shutdown)
349
350
  // Bun.serve keeps the event loop alive until stop().
350
351
  } else {
351
- let sort: SortOrder = "dirs-first"
352
- if (args.sort !== null) {
353
- if (args.sort !== "dirs-first" && args.sort !== "files-first") {
354
- console.error(`house: --sort must be "dirs-first" or "files-first", got "${args.sort}"`)
355
- process.exit(2)
356
- }
357
- sort = args.sort
358
- }
359
352
  let sidebarMode: SidebarMode = "auto"
360
353
  if (args.sidebar !== null) {
361
354
  if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
@@ -379,8 +372,7 @@ if (import.meta.main) {
379
372
  tone,
380
373
  maxWidth,
381
374
  show,
382
- sort,
383
- mdx,
375
+ extensions,
384
376
  sidebarMode,
385
377
  startupFocus,
386
378
  updateCheck: !args.noUpdateCheck,
@@ -395,8 +387,7 @@ interface TuiBootOptions {
395
387
  readonly tone: "dark" | "light"
396
388
  readonly maxWidth: number | null
397
389
  readonly show: readonly ShowCategory[]
398
- readonly sort: SortOrder
399
- readonly mdx: boolean
390
+ readonly extensions: readonly string[]
400
391
  readonly sidebarMode: SidebarMode
401
392
  readonly startupFocus: StartupFocus
402
393
  /** Run the npm-registry probe and surface the "update available" notice.
@@ -411,8 +402,7 @@ async function runTui({
411
402
  tone,
412
403
  maxWidth,
413
404
  show,
414
- sort,
415
- mdx,
405
+ extensions,
416
406
  sidebarMode,
417
407
  startupFocus,
418
408
  updateCheck,
@@ -455,8 +445,7 @@ async function runTui({
455
445
  target={discoveryRoot}
456
446
  initialQuery={initialQuery}
457
447
  initialShow={show}
458
- sort={sort}
459
- mdx={mdx}
448
+ extensions={extensions}
460
449
  maxWidth={maxWidth}
461
450
  sidebarMode={sidebarMode}
462
451
  startupFocus={startupFocus}
@@ -10,20 +10,15 @@
10
10
  * selected row); a future auto-scroll on the selected sidebar row can carry
11
11
  * the same information without altering layout for the rest.
12
12
  *
13
- * Truncation policy (head-elide, segment-aware):
14
- * - Full parent fits render whole.
15
- * - Else drop leading segments one at a time, prefixed with `…/`, until
16
- * the remainder fits never chops a segment mid-character.
17
- * - When even the tail segment with `…/` overflows, drop the marker.
18
- * - When the tail segment alone overflows, hard-truncate it from its head
19
- * (leading `…`) as a last resort.
20
- *
21
- * Why head-elide: the immediate parent is the segment closest to the file
22
- * and the most universally meaningful one when context shrinks.
13
+ * Truncation policy: the basename stays whole when possible; the parent path
14
+ * middle-truncates into `a/…/z`-style output once it exceeds the remaining
15
+ * width. This preserves both the start and the end of the path, which is
16
+ * usually what users need to disambiguate nested docs.
23
17
  */
24
18
 
25
- export const SIDEBAR_ROW_SEPARATOR = " · "
26
- const ELISION_PREFIX = "…/"
19
+ import { middleTruncate } from "../ui/middleTruncate.ts"
20
+
21
+ export const SIDEBAR_ROW_SEPARATOR = " "
27
22
  const MIN_PARENT_BUDGET = 3
28
23
 
29
24
  export interface SidebarRowParts {
@@ -54,18 +49,7 @@ export const formatSidebarRow = (relativePath: string, totalWidth: number): Side
54
49
  return row(basename, parentFull)
55
50
  }
56
51
 
57
- const segments = parentFull.split("/")
58
- for (let k = segments.length - 1; k >= 1; k--) {
59
- const candidate = ELISION_PREFIX + segments.slice(segments.length - k).join("/")
60
- if (candidate.length <= remaining) return row(basename, candidate)
61
- }
62
-
63
- // Even one segment with the `…/` marker doesn't fit. Try without the marker.
64
- const tail = segments[segments.length - 1]!
65
- if (tail.length <= remaining) return row(basename, tail)
66
-
67
- // Hard-chop the tail segment from its head as a last resort.
68
- return row(basename, "…" + tail.slice(tail.length - remaining + 1))
52
+ return row(basename, middleTruncate(parentFull, remaining))
69
53
  }
70
54
 
71
55
  const row = (basename: string, parent: string): SidebarRowParts => ({
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Middle truncation for technical strings.
3
+ *
4
+ * Keeps the start and end of a string visible while compressing the middle
5
+ * to a single ellipsis glyph (`…`). Use for paths, IDs, branch names, and
6
+ * other values whose head and tail both matter.
7
+ */
8
+
9
+ export interface MiddleTruncateOptions {
10
+ readonly ellipsis?: string
11
+ }
12
+
13
+ export const middleTruncate = (
14
+ value: string,
15
+ width: number,
16
+ options: MiddleTruncateOptions = {},
17
+ ): string => {
18
+ const ellipsis = options.ellipsis ?? "…"
19
+ if (width <= 0) return ""
20
+ if (value.length <= width) return value
21
+ if (width <= ellipsis.length) return value.slice(0, width)
22
+
23
+ const available = width - ellipsis.length
24
+ const left = Math.ceil(available / 2)
25
+ const right = Math.floor(available / 2)
26
+ return value.slice(0, left) + ellipsis + value.slice(value.length - right)
27
+ }