@carlesandres/house 0.3.1 → 0.4.1

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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * CommandPalette — modal overlay for searching and running commands.
3
+ *
4
+ * Render-only: state lives in Browser.tsx alongside helpVisible / filterOpen
5
+ * (#70 design log §state location). Key handling sits in Browser.tsx's
6
+ * `useKeyboard` palette-branch, mirroring the filterOpen pattern.
7
+ *
8
+ * V1 ships a flat list in `browserBindings` order — no category headers,
9
+ * no mouse, no recency. See #91/#93/#94/#95/#96 for the follow-ups.
10
+ */
11
+
12
+ import { RGBA } from "@opentui/core"
13
+ import { colors } from "./theme/colors.ts"
14
+ import type { AppCommand } from "./commands/types.ts"
15
+
16
+ // Semi-transparent black scrim painted across the viewport behind the modal.
17
+ // Opentui composites it over the chrome underneath, so the rest of the UI
18
+ // reads as darkened while the modal stays fully opaque. Mirrors opencode's
19
+ // dialog backdrop (cli/cmd/tui/ui/dialog.tsx).
20
+ const SCRIM = RGBA.fromInts(0, 0, 0, 150)
21
+
22
+ export interface CommandPaletteProps {
23
+ readonly commands: readonly AppCommand[]
24
+ readonly query: string
25
+ readonly selectedIndex: number
26
+ readonly viewportWidth: number
27
+ readonly viewportHeight: number
28
+ }
29
+
30
+ const FOOTER_HINT = "↑↓ select enter run esc close"
31
+
32
+ export const CommandPalette = ({
33
+ commands,
34
+ query,
35
+ selectedIndex,
36
+ viewportWidth,
37
+ viewportHeight,
38
+ }: CommandPaletteProps) => {
39
+ const overlayWidth = Math.min(viewportWidth - 4, 64)
40
+ // Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
41
+ // 1 spacer above footer, 1 footer row. Body gets the rest.
42
+ const chrome = 2 + 1 + 1 + 1 + 1
43
+ const maxBody = Math.max(1, viewportHeight - 4 - chrome)
44
+ const desiredBody = Math.max(1, commands.length || 1)
45
+ const bodyHeight = Math.min(desiredBody, maxBody)
46
+ const overlayHeight = chrome + bodyHeight
47
+ const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
48
+ const top = Math.max(0, Math.floor((viewportHeight - overlayHeight) / 2))
49
+
50
+ // Inner content width: overlay minus 1-cell border + 1-cell padding on each side.
51
+ const rowWidth = Math.max(4, overlayWidth - 4)
52
+
53
+ // Window the visible slice around the selection. With 9 commands in v1
54
+ // this is usually a no-op (list fits), but the math is in place for the
55
+ // inevitable backlog growth.
56
+ const scrollTop = (() => {
57
+ if (commands.length <= bodyHeight) return 0
58
+ const maxScroll = commands.length - bodyHeight
59
+ let s = 0
60
+ if (selectedIndex >= bodyHeight) s = selectedIndex - bodyHeight + 1
61
+ return Math.max(0, Math.min(s, maxScroll))
62
+ })()
63
+ const visible = commands.slice(scrollTop, scrollTop + bodyHeight)
64
+
65
+ // Shortcut column width — long enough for `shift+t`-style chords but
66
+ // trimmed to prevent the title from being squeezed below ~16 cells.
67
+ const SHORTCUT_WIDTH = 10
68
+ const titleWidth = Math.max(8, rowWidth - 2 /* selector */ - SHORTCUT_WIDTH - 1 /* gap */)
69
+
70
+ const fit = (s: string, width: number): string =>
71
+ s.length === width
72
+ ? s
73
+ : s.length > width
74
+ ? s.slice(0, Math.max(0, width - 1)) + "…"
75
+ : s + " ".repeat(width - s.length)
76
+
77
+ const fitRight = (s: string, width: number): string =>
78
+ s.length >= width ? s.slice(0, width) : " ".repeat(width - s.length) + s
79
+
80
+ return (
81
+ <box
82
+ position="absolute"
83
+ left={0}
84
+ top={0}
85
+ width={viewportWidth}
86
+ height={viewportHeight}
87
+ zIndex={20}
88
+ style={{ backgroundColor: SCRIM }}
89
+ >
90
+ <box
91
+ position="absolute"
92
+ left={left}
93
+ top={top}
94
+ width={overlayWidth}
95
+ height={overlayHeight}
96
+ title=" Commands "
97
+ titleAlignment="left"
98
+ paddingLeft={1}
99
+ paddingRight={1}
100
+ style={{
101
+ border: true,
102
+ borderColor: colors.textMuted,
103
+ flexDirection: "column",
104
+ backgroundColor: colors.surface,
105
+ }}
106
+ >
107
+ <text
108
+ wrapMode="none"
109
+ content={fit(`> ${query}▏`, rowWidth)}
110
+ style={{ fg: colors.textStrong }}
111
+ />
112
+ <text content=" " />
113
+ {commands.length === 0 ? (
114
+ <text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
115
+ ) : (
116
+ visible.map((cmd, i) => {
117
+ const realIdx = scrollTop + i
118
+ const isSelected = realIdx === selectedIndex
119
+ const selector = isSelected ? "▸ " : " "
120
+ const titleText = fit(cmd.title, titleWidth)
121
+ const shortcutText = cmd.shortcut
122
+ ? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
123
+ : " ".repeat(SHORTCUT_WIDTH)
124
+ // Title and shortcut render as separate spans so the shortcut
125
+ // can use `textMuted` while the title uses `text`/`textStrong`.
126
+ // Same trick opencode pulls with `--text-weak` — the theme
127
+ // guarantees the contrast, we just pick the right role.
128
+ const titleFg = isSelected ? colors.textStrong : colors.text
129
+ return (
130
+ <text
131
+ key={cmd.id}
132
+ wrapMode="none"
133
+ style={isSelected ? { bg: colors.selectedBg } : {}}
134
+ >
135
+ <span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
136
+ <span style={{ fg: colors.textMuted }}>{shortcutText}</span>
137
+ </text>
138
+ )
139
+ })
140
+ )}
141
+ <text content=" " />
142
+ <text
143
+ wrapMode="none"
144
+ content={fit(FOOTER_HINT, rowWidth)}
145
+ style={{ fg: colors.textMuted }}
146
+ />
147
+ </box>
148
+ </box>
149
+ )
150
+ }
package/src/Footer.tsx CHANGED
@@ -11,6 +11,10 @@
11
11
  * were discovered. Intentional — `q:quit` and `?:help` are exactly what an
12
12
  * empty-vault user needs as an exit and discoverability anchor.
13
13
  *
14
+ * The filter input does not live here — it renders as a row inside the
15
+ * sidebar (see Browser.tsx). The pattern mirrors ghui's PR list, where the
16
+ * filter is part of the list it filters.
17
+ *
14
18
  * Width math assumes hint labels are ASCII plus a small set of single-cell
15
19
  * BMP glyphs (see `displayKey`). `fitHints` and notice clipping use string
16
20
  * length as a proxy for cell count; introducing a CJK or emoji label would
@@ -29,10 +33,15 @@ export interface FooterProps<C> {
29
33
  readonly ctx: C
30
34
  readonly width: number
31
35
  readonly notice?: string | null
32
- /** When set, the footer row turns into the filter input — `/<query>▏` —
33
- * and suppresses both the hint row and the notice. Mirrors hunk's
34
- * StatusBar: one row of chrome, content swaps by state. */
35
- readonly filter?: { readonly query: string } | null
36
+ /** Persistent status line (e.g. "indexing… 42"). Distinct from `notice`:
37
+ * no TTL, cleared by the caller when the underlying activity finishes.
38
+ * Loses to `notice` when both are set so transient toasts still surface. */
39
+ readonly discoveryStatus?: string | null
40
+ /** When a filter is applied but the input is closed, surface a chip in the
41
+ * hint row so the user remembers `[`/`]` walks the filtered set. Pass null
42
+ * while the filter input is open (the sidebar already shows the query) or
43
+ * when no filter is applied. */
44
+ readonly filterQuery?: string | null
36
45
  }
37
46
 
38
47
  const HINT_SEPARATOR = " "
@@ -62,39 +71,52 @@ const displayKey = (raw: string): string => {
62
71
  }
63
72
  }
64
73
 
65
- const formatHint = <C,>(b: KeyBinding<C>): string | null => {
74
+ /** Hint row entries. `key === null` is a standalone chip (e.g. the filter
75
+ * chip) and renders as muted text without the key/label split. */
76
+ interface Hint {
77
+ readonly key: string | null
78
+ readonly label: string
79
+ }
80
+
81
+ const hintWidth = (h: Hint): number =>
82
+ h.key === null ? h.label.length : h.key.length + 1 + h.label.length // key + " " + label
83
+
84
+ const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
66
85
  if (!b.hint) return null
67
86
  const first = b.keys[0]
68
87
  if (!first) return null
69
- return `${displayKey(first)}:${b.hint}`
88
+ return { key: displayKey(first), label: b.hint }
70
89
  }
71
90
 
72
- /** Drop hints from the end until the joined string fits within `width`.
73
- * If not even the first hint fits, fall back to the bare key portion so
74
- * the user still sees a discoverability anchor (e.g. `?` instead of an
75
- * empty row on an 8-column terminal). */
76
- const fitHints = (hints: readonly string[], width: number): string => {
77
- if (width <= 0 || hints.length === 0) return ""
78
- let acc = ""
91
+ /** Drop hints from the end until they fit within `width`. If not even the
92
+ * first hint fits, fall back to the bare key (or label chip) truncated to
93
+ * width, so the row is never silently blank on tight viewports. */
94
+ const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
95
+ if (width <= 0 || hints.length === 0) return []
96
+ const acc: Hint[] = []
97
+ let used = 0
79
98
  for (const h of hints) {
80
- const next = acc.length === 0 ? h : `${acc}${HINT_SEPARATOR}${h}`
81
- if (next.length > width) break
82
- acc = next
99
+ const add = acc.length === 0 ? hintWidth(h) : HINT_SEPARATOR.length + hintWidth(h)
100
+ if (used + add > width) break
101
+ acc.push(h)
102
+ used += add
83
103
  }
84
104
  if (acc.length > 0) return acc
85
- // Nothing fit. Render just the first hint's key (everything before `:`)
86
- // truncated to width, so the row is never silently blank.
87
- const firstKey = hints[0]!.split(":")[0] ?? ""
88
- return firstKey.slice(0, width)
105
+ const first = hints[0]!
106
+ if (first.key === null) return [{ key: null, label: first.label.slice(0, width) }]
107
+ return [{ key: first.key.slice(0, width), label: "" }]
89
108
  }
90
109
 
91
- /** Hints shown alongside the filter input. Mirrors the modal-mode key
92
- * handler in `Browser.tsx`; if those bindings change, update both. */
93
- const FILTER_HINTS = "↵:open esc:cancel"
94
- /** Minimum gap between the filter input and its hints on the same row. */
95
- const FILTER_HINT_GAP = 2
110
+ const STATUS_SEPARATOR = " · "
96
111
 
97
- export const Footer = <C,>({ bindings, ctx, width, notice, filter }: FooterProps<C>) => {
112
+ export const Footer = <C,>({
113
+ bindings,
114
+ ctx,
115
+ width,
116
+ notice,
117
+ discoveryStatus,
118
+ filterQuery,
119
+ }: FooterProps<C>) => {
98
120
  const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
99
121
 
100
122
  const rowStyle = {
@@ -104,48 +126,93 @@ export const Footer = <C,>({ bindings, ctx, width, notice, filter }: FooterProps
104
126
  flexDirection: "row",
105
127
  paddingLeft: 1,
106
128
  paddingRight: 1,
107
- backgroundColor: colors.background,
129
+ backgroundColor: colors.surface,
108
130
  } as const
109
131
 
110
- // Filter mode is two-column: input left, hints right, separated by a
111
- // flex-grow spacer. Hints drop entirely on narrow viewports rather than
112
- // pushing the input off-screen the input is the primary surface.
113
- if (filter) {
114
- const input = `/${filter.query}▏`
115
- const showHints = input.length + FILTER_HINT_GAP + FILTER_HINTS.length <= usableWidth
116
- return (
117
- <box style={rowStyle}>
118
- <text content={input} wrapMode="none" style={{ fg: colors.textStrong }} />
119
- {showHints && (
120
- <>
121
- <box style={{ flexGrow: 1 }} />
122
- <text content={FILTER_HINTS} wrapMode="none" style={{ fg: colors.textMuted }} />
123
- </>
124
- )}
125
- </box>
126
- )
132
+ const hints: Hint[] = []
133
+ // The filter chip prepends to the hint row when a filter is applied and the
134
+ // input is closed. Bracketed to avoid looking like a `key:hint` binding —
135
+ // "filter" is not a key. Surfaces the otherwise-invisible invariant that
136
+ // `[`/`]` walks the filtered set. See DESIGN.md §7.1 Q1.
137
+ if (filterQuery && filterQuery.length > 0) {
138
+ hints.push({ key: null, label: `[filter: ${filterQuery}]` })
127
139
  }
128
-
129
- const hints: string[] = []
130
140
  for (const b of bindings) {
131
141
  if (b.when && !b.when(ctx)) continue
132
142
  const h = formatHint(b)
133
143
  if (h !== null) hints.push(h)
134
144
  }
135
- const hintContent = fitHints(hints, usableWidth)
145
+
146
+ // Discovery status sits left of the hints, separated by " · ". On tight
147
+ // viewports it claims its budget first; hints fit into the remainder so
148
+ // the indicator stays visible while less-essential hints drop off.
149
+ const status = discoveryStatus && discoveryStatus.length > 0 ? discoveryStatus : null
150
+ const statusBudget = status ? Math.min(status.length + STATUS_SEPARATOR.length, usableWidth) : 0
151
+ const hintsWidth = Math.max(0, usableWidth - statusBudget)
152
+ const visibleHints = fitHints(hints, hintsWidth)
153
+ const statusContent = status
154
+ ? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
155
+ : ""
156
+
136
157
  const noticeContent = notice
137
158
  ? notice.length > usableWidth
138
159
  ? notice.slice(0, usableWidth)
139
160
  : notice
140
161
  : null
141
162
 
142
- // Notice > hints. Notice fg is strong; hints are muted.
143
- const content = noticeContent ?? hintContent
144
- const fg = noticeContent ? colors.textStrong : colors.textMuted
163
+ // Two-tone hint row: keys render in `text` (foreground-strength), the
164
+ // `:label` portion in `textMuted`. Matches ghui's footer treatment so
165
+ // the key the actionable token — visually leads each hint.
166
+ const renderHints = () =>
167
+ visibleHints.flatMap((h, i) => {
168
+ const sep =
169
+ i > 0
170
+ ? [
171
+ <text
172
+ key={`s${i}`}
173
+ content={HINT_SEPARATOR}
174
+ wrapMode="none"
175
+ style={{ fg: colors.textMuted }}
176
+ />,
177
+ ]
178
+ : []
179
+ if (h.key === null) {
180
+ return [
181
+ ...sep,
182
+ <text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.textMuted }} />,
183
+ ]
184
+ }
185
+ return [
186
+ ...sep,
187
+ <text key={`k${i}`} content={h.key} wrapMode="none" style={{ fg: colors.text }} />,
188
+ <text
189
+ key={`l${i}`}
190
+ content={` ${h.label}`}
191
+ wrapMode="none"
192
+ style={{ fg: colors.textMuted }}
193
+ />,
194
+ ]
195
+ })
196
+
197
+ // Priority: notice > (status + hints). Notice fg is strong; status sits
198
+ // at the muted level so it reads as ambient state, not an event.
199
+ if (noticeContent !== null) {
200
+ return (
201
+ <box style={rowStyle}>
202
+ <text content={noticeContent} wrapMode="none" style={{ fg: colors.textStrong }} />
203
+ </box>
204
+ )
205
+ }
206
+
207
+ if (status !== null) {
208
+ return (
209
+ <box style={rowStyle}>
210
+ <text content={statusContent} wrapMode="none" style={{ fg: colors.textMuted }} />
211
+ <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
212
+ {renderHints()}
213
+ </box>
214
+ )
215
+ }
145
216
 
146
- return (
147
- <box style={rowStyle}>
148
- <text content={content} wrapMode="none" style={{ fg }} />
149
- </box>
150
- )
217
+ return <box style={rowStyle}>{renderHints()}</box>
151
218
  }
package/src/Header.tsx ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Header — single-row chrome above the two-pane area.
3
+ *
4
+ * Borderless single line modeled on ghui's PlainLine header: brand and
5
+ * current filename on the left, version on the right. The row is
6
+ * informational, not interactive — see issue #38 for the design discussion.
7
+ *
8
+ * Width degrades gracefully: the version drops first when the row gets
9
+ * tight, then the filename, leaving the brand mark as the irreducible
10
+ * identity element. Always rendered — the row is worth one cell on any
11
+ * viewport so the user never loses the filename indicator (notably, when
12
+ * the sidebar drawer overlays the reader on narrow viewports).
13
+ */
14
+
15
+ import pkg from "../package.json" with { type: "json" }
16
+ import { BRAND, BRAND_NAME } from "./brand.ts"
17
+ import { colors } from "./theme/colors.ts"
18
+
19
+ export const HEADER_HEIGHT = 1
20
+
21
+ const FILE_SEPARATOR = " · "
22
+
23
+ export interface HeaderProps {
24
+ readonly width: number
25
+ /** Currently selected file's relative path. When set, the Header shows
26
+ * it next to the brand mark — replaces the per-pane border title that
27
+ * used to carry this information. */
28
+ readonly currentFile?: string | null
29
+ /** Optional override for the version string (testing). Defaults to
30
+ * the running package's version. */
31
+ readonly version?: string
32
+ }
33
+
34
+ export const Header = ({ width, currentFile, version = pkg.version }: HeaderProps) => {
35
+ const brand = `${BRAND} ${BRAND_NAME}`
36
+ const right = `v${version}`
37
+ const file = currentFile && currentFile.length > 0 ? currentFile : null
38
+ const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
39
+
40
+ // Priority: brand > filename > version. Brand is the irreducible identity
41
+ // anchor. Filename is per-selection useful info — keep it before the
42
+ // largely-static version string. `1` is the minimum gap between left and
43
+ // right groups so they never visually collide.
44
+ const leftWithFile = file !== null ? `${brand}${FILE_SEPARATOR}${file}` : brand
45
+ const showFileWithVersion = leftWithFile.length + 1 + right.length <= usableWidth
46
+ const showFileWithoutVersion = leftWithFile.length <= usableWidth
47
+ const showFile = file !== null && (showFileWithVersion || showFileWithoutVersion)
48
+ const left = showFile ? leftWithFile : brand
49
+ const showRight = left.length + 1 + right.length <= usableWidth
50
+
51
+ return (
52
+ <box
53
+ style={{
54
+ width,
55
+ height: HEADER_HEIGHT,
56
+ flexShrink: 0,
57
+ flexDirection: "row",
58
+ justifyContent: "space-between",
59
+ paddingLeft: 1,
60
+ paddingRight: 1,
61
+ backgroundColor: colors.surface,
62
+ }}
63
+ >
64
+ <text content={left} wrapMode="none" style={{ fg: colors.text }} />
65
+ {showRight && <text content={right} wrapMode="none" style={{ fg: colors.text }} />}
66
+ </box>
67
+ )
68
+ }
@@ -6,9 +6,13 @@
6
6
  * keys — the dispatcher and the help text are the same source of truth.
7
7
  */
8
8
 
9
+ import { RGBA } from "@opentui/core"
9
10
  import type { KeyBinding } from "./keymap/keymap.ts"
10
11
  import { colors } from "./theme/colors.ts"
11
12
 
13
+ // See CommandPalette.tsx for the scrim rationale.
14
+ const SCRIM = RGBA.fromInts(0, 0, 0, 150)
15
+
12
16
  export interface HelpOverlayProps<C> {
13
17
  readonly bindings: readonly KeyBinding<C>[]
14
18
  readonly viewportWidth: number
@@ -82,49 +86,63 @@ export const HelpOverlay = <C,>({
82
86
  return (
83
87
  <box
84
88
  position="absolute"
85
- left={left}
86
- top={top}
87
- width={overlayWidth}
88
- height={overlayHeight}
89
+ left={0}
90
+ top={0}
91
+ width={viewportWidth}
92
+ height={viewportHeight}
89
93
  zIndex={10}
90
- title=" Help "
91
- titleAlignment="left"
92
- style={{
93
- border: true,
94
- borderColor: colors.borderActive,
95
- padding: 1,
96
- flexDirection: "column",
97
- backgroundColor: colors.surface,
98
- }}
94
+ style={{ backgroundColor: SCRIM }}
99
95
  >
100
- {rows.map((row) => {
101
- switch (row.kind) {
102
- case "header":
103
- return (
104
- <text
105
- key={row.key}
106
- wrapMode="none"
107
- content={row.text}
108
- style={{ fg: colors.borderActive, attributes: 1 /* bold */ }}
109
- />
110
- )
111
- case "footer":
112
- return (
113
- <text
114
- key={row.key}
115
- wrapMode="none"
116
- content={row.text}
117
- style={{ fg: colors.textMuted }}
118
- />
119
- )
120
- case "spacer":
121
- return <text key={row.key} content=" " />
122
- case "binding":
123
- return (
124
- <text key={row.key} wrapMode="none" content={row.text} style={{ fg: colors.text }} />
125
- )
126
- }
127
- })}
96
+ <box
97
+ position="absolute"
98
+ left={left}
99
+ top={top}
100
+ width={overlayWidth}
101
+ height={overlayHeight}
102
+ title=" Help "
103
+ titleAlignment="left"
104
+ style={{
105
+ border: true,
106
+ borderColor: colors.textMuted,
107
+ padding: 1,
108
+ flexDirection: "column",
109
+ backgroundColor: colors.surface,
110
+ }}
111
+ >
112
+ {rows.map((row) => {
113
+ switch (row.kind) {
114
+ case "header":
115
+ return (
116
+ <text
117
+ key={row.key}
118
+ wrapMode="none"
119
+ content={row.text}
120
+ style={{ fg: colors.borderActive, attributes: 1 /* bold */ }}
121
+ />
122
+ )
123
+ case "footer":
124
+ return (
125
+ <text
126
+ key={row.key}
127
+ wrapMode="none"
128
+ content={row.text}
129
+ style={{ fg: colors.textMuted }}
130
+ />
131
+ )
132
+ case "spacer":
133
+ return <text key={row.key} content=" " />
134
+ case "binding":
135
+ return (
136
+ <text
137
+ key={row.key}
138
+ wrapMode="none"
139
+ content={row.text}
140
+ style={{ fg: colors.text }}
141
+ />
142
+ )
143
+ }
144
+ })}
145
+ </box>
128
146
  </box>
129
147
  )
130
148
  }
package/src/brand.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Brand mark. U+2302 HOUSE is a single-cell glyph in every monospace font
3
+ * we care about; falls back to a tofu box on rare fonts that lack it.
4
+ * Single import point so future placements (#76) stay in sync.
5
+ */
6
+ export const BRAND = "⌂"
7
+ export const BRAND_NAME = "house"
package/src/cli/argv.ts CHANGED
@@ -21,6 +21,16 @@ export interface ParsedArgs {
21
21
  readonly help: boolean
22
22
  /** True when `--version` was passed. */
23
23
  readonly version: boolean
24
+ /** True when `--config-path` was passed: print resolved config path and exit. */
25
+ readonly configPath: boolean
26
+ /** Value of `--sidebar <mode>` (`auto`, `on`, `off`), or null. Validated by the boot layer. */
27
+ readonly sidebar: string | null
28
+ /** True when `--no-update-check` was passed: suppress the npm-registry
29
+ * probe and the "update available" notice. Mirrors the
30
+ * `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
31
+ readonly noUpdateCheck: boolean
32
+ /** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
33
+ readonly noMdx: boolean
24
34
  }
25
35
 
26
36
  /**
@@ -41,6 +51,10 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
41
51
  let port: string | null = null
42
52
  let help = false
43
53
  let version = false
54
+ let configPath = false
55
+ let sidebar: string | null = null
56
+ let noUpdateCheck = false
57
+ let noMdx = false
44
58
 
45
59
  for (let i = 0; i < argv.length; i++) {
46
60
  const arg = argv[i]!
@@ -79,13 +93,48 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
79
93
  case "-v":
80
94
  version = true
81
95
  continue
96
+ case "--config-path":
97
+ configPath = true
98
+ continue
99
+ case "--sidebar": {
100
+ // Don't swallow the following flag as the sidebar value.
101
+ // `--sidebar --width 80` should leave sidebar=null (the boot
102
+ // layer reports a missing value) without losing --width.
103
+ const next = argv[i + 1]
104
+ if (next !== undefined && !next.startsWith("-")) {
105
+ sidebar = next
106
+ i++
107
+ }
108
+ continue
109
+ }
110
+ case "--no-update-check":
111
+ noUpdateCheck = true
112
+ continue
113
+ case "--no-mdx":
114
+ noMdx = true
115
+ continue
82
116
  }
83
117
  if (path === null && !arg.startsWith("-")) {
84
118
  path = arg
85
119
  }
86
120
  }
87
121
 
88
- return { path, theme, tone, width, all, sort, serve, port, help, version }
122
+ return {
123
+ path,
124
+ theme,
125
+ tone,
126
+ width,
127
+ all,
128
+ sort,
129
+ serve,
130
+ port,
131
+ help,
132
+ version,
133
+ configPath,
134
+ sidebar,
135
+ noUpdateCheck,
136
+ noMdx,
137
+ }
89
138
  }
90
139
 
91
140
  const themeList = themeDefinitions.map((t) => t.id).join(", ")
@@ -100,7 +149,17 @@ options:
100
149
  --width <N> cap rendered markdown width at N columns
101
150
  --all include hidden and gitignored files in discovery
102
151
  --sort <mode> sidebar order: dirs-first (default) or files-first
152
+ --sidebar <m> initial sidebar visibility: auto (default), on, or off
103
153
  --serve serve the given file as HTML in the browser (skips TUI)
104
154
  --port <N> port for --serve (default: OS-assigned)
105
155
  -h, --help show this help and exit
106
- -v, --version print version and exit`
156
+ -v, --version print version and exit
157
+ --config-path print path to the config file and exit
158
+ --no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
159
+ --no-mdx exclude .mdx files from discovery (default: included)
160
+
161
+ configuration:
162
+ 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
165
+ precedence (high → low): flags → env → file → defaults`