@carlesandres/house 0.4.6 → 0.4.8

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
@@ -1,50 +1,30 @@
1
1
  #!/usr/bin/env bun
2
- /**
3
- * house — entry point.
4
- *
5
- * Reads a markdown file path from argv and renders it via opentui's built-in
6
- * <markdown> component inside a scrollbox. q / ctrl+c to quit.
7
- *
8
- * Discovery, sidebar, theming, and richer Effect wiring all land after this.
9
- */
2
+ /** house — entry point. Boots the browser TUI or `--serve` preview. */
10
3
 
11
4
  import { stat } from "node:fs/promises"
12
- import { createCliRenderer, SyntaxStyle } from "@opentui/core"
13
- import type { BorderSides } from "@opentui/core"
14
- import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
15
- import { RegistryProvider, useAtomSet, useAtomValue } from "@effect/atom-react"
5
+ import { dirname, isAbsolute, relative, resolve } from "node:path"
6
+ import { createCliRenderer } from "@opentui/core"
7
+ import { createRoot } from "@opentui/react"
8
+ import { RegistryProvider } from "@effect/atom-react"
16
9
  import { Cause, Duration, Effect, Fiber, Stream } from "effect"
17
- import { useEffect, useMemo, useRef, useState } from "react"
10
+ import { useEffect, useRef, useState } from "react"
18
11
  import pkg from "../package.json" with { type: "json" }
19
12
  import { Browser, type StartupFocus } from "./Browser.tsx"
20
13
  import { parseArgv, usage } from "./cli/argv.ts"
21
14
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
22
15
  import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
23
16
  import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
24
- import { Header } from "./Header.tsx"
25
- import { readFileText } from "./io/readFile.ts"
26
17
  import { openInBrowser } from "./serve/openBrowser.ts"
27
18
  import { startServer } from "./serve/server.ts"
28
- import { colors, setActiveTheme } from "./theme/colors.ts"
19
+ import { setActiveTheme } from "./theme/colors.ts"
29
20
  import { themeAtom, type ThemeState } from "./theme/atom.ts"
30
- import { getThemeDefinition, themeDefinitions } from "./theme/registry.ts"
21
+ import { getThemeDefinition } from "./theme/registry.ts"
31
22
  import { formatQuitNotice } from "./update/notice.ts"
32
23
  import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
33
24
  import { useUpdateNotice } from "./update/useUpdateNotice.ts"
34
25
 
35
- export interface AppProps {
36
- /** Markdown source to render. */
37
- readonly content: string
38
- /** Optional title shown in the header's current-file slot. Defaults to a generic label. */
39
- readonly title?: string
40
- /** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
41
- readonly maxWidth?: number | null
42
- /** Override quit behavior. Tests pass a spy; the binary uses the default. */
43
- readonly onQuit?: () => void
44
- }
45
-
46
26
  /**
47
- * DiscoverShell — owns the streaming walk for directory mode. Mounts Browser
27
+ * DiscoverShell — owns the streaming walk for the Browser. Mounts Browser
48
28
  * immediately with `files=[]` and pushes entries as the stream emits.
49
29
  *
50
30
  * Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
@@ -58,8 +38,69 @@ export interface AppProps {
58
38
  */
59
39
  export type SidebarMode = "auto" | "on" | "off"
60
40
 
41
+ const pathIsDirectory = async (path: string): Promise<boolean> => {
42
+ try {
43
+ return (await stat(path)).isDirectory()
44
+ } catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ const findGitRoot = async (cwd: string): Promise<string> => {
50
+ const start = resolve(cwd)
51
+ let current = start
52
+ for (;;) {
53
+ if (await pathIsDirectory(resolve(current, ".git"))) return current
54
+ const parent = dirname(current)
55
+ if (parent === current) return start
56
+ current = parent
57
+ }
58
+ }
59
+
60
+ export const resolveDiscoveryRoot = async ({
61
+ cliRoot,
62
+ defaultRoot,
63
+ cwd,
64
+ }: {
65
+ readonly cliRoot: string | null
66
+ readonly defaultRoot: "cwd" | "git"
67
+ readonly cwd: string
68
+ }): Promise<string> => {
69
+ if (cliRoot !== null) return cliRoot
70
+ if (defaultRoot === "git") return findGitRoot(cwd)
71
+ return cwd
72
+ }
73
+
74
+ export const validateDiscoveryRoot = async (root: string): Promise<void> => {
75
+ let stats: Awaited<ReturnType<typeof stat>>
76
+ try {
77
+ stats = await stat(root)
78
+ } catch (err) {
79
+ throw new Error(`cannot access discovery root ${root}: ${String(err)}`)
80
+ }
81
+ if (!stats.isDirectory()) {
82
+ throw new Error(`discovery root must be a directory, got file ${root}`)
83
+ }
84
+ }
85
+
86
+ export const formatPartialDiscoveryStatus = ({
87
+ skippedCount,
88
+ lastSkippedPath,
89
+ }: {
90
+ readonly skippedCount: number
91
+ readonly lastSkippedPath: string | null
92
+ }): string | null => {
93
+ if (skippedCount <= 0) return null
94
+ const noun = skippedCount === 1 ? "directory" : "directories"
95
+ const suffix = lastSkippedPath && skippedCount === 1 ? `: ${lastSkippedPath}` : ""
96
+ return `scan incomplete: skipped ${skippedCount} ${noun}${suffix}`
97
+ }
98
+
99
+ export const formatFatalDiscoveryStatus = (): string => "scan failed: unable to read discovery root"
100
+
61
101
  interface DiscoverShellProps {
62
102
  readonly target: string
103
+ readonly initialQuery: string
63
104
  /** Resolved discovery vocabulary from the config layer. The shift+a
64
105
  * toggle (#145) is session-only sugar that flips between this set
65
106
  * and the full vocabulary; the underlying categories remain
@@ -72,8 +113,9 @@ interface DiscoverShellProps {
72
113
  readonly startupFocus: StartupFocus
73
114
  }
74
115
 
75
- const DiscoverShell = ({
116
+ export const DiscoverShell = ({
76
117
  target,
118
+ initialQuery,
77
119
  initialShow,
78
120
  sort,
79
121
  mdx,
@@ -86,6 +128,8 @@ const DiscoverShell = ({
86
128
  const [files, setFiles] = useState<readonly FileEntry[]>([])
87
129
  const [scanning, setScanning] = useState<boolean>(true)
88
130
  const [scanError, setScanError] = useState<string | null>(null)
131
+ const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
132
+ const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
89
133
  // Files arrive in a ref-tracked count so the status string can show
90
134
  // "indexing… N" even when React hasn't yet flushed the latest setFiles.
91
135
  const countRef = useRef(0)
@@ -98,8 +142,23 @@ const DiscoverShell = ({
98
142
  setFiles([])
99
143
  setScanning(true)
100
144
  setScanError(null)
145
+ setSkippedDirCount(0)
146
+ setLastSkippedDir(null)
101
147
  countRef.current = 0
102
- const program = walk(target, { show, sort, mdx }).pipe(
148
+ const warnedProgram = walk(target, {
149
+ show,
150
+ sort,
151
+ mdx,
152
+ onWarning: ({ path }) => {
153
+ const relativePath = relative(resolve(target), path)
154
+ setSkippedDirCount((prev) => prev + 1)
155
+ setLastSkippedDir(
156
+ relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)
157
+ ? relativePath
158
+ : path,
159
+ )
160
+ },
161
+ }).pipe(
103
162
  Stream.groupedWithin(64, Duration.millis(60)),
104
163
  Stream.runForEach((chunk) =>
105
164
  Effect.sync(() => {
@@ -113,25 +172,33 @@ const DiscoverShell = ({
113
172
  onSuccess: () => Effect.sync(() => setScanning(false)),
114
173
  onFailure: (cause) =>
115
174
  Effect.sync(() => {
116
- // Interruption is the normal teardown path — don't surface it.
117
175
  if (Cause.hasInterrupts(cause)) return
118
- setScanError(`scan failed: ${Cause.pretty(cause)}`)
176
+ setScanError(formatFatalDiscoveryStatus())
119
177
  setScanning(false)
120
178
  }),
121
179
  }),
122
180
  )
123
- const fiber = Effect.runFork(program)
181
+ const fiber = Effect.runFork(warnedProgram)
124
182
  return () => {
125
183
  Effect.runFork(Fiber.interrupt(fiber))
126
184
  }
127
185
  }, [target, show, sort, mdx])
128
186
 
129
- const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
187
+ const discoveryStatus =
188
+ scanError ??
189
+ (scanning
190
+ ? `indexing… ${countRef.current}`
191
+ : formatPartialDiscoveryStatus({
192
+ skippedCount: skippedDirCount,
193
+ lastSkippedPath: lastSkippedDir,
194
+ }))
130
195
 
131
196
  return (
132
197
  <Browser
133
198
  files={files}
199
+ initialQuery={initialQuery}
134
200
  maxWidth={maxWidth}
201
+ emptyRootLabel={target}
135
202
  discoveryStatus={discoveryStatus}
136
203
  sidebarMode={sidebarMode}
137
204
  startupFocus={startupFocus}
@@ -149,79 +216,22 @@ const DiscoverShell = ({
149
216
  )
150
217
  }
151
218
 
152
- export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppProps) => {
153
- const renderer = useRenderer()
154
- const { width, height } = useTerminalDimensions()
155
- const theme = useAtomValue(themeAtom)
156
- const setTheme = useAtomSet(themeAtom)
157
- const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(colors.syntax), [theme])
158
-
159
- const cycleTheme = (delta: 1 | -1) => {
160
- const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
161
- const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
162
- if (!next) return
163
- setActiveTheme(next, theme.tone)
164
- setTheme({ id: next.id, tone: theme.tone })
165
- }
166
-
167
- const toggleTone = () => {
168
- const nextTone = theme.tone === "dark" ? "light" : "dark"
169
- const def = getThemeDefinition(theme.id)
170
- if (def) setActiveTheme(def, nextTone)
171
- setTheme({ id: theme.id, tone: nextTone })
172
- }
173
-
174
- useKeyboard((key) => {
175
- if (key.name === "q" || (key.ctrl && key.name === "c")) {
176
- if (onQuit) {
177
- onQuit()
178
- return
179
- }
180
- renderer?.destroy()
181
- process.exit(0)
182
- }
183
- if (key.name === "t" && !key.shift) cycleTheme(1)
184
- if (key.name === "t" && key.shift) cycleTheme(-1)
185
- if (key.name === "l" && key.shift) toggleTone()
186
- })
187
-
188
- const paneBorderSides: BorderSides[] = ["top", "bottom"]
189
-
190
- return (
191
- <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
192
- <Header width={width} currentFile={title} />
193
- <box
194
- style={{
195
- border: paneBorderSides,
196
- borderColor: colors.border,
197
- padding: 1,
198
- flexGrow: 1,
199
- flexShrink: 1,
200
- backgroundColor: colors.background,
201
- }}
202
- >
203
- <scrollbox
204
- style={{
205
- scrollY: true,
206
- scrollX: false,
207
- flexGrow: 1,
208
- flexShrink: 1,
209
- backgroundColor: colors.background,
210
- }}
211
- focused
212
- >
213
- <markdown
214
- content={content}
215
- syntaxStyle={syntaxStyle}
216
- fg={colors.text}
217
- bg={colors.background}
218
- conceal
219
- style={{ width: maxWidth ?? "100%" }}
220
- />
221
- </scrollbox>
222
- </box>
223
- </box>
224
- )
219
+ export const resolveInitialQuery = ({
220
+ pathArg,
221
+ discoveryRoot,
222
+ cwd,
223
+ }: {
224
+ readonly pathArg: string | null
225
+ readonly discoveryRoot: string
226
+ readonly cwd: string
227
+ }): string => {
228
+ if (pathArg === null) return ""
229
+ const resolvedPath = resolve(cwd, pathArg)
230
+ const rel = relative(discoveryRoot, resolvedPath)
231
+ if (rel.length === 0) return ""
232
+ if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
233
+ if (pathArg.startsWith("./")) return pathArg.slice(2)
234
+ return pathArg
225
235
  }
226
236
 
227
237
  let updateExitHookRegistered = false
@@ -277,7 +287,7 @@ if (import.meta.main) {
277
287
  console.error(`house: ${formatConfigError(err)}`)
278
288
  process.exit(2)
279
289
  })
280
- const { theme: themeId, tone, mdx, show, focus: startupFocus } = config
290
+ const { theme: themeId, tone, mdx, show, focus: startupFocus, defaultRoot } = config
281
291
  const themeDef = getThemeDefinition(themeId)
282
292
  if (themeDef === undefined) {
283
293
  // Unreachable: Config.schema validated themeId against themeDefinitions.
@@ -296,9 +306,16 @@ if (import.meta.main) {
296
306
  maxWidth = n
297
307
  }
298
308
 
299
- const target = args.path ?? "."
309
+ const cwd = process.cwd()
310
+ const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
311
+ const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
300
312
 
301
313
  if (args.serve) {
314
+ const target = args.path
315
+ if (target === null) {
316
+ console.error("house: --serve requires a file path")
317
+ process.exit(2)
318
+ }
302
319
  let stats: Awaited<ReturnType<typeof stat>>
303
320
  try {
304
321
  stats = await stat(target)
@@ -356,7 +373,8 @@ if (import.meta.main) {
356
373
  }
357
374
  }
358
375
  await runTui({
359
- target,
376
+ discoveryRoot,
377
+ initialQuery,
360
378
  themeId,
361
379
  tone,
362
380
  maxWidth,
@@ -371,7 +389,8 @@ if (import.meta.main) {
371
389
  }
372
390
 
373
391
  interface TuiBootOptions {
374
- readonly target: string
392
+ readonly discoveryRoot: string
393
+ readonly initialQuery: string
375
394
  readonly themeId: string
376
395
  readonly tone: "dark" | "light"
377
396
  readonly maxWidth: number | null
@@ -386,7 +405,8 @@ interface TuiBootOptions {
386
405
  }
387
406
 
388
407
  async function runTui({
389
- target,
408
+ discoveryRoot,
409
+ initialQuery,
390
410
  themeId,
391
411
  tone,
392
412
  maxWidth,
@@ -397,14 +417,6 @@ async function runTui({
397
417
  startupFocus,
398
418
  updateCheck,
399
419
  }: TuiBootOptions): Promise<void> {
400
- let stats: Awaited<ReturnType<typeof stat>>
401
- try {
402
- stats = await stat(target)
403
- } catch (err) {
404
- console.error(`house: cannot access ${target}: ${String(err)}`)
405
- process.exit(1)
406
- }
407
-
408
420
  if (updateCheck) {
409
421
  // Fire the npm-registry probe in the background. Result lands in a
410
422
  // module singleton; the React tree picks it up via `useUpdateNotice`
@@ -423,40 +435,32 @@ async function runTui({
423
435
  }
424
436
  }
425
437
 
426
- const renderer = await createCliRenderer({ exitOnCtrlC: false })
438
+ const renderer = await createCliRenderer({
439
+ exitOnCtrlC: false,
440
+ useMouse: true,
441
+ enableMouseMovement: true,
442
+ screenMode: "alternate-screen",
443
+ })
444
+ renderer.useMouse = true
427
445
  const initialTheme: ThemeState = { id: themeId, tone }
428
-
429
- if (stats.isDirectory()) {
430
- createRoot(renderer).render(
431
- <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
432
- <DiscoverShell
433
- target={target}
434
- initialShow={show}
435
- sort={sort}
436
- mdx={mdx}
437
- maxWidth={maxWidth}
438
- sidebarMode={sidebarMode}
439
- startupFocus={startupFocus}
440
- />
441
- </RegistryProvider>,
442
- )
443
- } else {
444
- const content = await Effect.runPromise(
445
- readFileText(target).pipe(
446
- Effect.tapError((err) =>
447
- Effect.sync(() => {
448
- console.error(`house: cannot read ${err.path}: ${String(err.cause)}`)
449
- }),
450
- ),
451
- ),
452
- ).catch(() => {
453
- process.exit(1)
454
- })
455
- if (typeof content !== "string") process.exit(1)
456
- createRoot(renderer).render(
457
- <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
458
- <App content={content} title={target} maxWidth={maxWidth} />
459
- </RegistryProvider>,
460
- )
446
+ try {
447
+ await validateDiscoveryRoot(discoveryRoot)
448
+ } catch (err) {
449
+ console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
450
+ process.exit(1)
461
451
  }
452
+ createRoot(renderer).render(
453
+ <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
454
+ <DiscoverShell
455
+ target={discoveryRoot}
456
+ initialQuery={initialQuery}
457
+ initialShow={show}
458
+ sort={sort}
459
+ mdx={mdx}
460
+ maxWidth={maxWidth}
461
+ sidebarMode={sidebarMode}
462
+ startupFocus={startupFocus}
463
+ />
464
+ </RegistryProvider>,
465
+ )
462
466
  }
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Browser keymap — the data backing `Browser.tsx`'s `useKeyboard` handler
3
- * and (next iteration) the `?` help overlay.
2
+ * Browser keymap — the data backing `Browser.tsx`'s `useKeyboard` handler.
4
3
  */
5
4
 
6
5
  import type { FileEntry } from "../discovery/walk.ts"
@@ -11,14 +10,13 @@ export type BrowserFocus = "sidebar" | "reader"
11
10
  export interface BrowserCtx {
12
11
  readonly files: readonly FileEntry[]
13
12
  /** True iff `files[selectedIndex]` resolves to an entry. The honest
14
- * predicate for File-group actions (`o`, `e`, `[`, `]`): with debounced
13
+ * predicate for File-group actions (`O`, `E`, `[`, `]`): with debounced
15
14
  * filter and sticky auto-select, `files.length > 0` can be true while
16
15
  * `selectedIndex` is invalid for the displayed list. See #115. */
17
16
  readonly hasSelected: boolean
18
17
  readonly focus: BrowserFocus
19
18
  /** User's sticky sidebar preference. Visibility is `shown || focus==="sidebar"`. */
20
19
  readonly sidebarShown: boolean
21
- readonly helpVisible: boolean
22
20
  readonly filterOpen: boolean
23
21
  readonly restoreFilterOnSidebarFocus: boolean
24
22
  /** Current applied/edited filter query. Used by `filter.clearOrOpen`'s
@@ -29,7 +27,6 @@ export interface BrowserCtx {
29
27
  readonly setSelectedIndex: (updater: (prev: number) => number) => void
30
28
  /** Toggle `shown` and adjust focus per DESIGN.md §7.1 (see s-behavior table). */
31
29
  readonly toggleShown: () => void
32
- readonly setHelpVisible: (updater: (prev: boolean) => boolean) => void
33
30
  readonly openFilter: () => void
34
31
  /** Clear the current filter query and open the filter modal in a single
35
32
  * action. Bound to `\` so users can reset a stranded zero-match filter
@@ -65,6 +62,7 @@ const stepBy = (c: BrowserCtx, delta: number) =>
65
62
  const inSidebar = (c: BrowserCtx) => c.focus === "sidebar"
66
63
  const filterClosed = (c: BrowserCtx) => !c.filterOpen
67
64
  const paletteClosed = (c: BrowserCtx) => !c.paletteOpen
65
+ const inputClosed = (c: BrowserCtx) => filterClosed(c) && paletteClosed(c)
68
66
  const inReader = (c: BrowserCtx) => c.focus === "reader"
69
67
  const inSidebarWithFiles = (c: BrowserCtx) => inSidebar(c) && haveFiles(c)
70
68
  /** Reader-only sibling-step gate: needs a current selection plus a sibling
@@ -80,6 +78,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
80
78
  description: "Quit",
81
79
  hint: "quit",
82
80
  keys: ["q", "ctrl+c"],
81
+ hintWhen: inputClosed,
83
82
  run: (c) => c.quit(),
84
83
  },
85
84
  {
@@ -102,16 +101,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
102
101
  description: "Toggle sidebar visibility",
103
102
  hint: "sidebar",
104
103
  keys: ["s"],
104
+ hintWhen: inputClosed,
105
105
  run: (c) => c.toggleShown(),
106
106
  },
107
- {
108
- id: "help.toggle",
109
- group: "Global",
110
- description: "Show / dismiss help",
111
- hint: "help",
112
- keys: ["?"],
113
- run: (c) => c.setHelpVisible((v) => !v),
114
- },
115
107
  {
116
108
  id: "filter.open",
117
109
  group: "Sidebar",
@@ -133,17 +125,16 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
133
125
  // Fires from anywhere outside the filter modal via the keymap.
134
126
  // Inside the filter modal it's intercepted directly in Browser.tsx
135
127
  // (the filter mode owns key handling), but the action is the same —
136
- // clear input, keep modal open. Palette/help branches short-circuit
128
+ // clear input, keep modal open. Palette branches short-circuit
137
129
  // dispatch in Browser.tsx, so we don't need to gate on them for
138
- // behavior; the `hintWhen` gate keeps the footer chip from showing
130
+ // behavior; the `hintWhen` gate keeps the footer hint from showing
139
131
  // when there's nothing to clear or when a modal owns the input.
140
132
  // Chord chosen over single `\` so the binding works inside the
141
133
  // filter input without colliding with the typed character; ctrl+u
142
134
  // is deliberately left to its reader/sidebar half-page-up role to
143
135
  // avoid overload.
144
136
  when: filterClosed,
145
- hintWhen: (c) =>
146
- filterClosed(c) && !c.paletteOpen && !c.helpVisible && c.filterQuery.length > 0,
137
+ hintWhen: (c) => filterClosed(c) && !c.paletteOpen && c.filterQuery.length > 0,
147
138
  run: (c) => c.clearAndOpenFilter(),
148
139
  },
149
140
  {
@@ -152,10 +143,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
152
143
  description: "Command palette",
153
144
  hint: "palette",
154
145
  keys: ["ctrl+p"],
155
- // Filter swallows ctrl+p as a typed character in its own branch, so this
156
- // `when` only matters when the palette is already open (which it
157
- // shouldn't re-open). #70 Q2 — fires from everywhere except the filter,
158
- // closes help on its way in (handled in Browser.tsx).
146
+ // The filter modal handles this chord directly so the palette can open
147
+ // while filter input owns the rest of the keyboard. This gate keeps the
148
+ // normal dispatcher from reopening an already-open palette.
159
149
  when: paletteClosed,
160
150
  run: (c) => c.openPalette(),
161
151
  },
@@ -178,6 +168,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
178
168
  description: "Next theme",
179
169
  hint: "theme",
180
170
  keys: ["t"],
171
+ hintWhen: inputClosed,
181
172
  run: (c) => c.cycleTheme(1),
182
173
  },
183
174
  {
@@ -267,6 +258,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
267
258
  hint: "open",
268
259
  keys: ["return", "right", "l"],
269
260
  when: inSidebar,
261
+ hintWhen: (c) => inSidebar(c) && hasSelected(c),
270
262
  run: (c) => c.setFocus("reader"),
271
263
  },
272
264
 
@@ -278,8 +270,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
278
270
  group: "File",
279
271
  description: "Open current file in browser as HTML",
280
272
  hint: "html",
281
- keys: ["o"],
273
+ keys: ["shift+o"],
282
274
  when: hasSelected,
275
+ hintWhen: (c) => inputClosed(c) && hasSelected(c),
283
276
  run: (c) => c.serveCurrent(),
284
277
  },
285
278
  {
@@ -287,8 +280,9 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
287
280
  group: "File",
288
281
  description: "Open current file in $EDITOR",
289
282
  hint: "edit",
290
- keys: ["e"],
283
+ keys: ["shift+e"],
291
284
  when: hasSelected,
285
+ hintWhen: (c) => inputClosed(c) && hasSelected(c),
292
286
  run: (c) => c.editCurrent(),
293
287
  },
294
288
  {
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Bindings are values: `{ id, description, keys, when?, run }`. A pure
5
5
  * `dispatch` looks up the first matching, enabled binding for a key event
6
- * and runs it. The same array drives the help overlay, so there is one
7
- * source of truth.
6
+ * and runs it. The same array also drives footer hints and command-palette
7
+ * derivation, so there is one source of truth.
8
8
  *
9
9
  * Deliberately *not* a port of ghui's `@ghui/keymap`: no chord sequences,
10
10
  * no count prefixes, no scope contramaps. See DESIGN.md §12 for the full
@@ -22,11 +22,11 @@ export interface KeyMatch {
22
22
  export interface KeyBinding<C> {
23
23
  /** Stable id; used for tests and (later) command-palette routing. */
24
24
  readonly id: string
25
- /** Human-readable summary, shown in the help overlay. */
25
+ /** Human-readable summary used by docs and palette command titles. */
26
26
  readonly description: string
27
27
  /** Key chords that trigger this binding, e.g. ["j", "down"], ["shift+k"], ["ctrl+c"]. */
28
28
  readonly keys: readonly string[]
29
- /** Optional grouping label for the help overlay. Bindings without a group are excluded from help. */
29
+ /** Optional grouping label for derived UI/documentation surfaces. */
30
30
  readonly group?: string
31
31
  /** Optional compact label for the footer hint row, e.g. "help" for `?:help`.
32
32
  * Bindings without a hint are excluded from the footer. Order in the bindings
@@ -0,0 +1,7 @@
1
+ const HEAD_ELISION_PREFIX = "…"
2
+
3
+ export const fitSidebarEmptyValue = (value: string, width: number): string => {
4
+ if (value.length <= width) return value
5
+ if (width <= 1) return value.slice(value.length - 1)
6
+ return HEAD_ELISION_PREFIX + value.slice(value.length - width + 1)
7
+ }
@@ -0,0 +1,62 @@
1
+ export interface FrontmatterField {
2
+ readonly key: string
3
+ readonly value: string
4
+ }
5
+
6
+ export interface FrontmatterRenderModel {
7
+ readonly body: string
8
+ readonly fields: readonly FrontmatterField[]
9
+ }
10
+
11
+ const FRONTMATTER_OPEN = "---"
12
+ const FRONTMATTER_CLOSE = "---"
13
+
14
+ const normalizeValue = (raw: string): string => {
15
+ const value = raw.trim()
16
+ if (
17
+ (value.startsWith('"') && value.endsWith('"')) ||
18
+ (value.startsWith("'") && value.endsWith("'"))
19
+ ) {
20
+ return value.slice(1, -1)
21
+ }
22
+ return value
23
+ }
24
+
25
+ const formatKey = (key: string): string => key.replace(/[-_]+/g, " ")
26
+
27
+ const toDisplayField = (key: string, value: string): FrontmatterField => ({
28
+ key: formatKey(key),
29
+ value: normalizeValue(value),
30
+ })
31
+
32
+ export const parseFrontmatter = (content: string): FrontmatterRenderModel => {
33
+ if (
34
+ !content.startsWith(`${FRONTMATTER_OPEN}\n`) &&
35
+ !content.startsWith(`${FRONTMATTER_OPEN}\r\n`)
36
+ ) {
37
+ return { body: content, fields: [] }
38
+ }
39
+
40
+ const lines = content.split(/\r?\n/)
41
+ if (lines[0] !== FRONTMATTER_OPEN) return { body: content, fields: [] }
42
+
43
+ const closeIndex = lines.indexOf(FRONTMATTER_CLOSE, 1)
44
+ if (closeIndex <= 0) return { body: content, fields: [] }
45
+
46
+ const fields: FrontmatterField[] = []
47
+ for (const line of lines.slice(1, closeIndex)) {
48
+ if (line.trim().length === 0) continue
49
+ const colon = line.indexOf(":")
50
+ if (colon <= 0) return { body: content, fields: [] }
51
+ const key = line.slice(0, colon).trim()
52
+ const value = line.slice(colon + 1)
53
+ if (key.length === 0) return { body: content, fields: [] }
54
+ fields.push(toDisplayField(key, value))
55
+ }
56
+
57
+ const body = lines
58
+ .slice(closeIndex + 1)
59
+ .join("\n")
60
+ .replace(/^\n+/, "")
61
+ return { body, fields }
62
+ }
@@ -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
  *