@carlesandres/house 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Footer.tsx CHANGED
@@ -21,7 +21,10 @@
21
21
  * require a real cell-width counter (e.g. East Asian Width).
22
22
  */
23
23
 
24
+ import type React from "react"
24
25
  import type { KeyBinding } from "./keymap/keymap.ts"
26
+ import { displayKey } from "./keymap/displayKey.ts"
27
+ import { Spinner } from "./Spinner.tsx"
25
28
  import { colors } from "./theme/colors.ts"
26
29
 
27
30
  /** Rows the Footer occupies. Importers use it for layout math so a future
@@ -42,35 +45,16 @@ export interface FooterProps<C> {
42
45
  * while the filter input is open (the sidebar already shows the query) or
43
46
  * when no filter is applied. */
44
47
  readonly filterQuery?: string | null
48
+ /** Test seam: override spinner tick speed so tests don't sleep on the full
49
+ * production interval. Ignored when discoveryStatus is null. */
50
+ readonly discoverySpinnerIntervalMs?: number
51
+ readonly discoverySpinnerInitialFrameIndex?: number
52
+ /** Test seam: deterministic footer spinner driver. */
53
+ readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
45
54
  }
46
55
 
47
56
  const HINT_SEPARATOR = " "
48
57
 
49
- /** Display form for the first key of a binding. Picks the first chord and
50
- * rewrites a few names to terminal-friendly shorthands.
51
- *
52
- * Footer policy: only the first key is shown, even when a binding has
53
- * aliases (e.g. `sidebar.open` accepts `return`/`right`/`l`). The footer
54
- * is a narrow real-estate budget, and listing every alias would push out
55
- * other bindings on tight viewports. The full alias list lives in the
56
- * help overlay (`?`). */
57
- const displayKey = (raw: string): string => {
58
- switch (raw) {
59
- case "return":
60
- return "↵"
61
- case "escape":
62
- return "esc"
63
- case "space":
64
- return "␣"
65
- case "pageup":
66
- return "pgup"
67
- case "pagedown":
68
- return "pgdn"
69
- default:
70
- return raw
71
- }
72
- }
73
-
74
58
  /** Hint row entries. `key === null` is a standalone chip (e.g. the filter
75
59
  * chip) and renders as muted text without the key/label split. */
76
60
  interface Hint {
@@ -116,6 +100,9 @@ export const Footer = <C,>({
116
100
  notice,
117
101
  discoveryStatus,
118
102
  filterQuery,
103
+ discoverySpinnerIntervalMs,
104
+ discoverySpinnerInitialFrameIndex,
105
+ discoverySpinnerRegisterTick,
119
106
  }: FooterProps<C>) => {
120
107
  const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
121
108
 
@@ -126,7 +113,7 @@ export const Footer = <C,>({
126
113
  flexDirection: "row",
127
114
  paddingLeft: 1,
128
115
  paddingRight: 1,
129
- backgroundColor: colors.surface,
116
+ backgroundColor: colors.backgroundPanel,
130
117
  } as const
131
118
 
132
119
  const hints: Hint[] = []
@@ -183,7 +170,7 @@ export const Footer = <C,>({
183
170
  if (h.key === null) {
184
171
  return [
185
172
  ...sep,
186
- <text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.textMuted }} />,
173
+ <text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.secondary }} />,
187
174
  ]
188
175
  }
189
176
  return [
@@ -198,20 +185,35 @@ export const Footer = <C,>({
198
185
  ]
199
186
  })
200
187
 
201
- // Priority: notice > (status + hints). Notice fg is strong; status sits
202
- // at the muted level so it reads as ambient state, not an event.
188
+ // Priority: notice > (status + hints). Discovery status uses the secondary
189
+ // token: active metadata, but not a warning/error event.
203
190
  if (noticeContent !== null) {
204
191
  return (
205
192
  <box style={rowStyle}>
206
- <text content={noticeContent} wrapMode="none" style={{ fg: colors.textStrong }} />
193
+ <text content={noticeContent} wrapMode="none" style={{ fg: colors.primary }} />
207
194
  </box>
208
195
  )
209
196
  }
210
197
 
211
198
  if (status !== null) {
199
+ const spinnerProps = {
200
+ fg: colors.secondary,
201
+ ...(discoverySpinnerIntervalMs === undefined
202
+ ? {}
203
+ : { intervalMs: discoverySpinnerIntervalMs }),
204
+ ...(discoverySpinnerInitialFrameIndex === undefined
205
+ ? {}
206
+ : { initialFrameIndex: discoverySpinnerInitialFrameIndex }),
207
+ ...(discoverySpinnerRegisterTick === undefined
208
+ ? {}
209
+ : { registerTick: discoverySpinnerRegisterTick }),
210
+ } satisfies React.ComponentProps<typeof Spinner>
211
+
212
212
  return (
213
213
  <box style={rowStyle}>
214
- <text content={statusContent} wrapMode="none" style={{ fg: colors.textMuted }} />
214
+ <Spinner {...spinnerProps} />
215
+ <text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
216
+ <text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
215
217
  <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
216
218
  {renderHints()}
217
219
  </box>
package/src/Header.tsx CHANGED
@@ -58,7 +58,7 @@ export const Header = ({ width, currentFile, version = pkg.version }: HeaderProp
58
58
  justifyContent: "space-between",
59
59
  paddingLeft: 1,
60
60
  paddingRight: 1,
61
- backgroundColor: colors.surface,
61
+ backgroundColor: colors.backgroundPanel,
62
62
  }}
63
63
  >
64
64
  <text content={left} wrapMode="none" style={{ fg: colors.text }} />
@@ -106,7 +106,7 @@ export const HelpOverlay = <C,>({
106
106
  borderColor: colors.textMuted,
107
107
  padding: 1,
108
108
  flexDirection: "column",
109
- backgroundColor: colors.surface,
109
+ backgroundColor: colors.backgroundPanel,
110
110
  }}
111
111
  >
112
112
  {rows.map((row) => {
package/src/PromptRow.tsx CHANGED
@@ -3,9 +3,9 @@
3
3
  * the command palette query input.
4
4
  *
5
5
  * Render-only: the parent owns query state and the focus/editing flag.
6
- * The `> ` prefix always renders in `textStrong` regardless of state so it
6
+ * The `> ` prefix renders in `primary` or `secondary` so it
7
7
  * reads as chrome, not placeholder text — only the body span shifts color
8
- * (textStrong while editing, text when applied, textMuted as placeholder).
8
+ * (primary while editing, text when applied, textMuted as placeholder).
9
9
  *
10
10
  * Overflow: when editing, an overflowing body anchors its right edge with a
11
11
  * leading `…` so the cursor stays on screen; otherwise it anchors the left
@@ -16,7 +16,7 @@ import { colors } from "./theme/colors.ts"
16
16
 
17
17
  export interface PromptRowProps {
18
18
  readonly query: string
19
- /** True while the input is focused — shows a cursor and uses textStrong fg. */
19
+ /** True while the input is focused — shows a cursor and uses primary fg. */
20
20
  readonly editing: boolean
21
21
  /** Body fallback when !editing && query === "". Pass without the `> ` prefix. */
22
22
  readonly placeholder?: string
@@ -31,7 +31,7 @@ export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRow
31
31
  const bodyBudget = Math.max(1, width - PREFIX.length)
32
32
 
33
33
  const rawBody = editing ? `${query}${CURSOR}` : query.length > 0 ? query : placeholder
34
- const bodyFg = editing ? colors.textStrong : query.length > 0 ? colors.text : colors.textMuted
34
+ const bodyFg = editing ? colors.primary : query.length > 0 ? colors.text : colors.textMuted
35
35
 
36
36
  const body =
37
37
  rawBody.length <= bodyBudget
@@ -40,9 +40,11 @@ export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRow
40
40
  ? "…" + rawBody.slice(rawBody.length - bodyBudget + 1)
41
41
  : rawBody.slice(0, bodyBudget - 1) + "…"
42
42
 
43
+ const prefixFg = editing ? colors.secondary : colors.primary
44
+
43
45
  return (
44
46
  <text wrapMode="none">
45
- <span style={{ fg: colors.textStrong }}>{PREFIX}</span>
47
+ <span style={{ fg: prefixFg }}>{PREFIX}</span>
46
48
  <span style={{ fg: bodyFg }}>{body}</span>
47
49
  </text>
48
50
  )
@@ -0,0 +1,39 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+ import { colors } from "./theme/colors.ts"
3
+
4
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
5
+ const FRAME_COUNT = FRAMES.length
6
+ const normalizeFrameIndex = (index: number) => ((index % FRAME_COUNT) + FRAME_COUNT) % FRAME_COUNT
7
+
8
+ export interface SpinnerProps {
9
+ readonly fg?: string
10
+ readonly intervalMs?: number
11
+ readonly initialFrameIndex?: number
12
+ /** Test seam: deterministic driver for frame advancement. When present,
13
+ * Spinner registers its tick callback here instead of starting an interval. */
14
+ readonly registerTick?: ((tick: () => void) => void) | null
15
+ }
16
+
17
+ export const Spinner = ({
18
+ fg = colors.textMuted,
19
+ intervalMs = 100,
20
+ initialFrameIndex = 0,
21
+ registerTick = null,
22
+ }: SpinnerProps) => {
23
+ const [index, setIndex] = useState(() => normalizeFrameIndex(initialFrameIndex))
24
+ const tickRef = useRef<() => void>(() => undefined)
25
+ tickRef.current = () => setIndex((prev) => (prev + 1) % FRAME_COUNT)
26
+
27
+ useEffect(() => {
28
+ if (registerTick) {
29
+ registerTick(() => tickRef.current())
30
+ return
31
+ }
32
+ const id = setInterval(() => {
33
+ tickRef.current()
34
+ }, intervalMs)
35
+ return () => clearInterval(id)
36
+ }, [intervalMs, registerTick])
37
+
38
+ return <text content={FRAMES[index] ?? FRAMES[0]} wrapMode="none" style={{ fg }} />
39
+ }
package/src/cli/argv.ts CHANGED
@@ -1,8 +1,11 @@
1
+ import { Command } from "commander"
1
2
  import { themeDefinitions } from "../theme/registry.ts"
2
3
 
3
4
  export interface ParsedArgs {
4
5
  /** First positional argument, or null if none was given. */
5
6
  readonly path: string | null
7
+ /** Value of `--root <dir>`, or null. */
8
+ readonly root: string | null
6
9
  /** Value of `--theme <id>`, or null. Validated by the boot layer against the registry. */
7
10
  readonly theme: string | null
8
11
  /** Value of `--tone dark|light`, or null. Validated by the boot layer. */
@@ -29,9 +32,9 @@ export interface ParsedArgs {
29
32
  readonly noUpdateCheck: boolean
30
33
  /** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
31
34
  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
+ /** Value of `--focus <mode>` (`sidebar`, `reader`, `filter`), or null.
36
+ * Validated by the boot layer. */
37
+ readonly focus: string | null
35
38
  /** Raw value of `--show <list>`, or null if the flag wasn't passed.
36
39
  * Comma-separated list of category names; the boot layer validates
37
40
  * tokens against the known vocabulary (see `discovery/show.ts`).
@@ -39,6 +42,67 @@ export interface ParsedArgs {
39
42
  readonly show: string | null
40
43
  }
41
44
 
45
+ const createProgram = () =>
46
+ new Command()
47
+ .allowUnknownOption(true)
48
+ .allowExcessArguments(true)
49
+ .exitOverride()
50
+ .helpOption(false)
51
+ .option("--theme [id]")
52
+ .option("--tone [mode]")
53
+ .option("--width [N]")
54
+ .option("--sort [mode]")
55
+ .option("--serve")
56
+ .option("--port [N]")
57
+ .option("--config-path")
58
+ .option("--sidebar [mode]")
59
+ .option("--no-update-check")
60
+ .option("--no-mdx")
61
+ .option("--focus [mode]")
62
+ .option("--show [list]")
63
+ .option("--root [dir]")
64
+ .option("-h, --help")
65
+ .option("-v, --version")
66
+ .argument("[path]")
67
+
68
+ const VALUE_FLAGS: ReadonlySet<string> = new Set([
69
+ "--theme",
70
+ "--tone",
71
+ "--width",
72
+ "--sort",
73
+ "--port",
74
+ "--sidebar",
75
+ "--focus",
76
+ "--show",
77
+ "--root",
78
+ ])
79
+
80
+ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
81
+ "--serve",
82
+ "--config-path",
83
+ "--no-update-check",
84
+ "--no-mdx",
85
+ "--help",
86
+ "-h",
87
+ "--version",
88
+ "-v",
89
+ ])
90
+
91
+ const findPathArg = (argv: readonly string[]): string | null => {
92
+ for (let i = 0; i < argv.length; i++) {
93
+ const arg = argv[i]!
94
+ if (VALUE_FLAGS.has(arg)) {
95
+ const next = argv[i + 1]
96
+ if (next !== undefined && !next.startsWith("-")) i++
97
+ continue
98
+ }
99
+ if (BOOLEAN_FLAGS.has(arg)) continue
100
+ if (arg.startsWith("-")) continue
101
+ return arg
102
+ }
103
+ return null
104
+ }
105
+
42
106
  /**
43
107
  * Minimal argv parser.
44
108
  *
@@ -47,110 +111,29 @@ export interface ParsedArgs {
47
111
  * without coupling the parser to it.
48
112
  */
49
113
  export const parseArgv = (argv: readonly string[]): ParsedArgs => {
50
- let path: string | null = null
51
- let theme: string | null = null
52
- let tone: string | null = null
53
- let width: string | null = null
54
- let sort: string | null = null
55
- let serve = false
56
- let port: string | null = null
57
- let help = false
58
- let version = false
59
- let configPath = false
60
- let sidebar: string | null = null
61
- let noUpdateCheck = false
62
- let noMdx = false
63
- let show: string | null = null
64
- let startInFilter = false
65
-
66
- for (let i = 0; i < argv.length; i++) {
67
- const arg = argv[i]!
68
- switch (arg) {
69
- case "--theme":
70
- theme = argv[i + 1] ?? null
71
- i++
72
- continue
73
- case "--tone":
74
- tone = argv[i + 1] ?? null
75
- i++
76
- continue
77
- case "--width":
78
- width = argv[i + 1] ?? null
79
- i++
80
- continue
81
- case "--sort":
82
- sort = argv[i + 1] ?? null
83
- i++
84
- continue
85
- case "--serve":
86
- serve = true
87
- continue
88
- case "--port":
89
- port = argv[i + 1] ?? null
90
- i++
91
- continue
92
- case "--help":
93
- case "-h":
94
- help = true
95
- continue
96
- case "--version":
97
- case "-v":
98
- version = true
99
- continue
100
- case "--config-path":
101
- configPath = true
102
- continue
103
- case "--sidebar": {
104
- // Don't swallow the following flag as the sidebar value.
105
- // `--sidebar --width 80` should leave sidebar=null (the boot
106
- // layer reports a missing value) without losing --width.
107
- const next = argv[i + 1]
108
- if (next !== undefined && !next.startsWith("-")) {
109
- sidebar = next
110
- i++
111
- }
112
- continue
113
- }
114
- case "--no-update-check":
115
- noUpdateCheck = true
116
- continue
117
- case "--no-mdx":
118
- noMdx = true
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
132
- }
133
- if (path === null && !arg.startsWith("-")) {
134
- path = arg
135
- }
136
- }
114
+ const program = createProgram()
115
+ program.parse([...argv], { from: "user" })
116
+ const opts = program.opts<Record<string, unknown>>()
117
+ const pathArg = findPathArg(argv)
118
+ const stringOrNull = (value: unknown): string | null => (typeof value === "string" ? value : null)
137
119
 
138
120
  return {
139
- path,
140
- theme,
141
- tone,
142
- width,
143
- sort,
144
- serve,
145
- port,
146
- help,
147
- version,
148
- configPath,
149
- sidebar,
150
- noUpdateCheck,
151
- noMdx,
152
- show,
153
- startInFilter,
121
+ path: typeof pathArg === "string" ? pathArg : null,
122
+ root: stringOrNull(opts["root"]),
123
+ theme: stringOrNull(opts["theme"]),
124
+ tone: stringOrNull(opts["tone"]),
125
+ width: stringOrNull(opts["width"]),
126
+ sort: stringOrNull(opts["sort"]),
127
+ serve: opts["serve"] === true,
128
+ port: stringOrNull(opts["port"]),
129
+ help: opts["help"] === true,
130
+ version: opts["version"] === true,
131
+ configPath: opts["configPath"] === true,
132
+ sidebar: stringOrNull(opts["sidebar"]),
133
+ noUpdateCheck: opts["noUpdateCheck"] === true,
134
+ noMdx: opts["mdx"] === false,
135
+ show: stringOrNull(opts["show"]),
136
+ focus: stringOrNull(opts["focus"]),
154
137
  }
155
138
  }
156
139
 
@@ -166,8 +149,10 @@ options:
166
149
  --width <N> cap rendered markdown width at N columns
167
150
  --show <list> reveal normally-skipped entries; comma-separated subset of:
168
151
  hidden, gitignored. Use --show "" to clear.
152
+ --root <dir> discovery root to walk (overrides defaultRoot config)
169
153
  --sort <mode> sidebar order: dirs-first (default) or files-first
170
154
  --sidebar <m> initial sidebar visibility: auto (default), on, or off
155
+ --focus <m> startup focus: sidebar, reader, or filter (default: filter)
171
156
  --serve serve the given file as HTML in the browser (skips TUI)
172
157
  --port <N> port for --serve (default: OS-assigned)
173
158
  -h, --help show this help and exit
@@ -175,10 +160,9 @@ options:
175
160
  --config-path print path to the config file and exit
176
161
  --no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
177
162
  --no-mdx exclude .mdx files from discovery (default: included)
178
- --start-in-filter open the sidebar filter prompt on launch
179
163
 
180
164
  configuration:
181
165
  file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
182
- keys: theme, tone, mdx, show, start_in_filter
183
- env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_START_IN_FILTER
166
+ keys: theme, tone, mdx, show, focus, defaultRoot
167
+ env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS, HOUSE_DEFAULT_ROOT
184
168
  precedence (high → low): flags → env → file → defaults`
@@ -20,14 +20,15 @@ export interface HouseConfig {
20
20
  readonly theme: string
21
21
  readonly tone: "dark" | "light"
22
22
  readonly mdx: boolean
23
+ /** Default discovery-root strategy when no explicit `--root` flag is passed. */
24
+ readonly defaultRoot: "cwd" | "git"
23
25
  /** Categories of normally-skipped entries to opt into. See
24
26
  * `src/discovery/show.ts` for the vocabulary. Empty array (the
25
27
  * default) yields the conservative discovery set. */
26
28
  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
29
+ /** Startup pane/input target. `filter` opens the sidebar filter prompt and
30
+ * focuses it immediately. */
31
+ readonly focus: "sidebar" | "reader" | "filter"
31
32
  }
32
33
 
33
34
  export interface CliOverrides {
@@ -38,14 +39,15 @@ export interface CliOverrides {
38
39
  * (no per-category merging — sets compose by replacement, like every
39
40
  * other CLI override here). `--show ""` sets the empty set. */
40
41
  readonly show: readonly ShowCategory[] | null
41
- readonly startInFilter: boolean | null
42
+ readonly focus: "sidebar" | "reader" | "filter" | null
42
43
  }
43
44
 
44
45
  const DEFAULT_THEME = "opencode"
45
46
  const DEFAULT_TONE: "dark" | "light" = "dark"
46
47
  const DEFAULT_MDX = true
48
+ const DEFAULT_ROOT: "cwd" | "git" = "cwd"
47
49
  const DEFAULT_SHOW = ""
48
- const DEFAULT_START_IN_FILTER = false
50
+ const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
49
51
 
50
52
  const themeIds = themeDefinitions.map((t) => t.id)
51
53
 
@@ -60,12 +62,14 @@ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
60
62
  "tone",
61
63
  "mdx",
62
64
  "show",
63
- "start_in_filter",
65
+ "focus",
66
+ "defaultRoot",
64
67
  ])
65
68
 
66
69
  const schema = Config.all({
67
70
  theme: Config.schema(Schema.Literals(themeIds), "theme"),
68
71
  tone: Config.schema(Schema.Literals(["dark", "light"] as const), "tone"),
72
+ defaultRoot: Config.schema(Schema.String, "defaultRoot"),
69
73
  // Boolean stored as string literal because providers stringify values
70
74
  // (TOML bools, env vars, CLI flags all flow through as text). Mapped to
71
75
  // a real boolean in `loadConfig` below.
@@ -75,16 +79,17 @@ const schema = Config.all({
75
79
  // `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
76
80
  // so the error message can list valid categories at the field's path.
77
81
  show: Config.schema(Schema.String, "show"),
78
- start_in_filter: Config.schema(Schema.Literals(["true", "false"] as const), "start_in_filter"),
82
+ focus: Config.schema(Schema.Literals(["sidebar", "reader", "filter"] as const), "focus"),
79
83
  })
80
84
 
81
85
  const defaultsProvider = (): ConfigProvider.ConfigProvider =>
82
86
  ConfigProvider.fromUnknown({
83
87
  theme: DEFAULT_THEME,
84
88
  tone: DEFAULT_TONE,
89
+ defaultRoot: DEFAULT_ROOT,
85
90
  mdx: String(DEFAULT_MDX),
86
91
  show: DEFAULT_SHOW,
87
- start_in_filter: String(DEFAULT_START_IN_FILTER),
92
+ focus: DEFAULT_FOCUS,
88
93
  })
89
94
 
90
95
  /**
@@ -199,14 +204,16 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
199
204
  const entries: Array<[string, string]> = []
200
205
  const theme = env["HOUSE_THEME"]
201
206
  const tone = env["HOUSE_TONE"]
207
+ const defaultRoot = env["HOUSE_DEFAULT_ROOT"]
202
208
  const mdx = env["HOUSE_MDX"]
203
209
  const show = env["HOUSE_SHOW"]
204
- const startInFilter = env["HOUSE_START_IN_FILTER"]
210
+ const focus = env["HOUSE_FOCUS"]
205
211
  if (theme !== undefined) entries.push(["theme", theme])
206
212
  if (tone !== undefined) entries.push(["tone", tone])
213
+ if (defaultRoot !== undefined) entries.push(["defaultRoot", defaultRoot])
207
214
  if (mdx !== undefined) entries.push(["mdx", mdx])
208
215
  if (show !== undefined) entries.push(["show", show])
209
- if (startInFilter !== undefined) entries.push(["start_in_filter", startInFilter])
216
+ if (focus !== undefined) entries.push(["focus", focus])
210
217
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
211
218
  }
212
219
 
@@ -216,8 +223,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
216
223
  if (overrides.tone !== null) entries.push(["tone", overrides.tone])
217
224
  if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
218
225
  if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
219
- if (overrides.startInFilter !== null)
220
- entries.push(["start_in_filter", String(overrides.startInFilter)])
226
+ if (overrides.focus !== null) entries.push(["focus", overrides.focus])
221
227
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
222
228
  }
223
229
 
@@ -260,7 +266,7 @@ export const loadConfig = (
260
266
  tone: null,
261
267
  mdx: null,
262
268
  show: null,
263
- startInFilter: null,
269
+ focus: null,
264
270
  }
265
271
  const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
266
272
  const provider = cliProvider(cli).pipe(
@@ -270,6 +276,13 @@ export const loadConfig = (
270
276
  )
271
277
  return schema.parse(provider).pipe(
272
278
  Effect.flatMap((raw) => {
279
+ const defaultRoot =
280
+ raw.defaultRoot === "cwd" || raw.defaultRoot === "git" ? raw.defaultRoot : DEFAULT_ROOT
281
+ if (raw.defaultRoot !== defaultRoot) {
282
+ onWarning(
283
+ `house: ignoring invalid value ${JSON.stringify(raw.defaultRoot)} for defaultRoot in config/env; using "${DEFAULT_ROOT}"`,
284
+ )
285
+ }
273
286
  const parsed = parseShowList(raw.show)
274
287
  if (!parsed.ok) {
275
288
  // Effect's `Config.ConfigError` requires a `SchemaError` or
@@ -286,9 +299,10 @@ export const loadConfig = (
286
299
  return Effect.succeed({
287
300
  theme: raw.theme,
288
301
  tone: raw.tone,
302
+ defaultRoot,
289
303
  mdx: raw.mdx === "true",
290
304
  show: parsed.value,
291
- startInFilter: raw.start_in_filter === "true",
305
+ focus: raw.focus,
292
306
  })
293
307
  }),
294
308
  )
@@ -1,18 +1,14 @@
1
1
  /**
2
2
  * Fuzzy filter for the sidebar.
3
3
  *
4
- * Matching: case-insensitive subsequence on `relativePath`. A query "drm"
5
- * matches "docs/readme.md". Scoring (higher is better):
6
- * - +10 for a match at the start of the string or right after `/`
7
- * (word boundary what the user typed lines up with a path segment)
8
- * - +5 when the current match is adjacent to the previous one
9
- * (consecutive runs read as "drm" matching the literal substring)
10
- * - +1 otherwise
4
+ * Matching stays intentionally small and pure: case-insensitive subsequence on
5
+ * the filename and full relative path. Ranking prefers what users usually mean
6
+ * in a sidebar:
7
+ * - filename matches above folder-only matches
8
+ * - files in the current folder above equally good nested matches
9
+ * - shallower paths above deeper ones as a soft tie-break
11
10
  *
12
- * The scorer is intentionally tiny — the only goal is to surface the
13
- * "obvious" match for short queries against a few hundred paths. A full
14
- * fzf-style scorer (with bonuses for camelCase, separators, etc.) is
15
- * deferred until the simple version proves insufficient.
11
+ * Empty query preserves discovery/tree order.
16
12
  */
17
13
 
18
14
  import type { FileEntry } from "./walk.ts"
@@ -36,6 +32,42 @@ export const fuzzyScore = (query: string, target: string): number | null => {
36
32
  return score
37
33
  }
38
34
 
35
+ const splitPath = (relativePath: string): { fileName: string; depth: number } => {
36
+ const slash = relativePath.lastIndexOf("/")
37
+ return {
38
+ fileName: slash >= 0 ? relativePath.slice(slash + 1) : relativePath,
39
+ depth: slash >= 0 ? relativePath.split("/").length - 1 : 0,
40
+ }
41
+ }
42
+
43
+ const fileStem = (fileName: string): string => {
44
+ const dot = fileName.lastIndexOf(".")
45
+ return dot > 0 ? fileName.slice(0, dot) : fileName
46
+ }
47
+
48
+ const rankFile = (query: string, file: FileEntry): number | null => {
49
+ const pathScore = fuzzyScore(query, file.relativePath)
50
+ if (pathScore === null) return null
51
+
52
+ const { fileName, depth } = splitPath(file.relativePath)
53
+ const q = query.toLowerCase()
54
+ const name = fileName.toLowerCase()
55
+ const stem = fileStem(fileName).toLowerCase()
56
+ const nameScore = fuzzyScore(query, fileName) ?? 0
57
+
58
+ let score = pathScore * 10
59
+ score += nameScore * 100
60
+
61
+ if (name === q || stem === q) score += 5_000
62
+ else if (name.startsWith(q) || stem.startsWith(q)) score += 2_000
63
+ else if (name.includes(q)) score += 1_000
64
+
65
+ if (depth === 0) score += 300
66
+ score -= depth * 10
67
+
68
+ return score
69
+ }
70
+
39
71
  /**
40
72
  * Filter and re-rank a file list by a query. Empty query returns the input
41
73
  * unchanged (preserves the discovery sort order). Non-empty query keeps
@@ -47,7 +79,7 @@ export const filterFiles = (files: readonly FileEntry[], query: string): readonl
47
79
  const scored: { file: FileEntry; score: number; index: number }[] = []
48
80
  for (let i = 0; i < files.length; i++) {
49
81
  const file = files[i]!
50
- const score = fuzzyScore(query, file.relativePath)
82
+ const score = rankFile(query, file)
51
83
  if (score === null) continue
52
84
  scored.push({ file, score, index: i })
53
85
  }