@carlesandres/house 0.4.3 → 0.4.4

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,22 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.4] — 2026-05-24
10
+
11
+ ### Added
12
+
13
+ - Discovery visibility controls: new `--show <list>` CLI flag, `HOUSE_SHOW` env var, and TOML `show = ["..."]` config, replacing boolean discovery toggles with explicit categories (`hidden`, `gitignored`).
14
+ - Session visibility toggle: `shift+a` flips discovery between the configured visibility set and showing all categories, preserving selection across the re-walk.
15
+ - Launch-in-filter option: `--start-in-filter` CLI flag, `HOUSE_START_IN_FILTER` env var, and TOML `start_in_filter = true` open the sidebar filter prompt focused at startup.
16
+
17
+ ### Changed
18
+
19
+ - Discovery plumbing now uses a first-class visibility-category model so future categories can be added without reshaping CLI/config/TUI surfaces.
20
+
21
+ ### Docs
22
+
23
+ - README updated for the discovery-show vocabulary (`--show`, `HOUSE_SHOW`, `show=[...]`) and the `shift+a` keybind.
24
+
9
25
  ## [0.4.3] — 2026-05-23
10
26
 
11
27
  ### Fixed
@@ -173,7 +189,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
173
189
 
174
190
  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.
175
191
 
176
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.3...HEAD
192
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.4...HEAD
193
+ [0.4.4]: https://github.com/carlesandres/house/compare/v0.4.3...v0.4.4
177
194
  [0.4.3]: https://github.com/carlesandres/house/compare/v0.4.2...v0.4.3
178
195
  [0.4.2]: https://github.com/carlesandres/house/compare/v0.4.1...v0.4.2
179
196
  [0.4.1]: https://github.com/carlesandres/house/compare/v0.4.0...v0.4.1
package/README.md CHANGED
@@ -47,12 +47,13 @@ house [options] <path>
47
47
  | `--theme <name>` | `opencode` | Starting theme (see list below) |
48
48
  | `--tone dark\|light` | `dark` | Starting tone |
49
49
  | `--width <N>` | — | Cap rendered markdown width at N columns |
50
- | `--all` | off | Include hidden and gitignored files in discovery |
50
+ | `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
51
51
  | `--sort <mode>` | `dirs-first` | Sidebar order: `dirs-first` or `files-first` |
52
52
  | `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
53
53
  | `--serve` | off | Serve the given file as HTML in the browser (skips TUI) |
54
54
  | `--port <N>` | OS-assigned | Port for `--serve` |
55
55
  | `--no-mdx` | off | Exclude `.mdx` files from discovery |
56
+ | `--start-in-filter` | off | Open the sidebar filter prompt on launch so you can type a query immediately. Press Esc to dismiss. |
56
57
  | `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
57
58
  | `--config-path` | — | Print the resolved config-file path and exit |
58
59
  | `-h`, `--help` | — | Show help and exit |
@@ -73,16 +74,22 @@ Run `house --config-path` to print the exact location.
73
74
  theme = "tokyonight"
74
75
  tone = "dark"
75
76
  mdx = true
77
+ show = ["hidden", "gitignored"]
78
+ start_in_filter = false
76
79
  ```
77
80
 
78
- Supported keys: `theme`, `tone`, `mdx`.
81
+ Supported keys: `theme`, `tone`, `mdx`, `show`, `start_in_filter`.
82
+
83
+ `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.
79
84
 
80
85
  Precedence, highest to lowest:
81
86
 
82
- 1. CLI flags (`--theme`, `--tone`, `--no-mdx`)
83
- 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`)
87
+ 1. CLI flags (`--theme`, `--tone`, `--no-mdx`, `--show`, `--start-in-filter`)
88
+ 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`, `HOUSE_SHOW`, `HOUSE_START_IN_FILTER`)
84
89
  3. Config file
85
- 4. Built-in defaults (`opencode` / `dark` / `mdx = true`)
90
+ 4. Built-in defaults (`opencode` / `dark` / `mdx = true` / `show = []` / `start_in_filter = false`)
91
+
92
+ `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.
86
93
 
87
94
  The file is optional — a missing file is fine. Invalid keys, unknown themes, or malformed TOML fail loudly with a one-line error. Per-project config (`.house/config.toml`) and additional keys are deferred.
88
95
 
@@ -116,6 +123,7 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
116
123
  | `g` | First file |
117
124
  | `G` | Last file |
118
125
  | `/` | Filter files (fuzzy match on path) |
126
+ | `A` | Toggle hidden + gitignored entries (session-only; round-trips with the configured `show`) |
119
127
  | `↵` / `→` / `l` | Open file (focus reader) |
120
128
 
121
129
  ### Reader
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/Browser.tsx CHANGED
@@ -67,6 +67,15 @@ export interface BrowserProps {
67
67
  /** TTL (ms) for the update-notice toast. Exposed so tests can use a small
68
68
  * value instead of sleeping for the production 10s window. */
69
69
  readonly updateNoticeTtlMs?: number
70
+ /** Flip the parent's discovery vocabulary (#145). Browser doesn't need
71
+ * to know which categories are currently on — the toggle is opaque
72
+ * from this side; we just snapshot the selected path so it can be
73
+ * restored across the re-walk the parent triggers. */
74
+ readonly onToggleAll?: () => void
75
+ /** Open the sidebar filter prompt on mount so the user can type
76
+ * immediately. Esc closes it through the normal close path — no
77
+ * special "first close" behavior. */
78
+ readonly startInFilter?: boolean
70
79
  }
71
80
 
72
81
  const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
@@ -95,6 +104,8 @@ export const Browser = ({
95
104
  readFile = defaultReadFile,
96
105
  updateNotice = null,
97
106
  updateNoticeTtlMs = 10000,
107
+ onToggleAll,
108
+ startInFilter = false,
98
109
  }: BrowserProps) => {
99
110
  const renderer = useRenderer()
100
111
  const { width, height } = useTerminalDimensions()
@@ -123,10 +134,16 @@ export const Browser = ({
123
134
  return initialShownForAuto(width)
124
135
  }
125
136
  })
126
- const [focus, setFocus] = useState<"sidebar" | "reader">(() => (shown ? "sidebar" : "reader"))
137
+ // startInFilter mirrors `openFilter`'s focus rule: the filter input lives
138
+ // in the sidebar, so opening it on mount also forces sidebar focus
139
+ // regardless of `--sidebar=off` (§7.1's visibility derivation surfaces
140
+ // the sidebar via focus even when `shown` is false).
141
+ const [focus, setFocus] = useState<"sidebar" | "reader">(() =>
142
+ shown || startInFilter ? "sidebar" : "reader",
143
+ )
127
144
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
128
145
  const [helpVisible, setHelpVisible] = useState<boolean>(false)
129
- const [filterOpen, setFilterOpen] = useState<boolean>(false)
146
+ const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
130
147
  const [filterQuery, setFilterQuery] = useState<string>("")
131
148
  const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
132
149
  const [paletteQuery, setPaletteQuery] = useState<string>("")
@@ -142,7 +159,7 @@ export const Browser = ({
142
159
  // updates even when multiple keys arrive in a single React batch (the
143
160
  // first key opens the filter; subsequent keys in the same tick would
144
161
  // otherwise still observe filterOpen=false through closure).
145
- const filterOpenRef = useRef(false)
162
+ const filterOpenRef = useRef(startInFilter)
146
163
  const filterQueryRef = useRef("")
147
164
  const [footerNotice, setFooterNoticeState] = useState<{
148
165
  readonly text: string
@@ -151,6 +168,13 @@ export const Browser = ({
151
168
  const pushFooterNotice = (text: string, ttlMs = 2000): void =>
152
169
  setFooterNoticeState({ text, ttlMs })
153
170
  const serverRef = useRef<ServerHandle | null>(null)
171
+ // #145 selection preservation across an `all` re-walk. When the user
172
+ // toggles, we snapshot the currently selected path; once the new file set
173
+ // streams in, we restore selection by path. If the path isn't present in
174
+ // the new set (e.g. it was a hidden file and `all` just went off), the
175
+ // ref stays armed so toggling back later re-selects it. Any user-driven
176
+ // selection move (j/k/g/G/click) clears it — user intent has moved on.
177
+ const pendingSelectionPathRef = useRef<string | null>(null)
154
178
 
155
179
  // Stop the preview server on unmount so re-mounts (tests) and clean
156
180
  // shutdowns don't leak a listening socket.
@@ -211,6 +235,22 @@ export const Browser = ({
211
235
  }
212
236
  }, [displayedFiles.length, selectedIndex])
213
237
 
238
+ // #145 selection restoration. Runs whenever the displayed file list
239
+ // changes (re-walk batches, filter changes). If the user has a path
240
+ // armed (set by toggleAll) and it now appears in the displayed subset,
241
+ // restore selection to that index and disarm. If the path is not
242
+ // present, keep the ref armed — toggling back later (or future stream
243
+ // batches in the same toggle) will find it.
244
+ useEffect(() => {
245
+ const target = pendingSelectionPathRef.current
246
+ if (target === null) return
247
+ const idx = displayedFiles.findIndex((f) => f.path === target)
248
+ if (idx >= 0) {
249
+ setSelectedIndex(idx)
250
+ pendingSelectionPathRef.current = null
251
+ }
252
+ }, [displayedFiles])
253
+
214
254
  const selected = displayedFiles[selectedIndex]
215
255
 
216
256
  // Track the path whose content is currently rendered. Updated lazily via
@@ -269,7 +309,15 @@ export const Browser = ({
269
309
  filterQuery,
270
310
  paletteOpen,
271
311
  setFocus,
272
- setSelectedIndex,
312
+ // Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
313
+ // prev/next) clears the pending-restore ref from #145. Internal
314
+ // callers that should NOT clear pending (the filter modal's typing
315
+ // branch, the post-filter clamp effect, the restoration effect
316
+ // itself) deliberately use the raw `setSelectedIndex` setter.
317
+ setSelectedIndex: (updater) => {
318
+ pendingSelectionPathRef.current = null
319
+ setSelectedIndex(updater)
320
+ },
273
321
  toggleShown: () => {
274
322
  // Two layout shapes, two behaviors:
275
323
  // wide → flip the sticky `shown` preference. Per DESIGN.md §7.1
@@ -330,6 +378,19 @@ export const Browser = ({
330
378
  },
331
379
  cycleTheme,
332
380
  toggleTone,
381
+ toggleAll: () => {
382
+ // Snapshot the currently displayed selection unless a snapshot is
383
+ // already armed (a prior toggle's selection survived the re-walk
384
+ // and is still waiting to come back). The armed path is the one
385
+ // the user originally chose; preserving it across a toggle-off /
386
+ // toggle-on round-trip is the headline ergonomic of #145.
387
+ // User-driven nav (j/k/g/G via the wrapped setSelectedIndex below)
388
+ // clears pending, so a follow-up toggle starts a fresh snapshot.
389
+ if (pendingSelectionPathRef.current === null && selected) {
390
+ pendingSelectionPathRef.current = selected.path
391
+ }
392
+ onToggleAll?.()
393
+ },
333
394
  serveCurrent: () => {
334
395
  const file = displayedFiles[selectedIndex]
335
396
  if (!file) return
package/src/cli/argv.ts CHANGED
@@ -9,8 +9,6 @@ export interface ParsedArgs {
9
9
  readonly tone: string | null
10
10
  /** Value of `--width <N>`, or null. Validated by the boot layer (must be a positive integer). */
11
11
  readonly width: string | null
12
- /** True when `--all` was passed: include hidden + gitignored files in discovery. */
13
- readonly all: boolean
14
12
  /** Value of `--sort <mode>` (`dirs-first` or `files-first`), or null. Validated by the boot layer. */
15
13
  readonly sort: string | null
16
14
  /** True when `--serve` was passed: serve the given file as HTML, skip TUI. */
@@ -31,6 +29,14 @@ export interface ParsedArgs {
31
29
  readonly noUpdateCheck: boolean
32
30
  /** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
33
31
  readonly noMdx: boolean
32
+ /** True when `--start-in-filter` was passed: open the sidebar filter
33
+ * prompt on launch (one-way override; default is off). */
34
+ readonly startInFilter: boolean
35
+ /** Raw value of `--show <list>`, or null if the flag wasn't passed.
36
+ * Comma-separated list of category names; the boot layer validates
37
+ * tokens against the known vocabulary (see `discovery/show.ts`).
38
+ * `--show ""` is a meaningful value: clears the set. */
39
+ readonly show: string | null
34
40
  }
35
41
 
36
42
  /**
@@ -45,7 +51,6 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
45
51
  let theme: string | null = null
46
52
  let tone: string | null = null
47
53
  let width: string | null = null
48
- let all = false
49
54
  let sort: string | null = null
50
55
  let serve = false
51
56
  let port: string | null = null
@@ -55,6 +60,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
55
60
  let sidebar: string | null = null
56
61
  let noUpdateCheck = false
57
62
  let noMdx = false
63
+ let show: string | null = null
64
+ let startInFilter = false
58
65
 
59
66
  for (let i = 0; i < argv.length; i++) {
60
67
  const arg = argv[i]!
@@ -71,9 +78,6 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
71
78
  width = argv[i + 1] ?? null
72
79
  i++
73
80
  continue
74
- case "--all":
75
- all = true
76
- continue
77
81
  case "--sort":
78
82
  sort = argv[i + 1] ?? null
79
83
  i++
@@ -113,6 +117,18 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
113
117
  case "--no-mdx":
114
118
  noMdx = true
115
119
  continue
120
+ case "--start-in-filter":
121
+ startInFilter = true
122
+ continue
123
+ case "--show":
124
+ // Always consume the following arg, even when it looks like
125
+ // a flag — `--show ""` is meaningful (explicit empty set),
126
+ // and the empty-string regression matters more than the
127
+ // near-miss case of someone forgetting the value. Boot
128
+ // validates tokens.
129
+ show = argv[i + 1] ?? null
130
+ i++
131
+ continue
116
132
  }
117
133
  if (path === null && !arg.startsWith("-")) {
118
134
  path = arg
@@ -124,7 +140,6 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
124
140
  theme,
125
141
  tone,
126
142
  width,
127
- all,
128
143
  sort,
129
144
  serve,
130
145
  port,
@@ -134,6 +149,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
134
149
  sidebar,
135
150
  noUpdateCheck,
136
151
  noMdx,
152
+ show,
153
+ startInFilter,
137
154
  }
138
155
  }
139
156
 
@@ -147,7 +164,8 @@ options:
147
164
  --theme <id> color theme: ${themeList} (default: opencode)
148
165
  --tone <mode> dark or light (default: dark)
149
166
  --width <N> cap rendered markdown width at N columns
150
- --all include hidden and gitignored files in discovery
167
+ --show <list> reveal normally-skipped entries; comma-separated subset of:
168
+ hidden, gitignored. Use --show "" to clear.
151
169
  --sort <mode> sidebar order: dirs-first (default) or files-first
152
170
  --sidebar <m> initial sidebar visibility: auto (default), on, or off
153
171
  --serve serve the given file as HTML in the browser (skips TUI)
@@ -157,9 +175,10 @@ options:
157
175
  --config-path print path to the config file and exit
158
176
  --no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
159
177
  --no-mdx exclude .mdx files from discovery (default: included)
178
+ --start-in-filter open the sidebar filter prompt on launch
160
179
 
161
180
  configuration:
162
181
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
163
- keys: theme, tone, mdx
164
- env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX
182
+ keys: theme, tone, mdx, show, start_in_filter
183
+ env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_START_IN_FILTER
165
184
  precedence (high → low): flags → env → file → defaults`
@@ -44,6 +44,11 @@ const annotations: Record<string, Annotation> = {
44
44
  "sidebar.toggle": { title: "Toggle sidebar", category: "View" },
45
45
  "help.toggle": { title: "Show help", category: "App" },
46
46
  "filter.open": { title: "Filter files…", category: "Navigation" },
47
+ "discovery.toggleAll": {
48
+ title: "Toggle hidden / gitignored files",
49
+ category: "Navigation",
50
+ keywords: ["hidden", "gitignore", "dotfiles", "all"],
51
+ },
47
52
  "serve.current": { title: "Open in browser", category: "File" },
48
53
  "file.edit": { title: "Open in editor", category: "File", keywords: ["editor", "vim", "vscode"] },
49
54
  "theme.next": { category: "Appearance" },
@@ -13,23 +13,39 @@
13
13
  import { homedir } from "node:os"
14
14
  import { join } from "node:path"
15
15
  import { Config, ConfigProvider, Effect, Schema } from "effect"
16
+ import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "../discovery/show.ts"
16
17
  import { themeDefinitions } from "../theme/registry.ts"
17
18
 
18
19
  export interface HouseConfig {
19
20
  readonly theme: string
20
21
  readonly tone: "dark" | "light"
21
22
  readonly mdx: boolean
23
+ /** Categories of normally-skipped entries to opt into. See
24
+ * `src/discovery/show.ts` for the vocabulary. Empty array (the
25
+ * default) yields the conservative discovery set. */
26
+ readonly show: readonly ShowCategory[]
27
+ /** Open the sidebar filter prompt on launch so the user can type
28
+ * immediately. Esc returns to normal focus (no special behavior).
29
+ * Default false. */
30
+ readonly startInFilter: boolean
22
31
  }
23
32
 
24
33
  export interface CliOverrides {
25
34
  readonly theme: string | null
26
35
  readonly tone: string | null
27
36
  readonly mdx: boolean | null
37
+ /** When non-null, the parsed `--show` list completely replaces env/file
38
+ * (no per-category merging — sets compose by replacement, like every
39
+ * other CLI override here). `--show ""` sets the empty set. */
40
+ readonly show: readonly ShowCategory[] | null
41
+ readonly startInFilter: boolean | null
28
42
  }
29
43
 
30
44
  const DEFAULT_THEME = "opencode"
31
45
  const DEFAULT_TONE: "dark" | "light" = "dark"
32
46
  const DEFAULT_MDX = true
47
+ const DEFAULT_SHOW = ""
48
+ const DEFAULT_START_IN_FILTER = false
33
49
 
34
50
  const themeIds = themeDefinitions.map((t) => t.id)
35
51
 
@@ -39,7 +55,13 @@ const themeIds = themeDefinitions.map((t) => t.id)
39
55
  * Used by `fileProvider` to warn about unrecognized keys (with a
40
56
  * did-you-mean hint when one is close) while still loading the rest.
41
57
  */
42
- const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx"])
58
+ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
59
+ "theme",
60
+ "tone",
61
+ "mdx",
62
+ "show",
63
+ "start_in_filter",
64
+ ])
43
65
 
44
66
  const schema = Config.all({
45
67
  theme: Config.schema(Schema.Literals(themeIds), "theme"),
@@ -48,6 +70,12 @@ const schema = Config.all({
48
70
  // (TOML bools, env vars, CLI flags all flow through as text). Mapped to
49
71
  // a real boolean in `loadConfig` below.
50
72
  mdx: Config.schema(Schema.Literals(["true", "false"] as const), "mdx"),
73
+ // `show` arrives as a comma-separated string from every provider
74
+ // (`fileProvider` coerces TOML arrays via `String()`, which produces
75
+ // `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
76
+ // so the error message can list valid categories at the field's path.
77
+ show: Config.schema(Schema.String, "show"),
78
+ start_in_filter: Config.schema(Schema.Literals(["true", "false"] as const), "start_in_filter"),
51
79
  })
52
80
 
53
81
  const defaultsProvider = (): ConfigProvider.ConfigProvider =>
@@ -55,6 +83,8 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
55
83
  theme: DEFAULT_THEME,
56
84
  tone: DEFAULT_TONE,
57
85
  mdx: String(DEFAULT_MDX),
86
+ show: DEFAULT_SHOW,
87
+ start_in_filter: String(DEFAULT_START_IN_FILTER),
58
88
  })
59
89
 
60
90
  /**
@@ -170,9 +200,13 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
170
200
  const theme = env["HOUSE_THEME"]
171
201
  const tone = env["HOUSE_TONE"]
172
202
  const mdx = env["HOUSE_MDX"]
203
+ const show = env["HOUSE_SHOW"]
204
+ const startInFilter = env["HOUSE_START_IN_FILTER"]
173
205
  if (theme !== undefined) entries.push(["theme", theme])
174
206
  if (tone !== undefined) entries.push(["tone", tone])
175
207
  if (mdx !== undefined) entries.push(["mdx", mdx])
208
+ if (show !== undefined) entries.push(["show", show])
209
+ if (startInFilter !== undefined) entries.push(["start_in_filter", startInFilter])
176
210
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
177
211
  }
178
212
 
@@ -181,6 +215,9 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
181
215
  if (overrides.theme !== null) entries.push(["theme", overrides.theme])
182
216
  if (overrides.tone !== null) entries.push(["tone", overrides.tone])
183
217
  if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
218
+ if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
219
+ if (overrides.startInFilter !== null)
220
+ entries.push(["start_in_filter", String(overrides.startInFilter)])
184
221
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
185
222
  }
186
223
 
@@ -217,15 +254,42 @@ export const formatConfigError = (err: unknown): string => {
217
254
 
218
255
  export const loadConfig = (
219
256
  options: LoadOptions = {},
220
- ): Effect.Effect<HouseConfig, Config.ConfigError> => {
221
- const cli = options.cli ?? { theme: null, tone: null, mdx: null }
257
+ ): Effect.Effect<HouseConfig, Config.ConfigError | Error> => {
258
+ const cli = options.cli ?? {
259
+ theme: null,
260
+ tone: null,
261
+ mdx: null,
262
+ show: null,
263
+ startInFilter: null,
264
+ }
222
265
  const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
223
266
  const provider = cliProvider(cli).pipe(
224
267
  ConfigProvider.orElse(envProvider(options.env ?? process.env)),
225
268
  ConfigProvider.orElse(fileProvider(options.filePath ?? defaultConfigPath(), onWarning)),
226
269
  ConfigProvider.orElse(defaultsProvider()),
227
270
  )
228
- return schema
229
- .parse(provider)
230
- .pipe(Effect.map((raw) => ({ theme: raw.theme, tone: raw.tone, mdx: raw.mdx === "true" })))
271
+ return schema.parse(provider).pipe(
272
+ Effect.flatMap((raw) => {
273
+ const parsed = parseShowList(raw.show)
274
+ if (!parsed.ok) {
275
+ // Effect's `Config.ConfigError` requires a `SchemaError` or
276
+ // `SourceError` cause that we don't have a clean constructor
277
+ // for here — surface as a plain Error and let the boot
278
+ // layer's existing `formatConfigError` (which already handles
279
+ // `instanceof Error`) render it.
280
+ return Effect.fail(
281
+ new Error(
282
+ `show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
283
+ ),
284
+ )
285
+ }
286
+ return Effect.succeed({
287
+ theme: raw.theme,
288
+ tone: raw.tone,
289
+ mdx: raw.mdx === "true",
290
+ show: parsed.value,
291
+ startInFilter: raw.start_in_filter === "true",
292
+ })
293
+ }),
294
+ )
231
295
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Discovery visibility categories.
3
+ *
4
+ * Each name identifies a class of entries that the walker normally skips and
5
+ * that the user can opt into showing. The set is intentionally an open vocab:
6
+ * adding a future category (e.g. `"build-artifacts"`) means one entry here,
7
+ * one branch in `walk`, and no churn in the CLI/config surface — `--show`,
8
+ * `HOUSE_SHOW`, and TOML `show = [...]` all carry whatever names live in this
9
+ * tuple.
10
+ */
11
+ export const SHOW_CATEGORIES = ["hidden", "gitignored"] as const
12
+
13
+ export type ShowCategory = (typeof SHOW_CATEGORIES)[number]
14
+
15
+ export const isShowCategory = (value: string): value is ShowCategory =>
16
+ (SHOW_CATEGORIES as readonly string[]).includes(value)
17
+
18
+ /**
19
+ * Parse a comma-separated list of category names. Whitespace around tokens
20
+ * is trimmed; empty tokens (trailing comma, double comma) are dropped.
21
+ * Returns `{ ok, value }` on success or `{ ok: false, invalid }` listing
22
+ * tokens that aren't known categories — callers shape the error message
23
+ * for their surface (CLI vs config).
24
+ */
25
+ export type ParseShowResult =
26
+ | { readonly ok: true; readonly value: readonly ShowCategory[] }
27
+ | { readonly ok: false; readonly invalid: readonly string[] }
28
+
29
+ export const parseShowList = (raw: string): ParseShowResult => {
30
+ const tokens = raw
31
+ .split(",")
32
+ .map((t) => t.trim())
33
+ .filter((t) => t.length > 0)
34
+ const invalid = tokens.filter((t) => !isShowCategory(t))
35
+ if (invalid.length > 0) return { ok: false, invalid }
36
+ // De-dupe while preserving the configured order — callers that care
37
+ // about presence use Set membership; emitting a unique sequence keeps
38
+ // the round-trip (encode/decode) stable.
39
+ const seen = new Set<ShowCategory>()
40
+ const out: ShowCategory[] = []
41
+ for (const t of tokens as ShowCategory[]) {
42
+ if (!seen.has(t)) {
43
+ seen.add(t)
44
+ out.push(t)
45
+ }
46
+ }
47
+ return { ok: true, value: out }
48
+ }
@@ -2,6 +2,7 @@ import { readdir, readFile } from "node:fs/promises"
2
2
  import { extname, join, relative, resolve } from "node:path"
3
3
  import { Data, Effect, Stream } from "effect"
4
4
  import ignore, { type Ignore } from "ignore"
5
+ import type { ShowCategory } from "./show.ts"
5
6
 
6
7
  export interface FileEntry {
7
8
  /** Absolute path on disk. */
@@ -15,8 +16,11 @@ export interface FileEntry {
15
16
  export type SortOrder = "dirs-first" | "files-first"
16
17
 
17
18
  export interface WalkOptions {
18
- /** Include hidden files and gitignored entries. Hard skips still apply. */
19
- readonly all?: boolean
19
+ /** Categories of normally-skipped entries to opt into. Empty (the
20
+ * default) yields the conservative set: no dotfiles, no gitignored
21
+ * entries. Order is irrelevant — semantics are set membership. Hard
22
+ * skips (`node_modules`, `.git`, `.venv`) always apply. */
23
+ readonly show?: Iterable<ShowCategory>
20
24
  /** Group order within each directory. Default `dirs-first`. */
21
25
  readonly sort?: SortOrder
22
26
  /** Include `.mdx` files alongside `.md`/`.markdown`. Default `true`. */
@@ -88,13 +92,13 @@ async function* walkDirGen(
88
92
  dirPath: string,
89
93
  rootPath: string,
90
94
  parentLevels: readonly IgnoreLevel[],
91
- opts: { all: boolean; sort: SortOrder; mdx: boolean },
95
+ opts: { showHidden: boolean; showGitignored: boolean; sort: SortOrder; mdx: boolean },
92
96
  signal: AbortSignal,
93
97
  ): AsyncGenerator<FileEntry, void, void> {
94
98
  if (signal.aborted) return
95
99
 
96
100
  let levels = parentLevels
97
- if (!opts.all) {
101
+ if (!opts.showGitignored) {
98
102
  const ig = await tryLoadGitignore(dirPath)
99
103
  if (signal.aborted) return
100
104
  if (ig) levels = [...parentLevels, { dir: dirPath, ig }]
@@ -114,17 +118,17 @@ async function* walkDirGen(
114
118
 
115
119
  if (entry.isDirectory()) {
116
120
  if (HARD_SKIP_DIRS.has(entry.name)) continue
117
- if (!opts.all && entry.name.startsWith(".")) continue
118
- if (!opts.all && isIgnored(entryPath, true, levels)) continue
121
+ if (!opts.showHidden && entry.name.startsWith(".")) continue
122
+ if (!opts.showGitignored && isIgnored(entryPath, true, levels)) continue
119
123
  yield* walkDirGen(entryPath, rootPath, levels, opts, signal)
120
124
  continue
121
125
  }
122
126
 
123
127
  if (!entry.isFile()) continue
124
- if (!opts.all && entry.name.startsWith(".")) continue
128
+ if (!opts.showHidden && entry.name.startsWith(".")) continue
125
129
  const allowed = opts.mdx ? MARKDOWN_EXTENSIONS : MARKDOWN_EXTENSIONS_NO_MDX
126
130
  if (!allowed.has(extname(entry.name).toLowerCase())) continue
127
- if (!opts.all && isIgnored(entryPath, false, levels)) continue
131
+ if (!opts.showGitignored && isIgnored(entryPath, false, levels)) continue
128
132
 
129
133
  yield {
130
134
  path: entryPath,
@@ -143,7 +147,7 @@ async function* walkDirGen(
143
147
  * Rules (see DESIGN.md §6):
144
148
  * - Extensions: `.md`, `.markdown`, and `.mdx` (unless `mdx: false`).
145
149
  * - Hard skips (always): `node_modules`, `.git`, `.venv`.
146
- * - Hidden files/dirs (leading `.`) skipped unless `all: true`.
150
+ * - Hidden files/dirs (leading `.`) skipped unless `show` contains `"hidden"`.
147
151
  * - `.gitignore` honored, including nested `.gitignore` files.
148
152
  * - Symlinks not followed.
149
153
  * - Sort: alphabetical within each group; directories before files
@@ -154,8 +158,10 @@ export const walk = (
154
158
  options: WalkOptions = {},
155
159
  ): Stream.Stream<FileEntry, DiscoveryError> => {
156
160
  const absRoot = resolve(root)
161
+ const show = new Set<ShowCategory>(options.show ?? [])
157
162
  const opts = {
158
- all: options.all ?? false,
163
+ showHidden: show.has("hidden"),
164
+ showGitignored: show.has("gitignored"),
159
165
  sort: options.sort ?? ("dirs-first" as SortOrder),
160
166
  mdx: options.mdx ?? true,
161
167
  }
package/src/index.tsx CHANGED
@@ -19,6 +19,7 @@ import pkg from "../package.json" with { type: "json" }
19
19
  import { Browser } from "./Browser.tsx"
20
20
  import { parseArgv, usage } from "./cli/argv.ts"
21
21
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
22
+ import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
22
23
  import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
23
24
  import { Header } from "./Header.tsx"
24
25
  import { readFileText } from "./io/readFile.ts"
@@ -59,15 +60,29 @@ export type SidebarMode = "auto" | "on" | "off"
59
60
 
60
61
  interface DiscoverShellProps {
61
62
  readonly target: string
62
- readonly all: boolean
63
+ /** Resolved discovery vocabulary from the config layer. The shift+a
64
+ * toggle (#145) is session-only sugar that flips between this set
65
+ * and the full vocabulary; the underlying categories remain
66
+ * independent everywhere else. */
67
+ readonly initialShow: readonly ShowCategory[]
63
68
  readonly sort: SortOrder
64
69
  readonly mdx: boolean
65
70
  readonly maxWidth: number | null
66
71
  readonly sidebarMode: SidebarMode
72
+ readonly startInFilter: boolean
67
73
  }
68
74
 
69
- const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: DiscoverShellProps) => {
75
+ const DiscoverShell = ({
76
+ target,
77
+ initialShow,
78
+ sort,
79
+ mdx,
80
+ maxWidth,
81
+ sidebarMode,
82
+ startInFilter,
83
+ }: DiscoverShellProps) => {
70
84
  const updateNotice = useUpdateNotice()
85
+ const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
71
86
  const [files, setFiles] = useState<readonly FileEntry[]>([])
72
87
  const [scanning, setScanning] = useState<boolean>(true)
73
88
  const [scanError, setScanError] = useState<string | null>(null)
@@ -76,7 +91,15 @@ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: Discov
76
91
  const countRef = useRef(0)
77
92
 
78
93
  useEffect(() => {
79
- const program = walk(target, { all, sort, mdx }).pipe(
94
+ // Restart from a clean slate every time the discovery set changes.
95
+ // Required for the `all` toggle (#145): without this, a flip would
96
+ // concatenate the new walk onto stale entries and leave `scanning`
97
+ // stuck on whatever the previous walk last set it to.
98
+ setFiles([])
99
+ setScanning(true)
100
+ setScanError(null)
101
+ countRef.current = 0
102
+ const program = walk(target, { show, sort, mdx }).pipe(
80
103
  Stream.groupedWithin(64, Duration.millis(60)),
81
104
  Stream.runForEach((chunk) =>
82
105
  Effect.sync(() => {
@@ -101,7 +124,7 @@ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: Discov
101
124
  return () => {
102
125
  Effect.runFork(Fiber.interrupt(fiber))
103
126
  }
104
- }, [target, all, sort, mdx])
127
+ }, [target, show, sort, mdx])
105
128
 
106
129
  const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
107
130
 
@@ -111,7 +134,17 @@ const DiscoverShell = ({ target, all, sort, mdx, maxWidth, sidebarMode }: Discov
111
134
  maxWidth={maxWidth}
112
135
  discoveryStatus={discoveryStatus}
113
136
  sidebarMode={sidebarMode}
137
+ startInFilter={startInFilter}
114
138
  updateNotice={updateNotice}
139
+ onToggleAll={() => {
140
+ // shift+a is the only place the categories are treated as a
141
+ // single thing. If every category is already on, fall back
142
+ // to "show none"; otherwise opt into the full vocabulary.
143
+ // Each press is a stable round-trip between [] and full.
144
+ const next: readonly ShowCategory[] =
145
+ show.length === SHOW_CATEGORIES.length ? [] : [...SHOW_CATEGORIES]
146
+ setShow(next)
147
+ }}
115
148
  />
116
149
  )
117
150
  }
@@ -209,6 +242,20 @@ if (import.meta.main) {
209
242
  process.exit(0)
210
243
  }
211
244
 
245
+ // Parse --show eagerly so an invalid token fails fast with the CLI-style
246
+ // "house: ..." message, before any I/O work in loadConfig kicks off.
247
+ let cliShow: readonly ShowCategory[] | null = null
248
+ if (args.show !== null) {
249
+ const parsed = parseShowList(args.show)
250
+ if (!parsed.ok) {
251
+ console.error(
252
+ `house: --show: unknown category "${parsed.invalid.join('", "')}" (valid: ${SHOW_CATEGORIES.join(", ")})`,
253
+ )
254
+ process.exit(2)
255
+ }
256
+ cliShow = parsed.value
257
+ }
258
+
212
259
  const config = await Effect.runPromise(
213
260
  loadConfig({
214
261
  cli: {
@@ -217,13 +264,18 @@ if (import.meta.main) {
217
264
  // --no-mdx is a one-way override: present means "off". When
218
265
  // absent, fall through to env/file/default.
219
266
  mdx: args.noMdx ? false : null,
267
+ // `--show` replaces env/file when present (set semantics —
268
+ // no per-category merge across sources). `null` falls through.
269
+ show: cliShow,
270
+ // One-way override: present means "on". Absent → env/file/default.
271
+ startInFilter: args.startInFilter ? true : null,
220
272
  },
221
273
  }),
222
274
  ).catch((err: unknown) => {
223
275
  console.error(`house: ${formatConfigError(err)}`)
224
276
  process.exit(2)
225
277
  })
226
- const { theme: themeId, tone, mdx } = config
278
+ const { theme: themeId, tone, mdx, show, startInFilter } = config
227
279
  const themeDef = getThemeDefinition(themeId)
228
280
  if (themeDef === undefined) {
229
281
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -298,10 +350,11 @@ if (import.meta.main) {
298
350
  themeId,
299
351
  tone,
300
352
  maxWidth,
301
- all: args.all,
353
+ show,
302
354
  sort,
303
355
  mdx,
304
356
  sidebarMode,
357
+ startInFilter,
305
358
  updateCheck: !args.noUpdateCheck,
306
359
  })
307
360
  }
@@ -312,10 +365,11 @@ interface TuiBootOptions {
312
365
  readonly themeId: string
313
366
  readonly tone: "dark" | "light"
314
367
  readonly maxWidth: number | null
315
- readonly all: boolean
368
+ readonly show: readonly ShowCategory[]
316
369
  readonly sort: SortOrder
317
370
  readonly mdx: boolean
318
371
  readonly sidebarMode: SidebarMode
372
+ readonly startInFilter: boolean
319
373
  /** Run the npm-registry probe and surface the "update available" notice.
320
374
  * False suppresses both the toast and the quit-time print. */
321
375
  readonly updateCheck: boolean
@@ -326,10 +380,11 @@ async function runTui({
326
380
  themeId,
327
381
  tone,
328
382
  maxWidth,
329
- all,
383
+ show,
330
384
  sort,
331
385
  mdx,
332
386
  sidebarMode,
387
+ startInFilter,
333
388
  updateCheck,
334
389
  }: TuiBootOptions): Promise<void> {
335
390
  let stats: Awaited<ReturnType<typeof stat>>
@@ -366,11 +421,12 @@ async function runTui({
366
421
  <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
367
422
  <DiscoverShell
368
423
  target={target}
369
- all={all}
424
+ initialShow={show}
370
425
  sort={sort}
371
426
  mdx={mdx}
372
427
  maxWidth={maxWidth}
373
428
  sidebarMode={sidebarMode}
429
+ startInFilter={startInFilter}
374
430
  />
375
431
  </RegistryProvider>,
376
432
  )
@@ -43,6 +43,11 @@ export interface BrowserCtx {
43
43
  /** Suspend the TUI, hand the TTY to `$EDITOR`, resume and re-read on
44
44
  * exit. No-op when nothing is selected; gating is the binding's job. */
45
45
  readonly editCurrent: () => void
46
+ /** Toggle hidden + gitignored discovery axes together (#145 — UI sugar
47
+ * for `shift+a`; the underlying flags stay independent everywhere
48
+ * else). Snapshots the current selected path so it can be restored
49
+ * once the re-walk completes. */
50
+ readonly toggleAll: () => void
46
51
  }
47
52
 
48
53
  /** Step size for shift+j/k and the space/b/page keys. Constant for v1; could
@@ -147,6 +152,19 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
147
152
  when: paletteClosed,
148
153
  run: (c) => c.openPalette(),
149
154
  },
155
+ {
156
+ id: "discovery.toggleAll",
157
+ group: "Sidebar",
158
+ description: "Show / hide hidden and gitignored files",
159
+ // No footer hint: shift+a is help/palette only. The footer hint row is
160
+ // already at width capacity on narrow viewports, and the toggle isn't
161
+ // a per-row action you reach for constantly.
162
+ keys: ["shift+a"],
163
+ // Selection-preservation logic lives in Browser.tsx — see the
164
+ // `pendingSelectionPath` ref. The toggle itself is session-only and
165
+ // does not write back to the TOML config.
166
+ run: (c) => c.toggleAll(),
167
+ },
150
168
  {
151
169
  id: "theme.next",
152
170
  group: "Global",