@carlesandres/house 0.4.7 → 0.4.9

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,51 +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 { dirname, resolve } from "node:path"
13
- import { createCliRenderer, SyntaxStyle } from "@opentui/core"
14
- import type { BorderSides } from "@opentui/core"
15
- import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
16
- 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"
17
9
  import { Cause, Duration, Effect, Fiber, Stream } from "effect"
18
- import { useEffect, useMemo, useRef, useState } from "react"
10
+ import { useEffect, useRef, useState } from "react"
19
11
  import pkg from "../package.json" with { type: "json" }
20
12
  import { Browser, type StartupFocus } from "./Browser.tsx"
21
13
  import { parseArgv, usage } from "./cli/argv.ts"
22
14
  import { defaultConfigPath, formatConfigError, loadConfig } from "./config/load.ts"
23
15
  import { parseShowList, SHOW_CATEGORIES, type ShowCategory } from "./discovery/show.ts"
24
- import { walk, type FileEntry, type SortOrder } from "./discovery/walk.ts"
25
- import { Header } from "./Header.tsx"
26
- import { readFileText } from "./io/readFile.ts"
16
+ import { walk, type FileEntry } from "./discovery/walk.ts"
27
17
  import { openInBrowser } from "./serve/openBrowser.ts"
28
18
  import { startServer } from "./serve/server.ts"
29
- import { colors, setActiveTheme } from "./theme/colors.ts"
19
+ import { setActiveTheme } from "./theme/colors.ts"
30
20
  import { themeAtom, type ThemeState } from "./theme/atom.ts"
31
- import { getThemeDefinition, themeDefinitions } from "./theme/registry.ts"
21
+ import { getThemeDefinition } from "./theme/registry.ts"
32
22
  import { formatQuitNotice } from "./update/notice.ts"
33
23
  import { currentUpdateInfo, startUpdateProbe } from "./update/runtime.ts"
34
24
  import { useUpdateNotice } from "./update/useUpdateNotice.ts"
35
25
 
36
- export interface AppProps {
37
- /** Markdown source to render. */
38
- readonly content: string
39
- /** Optional title shown in the header's current-file slot. Defaults to a generic label. */
40
- readonly title?: string
41
- /** Cap the rendered markdown's width at N columns (left-aligned). Null = fill the pane. */
42
- readonly maxWidth?: number | null
43
- /** Override quit behavior. Tests pass a spy; the binary uses the default. */
44
- readonly onQuit?: () => void
45
- }
46
-
47
26
  /**
48
- * DiscoverShell — owns the streaming walk for directory mode. Mounts Browser
27
+ * DiscoverShell — owns the streaming walk for the Browser. Mounts Browser
49
28
  * immediately with `files=[]` and pushes entries as the stream emits.
50
29
  *
51
30
  * Batching: `Stream.groupedWithin(64, 60ms)` coalesces bursts so we don't
@@ -92,24 +71,51 @@ export const resolveDiscoveryRoot = async ({
92
71
  return cwd
93
72
  }
94
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
+
95
101
  interface DiscoverShellProps {
96
102
  readonly target: string
103
+ readonly initialQuery: string
97
104
  /** Resolved discovery vocabulary from the config layer. The shift+a
98
105
  * toggle (#145) is session-only sugar that flips between this set
99
106
  * and the full vocabulary; the underlying categories remain
100
107
  * independent everywhere else. */
101
108
  readonly initialShow: readonly ShowCategory[]
102
- readonly sort: SortOrder
103
109
  readonly mdx: boolean
104
110
  readonly maxWidth: number | null
105
111
  readonly sidebarMode: SidebarMode
106
112
  readonly startupFocus: StartupFocus
107
113
  }
108
114
 
109
- const DiscoverShell = ({
115
+ export const DiscoverShell = ({
110
116
  target,
117
+ initialQuery,
111
118
  initialShow,
112
- sort,
113
119
  mdx,
114
120
  maxWidth,
115
121
  sidebarMode,
@@ -120,6 +126,8 @@ const DiscoverShell = ({
120
126
  const [files, setFiles] = useState<readonly FileEntry[]>([])
121
127
  const [scanning, setScanning] = useState<boolean>(true)
122
128
  const [scanError, setScanError] = useState<string | null>(null)
129
+ const [skippedDirCount, setSkippedDirCount] = useState<number>(0)
130
+ const [lastSkippedDir, setLastSkippedDir] = useState<string | null>(null)
123
131
  // Files arrive in a ref-tracked count so the status string can show
124
132
  // "indexing… N" even when React hasn't yet flushed the latest setFiles.
125
133
  const countRef = useRef(0)
@@ -132,8 +140,22 @@ const DiscoverShell = ({
132
140
  setFiles([])
133
141
  setScanning(true)
134
142
  setScanError(null)
143
+ setSkippedDirCount(0)
144
+ setLastSkippedDir(null)
135
145
  countRef.current = 0
136
- const program = walk(target, { show, sort, mdx }).pipe(
146
+ const warnedProgram = walk(target, {
147
+ show,
148
+ mdx,
149
+ onWarning: ({ path }) => {
150
+ const relativePath = relative(resolve(target), path)
151
+ setSkippedDirCount((prev) => prev + 1)
152
+ setLastSkippedDir(
153
+ relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath)
154
+ ? relativePath
155
+ : path,
156
+ )
157
+ },
158
+ }).pipe(
137
159
  Stream.groupedWithin(64, Duration.millis(60)),
138
160
  Stream.runForEach((chunk) =>
139
161
  Effect.sync(() => {
@@ -147,25 +169,33 @@ const DiscoverShell = ({
147
169
  onSuccess: () => Effect.sync(() => setScanning(false)),
148
170
  onFailure: (cause) =>
149
171
  Effect.sync(() => {
150
- // Interruption is the normal teardown path — don't surface it.
151
172
  if (Cause.hasInterrupts(cause)) return
152
- setScanError(`scan failed: ${Cause.pretty(cause)}`)
173
+ setScanError(formatFatalDiscoveryStatus())
153
174
  setScanning(false)
154
175
  }),
155
176
  }),
156
177
  )
157
- const fiber = Effect.runFork(program)
178
+ const fiber = Effect.runFork(warnedProgram)
158
179
  return () => {
159
180
  Effect.runFork(Fiber.interrupt(fiber))
160
181
  }
161
- }, [target, show, sort, mdx])
182
+ }, [target, show, mdx])
162
183
 
163
- const discoveryStatus = scanError ?? (scanning ? `indexing… ${countRef.current}` : null)
184
+ const discoveryStatus =
185
+ scanError ??
186
+ (scanning
187
+ ? `indexing… ${countRef.current}`
188
+ : formatPartialDiscoveryStatus({
189
+ skippedCount: skippedDirCount,
190
+ lastSkippedPath: lastSkippedDir,
191
+ }))
164
192
 
165
193
  return (
166
194
  <Browser
167
195
  files={files}
196
+ initialQuery={initialQuery}
168
197
  maxWidth={maxWidth}
198
+ emptyRootLabel={target}
169
199
  discoveryStatus={discoveryStatus}
170
200
  sidebarMode={sidebarMode}
171
201
  startupFocus={startupFocus}
@@ -183,79 +213,22 @@ const DiscoverShell = ({
183
213
  )
184
214
  }
185
215
 
186
- export const App = ({ content, title = "house", maxWidth = null, onQuit }: AppProps) => {
187
- const renderer = useRenderer()
188
- const { width, height } = useTerminalDimensions()
189
- const theme = useAtomValue(themeAtom)
190
- const setTheme = useAtomSet(themeAtom)
191
- const syntaxStyle = useMemo(() => SyntaxStyle.fromStyles(colors.syntax), [theme])
192
-
193
- const cycleTheme = (delta: 1 | -1) => {
194
- const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
195
- const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
196
- if (!next) return
197
- setActiveTheme(next, theme.tone)
198
- setTheme({ id: next.id, tone: theme.tone })
199
- }
200
-
201
- const toggleTone = () => {
202
- const nextTone = theme.tone === "dark" ? "light" : "dark"
203
- const def = getThemeDefinition(theme.id)
204
- if (def) setActiveTheme(def, nextTone)
205
- setTheme({ id: theme.id, tone: nextTone })
206
- }
207
-
208
- useKeyboard((key) => {
209
- if (key.name === "q" || (key.ctrl && key.name === "c")) {
210
- if (onQuit) {
211
- onQuit()
212
- return
213
- }
214
- renderer?.destroy()
215
- process.exit(0)
216
- }
217
- if (key.name === "t" && !key.shift) cycleTheme(1)
218
- if (key.name === "t" && key.shift) cycleTheme(-1)
219
- if (key.name === "l" && key.shift) toggleTone()
220
- })
221
-
222
- const paneBorderSides: BorderSides[] = ["top", "bottom"]
223
-
224
- return (
225
- <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
226
- <Header width={width} currentFile={title} />
227
- <box
228
- style={{
229
- border: paneBorderSides,
230
- borderColor: colors.border,
231
- padding: 1,
232
- flexGrow: 1,
233
- flexShrink: 1,
234
- backgroundColor: colors.background,
235
- }}
236
- >
237
- <scrollbox
238
- style={{
239
- scrollY: true,
240
- scrollX: false,
241
- flexGrow: 1,
242
- flexShrink: 1,
243
- backgroundColor: colors.background,
244
- }}
245
- focused
246
- >
247
- <markdown
248
- content={content}
249
- syntaxStyle={syntaxStyle}
250
- fg={colors.text}
251
- bg={colors.background}
252
- conceal
253
- style={{ width: maxWidth ?? "100%" }}
254
- />
255
- </scrollbox>
256
- </box>
257
- </box>
258
- )
216
+ export const resolveInitialQuery = ({
217
+ pathArg,
218
+ discoveryRoot,
219
+ cwd,
220
+ }: {
221
+ readonly pathArg: string | null
222
+ readonly discoveryRoot: string
223
+ readonly cwd: string
224
+ }): string => {
225
+ if (pathArg === null) return ""
226
+ const resolvedPath = resolve(cwd, pathArg)
227
+ const rel = relative(discoveryRoot, resolvedPath)
228
+ if (rel.length === 0) return ""
229
+ if (!rel.startsWith("..") && !isAbsolute(rel)) return rel
230
+ if (pathArg.startsWith("./")) return pathArg.slice(2)
231
+ return pathArg
259
232
  }
260
233
 
261
234
  let updateExitHookRegistered = false
@@ -331,10 +304,15 @@ if (import.meta.main) {
331
304
  }
332
305
 
333
306
  const cwd = process.cwd()
334
- const target = args.path ?? "."
335
307
  const discoveryRoot = await resolveDiscoveryRoot({ cliRoot: args.root, defaultRoot, cwd })
308
+ const initialQuery = resolveInitialQuery({ pathArg: args.path, discoveryRoot, cwd })
336
309
 
337
310
  if (args.serve) {
311
+ const target = args.path
312
+ if (target === null) {
313
+ console.error("house: --serve requires a file path")
314
+ process.exit(2)
315
+ }
338
316
  let stats: Awaited<ReturnType<typeof stat>>
339
317
  try {
340
318
  stats = await stat(target)
@@ -367,14 +345,6 @@ if (import.meta.main) {
367
345
  process.on("SIGTERM", shutdown)
368
346
  // Bun.serve keeps the event loop alive until stop().
369
347
  } else {
370
- let sort: SortOrder = "dirs-first"
371
- if (args.sort !== null) {
372
- if (args.sort !== "dirs-first" && args.sort !== "files-first") {
373
- console.error(`house: --sort must be "dirs-first" or "files-first", got "${args.sort}"`)
374
- process.exit(2)
375
- }
376
- sort = args.sort
377
- }
378
348
  let sidebarMode: SidebarMode = "auto"
379
349
  if (args.sidebar !== null) {
380
350
  if (args.sidebar !== "auto" && args.sidebar !== "on" && args.sidebar !== "off") {
@@ -392,13 +362,12 @@ if (import.meta.main) {
392
362
  }
393
363
  }
394
364
  await runTui({
395
- target,
396
365
  discoveryRoot,
366
+ initialQuery,
397
367
  themeId,
398
368
  tone,
399
369
  maxWidth,
400
370
  show,
401
- sort,
402
371
  mdx,
403
372
  sidebarMode,
404
373
  startupFocus,
@@ -408,13 +377,12 @@ if (import.meta.main) {
408
377
  }
409
378
 
410
379
  interface TuiBootOptions {
411
- readonly target: string
412
380
  readonly discoveryRoot: string
381
+ readonly initialQuery: string
413
382
  readonly themeId: string
414
383
  readonly tone: "dark" | "light"
415
384
  readonly maxWidth: number | null
416
385
  readonly show: readonly ShowCategory[]
417
- readonly sort: SortOrder
418
386
  readonly mdx: boolean
419
387
  readonly sidebarMode: SidebarMode
420
388
  readonly startupFocus: StartupFocus
@@ -424,26 +392,17 @@ interface TuiBootOptions {
424
392
  }
425
393
 
426
394
  async function runTui({
427
- target,
428
395
  discoveryRoot,
396
+ initialQuery,
429
397
  themeId,
430
398
  tone,
431
399
  maxWidth,
432
400
  show,
433
- sort,
434
401
  mdx,
435
402
  sidebarMode,
436
403
  startupFocus,
437
404
  updateCheck,
438
405
  }: TuiBootOptions): Promise<void> {
439
- let stats: Awaited<ReturnType<typeof stat>>
440
- try {
441
- stats = await stat(target)
442
- } catch (err) {
443
- console.error(`house: cannot access ${target}: ${String(err)}`)
444
- process.exit(1)
445
- }
446
-
447
406
  if (updateCheck) {
448
407
  // Fire the npm-registry probe in the background. Result lands in a
449
408
  // module singleton; the React tree picks it up via `useUpdateNotice`
@@ -462,40 +421,31 @@ async function runTui({
462
421
  }
463
422
  }
464
423
 
465
- const renderer = await createCliRenderer({ exitOnCtrlC: false })
424
+ const renderer = await createCliRenderer({
425
+ exitOnCtrlC: false,
426
+ useMouse: true,
427
+ enableMouseMovement: true,
428
+ screenMode: "alternate-screen",
429
+ })
430
+ renderer.useMouse = true
466
431
  const initialTheme: ThemeState = { id: themeId, tone }
467
-
468
- if (stats.isDirectory()) {
469
- createRoot(renderer).render(
470
- <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
471
- <DiscoverShell
472
- target={discoveryRoot}
473
- initialShow={show}
474
- sort={sort}
475
- mdx={mdx}
476
- maxWidth={maxWidth}
477
- sidebarMode={sidebarMode}
478
- startupFocus={startupFocus}
479
- />
480
- </RegistryProvider>,
481
- )
482
- } else {
483
- const content = await Effect.runPromise(
484
- readFileText(target).pipe(
485
- Effect.tapError((err) =>
486
- Effect.sync(() => {
487
- console.error(`house: cannot read ${err.path}: ${String(err.cause)}`)
488
- }),
489
- ),
490
- ),
491
- ).catch(() => {
492
- process.exit(1)
493
- })
494
- if (typeof content !== "string") process.exit(1)
495
- createRoot(renderer).render(
496
- <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
497
- <App content={content} title={target} maxWidth={maxWidth} />
498
- </RegistryProvider>,
499
- )
432
+ try {
433
+ await validateDiscoveryRoot(discoveryRoot)
434
+ } catch (err) {
435
+ console.error(`house: ${err instanceof Error ? err.message : String(err)}`)
436
+ process.exit(1)
500
437
  }
438
+ createRoot(renderer).render(
439
+ <RegistryProvider initialValues={[[themeAtom, initialTheme]]}>
440
+ <DiscoverShell
441
+ target={discoveryRoot}
442
+ initialQuery={initialQuery}
443
+ initialShow={show}
444
+ mdx={mdx}
445
+ maxWidth={maxWidth}
446
+ sidebarMode={sidebarMode}
447
+ startupFocus={startupFocus}
448
+ />
449
+ </RegistryProvider>,
450
+ )
501
451
  }
@@ -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"
@@ -18,7 +17,6 @@ export interface BrowserCtx {
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
 
@@ -280,6 +272,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
280
272
  hint: "html",
281
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
  {
@@ -289,6 +282,7 @@ export const browserBindings: readonly KeyBinding<BrowserCtx>[] = [
289
282
  hint: "edit",
290
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
+ }
@@ -22,7 +22,7 @@
22
22
  * and the most universally meaningful one when context shrinks.
23
23
  */
24
24
 
25
- export const SIDEBAR_ROW_SEPARATOR = " · "
25
+ export const SIDEBAR_ROW_SEPARATOR = " "
26
26
  const ELISION_PREFIX = "…/"
27
27
  const MIN_PARENT_BUDGET = 3
28
28
 
@@ -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
+ }
@@ -82,7 +82,7 @@ const HARD_FALLBACK: Readonly<Record<TokenName, HexColor>> = Object.fromEntries(
82
82
  * the theme omits them. Mirrors opencode's resolve behavior.
83
83
  */
84
84
  const TOKEN_FALLBACK: Partial<Record<TokenName, TokenName>> = {
85
- selectedListItemText: "background",
85
+ selectedListItemText: "text",
86
86
  markdownText: "text",
87
87
  markdownCodeBlock: "text",
88
88
  borderSubtle: "border",
@@ -25,7 +25,7 @@
25
25
  "textMuted": "darkFgMuted",
26
26
  "background": "darkBg",
27
27
  "backgroundPanel": "darkBgPanel",
28
- "backgroundElement": "darkBgPanel",
28
+ "backgroundElement": "darkBorder",
29
29
  "border": "darkBorder",
30
30
  "borderActive": "darkFgMuted",
31
31
  "borderSubtle": "darkBorder",