@carlesandres/house 0.4.9 → 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,17 @@ 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
+
9
20
  ## [0.4.9] — 2026-06-09
10
21
 
11
22
  ### Fixed
@@ -259,7 +270,7 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
259
270
 
260
271
  ### Added — discovery
261
272
 
262
- - 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.
263
274
  - Honors `.gitignore` (root + nested).
264
275
  - Hard-skips `node_modules`, `.git`, `.venv` (always, even with `--all`).
265
276
  - Does not follow symlinks.
@@ -294,7 +305,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
294
305
 
295
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.
296
307
 
297
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.9...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
298
310
  [0.4.9]: https://github.com/carlesandres/house/compare/v0.4.8...v0.4.9
299
311
  [0.4.8]: https://github.com/carlesandres/house/compare/v0.4.7...v0.4.8
300
312
  [0.4.7]: https://github.com/carlesandres/house/compare/v0.4.6...v0.4.7
package/README.md CHANGED
@@ -71,7 +71,7 @@ house --serve README.md
71
71
  | `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
72
72
  | `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
73
73
  | `--port <N>` | OS-assigned | Port for `--serve` |
74
- | `--no-mdx` | off | Exclude `.mdx` files from discovery |
74
+ | `--ext <list>` | none | Include extra file extensions (comma-separated) |
75
75
  | `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
76
76
  | `--config-path` | — | Print the resolved config-file path and exit |
77
77
  | `-h`, `--help` | — | Show help and exit |
@@ -91,22 +91,22 @@ Run `house --config-path` to print the exact location.
91
91
  # ~/.config/house/config.toml
92
92
  theme = "tokyonight"
93
93
  tone = "dark"
94
- mdx = true
94
+ extensions = []
95
95
  show = ["hidden", "gitignored"]
96
96
  focus = "filter"
97
97
  defaultRoot = "cwd" # or "git"
98
98
  ```
99
99
 
100
- Supported keys: `theme`, `tone`, `mdx`, `show`, `focus`, `defaultRoot`.
100
+ Supported keys: `theme`, `tone`, `extensions`, `show`, `focus`, `defaultRoot`.
101
101
 
102
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.
103
103
 
104
104
  Precedence, highest to lowest:
105
105
 
106
- 1. CLI flags (`--theme`, `--tone`, `--no-mdx`, `--show`, `--focus`, `--root`)
107
- 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`)
108
108
  3. Config file
109
- 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"`)
110
110
 
111
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.
112
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
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/cli/argv.ts CHANGED
@@ -28,8 +28,8 @@ export interface ParsedArgs {
28
28
  * probe and the "update available" notice. Mirrors the
29
29
  * `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
30
30
  readonly noUpdateCheck: boolean
31
- /** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
32
- readonly noMdx: boolean
31
+ /** Raw value of `--ext [list]`, or null if absent. Comma-separated list. */
32
+ readonly extensions: string | null
33
33
  /** Value of `--focus <mode>` (`sidebar`, `reader`, `filter`), or null.
34
34
  * Validated by the boot layer. */
35
35
  readonly focus: string | null
@@ -54,7 +54,7 @@ const createProgram = () =>
54
54
  .option("--config-path")
55
55
  .option("--sidebar [mode]")
56
56
  .option("--no-update-check")
57
- .option("--no-mdx")
57
+ .option("--ext [list]")
58
58
  .option("--focus [mode]")
59
59
  .option("--show [list]")
60
60
  .option("--root [dir]")
@@ -71,6 +71,7 @@ const VALUE_FLAGS: ReadonlySet<string> = new Set([
71
71
  "--focus",
72
72
  "--show",
73
73
  "--root",
74
+ "--ext",
74
75
  ])
75
76
 
76
77
  const REMOVED_VALUE_FLAGS: ReadonlySet<string> = new Set(["--sort"])
@@ -79,7 +80,6 @@ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
79
80
  "--serve",
80
81
  "--config-path",
81
82
  "--no-update-check",
82
- "--no-mdx",
83
83
  "--help",
84
84
  "-h",
85
85
  "--version",
@@ -128,7 +128,7 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
128
128
  configPath: opts["configPath"] === true,
129
129
  sidebar: stringOrNull(opts["sidebar"]),
130
130
  noUpdateCheck: opts["noUpdateCheck"] === true,
131
- noMdx: opts["mdx"] === false,
131
+ extensions: stringOrNull(opts["ext"]),
132
132
  show: stringOrNull(opts["show"]),
133
133
  focus: stringOrNull(opts["focus"]),
134
134
  }
@@ -157,7 +157,7 @@ options:
157
157
  -v, --version print version and exit
158
158
  --config-path print path to the config file and exit
159
159
  --no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
160
- --no-mdx exclude .mdx files from discovery (default: included)
160
+ --ext <list> include extra file extensions (comma-separated)
161
161
 
162
162
  examples:
163
163
  house README.md
@@ -166,6 +166,6 @@ examples:
166
166
 
167
167
  configuration:
168
168
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
169
- keys: theme, tone, mdx, show, focus, defaultRoot
170
- 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
171
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
+ }
@@ -24,8 +24,9 @@ export interface WalkOptions {
24
24
  * entries. Order is irrelevant — semantics are set membership. Hard
25
25
  * skips (`node_modules`, `.git`, `.venv`) always apply. */
26
26
  readonly show?: Iterable<ShowCategory>
27
- /** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
28
- 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>
29
30
  /** Non-fatal subtree read errors. Root-level failures still error the walk. */
30
31
  readonly onWarning?: ((warning: DiscoveryWarning) => void) | null
31
32
  }
@@ -35,10 +36,14 @@ export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
35
36
  readonly cause: unknown
36
37
  }> {}
37
38
 
38
- const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown", ".mdx"])
39
- const MARKDOWN_EXTENSIONS_NO_MDX = new Set([".md", ".markdown"])
39
+ const BASE_EXTENSIONS = new Set([".md", ".markdown"])
40
40
  const HARD_SKIP_DIRS = new Set(["node_modules", ".git", ".venv"])
41
41
 
42
+ const normalizeExtension = (ext: string): string => {
43
+ const trimmed = ext.trim().toLowerCase()
44
+ return trimmed.startsWith(".") ? trimmed : `.${trimmed}`
45
+ }
46
+
42
47
  interface IgnoreLevel {
43
48
  readonly dir: string
44
49
  readonly ig: Ignore
@@ -93,7 +98,7 @@ async function* walkDirGen(
93
98
  dirPath: string,
94
99
  rootPath: string,
95
100
  parentLevels: readonly IgnoreLevel[],
96
- opts: { showHidden: boolean; showGitignored: boolean; mdx: boolean },
101
+ opts: { showHidden: boolean; showGitignored: boolean; extensions: ReadonlySet<string> },
97
102
  onWarning: ((warning: DiscoveryWarning) => void) | null,
98
103
  signal: AbortSignal,
99
104
  ): AsyncGenerator<FileEntry, void, void> {
@@ -136,7 +141,7 @@ async function* walkDirGen(
136
141
 
137
142
  if (!entry.isFile()) continue
138
143
  if (!opts.showHidden && entry.name.startsWith(".")) continue
139
- const allowed = opts.mdx ? MARKDOWN_EXTENSIONS : MARKDOWN_EXTENSIONS_NO_MDX
144
+ const allowed = opts.extensions
140
145
  if (!allowed.has(extname(entry.name).toLowerCase())) continue
141
146
  if (!opts.showGitignored && isIgnored(entryPath, false, levels)) continue
142
147
 
@@ -155,7 +160,7 @@ async function* walkDirGen(
155
160
  * at its next `signal.aborted` check.
156
161
  *
157
162
  * Rules (see DESIGN.md §6):
158
- * - Extensions: `.md`, `.markdown`, and `.mdx` (unless `mdx: false`).
163
+ * - Extensions: `.md`, `.markdown`, plus configured extras.
159
164
  * - Hard skips (always): `node_modules`, `.git`, `.venv`.
160
165
  * - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
161
166
  * - `.gitignore` honored, including nested `.gitignore` files.
@@ -168,10 +173,12 @@ export const walk = (
168
173
  ): Stream.Stream<FileEntry, DiscoveryError> => {
169
174
  const absRoot = resolve(root)
170
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))
171
178
  const opts = {
172
179
  showHidden: show.has("hidden"),
173
180
  showGitignored: show.has("gitignored"),
174
- mdx: options.mdx ?? true,
181
+ extensions,
175
182
  }
176
183
  const onWarning = options.onWarning ?? null
177
184
  const controller = new AbortController()
package/src/index.tsx CHANGED
@@ -106,7 +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 mdx: boolean
109
+ readonly extensions: readonly string[]
110
110
  readonly maxWidth: number | null
111
111
  readonly sidebarMode: SidebarMode
112
112
  readonly startupFocus: StartupFocus
@@ -116,7 +116,7 @@ export const DiscoverShell = ({
116
116
  target,
117
117
  initialQuery,
118
118
  initialShow,
119
- mdx,
119
+ extensions,
120
120
  maxWidth,
121
121
  sidebarMode,
122
122
  startupFocus,
@@ -145,7 +145,7 @@ export const DiscoverShell = ({
145
145
  countRef.current = 0
146
146
  const warnedProgram = walk(target, {
147
147
  show,
148
- mdx,
148
+ extensions,
149
149
  onWarning: ({ path }) => {
150
150
  const relativePath = relative(resolve(target), path)
151
151
  setSkippedDirCount((prev) => prev + 1)
@@ -179,7 +179,7 @@ export const DiscoverShell = ({
179
179
  return () => {
180
180
  Effect.runFork(Fiber.interrupt(fiber))
181
181
  }
182
- }, [target, show, mdx])
182
+ }, [target, show, extensions])
183
183
 
184
184
  const discoveryStatus =
185
185
  scanError ??
@@ -268,9 +268,13 @@ if (import.meta.main) {
268
268
  cli: {
269
269
  theme: args.theme,
270
270
  tone: args.tone,
271
- // --no-mdx is a one-way override: present means "off". When
272
- // absent, fall through to env/file/default.
273
- 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),
274
278
  // `--show` replaces env/file when present (set semantics —
275
279
  // no per-category merge across sources). `null` falls through.
276
280
  show: cliShow,
@@ -284,7 +288,7 @@ if (import.meta.main) {
284
288
  console.error(`house: ${formatConfigError(err)}`)
285
289
  process.exit(2)
286
290
  })
287
- const { theme: themeId, tone, mdx, show, focus: startupFocus, defaultRoot } = config
291
+ const { theme: themeId, tone, extensions, show, focus: startupFocus, defaultRoot } = config
288
292
  const themeDef = getThemeDefinition(themeId)
289
293
  if (themeDef === undefined) {
290
294
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -368,7 +372,7 @@ if (import.meta.main) {
368
372
  tone,
369
373
  maxWidth,
370
374
  show,
371
- mdx,
375
+ extensions,
372
376
  sidebarMode,
373
377
  startupFocus,
374
378
  updateCheck: !args.noUpdateCheck,
@@ -383,7 +387,7 @@ interface TuiBootOptions {
383
387
  readonly tone: "dark" | "light"
384
388
  readonly maxWidth: number | null
385
389
  readonly show: readonly ShowCategory[]
386
- readonly mdx: boolean
390
+ readonly extensions: readonly string[]
387
391
  readonly sidebarMode: SidebarMode
388
392
  readonly startupFocus: StartupFocus
389
393
  /** Run the npm-registry probe and surface the "update available" notice.
@@ -398,7 +402,7 @@ async function runTui({
398
402
  tone,
399
403
  maxWidth,
400
404
  show,
401
- mdx,
405
+ extensions,
402
406
  sidebarMode,
403
407
  startupFocus,
404
408
  updateCheck,
@@ -441,7 +445,7 @@ async function runTui({
441
445
  target={discoveryRoot}
442
446
  initialQuery={initialQuery}
443
447
  initialShow={show}
444
- mdx={mdx}
448
+ extensions={extensions}
445
449
  maxWidth={maxWidth}
446
450
  sidebarMode={sidebarMode}
447
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
 
19
+ import { middleTruncate } from "../ui/middleTruncate.ts"
20
+
25
21
  export const SIDEBAR_ROW_SEPARATOR = " "
26
- const ELISION_PREFIX = "…/"
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
+ }