@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/index.tsx CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { stat } from "node:fs/promises"
12
+ import { dirname, resolve } from "node:path"
12
13
  import { createCliRenderer, SyntaxStyle } from "@opentui/core"
13
14
  import type { BorderSides } from "@opentui/core"
14
15
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
@@ -16,7 +17,7 @@ import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
16
17
  import { Cause, Duration, Effect, Fiber, Stream } from "effect"
17
18
  import { useEffect, useMemo, useRef, useState } from "react"
18
19
  import pkg from "../package.json" with { type: "json" }
19
- import { Browser } from "./Browser.tsx"
20
+ import { Browser, type StartupFocus } from "./Browser.tsx"
20
21
  import { parseArgv, usage } from "./cli/argv.ts"
21
22
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
22
23
  import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
@@ -58,6 +59,39 @@ export interface AppProps {
58
59
  */
59
60
  export type SidebarMode = "auto" | "on" | "off"
60
61
 
62
+ const pathIsDirectory = async (path: string): Promise<boolean> => {
63
+ try {
64
+ return (await stat(path)).isDirectory()
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ const findGitRoot = async (cwd: string): Promise<string> => {
71
+ const start = resolve(cwd)
72
+ let current = start
73
+ for (;;) {
74
+ if (await pathIsDirectory(resolve(current, ".git"))) return current
75
+ const parent = dirname(current)
76
+ if (parent === current) return start
77
+ current = parent
78
+ }
79
+ }
80
+
81
+ export const resolveDiscoveryRoot = async ({
82
+ cliRoot,
83
+ defaultRoot,
84
+ cwd,
85
+ }: {
86
+ readonly cliRoot: string | null
87
+ readonly defaultRoot: "cwd" | "git"
88
+ readonly cwd: string
89
+ }): Promise<string> => {
90
+ if (cliRoot !== null) return cliRoot
91
+ if (defaultRoot === "git") return findGitRoot(cwd)
92
+ return cwd
93
+ }
94
+
61
95
  interface DiscoverShellProps {
62
96
  readonly target: string
63
97
  /** Resolved discovery vocabulary from the config layer. The shift+a
@@ -69,7 +103,7 @@ interface DiscoverShellProps {
69
103
  readonly mdx: boolean
70
104
  readonly maxWidth: number | null
71
105
  readonly sidebarMode: SidebarMode
72
- readonly startInFilter: boolean
106
+ readonly startupFocus: StartupFocus
73
107
  }
74
108
 
75
109
  const DiscoverShell = ({
@@ -79,7 +113,7 @@ const DiscoverShell = ({
79
113
  mdx,
80
114
  maxWidth,
81
115
  sidebarMode,
82
- startInFilter,
116
+ startupFocus,
83
117
  }: DiscoverShellProps) => {
84
118
  const updateNotice = useUpdateNotice()
85
119
  const [show, setShow] = useState<readonly ShowCategory[]>(initialShow)
@@ -134,7 +168,7 @@ const DiscoverShell = ({
134
168
  maxWidth={maxWidth}
135
169
  discoveryStatus={discoveryStatus}
136
170
  sidebarMode={sidebarMode}
137
- startInFilter={startInFilter}
171
+ startupFocus={startupFocus}
138
172
  updateNotice={updateNotice}
139
173
  onToggleAll={() => {
140
174
  // shift+a is the only place the categories are treated as a
@@ -267,15 +301,17 @@ if (import.meta.main) {
267
301
  // `--show` replaces env/file when present (set semantics —
268
302
  // no per-category merge across sources). `null` falls through.
269
303
  show: cliShow,
270
- // One-way override: present means "on". Absent → env/file/default.
271
- startInFilter: args.startInFilter ? true : null,
304
+ focus:
305
+ args.focus === "sidebar" || args.focus === "reader" || args.focus === "filter"
306
+ ? args.focus
307
+ : null,
272
308
  },
273
309
  }),
274
310
  ).catch((err: unknown) => {
275
311
  console.error(`house: ${formatConfigError(err)}`)
276
312
  process.exit(2)
277
313
  })
278
- const { theme: themeId, tone, mdx, show, startInFilter } = config
314
+ const { theme: themeId, tone, mdx, show, focus: startupFocus, defaultRoot } = config
279
315
  const themeDef = getThemeDefinition(themeId)
280
316
  if (themeDef === undefined) {
281
317
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -294,7 +330,9 @@ if (import.meta.main) {
294
330
  maxWidth = n
295
331
  }
296
332
 
333
+ const cwd = process.cwd()
297
334
  const target = args.path ?? "."
335
+ const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
298
336
 
299
337
  if (args.serve) {
300
338
  let stats: Awaited<ReturnType<typeof stat>>
@@ -345,8 +383,17 @@ if (import.meta.main) {
345
383
  }
346
384
  sidebarMode = args.sidebar
347
385
  }
386
+ if (args.focus !== null) {
387
+ if (args.focus !== "sidebar" && args.focus !== "reader" && args.focus !== "filter") {
388
+ console.error(
389
+ `house: --focus must be "sidebar", "reader", or "filter", got "${args.focus}"`,
390
+ )
391
+ process.exit(2)
392
+ }
393
+ }
348
394
  await runTui({
349
395
  target,
396
+ discoveryRoot,
350
397
  themeId,
351
398
  tone,
352
399
  maxWidth,
@@ -354,7 +401,7 @@ if (import.meta.main) {
354
401
  sort,
355
402
  mdx,
356
403
  sidebarMode,
357
- startInFilter,
404
+ startupFocus,
358
405
  updateCheck: !args.noUpdateCheck,
359
406
  })
360
407
  }
@@ -362,6 +409,7 @@ if (import.meta.main) {
362
409
 
363
410
  interface TuiBootOptions {
364
411
  readonly target: string
412
+ readonly discoveryRoot: string
365
413
  readonly themeId: string
366
414
  readonly tone: "dark" | "light"
367
415
  readonly maxWidth: number | null
@@ -369,7 +417,7 @@ interface TuiBootOptions {
369
417
  readonly sort: SortOrder
370
418
  readonly mdx: boolean
371
419
  readonly sidebarMode: SidebarMode
372
- readonly startInFilter: boolean
420
+ readonly startupFocus: StartupFocus
373
421
  /** Run the npm-registry probe and surface the "update available" notice.
374
422
  * False suppresses both the toast and the quit-time print. */
375
423
  readonly updateCheck: boolean
@@ -377,6 +425,7 @@ interface TuiBootOptions {
377
425
 
378
426
  async function runTui({
379
427
  target,
428
+ discoveryRoot,
380
429
  themeId,
381
430
  tone,
382
431
  maxWidth,
@@ -384,7 +433,7 @@ async function runTui({
384
433
  sort,
385
434
  mdx,
386
435
  sidebarMode,
387
- startInFilter,
436
+ startupFocus,
388
437
  updateCheck,
389
438
  }: TuiBootOptions): Promise<void> {
390
439
  let stats: Awaited<ReturnType<typeof stat>>
@@ -420,13 +469,13 @@ async function runTui({
420
469
  createRoot(renderer).render(
421
470
  <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
422
471
  <DiscoverShell
423
- target={target}
472
+ target={discoveryRoot}
424
473
  initialShow={show}
425
474
  sort={sort}
426
475
  mdx={mdx}
427
476
  maxWidth={maxWidth}
428
477
  sidebarMode={sidebarMode}
429
- startInFilter={startInFilter}
478
+ startupFocus={startupFocus}
430
479
  />
431
480
  </RegistryProvider>,
432
481
  )
@@ -11,7 +11,7 @@ export type BrowserFocus = "sidebar" | "reader"
11
11
  export interface BrowserCtx {
12
12
  readonly files: readonly FileEntry[]
13
13
  /** True iff `files[selectedIndex]` resolves to an entry. The honest
14
- * predicate for File-group actions (`o`, `e`, `[`, `]`): with debounced
14
+ * predicate for File-group actions (`O`, `E`, `[`, `]`): with debounced
15
15
  * filter and sticky auto-select, `files.length > 0` can be true while
16
16
  * `selectedIndex` is invalid for the displayed list. See #115. */
17
17
  readonly hasSelected: boolean
@@ -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",
@@ -271,7 +278,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
271
278
  group: "File",
272
279
  description: "Open current file in browser as HTML",
273
280
  hint: "html",
274
- keys: ["o"],
281
+ keys: ["shift+o"],
275
282
  when: hasSelected,
276
283
  run: (c) => c.serveCurrent(),
277
284
  },
@@ -280,7 +287,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
280
287
  group: "File",
281
288
  description: "Open current file in $EDITOR",
282
289
  hint: "edit",
283
- keys: ["e"],
290
+ keys: ["shift+e"],
284
291
  when: hasSelected,
285
292
  run: (c) => c.editCurrent(),
286
293
  },
@@ -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
@@ -2,7 +2,7 @@
2
2
  * Local HTML preview server for a single markdown file.
3
3
  *
4
4
  * One long-lived `Bun.serve` instance. The served file path is swappable
5
- * via `setTarget(path)` — used by the TUI's `o` binding so pressing it on a
5
+ * via `setTarget(path)` — used by the TUI's `O` binding so pressing it on a
6
6
  * new file retargets the existing server (live-reload fires) instead of
7
7
  * spawning a second one.
8
8
  *
@@ -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,
@@ -82,21 +82,20 @@ export type TokenName = keyof ThemeTokens
82
82
  export type ResolvedTheme = Readonly<Record<TokenName, HexColor>>
83
83
 
84
84
  /**
85
- * The flat color object consumed by Browser/HelpOverlay/index. Keeps the
86
- * existing names (`background`, `surface`, `text`, `syntax`, …) so the
87
- * UI files don't all have to change. Values come from
88
- * `colors.ts`'s adapter, which maps `ResolvedTheme` → this shape.
85
+ * The flat color object consumed by Browser/HelpOverlay/index. Uses the
86
+ * OpenCode-aligned UI token names directly (`background`, `backgroundPanel`,
87
+ * `backgroundElement`, `borderSubtle`, `primary`, `secondary`, …). Values
88
+ * come from `colors.ts`'s adapter, which maps `ResolvedTheme` → this shape.
89
89
  */
90
90
  export interface ColorPalette {
91
91
  readonly background: string
92
- readonly surface: string
92
+ readonly backgroundPanel: string
93
+ readonly backgroundElement: string
93
94
  readonly text: string
94
- readonly textStrong: string
95
95
  readonly textMuted: string
96
96
  readonly border: string
97
97
  readonly borderActive: string
98
- readonly selectedBg: string
99
- readonly selectedBgInactive: string
98
+ readonly borderSubtle: string
100
99
  readonly selectedListItemText: string
101
100
  readonly primary: string
102
101
  readonly secondary: string
package/src/tips.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { BrowserCtx } from "./keymap/browser.ts"
2
+ import type { KeyBinding } from "./keymap/keymap.ts"
3
+ import { displayKey } from "./keymap/displayKey.ts"
4
+
5
+ export interface TipLine {
6
+ readonly id: string
7
+ readonly text: string
8
+ }
9
+
10
+ interface TipDefinition {
11
+ readonly id: string
12
+ readonly bindingId?: string
13
+ readonly render: (parts: { readonly key: string | null }) => string
14
+ readonly when?: (ctx: BrowserCtx) => boolean
15
+ }
16
+
17
+ const tipDefinitions: readonly TipDefinition[] = [
18
+ {
19
+ id: "filter.start",
20
+ bindingId: "filter.open",
21
+ render: ({ key }) => `Press ${key ?? "/"} to start filtering files by path.`,
22
+ when: (ctx) => ctx.filterQuery.length === 0 && !ctx.filterOpen,
23
+ },
24
+ {
25
+ id: "filter.resume",
26
+ bindingId: "filter.open",
27
+ render: ({ key }) => `Press ${key ?? "/"} to reopen the current filter and keep refining it.`,
28
+ when: (ctx) => ctx.filterQuery.length > 0 && !ctx.filterOpen,
29
+ },
30
+ {
31
+ id: "filter.commit",
32
+ bindingId: "filter.open",
33
+ render: () => "Press Enter in the filter to open the selected match in the reader.",
34
+ when: (ctx) => ctx.filterQuery.length === 0,
35
+ },
36
+ {
37
+ id: "filter.clear",
38
+ bindingId: "filter.clearOrOpen",
39
+ render: ({ key }) => `Press ${key ?? "ctrl+\\"} to clear the current filter and start over.`,
40
+ when: (ctx) => ctx.filterQuery.length > 0,
41
+ },
42
+ {
43
+ id: "filtered-navigation",
44
+ render: () => "Use [ and ] to move through files while the current filter stays applied.",
45
+ when: (ctx) => ctx.filterQuery.length > 0,
46
+ },
47
+ {
48
+ id: "focus.toggle",
49
+ bindingId: "focus.toggle",
50
+ render: ({ key }) => `Press ${key ?? "tab"} to switch between the sidebar and reader.`,
51
+ },
52
+ {
53
+ id: "sidebar.toggle",
54
+ bindingId: "sidebar.toggle",
55
+ render: ({ key }) =>
56
+ `Press ${key ?? "s"} to hide or show the sidebar without losing your place.`,
57
+ },
58
+ {
59
+ id: "help.open",
60
+ bindingId: "help.toggle",
61
+ render: ({ key }) => `Press ${key ?? "?"} to open the full keyboard help at any time.`,
62
+ },
63
+ ]
64
+
65
+ const firstKeyByBindingId = <C>(bindings: readonly KeyBinding<C>[]): ReadonlyMap<string, string> =>
66
+ new Map(
67
+ bindings.flatMap((binding) => {
68
+ const firstKey = binding.keys[0]
69
+ return firstKey ? [[binding.id, displayKey(firstKey)] as const] : []
70
+ }),
71
+ )
72
+
73
+ export const buildReaderEmptyStateTips = (
74
+ bindings: readonly KeyBinding<BrowserCtx>[],
75
+ ctx: BrowserCtx,
76
+ ): readonly TipLine[] => {
77
+ const keyByBindingId = firstKeyByBindingId(bindings)
78
+
79
+ return tipDefinitions
80
+ .filter((tip) => !tip.when || tip.when(ctx))
81
+ .map((tip) => ({
82
+ id: tip.id,
83
+ text: `Tip: ${tip.render({ key: tip.bindingId ? (keyByBindingId.get(tip.bindingId) ?? null) : null })}`,
84
+ }))
85
+ }
86
+
87
+ export const pickTipByRotation = (
88
+ tips: readonly TipLine[],
89
+ rotationIndex: number,
90
+ ): TipLine | null => {
91
+ if (tips.length === 0) return null
92
+ return tips[((rotationIndex % tips.length) + tips.length) % tips.length] ?? null
93
+ }