@carlesandres/house 0.3.1 → 0.4.1

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/Browser.tsx CHANGED
@@ -11,32 +11,59 @@
11
11
  */
12
12
 
13
13
  import { SyntaxStyle } from "@opentui/core"
14
+ import type { BorderSides } from "@opentui/core"
14
15
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
15
16
  import { useAtomValue, useAtomSet } from "@effect/atom-react"
16
17
  import { Effect } from "effect"
17
18
  import { useCallback, useEffect, useMemo, useRef, useState } from "react"
19
+ import { buildCommands } from "./commands/buildCommands.ts"
20
+ import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
21
+ import { CommandPalette } from "./CommandPalette.tsx"
18
22
  import { filterFiles } from "./discovery/filter.ts"
19
23
  import { type FileEntry } from "./discovery/walk.ts"
24
+ import { BRAND, BRAND_NAME } from "./brand.ts"
20
25
  import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
26
+ import { Header, HEADER_HEIGHT } from "./Header.tsx"
21
27
  import { HelpOverlay } from "./HelpOverlay.tsx"
22
28
  import { readFileText } from "./io/readFile.ts"
23
29
  import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
24
30
  import { dispatch } from "./keymap/keymap.ts"
31
+ import {
32
+ canFitInline,
33
+ defaultPreferredWidth,
34
+ initialShownForAuto,
35
+ resolveSidebarWidth,
36
+ } from "./layout/resolve.ts"
25
37
  import { openInBrowser } from "./serve/openBrowser.ts"
26
38
  import { startServer, type ServerHandle } from "./serve/server.ts"
27
39
  import { colors, setActiveTheme } from "./theme/colors.ts"
28
40
  import { themeAtom } from "./theme/atom.ts"
29
41
  import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
30
42
 
43
+ export type SidebarMode = "auto" | "on" | "off"
44
+
31
45
  export interface BrowserProps {
32
46
  readonly files: readonly FileEntry[]
33
- readonly title?: string
34
47
  readonly initialIndex?: number
35
48
  /** Cap the rendered markdown's width at N columns. Null = fill the pane. */
36
49
  readonly maxWidth?: number | null
50
+ /** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
51
+ * when discovery has finished; the indicator clears. */
52
+ readonly discoveryStatus?: string | null
53
+ /** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
54
+ * launch viewport bucket once; subsequent visibility goes through `s`. */
55
+ readonly sidebarMode?: SidebarMode
37
56
  readonly onQuit?: () => void
38
57
  /** Test seam: replaces the file reader. */
39
58
  readonly readFile?: (path: string) => Promise<string>
59
+ /** Optional one-shot footer toast surfaced on first appearance (e.g. the
60
+ * "update available" nudge). Shown with an extended TTL so the user has
61
+ * time to read it; subsequent transient toasts (theme cycle, etc.)
62
+ * preempt it via the same single-slot channel. Null disables. */
63
+ readonly updateNotice?: string | null
64
+ /** TTL (ms) for the update-notice toast. Exposed so tests can use a small
65
+ * value instead of sleeping for the production 10s window. */
66
+ readonly updateNoticeTtlMs?: number
40
67
  }
41
68
 
42
69
  const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
@@ -44,21 +71,27 @@ const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(rea
44
71
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
45
72
 
46
73
  /** Bindings the help overlay lets through. Single source of truth for both
47
- * the keyboard early-return and the footer hint filter. */
74
+ * the keyboard early-return and the footer hint filter. `palette.open`
75
+ * passes through so users can jump from help into the palette in one
76
+ * keystroke — `openPalette` closes help on its way in. */
48
77
  const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
49
78
  "help.toggle",
50
79
  "theme.next",
51
80
  "theme.prev",
52
81
  "theme.toneToggle",
82
+ "palette.open",
53
83
  ])
54
84
 
55
85
  export const Browser = ({
56
86
  files,
57
- title = "house",
58
87
  initialIndex = 0,
59
88
  maxWidth = null,
89
+ discoveryStatus = null,
90
+ sidebarMode = "auto",
60
91
  onQuit,
61
92
  readFile = defaultReadFile,
93
+ updateNotice = null,
94
+ updateNoticeTtlMs = 10000,
62
95
  }: BrowserProps) => {
63
96
  const renderer = useRenderer()
64
97
  const { width, height } = useTerminalDimensions()
@@ -71,19 +104,52 @@ export const Browser = ({
71
104
  )
72
105
  const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
73
106
  const [error, setError] = useState<string | null>(null)
74
- const [focus, setFocus] = useState<"sidebar" | "reader">("sidebar")
75
- const [sidebarVisible, setSidebarVisible] = useState<boolean>(true)
107
+ // `shown` is the user's sticky preference. Visibility is derived:
108
+ // `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
109
+ //
110
+ // Launch consults the viewport bucket once for `--sidebar=auto`. The
111
+ // useState initializer pins this to the first render — buckets are
112
+ // launch-only by design, so resize must NOT re-evaluate.
113
+ const [shown, setShown] = useState<boolean>(() => {
114
+ switch (sidebarMode) {
115
+ case "on":
116
+ return true
117
+ case "off":
118
+ return false
119
+ case "auto":
120
+ return initialShownForAuto(width)
121
+ }
122
+ })
123
+ const [focus, setFocus] = useState<"sidebar" | "reader">(() => (shown ? "sidebar" : "reader"))
76
124
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
77
125
  const [helpVisible, setHelpVisible] = useState<boolean>(false)
78
126
  const [filterOpen, setFilterOpen] = useState<boolean>(false)
79
127
  const [filterQuery, setFilterQuery] = useState<string>("")
128
+ const [paletteOpen, setPaletteOpen] = useState<boolean>(false)
129
+ const [paletteQuery, setPaletteQuery] = useState<string>("")
130
+ const [paletteIndex, setPaletteIndex] = useState<number>(0)
131
+ // Synchronous mirrors for the keyboard handler — same reason filterOpenRef
132
+ // exists. Modal input can arrive in one React batch (e.g. ctrl+p, Down,
133
+ // Return), so every palette field read by later keys must update its ref
134
+ // before React state commits.
135
+ const paletteOpenRef = useRef(false)
136
+ const paletteQueryRef = useRef("")
137
+ const paletteIndexRef = useRef(0)
80
138
  // Mirror filter state into refs so the keyboard handler sees synchronous
81
139
  // updates even when multiple keys arrive in a single React batch (the
82
140
  // first key opens the filter; subsequent keys in the same tick would
83
141
  // otherwise still observe filterOpen=false through closure).
84
142
  const filterOpenRef = useRef(false)
85
143
  const filterQueryRef = useRef("")
86
- const [footerNotice, setFooterNotice] = useState<string | null>(null)
144
+ // Snapshot the query at filter-open so Esc reverts edits but commit (Return)
145
+ // keeps them.
146
+ const priorFilterQueryRef = useRef("")
147
+ const [footerNotice, setFooterNoticeState] = useState<{
148
+ readonly text: string
149
+ readonly ttlMs: number
150
+ } | null>(null)
151
+ const pushFooterNotice = (text: string, ttlMs = 2000): void =>
152
+ setFooterNoticeState({ text, ttlMs })
87
153
  const serverRef = useRef<ServerHandle | null>(null)
88
154
 
89
155
  // Stop the preview server on unmount so re-mounts (tests) and clean
@@ -95,21 +161,36 @@ export const Browser = ({
95
161
  }
96
162
  }, [])
97
163
 
98
- // Single-slot notice with a 2s TTL. A new notice cancels the pending
99
- // timer so the latest message gets its own full window.
164
+ // Single-slot notice with a per-message TTL. A new notice cancels the
165
+ // pending timer so the latest message gets its own full window. The TTL
166
+ // travels with the message so a long-lived nudge (update available) and a
167
+ // transient toast (theme cycle) can share one slot without one stealing
168
+ // the other's display window.
100
169
  useEffect(() => {
101
170
  if (footerNotice === null) return
102
- const timer = setTimeout(() => setFooterNotice(null), 2000)
171
+ const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
103
172
  return () => clearTimeout(timer)
104
173
  }, [footerNotice])
105
174
 
175
+ // Push the update-available nudge once, when it arrives from the parent
176
+ // (the registry probe resolves asynchronously after boot). 10s gives the
177
+ // user time to read it before it auto-clears; the quit-time stderr print
178
+ // is the durable record they can copy from scrollback.
179
+ const updateNoticeSeenRef = useRef<string | null>(null)
180
+ useEffect(() => {
181
+ if (!updateNotice) return
182
+ if (updateNoticeSeenRef.current === updateNotice) return
183
+ updateNoticeSeenRef.current = updateNotice
184
+ pushFooterNotice(updateNotice, updateNoticeTtlMs)
185
+ }, [updateNotice, updateNoticeTtlMs])
186
+
106
187
  const cycleTheme = (delta: 1 | -1) => {
107
188
  const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
108
189
  const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
109
190
  if (!next) return
110
191
  setActiveTheme(next, theme.tone)
111
192
  setTheme({ id: next.id, tone: theme.tone })
112
- setFooterNotice(`theme: ${next.name}`)
193
+ pushFooterNotice(`theme: ${next.name}`)
113
194
  }
114
195
 
115
196
  const toggleTone = () => {
@@ -117,7 +198,7 @@ export const Browser = ({
117
198
  const def = getThemeDefinition(theme.id)
118
199
  if (def) setActiveTheme(def, nextTone)
119
200
  setTheme({ id: theme.id, tone: nextTone })
120
- setFooterNotice(`tone: ${nextTone}`)
201
+ pushFooterNotice(`tone: ${nextTone}`)
121
202
  }
122
203
 
123
204
  const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
@@ -181,17 +262,60 @@ export const Browser = ({
181
262
  const ctx: BrowserCtx = {
182
263
  files: displayedFiles,
183
264
  focus,
184
- sidebarVisible,
265
+ sidebarShown: shown,
185
266
  helpVisible,
186
267
  filterOpen,
268
+ paletteOpen,
187
269
  setFocus,
188
270
  setSelectedIndex,
189
- setSidebarVisible,
271
+ toggleShown: () => {
272
+ // Two layout shapes, two behaviors:
273
+ // wide → flip the sticky `shown` preference. Per DESIGN.md §7.1
274
+ // s-behavior table: also nudge focus so the visibility
275
+ // rule (`visible = shown || focus==="sidebar"`) reflects
276
+ // the user's intent instead of forcing the sidebar back
277
+ // on via focus.
278
+ // narrow → swap which screen is up (focus is the source of truth
279
+ // for render). Sync `shown` to the new screen so a later
280
+ // resize to wide opens with the right pane visible.
281
+ if (!canFitInline(width)) {
282
+ const next = focus === "sidebar" ? "reader" : "sidebar"
283
+ setFocus(next)
284
+ setShown(next === "sidebar")
285
+ return
286
+ }
287
+ if (shown) {
288
+ setShown(false)
289
+ if (focus === "sidebar") setFocus("reader")
290
+ } else {
291
+ setShown(true)
292
+ if (focus === "reader") setFocus("sidebar")
293
+ }
294
+ },
190
295
  setHelpVisible,
191
296
  openFilter: () => {
297
+ // Focus the sidebar so the filter input has a home. In wide,
298
+ // §7.1's visibility rule (`shown || focus === "sidebar"`) brings
299
+ // the inline sidebar back on screen if it was hidden. In narrow,
300
+ // focusing the sidebar swaps to the sidebar screen. Either way
301
+ // no need to mutate `shown`.
302
+ priorFilterQueryRef.current = filterQueryRef.current
303
+ if (focus !== "sidebar") setFocus("sidebar")
192
304
  filterOpenRef.current = true
193
305
  setFilterOpen(true)
194
306
  },
307
+ openPalette: () => {
308
+ // Close help if it was open — palette is the active modal now.
309
+ // Reset query/index so each open starts fresh (no stale state from
310
+ // the previous session).
311
+ if (helpVisible) setHelpVisible(() => false)
312
+ paletteQueryRef.current = ""
313
+ setPaletteQuery("")
314
+ paletteIndexRef.current = 0
315
+ setPaletteIndex(0)
316
+ paletteOpenRef.current = true
317
+ setPaletteOpen(true)
318
+ },
195
319
  cycleTheme,
196
320
  toggleTone,
197
321
  serveCurrent: () => {
@@ -203,9 +327,9 @@ export const Browser = ({
203
327
  handle = startServer({ path: file.path })
204
328
  serverRef.current = handle
205
329
  openInBrowser(handle.url)
206
- setFooterNotice(`serving at ${handle.url}`)
330
+ pushFooterNotice(`serving at ${handle.url}`)
207
331
  } catch (err) {
208
- setFooterNotice(`serve failed: ${String(err)}`)
332
+ pushFooterNotice(`serve failed: ${String(err)}`)
209
333
  }
210
334
  return
211
335
  }
@@ -217,7 +341,7 @@ export const Browser = ({
217
341
  // an existing tab on the same URL when one is open, so this is
218
342
  // idempotent for the common case.
219
343
  openInBrowser(handle.url)
220
- setFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
344
+ pushFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
221
345
  },
222
346
  quit: () => {
223
347
  if (onQuit) {
@@ -249,17 +373,48 @@ export const Browser = ({
249
373
  //
250
374
  // Centralized so the dual filterOpenRef / filterOpen invariant
251
375
  // only has to be maintained in one place (plus `openFilter`).
252
- const closeFilter = (focusReader: boolean) => {
376
+ const closeFilter = (commit: boolean) => {
253
377
  const picked = displayedFiles[selectedIndex] ?? null
378
+ // Return on a zero-match list has nothing to commit. Treat it
379
+ // as Esc so the user isn't stranded in an "applied filter with
380
+ // no visible files" state they'd have to back out of manually.
381
+ const effectiveCommit = commit && picked !== null
254
382
  filterOpenRef.current = false
255
- filterQueryRef.current = ""
256
383
  setFilterOpen(false)
257
- setFilterQuery("")
258
- if (picked) {
259
- const fullIdx = files.findIndex((f) => f.path === picked.path)
260
- if (fullIdx >= 0) setSelectedIndex(() => fullIdx)
384
+ if (effectiveCommit) {
385
+ // Return keeps the query. selectedIndex is already a valid
386
+ // position in the (still-filtered) displayedFiles list, so
387
+ // no translation is needed.
388
+ } else {
389
+ // Esc reverts the query to its pre-session value. After the
390
+ // revert, displayedFiles may change shape — translate the
391
+ // cursor by path so it stays on whatever the user was
392
+ // looking at, instead of snapping to a numerically-equivalent
393
+ // row in the restored list.
394
+ const before = priorFilterQueryRef.current
395
+ filterQueryRef.current = before
396
+ setFilterQuery(before)
397
+ if (picked) {
398
+ const restored = before === "" ? files : filterFiles(files, before)
399
+ const idx = restored.findIndex((f) => f.path === picked.path)
400
+ if (idx >= 0) setSelectedIndex(() => idx)
401
+ }
402
+ }
403
+ // Where focus lands after the filter closes:
404
+ // commit (Return on a real pick) → reader, always. The user
405
+ // asked to open the match; show them what they picked.
406
+ // cancel (Esc, or Return with no pick) → restore the user's
407
+ // pre-filter intent. If the sidebar was up before the
408
+ // filter opened (shown=true), stay in it so j/k keeps
409
+ // walking. If the sidebar was hidden (shown=false), go
410
+ // back to the reader so the sidebar dismisses — in narrow
411
+ // that swaps screens; in wide that drops the focus-driven
412
+ // sidebar revival.
413
+ if (effectiveCommit) {
414
+ setFocus("reader")
415
+ } else {
416
+ setFocus(shown ? "sidebar" : "reader")
261
417
  }
262
- if (focusReader && picked) setFocus("reader")
263
418
  }
264
419
  if (key.name === "escape") {
265
420
  closeFilter(false)
@@ -269,7 +424,15 @@ export const Browser = ({
269
424
  closeFilter(true)
270
425
  return
271
426
  }
272
- if (key.name === "backspace") {
427
+ if (key.name === "backspace" || key.name === "delete") {
428
+ // Pressing backspace/delete with no query left removes the
429
+ // leading `/` — i.e. closes the modal. Equivalent to Esc:
430
+ // reverts to the pre-session query (so an applied filter
431
+ // survives a "I changed my mind" tap).
432
+ if (filterQueryRef.current.length === 0) {
433
+ closeFilter(false)
434
+ return
435
+ }
273
436
  filterQueryRef.current = filterQueryRef.current.slice(0, -1)
274
437
  setFilterQuery(filterQueryRef.current)
275
438
  setSelectedIndex(() => 0)
@@ -296,6 +459,74 @@ export const Browser = ({
296
459
  }
297
460
  return
298
461
  }
462
+ // Command palette modal: capture keystrokes for the query input and
463
+ // list navigation. Esc closes (single press, regardless of query —
464
+ // #70 Q7a). Return runs the selected command. Up/Down navigate.
465
+ // Backspace edits the query and is a no-op on empty (#70 Q7b —
466
+ // intentionally diverges from the filter modal, which closes on
467
+ // empty-backspace, because accidental close feels worse in the
468
+ // palette). Printable characters extend the query and snap selection
469
+ // to 0 (#70 Q7c). Ctrl/Meta-modified keys are swallowed except
470
+ // ctrl+p, which toggles the palette closed (matches help-toggle's
471
+ // re-press-to-close behavior).
472
+ if (paletteOpenRef.current) {
473
+ const closePalette = () => {
474
+ paletteOpenRef.current = false
475
+ paletteQueryRef.current = ""
476
+ paletteIndexRef.current = 0
477
+ setPaletteOpen(false)
478
+ setPaletteQuery("")
479
+ setPaletteIndex(0)
480
+ }
481
+ const setPaletteIndexSync = (next: number) => {
482
+ paletteIndexRef.current = next
483
+ setPaletteIndex(next)
484
+ }
485
+ const allCommands = buildCommands(ctx)
486
+ const filtered = filterCommands(allCommands, paletteQueryRef.current)
487
+ if (key.name === "escape") {
488
+ closePalette()
489
+ return
490
+ }
491
+ if (key.name === "return") {
492
+ const picked = filtered[clampSelectedIndex(paletteIndexRef.current, filtered)]
493
+ closePalette()
494
+ picked?.run()
495
+ return
496
+ }
497
+ if (key.name === "up") {
498
+ setPaletteIndexSync(Math.max(0, paletteIndexRef.current - 1))
499
+ return
500
+ }
501
+ if (key.name === "down") {
502
+ setPaletteIndexSync(Math.min(Math.max(0, filtered.length - 1), paletteIndexRef.current + 1))
503
+ return
504
+ }
505
+ if (key.name === "backspace" || key.name === "delete") {
506
+ if (paletteQueryRef.current.length === 0) return
507
+ paletteQueryRef.current = paletteQueryRef.current.slice(0, -1)
508
+ setPaletteQuery(paletteQueryRef.current)
509
+ setPaletteIndexSync(0)
510
+ return
511
+ }
512
+ // ctrl+p again closes — matches help-toggle behavior.
513
+ if (key.ctrl && !key.meta && key.name === "p") {
514
+ closePalette()
515
+ return
516
+ }
517
+ if (key.ctrl || key.meta) return
518
+ let char: string | null = null
519
+ if (key.name === "space") char = " "
520
+ else if (typeof key.name === "string" && key.name.length === 1) {
521
+ char = key.shift ? key.name.toUpperCase() : key.name
522
+ }
523
+ if (char !== null) {
524
+ paletteQueryRef.current = paletteQueryRef.current + char
525
+ setPaletteQuery(paletteQueryRef.current)
526
+ setPaletteIndexSync(0)
527
+ }
528
+ return
529
+ }
299
530
  // While help is open, swallow most keys: only ? (toggle), esc
300
531
  // (close), and the theme bindings pass through. Theme keys stay live
301
532
  // so users can preview palette changes against the overlay itself —
@@ -320,23 +551,40 @@ export const Browser = ({
320
551
  dispatch(browserBindings, ctx, key)
321
552
  })
322
553
 
323
- // Min/max-clamped percentage of viewport: narrow terminals stay readable,
324
- // wide terminals get more room without wasting space at extremes.
325
- // User-configurable width is deferred — see DESIGN.md §12.
326
- const sidebarWidth = Math.max(28, Math.min(60, Math.floor(width * 0.25)))
554
+ // Sidebar width is a pure function of viewport (DESIGN.md §7.1). Until
555
+ // persistent config (#13) lands, `preferred` is derived from viewport,
556
+ // matching the pre-#22 inline math.
557
+ const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
327
558
  const sidebarActive = focus === "sidebar"
328
559
  const readerActive = focus === "reader"
329
- const sidebarTitle = sidebarActive ? " files " : " files "
330
- const readerLabel = selected?.relativePath ?? title
331
- const readerTitle = readerActive ? ` ${readerLabel} ` : ` ${readerLabel} `
560
+ // Wide vs narrow drives the entire layout shape.
561
+ // wide → inline two-pane (today). visible = shown || focus==="sidebar".
562
+ // narrow single-pane stack: whichever pane has focus fills the area.
563
+ // `shown` is silently ignored for render in narrow; `focus`
564
+ // is the single source of truth.
565
+ // See DESIGN.md §7.1.
566
+ const isNarrow = !canFitInline(width)
567
+ const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
568
+ const readerVisible = isNarrow ? readerActive : true
569
+ // Currently-selected file shown in the Header (which replaced the
570
+ // per-pane border title that used to carry this information).
571
+ const currentFile = selected?.relativePath ?? null
332
572
  const content = loaded?.path === renderedPath ? loaded.content : ""
333
573
 
334
574
  // Sidebar virtualization: render only the visible window. Without this,
335
575
  // every keystroke re-renders all N file rows even though only the bg of
336
576
  // two of them changed (old + new selected). On a 195-file vault that
337
577
  // dominates the per-keystroke cost.
338
- // Sidebar box adds top/bottom borders (2); footer eats FOOTER_HEIGHT.
339
- const sidebarBodyHeight = Math.max(1, height - 2 - FOOTER_HEIGHT)
578
+ // Chrome budget: header + pane top border + pane bottom border + footer.
579
+ // The filter row eats one more cell when files are present *or* while
580
+ // discovery is in flight (allocates the row up front so it doesn't pop
581
+ // in when the first file arrives).
582
+ const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
583
+ const filterRowVisible = files.length > 0 || discoveryActive
584
+ const sidebarBodyHeight = Math.max(
585
+ 1,
586
+ height - FOOTER_HEIGHT - HEADER_HEIGHT - 2 - (filterRowVisible ? 1 : 0),
587
+ )
340
588
  const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
341
589
  const desiredScroll = (() => {
342
590
  let s = sidebarScroll
@@ -348,8 +596,12 @@ export const Browser = ({
348
596
  if (desiredScroll !== sidebarScroll) setSidebarScroll(desiredScroll)
349
597
  }, [desiredScroll, sidebarScroll])
350
598
  const visibleFiles = displayedFiles.slice(desiredScroll, desiredScroll + sidebarBodyHeight)
351
- // Available width for sidebar text rows: box width minus 1-cell border on each side.
352
- const sidebarTextWidth = Math.max(4, sidebarWidth - 2)
599
+ // Available width for sidebar text rows. Wide-inline: sidebarWidth minus
600
+ // 1-cell left padding and 1-cell right divider border. Narrow-stack: the
601
+ // sidebar flex-grows to fill the viewport with no right divider, so the
602
+ // budget is the viewport minus the 1-cell left padding only.
603
+ const sidebarPaneWidth = isNarrow ? width : sidebarWidth
604
+ const sidebarTextWidth = Math.max(4, sidebarPaneWidth - (isNarrow ? 1 : 2))
353
605
  // Right-anchored truncation: keep the filename visible, lose the prefix
354
606
  // with a leading ellipsis when the path is too long.
355
607
  const truncatePath = useCallback(
@@ -358,6 +610,30 @@ export const Browser = ({
358
610
  [sidebarTextWidth],
359
611
  )
360
612
 
613
+ // Filter row content + color. Three reachable states:
614
+ // editing — filterOpen=true → /<query>▏ in textStrong
615
+ // applied — !filterOpen && query !== "" → /<query> in text
616
+ // idle — !filterOpen && query === "" → "/ filter…" in textMuted
617
+ const filterRowFg = filterOpen
618
+ ? colors.textStrong
619
+ : filterQuery.length > 0
620
+ ? colors.text
621
+ : colors.textMuted
622
+ const filterRowRaw = filterOpen
623
+ ? `/${filterQuery}▏`
624
+ : filterQuery.length > 0
625
+ ? `/${filterQuery}`
626
+ : "/ filter…"
627
+ // Editing keeps the cursor visible — anchor the right edge with a leading
628
+ // ellipsis when the query overflows. Applied/idle anchor the left edge
629
+ // (lose the tail) so the leading `/` always reads as a filter marker.
630
+ const filterRowContent =
631
+ filterRowRaw.length <= sidebarTextWidth
632
+ ? filterRowRaw
633
+ : filterOpen
634
+ ? "…" + filterRowRaw.slice(filterRowRaw.length - sidebarTextWidth + 1)
635
+ : filterRowRaw.slice(0, sidebarTextWidth - 1) + "…"
636
+
361
637
  // While help is open, the `?` key closes the overlay — relabel its hint
362
638
  // so the footer accurately describes what pressing the key will do.
363
639
  // Memoized: `helpVisible` changes rarely; `browserBindings` and
@@ -372,110 +648,207 @@ export const Browser = ({
372
648
  [helpVisible],
373
649
  )
374
650
 
651
+ // One sidebar body for both wide-inline and narrow-stack rendering; only
652
+ // the wrapper differs (fixed-width sibling vs flex-grow full-pane).
653
+ const sidebarBody = (
654
+ <>
655
+ {filterRowVisible && (
656
+ <text content={filterRowContent} wrapMode="none" style={{ fg: filterRowFg }} />
657
+ )}
658
+ {displayedFiles.length === 0 ? (
659
+ <text
660
+ content={
661
+ files.length === 0
662
+ ? discoveryActive
663
+ ? "(scanning…)"
664
+ : "(no markdown files)"
665
+ : "(no matches)"
666
+ }
667
+ style={{ fg: colors.textMuted }}
668
+ />
669
+ ) : (
670
+ visibleFiles.map((file, idx) => {
671
+ const realIdx = desiredScroll + idx
672
+ const isSelected = realIdx === selectedIndex
673
+ const display = truncatePath(file.relativePath)
674
+ if (!isSelected) {
675
+ return (
676
+ <text key={file.path} content={display} wrapMode="none" style={{ fg: colors.text }} />
677
+ )
678
+ }
679
+ const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
680
+ return (
681
+ <text
682
+ key={file.path}
683
+ content={display}
684
+ wrapMode="none"
685
+ style={{ fg: colors.textStrong, bg }}
686
+ />
687
+ )
688
+ })
689
+ )}
690
+ </>
691
+ )
692
+
693
+ // Pane borders draw a connected frame: each pane's top/bottom edges
694
+ // (the horizontal rules) and the sidebar's right edge (the vertical
695
+ // divider) are rendered by opentui in one pass, so the junctions
696
+ // never get painted over by sibling elements. customBorderChars on
697
+ // the sidebar turns its right-side corners from `┐ ┘` into `┬ ┴` so
698
+ // they connect cleanly with the reader's top/bottom rules.
699
+ const sidebarBorderSides: BorderSides[] = ["top", "bottom", "right"]
700
+ const readerBorderSides: BorderSides[] = ["top", "bottom"]
701
+ const SIDEBAR_BORDER_CHARS = {
702
+ topLeft: "┌",
703
+ topRight: "┬",
704
+ bottomLeft: "└",
705
+ bottomRight: "┴",
706
+ horizontal: "─",
707
+ vertical: "│",
708
+ topT: "┬",
709
+ bottomT: "┴",
710
+ leftT: "├",
711
+ rightT: "┤",
712
+ cross: "┼",
713
+ } as const
714
+
375
715
  return (
376
- <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
716
+ <box style={{ width, height, flexDirection: "column", backgroundColor: colors.surface }}>
717
+ <Header width={width} currentFile={currentFile} />
377
718
  <box
378
719
  style={{
379
720
  flexDirection: "row",
380
721
  flexGrow: 1,
381
722
  flexShrink: 1,
382
- backgroundColor: colors.background,
723
+ backgroundColor: colors.surface,
383
724
  }}
384
725
  >
385
- {sidebarVisible && (
726
+ {sidebarInline && (
386
727
  <box
387
- title={sidebarTitle}
388
- titleAlignment="left"
389
728
  style={{
390
- border: true,
391
- borderColor: sidebarActive ? colors.borderActive : colors.border,
392
- width: sidebarWidth,
393
- flexShrink: 0,
729
+ // Narrow mode runs single-pane: the sidebar fills the area
730
+ // and drops its right divider (no neighbour to abut).
731
+ border: isNarrow ? readerBorderSides : sidebarBorderSides,
732
+ borderColor: colors.textMuted,
733
+ ...(isNarrow
734
+ ? { flexGrow: 1, flexShrink: 1 }
735
+ : { width: sidebarWidth, flexShrink: 0 }),
394
736
  flexDirection: "column",
737
+ // Dim by default. Borders/separators ride on this so they read
738
+ // as a single connected frame regardless of focus; only the
739
+ // active pane's inner body overrides to the raised tint below.
395
740
  backgroundColor: colors.surface,
396
741
  }}
742
+ {...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
397
743
  >
398
- {displayedFiles.length === 0 ? (
399
- <text
400
- content={files.length === 0 ? "(no markdown files)" : "(no matches)"}
401
- style={{ fg: colors.textMuted }}
402
- />
403
- ) : (
404
- visibleFiles.map((file, idx) => {
405
- const realIdx = desiredScroll + idx
406
- const isSelected = realIdx === selectedIndex
407
- const display = truncatePath(file.relativePath)
408
- if (!isSelected) {
409
- return (
410
- <text
411
- key={file.path}
412
- content={display}
413
- wrapMode="none"
414
- style={{ fg: colors.text }}
415
- />
416
- )
417
- }
418
- const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
419
- return (
420
- <text
421
- key={file.path}
422
- content={display}
423
- wrapMode="none"
424
- style={{ fg: colors.textStrong, bg }}
425
- />
426
- )
427
- })
428
- )}
744
+ <box
745
+ style={{
746
+ flexGrow: 1,
747
+ flexShrink: 1,
748
+ flexDirection: "column",
749
+ paddingLeft: 1,
750
+ backgroundColor: sidebarActive ? colors.background : colors.surface,
751
+ }}
752
+ >
753
+ {sidebarBody}
754
+ </box>
429
755
  </box>
430
756
  )}
431
- <box
432
- title={readerTitle}
433
- titleAlignment="left"
434
- style={{
435
- border: true,
436
- borderColor: readerActive ? colors.borderActive : colors.border,
437
- padding: 1,
438
- flexGrow: 1,
439
- flexShrink: 1,
440
- backgroundColor: colors.background,
441
- }}
442
- >
443
- {error ? (
444
- <text content={error} style={{ fg: colors.error }} />
445
- ) : (
446
- <scrollbox
757
+ {readerVisible && (
758
+ <box
759
+ style={{
760
+ border: readerBorderSides,
761
+ borderColor: colors.textMuted,
762
+ flexGrow: 1,
763
+ flexShrink: 1,
764
+ flexDirection: "column",
765
+ // Dim by default (see sidebar note); inner body overrides when active.
766
+ backgroundColor: colors.surface,
767
+ }}
768
+ >
769
+ <box
447
770
  style={{
448
- scrollY: true,
449
- scrollX: false,
450
771
  flexGrow: 1,
451
772
  flexShrink: 1,
452
- backgroundColor: colors.background,
773
+ flexDirection: "column",
774
+ padding: 1,
775
+ backgroundColor: readerActive ? colors.background : colors.surface,
453
776
  }}
454
- focused={readerActive}
455
777
  >
456
- <markdown
457
- key={renderedPath ?? "empty"}
458
- content={content}
459
- syntaxStyle={syntaxStyle}
460
- fg={colors.text}
461
- bg={colors.background}
462
- conceal
463
- style={{ width: maxWidth ?? "100%" }}
464
- />
465
- </scrollbox>
466
- )}
467
- </box>
778
+ {error ? (
779
+ <text content={error} style={{ fg: colors.error }} />
780
+ ) : !renderedPath ? (
781
+ // Reader empty state — no file selected. Brand mark centered as a
782
+ // welcome anchor; in-app tips (#47) will live here too.
783
+ <box
784
+ style={{
785
+ flexGrow: 1,
786
+ flexShrink: 1,
787
+ alignItems: "center",
788
+ justifyContent: "center",
789
+ backgroundColor: readerActive ? colors.background : colors.surface,
790
+ }}
791
+ >
792
+ <text
793
+ content={`${BRAND} ${BRAND_NAME}`}
794
+ wrapMode="none"
795
+ style={{ fg: colors.textMuted }}
796
+ />
797
+ </box>
798
+ ) : (
799
+ <scrollbox
800
+ style={{
801
+ scrollY: true,
802
+ scrollX: false,
803
+ flexGrow: 1,
804
+ flexShrink: 1,
805
+ backgroundColor: readerActive ? colors.background : colors.surface,
806
+ }}
807
+ // opentui's scrollbox consumes arrow keys at the focused-element
808
+ // level *before* useKeyboard fires, so a modal that handles
809
+ // arrow keys itself (palette nav, help dismissal) would still
810
+ // see the reader scroll alongside its own action. Unfocus the
811
+ // scrollbox while any blocking modal is up — useKeyboard's
812
+ // modal branches own the keys in that state. Filter is not
813
+ // listed because it force-focuses the sidebar (readerActive
814
+ // is already false).
815
+ focused={readerActive && !paletteOpen && !helpVisible}
816
+ >
817
+ <markdown
818
+ key={renderedPath ?? "empty"}
819
+ content={content}
820
+ syntaxStyle={syntaxStyle}
821
+ fg={colors.text}
822
+ bg={readerActive ? colors.background : colors.surface}
823
+ conceal
824
+ style={{ width: maxWidth ?? "100%" }}
825
+ />
826
+ </scrollbox>
827
+ )}
828
+ </box>
829
+ </box>
830
+ )}
468
831
  </box>
469
832
  <Footer
470
833
  bindings={footerBindings}
471
834
  ctx={ctx}
472
835
  width={width}
473
- notice={footerNotice}
474
- filter={filterOpen ? { query: filterQuery } : null}
836
+ notice={footerNotice?.text ?? null}
837
+ discoveryStatus={discoveryStatus}
838
+ filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
475
839
  />
476
840
  {helpVisible && (
477
841
  <HelpOverlay bindings={browserBindings} viewportWidth={width} viewportHeight={height} />
478
842
  )}
843
+ {paletteOpen && (
844
+ <CommandPalette
845
+ commands={filterCommands(buildCommands(ctx), paletteQuery)}
846
+ query={paletteQuery}
847
+ selectedIndex={paletteIndex}
848
+ viewportWidth={width}
849
+ viewportHeight={height}
850
+ />
851
+ )}
479
852
  </box>
480
853
  )
481
854
  }