@carlesandres/house 0.4.4 → 0.4.6

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/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
  )
package/src/cli/argv.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Command } from "commander"
1
2
  import { themeDefinitions } from "../theme/registry.ts"
2
3
 
3
4
  export interface ParsedArgs {
@@ -29,9 +30,9 @@ export interface ParsedArgs {
29
30
  readonly noUpdateCheck: boolean
30
31
  /** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
31
32
  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
33
+ /** Value of `--focus <mode>` (`sidebar`, `reader`, `filter`), or null.
34
+ * Validated by the boot layer. */
35
+ readonly focus: string | null
35
36
  /** Raw value of `--show <list>`, or null if the flag wasn't passed.
36
37
  * Comma-separated list of category names; the boot layer validates
37
38
  * tokens against the known vocabulary (see `discovery/show.ts`).
@@ -39,6 +40,65 @@ export interface ParsedArgs {
39
40
  readonly show: string | null
40
41
  }
41
42
 
43
+ const createProgram = () =>
44
+ new Command()
45
+ .allowUnknownOption(true)
46
+ .allowExcessArguments(true)
47
+ .exitOverride()
48
+ .helpOption(false)
49
+ .option("--theme [id]")
50
+ .option("--tone [mode]")
51
+ .option("--width [N]")
52
+ .option("--sort [mode]")
53
+ .option("--serve")
54
+ .option("--port [N]")
55
+ .option("--config-path")
56
+ .option("--sidebar [mode]")
57
+ .option("--no-update-check")
58
+ .option("--no-mdx")
59
+ .option("--focus [mode]")
60
+ .option("--show [list]")
61
+ .option("-h, --help")
62
+ .option("-v, --version")
63
+ .argument("[path]")
64
+
65
+ const VALUE_FLAGS: ReadonlySet<string> = new Set([
66
+ "--theme",
67
+ "--tone",
68
+ "--width",
69
+ "--sort",
70
+ "--port",
71
+ "--sidebar",
72
+ "--focus",
73
+ "--show",
74
+ ])
75
+
76
+ const BOOLEAN_FLAGS: ReadonlySet<string> = new Set([
77
+ "--serve",
78
+ "--config-path",
79
+ "--no-update-check",
80
+ "--no-mdx",
81
+ "--help",
82
+ "-h",
83
+ "--version",
84
+ "-v",
85
+ ])
86
+
87
+ const findPathArg = (argv: readonly string[]): string | null => {
88
+ for (let i = 0; i < argv.length; i++) {
89
+ const arg = argv[i]!
90
+ if (VALUE_FLAGS.has(arg)) {
91
+ const next = argv[i + 1]
92
+ if (next !== undefined && !next.startsWith("-")) i++
93
+ continue
94
+ }
95
+ if (BOOLEAN_FLAGS.has(arg)) continue
96
+ if (arg.startsWith("-")) continue
97
+ return arg
98
+ }
99
+ return null
100
+ }
101
+
42
102
  /**
43
103
  * Minimal argv parser.
44
104
  *
@@ -47,110 +107,28 @@ export interface ParsedArgs {
47
107
  * without coupling the parser to it.
48
108
  */
49
109
  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
- }
110
+ const program = createProgram()
111
+ program.parse([...argv], { from: "user" })
112
+ const opts = program.opts<Record<string, unknown>>()
113
+ const pathArg = findPathArg(argv)
114
+ const stringOrNull = (value: unknown): string | null => (typeof value === "string" ? value : null)
137
115
 
138
116
  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,
117
+ path: typeof pathArg === "string" ? pathArg : null,
118
+ theme: stringOrNull(opts["theme"]),
119
+ tone: stringOrNull(opts["tone"]),
120
+ width: stringOrNull(opts["width"]),
121
+ sort: stringOrNull(opts["sort"]),
122
+ serve: opts["serve"] === true,
123
+ port: stringOrNull(opts["port"]),
124
+ help: opts["help"] === true,
125
+ version: opts["version"] === true,
126
+ configPath: opts["configPath"] === true,
127
+ sidebar: stringOrNull(opts["sidebar"]),
128
+ noUpdateCheck: opts["noUpdateCheck"] === true,
129
+ noMdx: opts["mdx"] === false,
130
+ show: stringOrNull(opts["show"]),
131
+ focus: stringOrNull(opts["focus"]),
154
132
  }
155
133
  }
156
134
 
@@ -168,6 +146,7 @@ options:
168
146
  hidden, gitignored. Use --show "" to clear.
169
147
  --sort <mode> sidebar order: dirs-first (default) or files-first
170
148
  --sidebar <m> initial sidebar visibility: auto (default), on, or off
149
+ --focus <m> startup focus: sidebar, reader, or filter (default: filter)
171
150
  --serve serve the given file as HTML in the browser (skips TUI)
172
151
  --port <N> port for --serve (default: OS-assigned)
173
152
  -h, --help show this help and exit
@@ -175,10 +154,9 @@ options:
175
154
  --config-path print path to the config file and exit
176
155
  --no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
177
156
  --no-mdx exclude .mdx files from discovery (default: included)
178
- --start-in-filter open the sidebar filter prompt on launch
179
157
 
180
158
  configuration:
181
159
  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
160
+ keys: theme, tone, mdx, show, focus
161
+ env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX, HOUSE_SHOW, HOUSE_FOCUS
184
162
  precedence (high → low): flags → env → file → defaults`
@@ -24,10 +24,9 @@ export interface HouseConfig {
24
24
  * `src/discovery/show.ts` for the vocabulary. Empty array (the
25
25
  * default) yields the conservative discovery set. */
26
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
27
+ /** Startup pane/input target. `filter` opens the sidebar filter prompt and
28
+ * focuses it immediately. */
29
+ readonly focus: "sidebar" | "reader" | "filter"
31
30
  }
32
31
 
33
32
  export interface CliOverrides {
@@ -38,14 +37,14 @@ export interface CliOverrides {
38
37
  * (no per-category merging — sets compose by replacement, like every
39
38
  * other CLI override here). `--show ""` sets the empty set. */
40
39
  readonly show: readonly ShowCategory[] | null
41
- readonly startInFilter: boolean | null
40
+ readonly focus: "sidebar" | "reader" | "filter" | null
42
41
  }
43
42
 
44
43
  const DEFAULT_THEME = "opencode"
45
44
  const DEFAULT_TONE: "dark" | "light" = "dark"
46
45
  const DEFAULT_MDX = true
47
46
  const DEFAULT_SHOW = ""
48
- const DEFAULT_START_IN_FILTER = false
47
+ const DEFAULT_FOCUS: "sidebar" | "reader" | "filter" = "filter"
49
48
 
50
49
  const themeIds = themeDefinitions.map((t) => t.id)
51
50
 
@@ -55,13 +54,7 @@ const themeIds = themeDefinitions.map((t) => t.id)
55
54
  * Used by `fileProvider` to warn about unrecognized keys (with a
56
55
  * did-you-mean hint when one is close) while still loading the rest.
57
56
  */
58
- const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set([
59
- "theme",
60
- "tone",
61
- "mdx",
62
- "show",
63
- "start_in_filter",
64
- ])
57
+ const KNOWN_FILE_KEYS: ReadonlySet<string> = new Set(["theme", "tone", "mdx", "show", "focus"])
65
58
 
66
59
  const schema = Config.all({
67
60
  theme: Config.schema(Schema.Literals(themeIds), "theme"),
@@ -75,7 +68,7 @@ const schema = Config.all({
75
68
  // `"hidden,gitignored"`). Token-level validation happens in `loadConfig`
76
69
  // so the error message can list valid categories at the field's path.
77
70
  show: Config.schema(Schema.String, "show"),
78
- start_in_filter: Config.schema(Schema.Literals(["true", "false"] as const), "start_in_filter"),
71
+ focus: Config.schema(Schema.Literals(["sidebar", "reader", "filter"] as const), "focus"),
79
72
  })
80
73
 
81
74
  const defaultsProvider = (): ConfigProvider.ConfigProvider =>
@@ -84,7 +77,7 @@ const defaultsProvider = (): ConfigProvider.ConfigProvider =>
84
77
  tone: DEFAULT_TONE,
85
78
  mdx: String(DEFAULT_MDX),
86
79
  show: DEFAULT_SHOW,
87
- start_in_filter: String(DEFAULT_START_IN_FILTER),
80
+ focus: DEFAULT_FOCUS,
88
81
  })
89
82
 
90
83
  /**
@@ -201,12 +194,12 @@ const envProvider = (env: Record<string, string | undefined>): ConfigProvider.Co
201
194
  const tone = env["HOUSE_TONE"]
202
195
  const mdx = env["HOUSE_MDX"]
203
196
  const show = env["HOUSE_SHOW"]
204
- const startInFilter = env["HOUSE_START_IN_FILTER"]
197
+ const focus = env["HOUSE_FOCUS"]
205
198
  if (theme !== undefined) entries.push(["theme", theme])
206
199
  if (tone !== undefined) entries.push(["tone", tone])
207
200
  if (mdx !== undefined) entries.push(["mdx", mdx])
208
201
  if (show !== undefined) entries.push(["show", show])
209
- if (startInFilter !== undefined) entries.push(["start_in_filter", startInFilter])
202
+ if (focus !== undefined) entries.push(["focus", focus])
210
203
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
211
204
  }
212
205
 
@@ -216,8 +209,7 @@ const cliProvider = (overrides: CliOverrides): ConfigProvider.ConfigProvider =>
216
209
  if (overrides.tone !== null) entries.push(["tone", overrides.tone])
217
210
  if (overrides.mdx !== null) entries.push(["mdx", String(overrides.mdx)])
218
211
  if (overrides.show !== null) entries.push(["show", overrides.show.join(",")])
219
- if (overrides.startInFilter !== null)
220
- entries.push(["start_in_filter", String(overrides.startInFilter)])
212
+ if (overrides.focus !== null) entries.push(["focus", overrides.focus])
221
213
  return ConfigProvider.fromUnknown(Object.fromEntries(entries))
222
214
  }
223
215
 
@@ -260,7 +252,7 @@ export const loadConfig = (
260
252
  tone: null,
261
253
  mdx: null,
262
254
  show: null,
263
- startInFilter: null,
255
+ focus: null,
264
256
  }
265
257
  const onWarning = options.onWarning ?? ((msg) => process.stderr.write(`${msg}\n`))
266
258
  const provider = cliProvider(cli).pipe(
@@ -288,7 +280,7 @@ export const loadConfig = (
288
280
  tone: raw.tone,
289
281
  mdx: raw.mdx === "true",
290
282
  show: parsed.value,
291
- startInFilter: raw.start_in_filter === "true",
283
+ focus: raw.focus,
292
284
  })
293
285
  }),
294
286
  )
@@ -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
  }
package/src/index.tsx CHANGED
@@ -16,7 +16,7 @@ import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
16
16
  import { Cause, Duration, Effect, Fiber, Stream } from "effect"
17
17
  import { useEffect, useMemo, useRef, useState } from "react"
18
18
  import pkg from "../package.json" with { type: "json" }
19
- import { Browser } from "./Browser.tsx"
19
+ import { Browser, type StartupFocus } from "./Browser.tsx"
20
20
  import { parseArgv, usage } from "./cli/argv.ts"
21
21
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
22
22
  import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
@@ -69,7 +69,7 @@ interface DiscoverShellProps {
69
69
  readonly mdx: boolean
70
70
  readonly maxWidth: number | null
71
71
  readonly sidebarMode: SidebarMode
72
- readonly startInFilter: boolean
72
+ readonly startupFocus: StartupFocus
73
73
  }
74
74
 
75
75
  const DiscoverShell = ({
@@ -79,7 +79,7 @@ const DiscoverShell = ({
79
79
  mdx,
80
80
  maxWidth,
81
81
  sidebarMode,
82
- startInFilter,
82
+ startupFocus,
83
83
  }: DiscoverShellProps) => {
84
84
  const updateNotice = useUpdateNotice()
85
85
  const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
@@ -134,7 +134,7 @@ const DiscoverShell = ({
134
134
  maxWidth={maxWidth}
135
135
  discoveryStatus={discoveryStatus}
136
136
  sidebarMode={sidebarMode}
137
- startInFilter={startInFilter}
137
+ startupFocus={startupFocus}
138
138
  updateNotice={updateNotice}
139
139
  onToggleAll={() => {
140
140
  // shift+a is the only place the categories are treated as a
@@ -267,15 +267,17 @@ if (import.meta.main) {
267
267
  // `--show` replaces env/file when present (set semantics —
268
268
  // no per-category merge across sources). `null` falls through.
269
269
  show: cliShow,
270
- // One-way override: present means "on". Absent → env/file/default.
271
- startInFilter: args.startInFilter ? true : null,
270
+ focus:
271
+ args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
272
+ ? args.focus
273
+ : null,
272
274
  },
273
275
  }),
274
276
  ).catch((err: unknown) => {
275
277
  console.error(`house: ${formatConfigError(err)}`)
276
278
  process.exit(2)
277
279
  })
278
- const { theme: themeId, tone, mdx, show, startInFilter } = config
280
+ const { theme: themeId, tone, mdx, show, focus: startupFocus } = config
279
281
  const themeDef = getThemeDefinition(themeId)
280
282
  if (themeDef === undefined) {
281
283
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -345,6 +347,14 @@ if (import.meta.main) {
345
347
  }
346
348
  sidebarMode = args.sidebar
347
349
  }
350
+ if (args.focus !== null) {
351
+ if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
352
+ console.error(
353
+ `house: --focus must be "sidebar", "reader", or "filter", got "${args.focus}"`,
354
+ )
355
+ process.exit(2)
356
+ }
357
+ }
348
358
  await runTui({
349
359
  target,
350
360
  themeId,
@@ -354,7 +364,7 @@ if (import.meta.main) {
354
364
  sort,
355
365
  mdx,
356
366
  sidebarMode,
357
- startInFilter,
367
+ startupFocus,
358
368
  updateCheck: !args.noUpdateCheck,
359
369
  })
360
370
  }
@@ -369,7 +379,7 @@ interface TuiBootOptions {
369
379
  readonly sort: SortOrder
370
380
  readonly mdx: boolean
371
381
  readonly sidebarMode: SidebarMode
372
- readonly startInFilter: boolean
382
+ readonly startupFocus: StartupFocus
373
383
  /** Run the npm-registry probe and surface the "update available" notice.
374
384
  * False suppresses both the toast and the quit-time print. */
375
385
  readonly updateCheck: boolean
@@ -384,7 +394,7 @@ async function runTui({
384
394
  sort,
385
395
  mdx,
386
396
  sidebarMode,
387
- startInFilter,
397
+ startupFocus,
388
398
  updateCheck,
389
399
  }: TuiBootOptions): Promise<void> {
390
400
  let stats: Awaited<ReturnType<typeof stat>>
@@ -426,7 +436,7 @@ async function runTui({
426
436
  mdx={mdx}
427
437
  maxWidth={maxWidth}
428
438
  sidebarMode={sidebarMode}
429
- startInFilter={startInFilter}
439
+ startupFocus={startupFocus}
430
440
  />
431
441
  </RegistryProvider>,
432
442
  )
@@ -20,6 +20,7 @@ export interface BrowserCtx {
20
20
  readonly sidebarShown: boolean
21
21
  readonly helpVisible: boolean
22
22
  readonly filterOpen: boolean
23
+ readonly restoreFilterOnSidebarFocus: boolean
23
24
  /** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
24
25
  * hint gate so the hint only appears when there is something to clear. */
25
26
  readonly filterQuery: string
@@ -87,7 +88,13 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
87
88
  description: "Toggle focus (sidebar ↔ reader)",
88
89
  hint: "focus",
89
90
  keys: ["tab"],
90
- run: (c) => c.setFocus((f) => (f === "sidebar" ? "reader" : "sidebar")),
91
+ run: (c) => {
92
+ if (c.focus === "reader" && c.restoreFilterOnSidebarFocus) {
93
+ c.openFilter()
94
+ } else {
95
+ c.setFocus((f) => (f === "sidebar" ? "reader" : "sidebar"))
96
+ }
97
+ },
91
98
  },
92
99
  {
93
100
  id: "sidebar.toggle",
@@ -0,0 +1,17 @@
1
+ /** Terminal-friendly display form for a binding's first key chord. */
2
+ export const displayKey = (raw: string): string => {
3
+ switch (raw) {
4
+ case "return":
5
+ return "↵"
6
+ case "escape":
7
+ return "esc"
8
+ case "space":
9
+ return "␣"
10
+ case "pageup":
11
+ return "pgup"
12
+ case "pagedown":
13
+ return "pgdn"
14
+ default:
15
+ return raw
16
+ }
17
+ }
@@ -62,6 +62,9 @@ const parseChord = (raw: string): ParsedChord => {
62
62
  }
63
63
 
64
64
  const chordMatches = (chord: ParsedChord, key: KeyMatch): boolean => {
65
+ if (chord.key === "tab" && key.name === "i" && key.ctrl && !key.shift && !key.meta) {
66
+ return true
67
+ }
65
68
  if (chord.key !== key.name) return false
66
69
  if (chord.shift !== Boolean(key.shift)) return false
67
70
  if (chord.ctrl !== Boolean(key.ctrl)) return false
@@ -6,52 +6,20 @@ import type { ColorPalette, ResolvedTheme, ThemeDefinition, Tone } from "./types
6
6
  * Adapt a resolved theme (opencode-shaped flat tokens) to the
7
7
  * {@link ColorPalette} shape consumed by Browser / HelpOverlay / index.
8
8
  *
9
- * - UI tokens map name-for-name where they overlap.
10
- * - `surface` ← `backgroundPanel`, `selectedBg` ← `backgroundElement`,
11
- * `selectedBgInactive` ← `borderSubtle`.
12
- * - `textStrong` ← `primary` (opencode's convention for emphasized brand
13
- * text in UI chrome). Markdown rendering still reads `markdownStrong`
14
- * directly via the syntax map.
9
+ * - UI tokens map name-for-name with OpenCode's TUI theme semantics.
15
10
  * - `syntax` is a fully populated opentui tree-sitter scope map built from
16
11
  * `markdown*` and `syntax*` tokens.
17
12
  */
18
- /** Relative luminance of a `#rrggbb` color (0..1). Sufficient for ordering
19
- * two near-neutral chrome colors — not a full WCAG contrast calculation.
20
- * Non-hex inputs fall back to 0.5 so the caller's ordering is a no-op. */
21
- const luminance = (hex: string): number => {
22
- const m = /^#([0-9a-fA-F]{6})$/.exec(hex)
23
- if (!m) return 0.5
24
- const h = m[1]!
25
- const r = parseInt(h.slice(0, 2), 16)
26
- const g = parseInt(h.slice(2, 4), 16)
27
- const b = parseInt(h.slice(4, 6), 16)
28
- // Rec. 601 weighting — perceptual ordering, not the linearized WCAG variant.
29
- return (0.299 * r + 0.587 * g + 0.114 * b) / 255
30
- }
31
-
32
- /** Pane chrome assumes the active/raised pane sits on the darker of the two
33
- * background tokens and the dim chrome on the lighter one. Most themes
34
- * define `background` darker than `backgroundPanel` and this is a no-op;
35
- * some (e.g. cursor) flip the polarity, in which case we swap so the
36
- * active-pane convention stays consistent across themes. */
37
- const orientChrome = (r: ResolvedTheme): { raised: string; dim: string } => {
38
- const bg = r.background
39
- const panel = r.backgroundPanel
40
- return luminance(bg) <= luminance(panel) ? { raised: bg, dim: panel } : { raised: panel, dim: bg }
41
- }
42
-
43
13
  const buildPalette = (r: ResolvedTheme): ColorPalette => {
44
- const { raised, dim } = orientChrome(r)
45
14
  return {
46
- background: raised,
47
- surface: dim,
15
+ background: r.background,
16
+ backgroundPanel: r.backgroundPanel,
17
+ backgroundElement: r.backgroundElement,
48
18
  text: r.text,
49
- textStrong: r.primary,
50
19
  textMuted: r.textMuted,
51
20
  border: r.border,
52
21
  borderActive: r.borderActive,
53
- selectedBg: r.backgroundElement,
54
- selectedBgInactive: r.borderSubtle,
22
+ borderSubtle: r.borderSubtle,
55
23
  selectedListItemText: r.selectedListItemText,
56
24
  primary: r.primary,
57
25
  secondary: r.secondary,