@carlesandres/house 0.4.0 → 0.4.2

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,6 +11,7 @@
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"
@@ -20,8 +21,11 @@ import { clampSelectedIndex, filterCommands } from "./commands/score.ts"
20
21
  import { CommandPalette } from "./CommandPalette.tsx"
21
22
  import { filterFiles } from "./discovery/filter.ts"
22
23
  import { type FileEntry } from "./discovery/walk.ts"
24
+ import { BRAND, BRAND_NAME } from "./brand.ts"
23
25
  import { Footer, FOOTER_HEIGHT } from "./Footer.tsx"
26
+ import { Header, HEADER_HEIGHT } from "./Header.tsx"
24
27
  import { HelpOverlay } from "./HelpOverlay.tsx"
28
+ import { openInEditor, resolveEditor } from "./io/editor.ts"
25
29
  import { readFileText } from "./io/readFile.ts"
26
30
  import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
27
31
  import { dispatch } from "./keymap/keymap.ts"
@@ -31,6 +35,8 @@ import {
31
35
  initialShownForAuto,
32
36
  resolveSidebarWidth,
33
37
  } from "./layout/resolve.ts"
38
+ import { formatSidebarRow } from "./layout/sidebarRow.ts"
39
+ import { PromptRow } from "./PromptRow.tsx"
34
40
  import { openInBrowser } from "./serve/openBrowser.ts"
35
41
  import { startServer, type ServerHandle } from "./serve/server.ts"
36
42
  import { colors, setActiveTheme } from "./theme/colors.ts"
@@ -41,7 +47,6 @@ export type SidebarMode = "auto" | "on" | "off"
41
47
 
42
48
  export interface BrowserProps {
43
49
  readonly files: readonly FileEntry[]
44
- readonly title?: string
45
50
  readonly initialIndex?: number
46
51
  /** Cap the rendered markdown's width at N columns. Null = fill the pane. */
47
52
  readonly maxWidth?: number | null
@@ -54,6 +59,14 @@ export interface BrowserProps {
54
59
  readonly onQuit?: () => void
55
60
  /** Test seam: replaces the file reader. */
56
61
  readonly readFile?: (path: string) => Promise<string>
62
+ /** Optional one-shot footer toast surfaced on first appearance (e.g. the
63
+ * "update available" nudge). Shown with an extended TTL so the user has
64
+ * time to read it; subsequent transient toasts (theme cycle, etc.)
65
+ * preempt it via the same single-slot channel. Null disables. */
66
+ readonly updateNotice?: string | null
67
+ /** TTL (ms) for the update-notice toast. Exposed so tests can use a small
68
+ * value instead of sleeping for the production 10s window. */
69
+ readonly updateNoticeTtlMs?: number
57
70
  }
58
71
 
59
72
  const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
@@ -74,13 +87,14 @@ const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
74
87
 
75
88
  export const Browser = ({
76
89
  files,
77
- title = "house",
78
90
  initialIndex = 0,
79
91
  maxWidth = null,
80
92
  discoveryStatus = null,
81
93
  sidebarMode = "auto",
82
94
  onQuit,
83
95
  readFile = defaultReadFile,
96
+ updateNotice = null,
97
+ updateNoticeTtlMs = 10000,
84
98
  }: BrowserProps) => {
85
99
  const renderer = useRenderer()
86
100
  const { width, height } = useTerminalDimensions()
@@ -130,11 +144,12 @@ export const Browser = ({
130
144
  // otherwise still observe filterOpen=false through closure).
131
145
  const filterOpenRef = useRef(false)
132
146
  const filterQueryRef = useRef("")
133
- // Snapshot the query at filter-open so Esc reverts edits but commit (Return)
134
- // keeps them. Layout snapshots are no longer needed — focus drives drawer
135
- // dismissal under the §7.1 visibility rule.
136
- const priorFilterQueryRef = useRef("")
137
- const [footerNotice, setFooterNotice] = useState<string | null>(null)
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 })
138
153
  const serverRef = useRef<ServerHandle | null>(null)
139
154
 
140
155
  // Stop the preview server on unmount so re-mounts (tests) and clean
@@ -146,21 +161,36 @@ export const Browser = ({
146
161
  }
147
162
  }, [])
148
163
 
149
- // Single-slot notice with a 2s TTL. A new notice cancels the pending
150
- // 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.
151
169
  useEffect(() => {
152
170
  if (footerNotice === null) return
153
- const timer = setTimeout(() => setFooterNotice(null), 2000)
171
+ const timer = setTimeout(() => setFooterNoticeState(null), footerNotice.ttlMs)
154
172
  return () => clearTimeout(timer)
155
173
  }, [footerNotice])
156
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
+
157
187
  const cycleTheme = (delta: 1 | -1) => {
158
188
  const idx = themeDefinitions.findIndex((d) => d.id === theme.id)
159
189
  const next = themeDefinitions[(idx + delta + themeDefinitions.length) % themeDefinitions.length]
160
190
  if (!next) return
161
191
  setActiveTheme(next, theme.tone)
162
192
  setTheme({ id: next.id, tone: theme.tone })
163
- setFooterNotice(`theme: ${next.name}`)
193
+ pushFooterNotice(`theme: ${next.name}`)
164
194
  }
165
195
 
166
196
  const toggleTone = () => {
@@ -168,7 +198,7 @@ export const Browser = ({
168
198
  const def = getThemeDefinition(theme.id)
169
199
  if (def) setActiveTheme(def, nextTone)
170
200
  setTheme({ id: theme.id, tone: nextTone })
171
- setFooterNotice(`tone: ${nextTone}`)
201
+ pushFooterNotice(`tone: ${nextTone}`)
172
202
  }
173
203
 
174
204
  const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
@@ -231,21 +261,31 @@ export const Browser = ({
231
261
  // operate on what the user actually sees.
232
262
  const ctx: BrowserCtx = {
233
263
  files: displayedFiles,
264
+ hasSelected: selected != null,
234
265
  focus,
235
266
  sidebarShown: shown,
236
267
  helpVisible,
237
268
  filterOpen,
269
+ filterQuery,
238
270
  paletteOpen,
239
271
  setFocus,
240
272
  setSelectedIndex,
241
273
  toggleShown: () => {
242
- // Per DESIGN.md §7.1 s-behavior table:
243
- // shown=true, focus=sidebar → shown=false, focus=reader
244
- // (otherwise the drawer would
245
- // immediately re-appear)
246
- // shown=true, focus=reader → shown=false, focus=reader
247
- // shown=false, focus=reader → shown=true, focus=sidebar
248
- // shown=false, focus=sidebar shown=true, focus=sidebar
274
+ // Two layout shapes, two behaviors:
275
+ // wideflip the sticky `shown` preference. Per DESIGN.md §7.1
276
+ // s-behavior table: also nudge focus so the visibility
277
+ // rule (`visible = shown || focus==="sidebar"`) reflects
278
+ // the user's intent instead of forcing the sidebar back
279
+ // on via focus.
280
+ // narrow → swap which screen is up (focus is the source of truth
281
+ // for render). Sync `shown` to the new screen so a later
282
+ // resize to wide opens with the right pane visible.
283
+ if (!canFitInline(width)) {
284
+ const next = focus === "sidebar" ? "reader" : "sidebar"
285
+ setFocus(next)
286
+ setShown(next === "sidebar")
287
+ return
288
+ }
249
289
  if (shown) {
250
290
  setShown(false)
251
291
  if (focus === "sidebar") setFocus("reader")
@@ -256,11 +296,22 @@ export const Browser = ({
256
296
  },
257
297
  setHelpVisible,
258
298
  openFilter: () => {
259
- // Focus the sidebar so the filter input has a home. Under §7.1's
260
- // `visible = shown || focus==="sidebar"` rule, focus alone makes
261
- // the sidebar visible (as a drawer when `shown=false`), so we no
262
- // longer need to mutate `shown` here.
263
- priorFilterQueryRef.current = filterQueryRef.current
299
+ // Focus the sidebar so the filter input has a home. In wide,
300
+ // §7.1's visibility rule (`shown || focus === "sidebar"`) brings
301
+ // the inline sidebar back on screen if it was hidden. In narrow,
302
+ // focusing the sidebar swaps to the sidebar screen. Either way
303
+ // no need to mutate `shown`.
304
+ if (focus !== "sidebar") setFocus("sidebar")
305
+ filterOpenRef.current = true
306
+ setFilterOpen(true)
307
+ },
308
+ clearAndOpenFilter: () => {
309
+ // Reset both the ref and the state so the freshly-opened modal
310
+ // shows an empty input and selection lands on the first file in
311
+ // the (now unfiltered) list.
312
+ filterQueryRef.current = ""
313
+ setFilterQuery("")
314
+ setSelectedIndex(() => 0)
264
315
  if (focus !== "sidebar") setFocus("sidebar")
265
316
  filterOpenRef.current = true
266
317
  setFilterOpen(true)
@@ -288,9 +339,9 @@ export const Browser = ({
288
339
  handle = startServer({ path: file.path })
289
340
  serverRef.current = handle
290
341
  openInBrowser(handle.url)
291
- setFooterNotice(`serving at ${handle.url}`)
342
+ pushFooterNotice(`serving at ${handle.url}`)
292
343
  } catch (err) {
293
- setFooterNotice(`serve failed: ${String(err)}`)
344
+ pushFooterNotice(`serve failed: ${String(err)}`)
294
345
  }
295
346
  return
296
347
  }
@@ -302,7 +353,7 @@ export const Browser = ({
302
353
  // an existing tab on the same URL when one is open, so this is
303
354
  // idempotent for the common case.
304
355
  openInBrowser(handle.url)
305
- setFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
356
+ pushFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
306
357
  },
307
358
  quit: () => {
308
359
  if (onQuit) {
@@ -312,67 +363,100 @@ export const Browser = ({
312
363
  renderer?.destroy()
313
364
  process.exit(0)
314
365
  },
366
+ editCurrent: () => {
367
+ const file = displayedFiles[selectedIndex]
368
+ if (!file) return
369
+ const editor = resolveEditor(process.env)
370
+ if (!editor) {
371
+ pushFooterNotice("set $EDITOR or $VISUAL to use e")
372
+ return
373
+ }
374
+ if (!renderer) {
375
+ // Test environments without a real renderer (e.g. testRender's
376
+ // host) don't expose suspend/resume. Nothing safe to do here.
377
+ pushFooterNotice("editor unavailable in this environment")
378
+ return
379
+ }
380
+ // Fire-and-forget: useKeyboard's run() is synchronous, but the
381
+ // editor session is naturally async. We always re-enter the
382
+ // renderer in the finally block so a thrown error never leaves
383
+ // the user staring at a dead terminal.
384
+ void (async () => {
385
+ renderer.suspend()
386
+ renderer.currentRenderBuffer.clear()
387
+ let result
388
+ try {
389
+ result = await openInEditor({ editor, filePath: file.path })
390
+ } finally {
391
+ renderer.currentRenderBuffer.clear()
392
+ renderer.resume()
393
+ renderer.requestRender()
394
+ }
395
+ // Only reload the in-memory cache when the edited file is the
396
+ // one currently displayed. Editing a sidebar-selected file that
397
+ // the reader hasn't caught up to (debounce in flight) is fine —
398
+ // the regular load path picks up the new mtime when renderedPath
399
+ // advances.
400
+ if (file.path === renderedPath) {
401
+ try {
402
+ const text = await readFile(file.path)
403
+ setLoaded({ path: file.path, content: text })
404
+ setError(null)
405
+ } catch (err) {
406
+ const message = String(err)
407
+ const enoent =
408
+ (err as { code?: string } | null)?.code === "ENOENT" || message.includes("ENOENT")
409
+ if (enoent) {
410
+ pushFooterNotice(`${file.relativePath} no longer exists`)
411
+ setError(`Cannot read ${file.path}: ${message}`)
412
+ setLoaded(null)
413
+ } else {
414
+ pushFooterNotice(`reload failed: ${message}`)
415
+ }
416
+ }
417
+ }
418
+ if (!result.ok) {
419
+ if (result.reason === "spawn-failed") {
420
+ pushFooterNotice(`editor not found: ${editor.cmd}`)
421
+ } else if (result.reason === "non-zero") {
422
+ pushFooterNotice(`editor exited ${result.detail}`)
423
+ }
424
+ }
425
+ })()
426
+ },
315
427
  }
316
428
 
317
429
  useKeyboard((key) => {
318
- // Filter modal: capture keystrokes for the input. Esc closes and
319
- // clears; Return closes, clears, and focuses the reader (open the
320
- // match); Backspace edits; Up/Down navigate the filtered list;
321
- // printable characters extend the query and reset selection to 0.
322
- // Everything else is swallowed so normal bindings (j/k as nav,
323
- // `s`, `t`, …) don't fire while the user is typing. This sits
324
- // outside the data-driven keymap for the same reason the help
325
- // branch does see DESIGN.md §12.
430
+ // Filter modal: capture keystrokes for the input. Esc closes,
431
+ // leaving the typed query applied as the active filter; Return
432
+ // closes and focuses the reader (open the match); Ctrl+\ clears
433
+ // the input but stays in filter mode (same binding used from
434
+ // outside the modal single chord, single mental model. Ctrl+U
435
+ // is deliberately not overloaded here; it stays reserved for its
436
+ // sidebar/reader half-page-up role); Backspace edits; Up/Down
437
+ // navigate the filtered list; printable characters extend the
438
+ // query and reset selection to 0. Everything else is swallowed
439
+ // so normal bindings (j/k as nav, `s`, `t`, …) don't fire while
440
+ // the user is typing. This sits outside the data-driven keymap
441
+ // for the same reason the help branch does — see DESIGN.md §12.
326
442
  if (filterOpenRef.current) {
327
- // One close path used by both Esc and Return. Closing the filter
328
- // restores the full list; translating the highlighted match to
329
- // its index in `files` keeps the cursor on whatever the user was
330
- // looking at when they hit the key, instead of landing on a
331
- // random file at the same numeric position in a now-different
332
- // list. `focusReader=true` is the Return semantic (open the
333
- // match); false is Esc (cancel, stay in sidebar).
334
- //
335
- // Centralized so the dual filterOpenRef / filterOpen invariant
336
- // only has to be maintained in one place (plus `openFilter`).
443
+ // One close path used by both Esc and Return. `commit=true` is
444
+ // the Return semantic (open the match in the reader); false is
445
+ // Esc (stop typing, keep the applied filter, stay in sidebar).
337
446
  const closeFilter = (commit: boolean) => {
338
447
  const picked = displayedFiles[selectedIndex] ?? null
339
- // Return on a zero-match list has nothing to commit. Treat it
340
- // as Esc so the user isn't stranded in an "applied filter with
341
- // no visible files" state they'd have to back out of manually.
342
448
  const effectiveCommit = commit && picked !== null
343
449
  filterOpenRef.current = false
344
450
  setFilterOpen(false)
345
- if (effectiveCommit) {
346
- // Return keeps the query. selectedIndex is already a valid
347
- // position in the (still-filtered) displayedFiles list, so
348
- // no translation is needed.
349
- } else {
350
- // Esc reverts the query to its pre-session value. After the
351
- // revert, displayedFiles may change shape — translate the
352
- // cursor by path so it stays on whatever the user was
353
- // looking at, instead of snapping to a numerically-equivalent
354
- // row in the restored list.
355
- const before = priorFilterQueryRef.current
356
- filterQueryRef.current = before
357
- setFilterQuery(before)
358
- if (picked) {
359
- const restored = before === "" ? files : filterFiles(files, before)
360
- const idx = restored.findIndex((f) => f.path === picked.path)
361
- if (idx >= 0) setSelectedIndex(() => idx)
362
- }
363
- }
364
- // Where focus lands depends on layout intent (DESIGN.md §7.1):
365
- // commit (Return on a real pick) → reader, always. The user
366
- // asked to open the match; show them what they picked.
367
- // cancel (Esc, or Return with no pick) → if the sidebar is
368
- // inline (shown && fits), keep focus there so j/k keeps
369
- // walking the list; if the sidebar was only up as a drawer
370
- // (shown=false), dismiss focus to the reader so the drawer
371
- // disappears under the §7.1 visibility rule.
451
+ // Where focus lands after the filter closes:
452
+ // commit (Return on a real pick) reader. The user asked
453
+ // to open the match; show them what they picked.
454
+ // otherwise sidebar if it's up so j/k keeps walking the
455
+ // filtered list; reader if the sidebar was hidden.
372
456
  if (effectiveCommit) {
373
457
  setFocus("reader")
374
458
  } else {
375
- setFocus(shown && canFitInline(width) ? "sidebar" : "reader")
459
+ setFocus(shown ? "sidebar" : "reader")
376
460
  }
377
461
  }
378
462
  if (key.name === "escape") {
@@ -383,11 +467,19 @@ export const Browser = ({
383
467
  closeFilter(true)
384
468
  return
385
469
  }
470
+ if (key.ctrl && key.name === "\\") {
471
+ // Same action as the `filter.clearOrOpen` binding fires from
472
+ // outside the modal: clear the query, reset selection. The
473
+ // keymap doesn't see keys in filter mode, so this branch is
474
+ // the in-modal half of that single chord.
475
+ filterQueryRef.current = ""
476
+ setFilterQuery("")
477
+ setSelectedIndex(() => 0)
478
+ return
479
+ }
386
480
  if (key.name === "backspace" || key.name === "delete") {
387
- // Pressing backspace/delete with no query left removes the
388
- // leading `/` i.e. closes the modal. Equivalent to Esc:
389
- // reverts to the pre-session query (so an applied filter
390
- // survives a "I changed my mind" tap).
481
+ // Backspace on empty input closes the modal the leading `/`
482
+ // chevron is the last thing left to "delete."
391
483
  if (filterQueryRef.current.length === 0) {
392
484
  closeFilter(false)
393
485
  return
@@ -516,42 +608,33 @@ export const Browser = ({
516
608
  const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
517
609
  const sidebarActive = focus === "sidebar"
518
610
  const readerActive = focus === "reader"
519
- // Visibility = shown OR sidebar-focused. When visible-because-focused
520
- // only, render as a drawer (absolute) on top of the reader. We also
521
- // fall back to drawer rendering when the viewport is too narrow for
522
- // the inline two-pane layout even with `shown=true` (Q2 in DESIGN.md
523
- // §7.1) — preserves the user's preference without squeezing the reader
524
- // below READER_MIN_WIDTH.
525
- const sidebarVisible = shown || sidebarActive
526
- const sidebarAsDrawer = sidebarVisible && (!shown || !canFitInline(width))
527
- const sidebarInline = sidebarVisible && !sidebarAsDrawer
528
- // Drawer is offset 1 row from the top so the reader's title stays visible.
529
- // That row comes off the drawer's own height, so the body has one fewer
530
- // usable row than the inline sidebar. Tracked here so the virtualization
531
- // slice matches the wrapper that actually paints it.
532
- const drawerTopOffset = 1
533
- const sidebarTitle = sidebarActive ? " ▸ files " : " files "
534
- const readerLabel = selected?.relativePath ?? title
535
- const readerTitle = readerActive ? ` ▸ ${readerLabel} ` : ` ${readerLabel} `
611
+ // Wide vs narrow drives the entire layout shape.
612
+ // wide → inline two-pane (today). visible = shown || focus==="sidebar".
613
+ // narrow single-pane stack: whichever pane has focus fills the area.
614
+ // `shown` is silently ignored for render in narrow; `focus`
615
+ // is the single source of truth.
616
+ // See DESIGN.md §7.1.
617
+ const isNarrow = !canFitInline(width)
618
+ const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
619
+ const readerVisible = isNarrow ? readerActive : true
620
+ // Currently-selected file shown in the Header (which replaced the
621
+ // per-pane border title that used to carry this information).
622
+ const currentFile = selected?.relativePath ?? null
536
623
  const content = loaded?.path === renderedPath ? loaded.content : ""
537
624
 
538
625
  // Sidebar virtualization: render only the visible window. Without this,
539
626
  // every keystroke re-renders all N file rows even though only the bg of
540
627
  // two of them changed (old + new selected). On a 195-file vault that
541
628
  // dominates the per-keystroke cost.
542
- // Sidebar box adds top/bottom borders (2); footer eats FOOTER_HEIGHT;
543
- // the filter row eats one more cell when files are present *or* while
629
+ // Chrome budget: header + pane top border + pane bottom border + footer.
630
+ // The filter row eats one more cell when files are present *or* while
544
631
  // discovery is in flight (allocates the row up front so it doesn't pop
545
632
  // in when the first file arrives).
546
633
  const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
547
634
  const filterRowVisible = files.length > 0 || discoveryActive
548
635
  const sidebarBodyHeight = Math.max(
549
636
  1,
550
- height -
551
- 2 -
552
- FOOTER_HEIGHT -
553
- (filterRowVisible ? 1 : 0) -
554
- (sidebarAsDrawer ? drawerTopOffset : 0),
637
+ height - FOOTER_HEIGHT - HEADER_HEIGHT - 2 - (filterRowVisible ? 1 : 0),
555
638
  )
556
639
  const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
557
640
  const desiredScroll = (() => {
@@ -564,40 +647,17 @@ export const Browser = ({
564
647
  if (desiredScroll !== sidebarScroll) setSidebarScroll(desiredScroll)
565
648
  }, [desiredScroll, sidebarScroll])
566
649
  const visibleFiles = displayedFiles.slice(desiredScroll, desiredScroll + sidebarBodyHeight)
567
- // Available width for sidebar text rows: box width minus 1-cell border on each side.
568
- const sidebarTextWidth = Math.max(4, sidebarWidth - 2)
569
- // Right-anchored truncation: keep the filename visible, lose the prefix
570
- // with a leading ellipsis when the path is too long.
571
- const truncatePath = useCallback(
572
- (s: string): string =>
573
- s.length <= sidebarTextWidth ? s : "…" + s.slice(s.length - sidebarTextWidth + 1),
650
+ // Available width for sidebar text rows. Wide-inline: sidebarWidth minus
651
+ // 1-cell left padding and 1-cell right divider border. Narrow-stack: the
652
+ // sidebar flex-grows to fill the viewport with no right divider, so the
653
+ // budget is the viewport minus the 1-cell left padding only.
654
+ const sidebarPaneWidth = isNarrow ? width : sidebarWidth
655
+ const sidebarTextWidth = Math.max(4, sidebarPaneWidth - (isNarrow ? 1 : 2))
656
+ const layoutSidebarRow = useCallback(
657
+ (relativePath: string) => formatSidebarRow(relativePath, sidebarTextWidth),
574
658
  [sidebarTextWidth],
575
659
  )
576
660
 
577
- // Filter row content + color. Three reachable states:
578
- // editing — filterOpen=true → /<query>▏ in textStrong
579
- // applied — !filterOpen && query !== "" → /<query> in text
580
- // idle — !filterOpen && query === "" → "/ filter…" in textMuted
581
- const filterRowFg = filterOpen
582
- ? colors.textStrong
583
- : filterQuery.length > 0
584
- ? colors.text
585
- : colors.textMuted
586
- const filterRowRaw = filterOpen
587
- ? `/${filterQuery}▏`
588
- : filterQuery.length > 0
589
- ? `/${filterQuery}`
590
- : "/ filter…"
591
- // Editing keeps the cursor visible — anchor the right edge with a leading
592
- // ellipsis when the query overflows. Applied/idle anchor the left edge
593
- // (lose the tail) so the leading `/` always reads as a filter marker.
594
- const filterRowContent =
595
- filterRowRaw.length <= sidebarTextWidth
596
- ? filterRowRaw
597
- : filterOpen
598
- ? "…" + filterRowRaw.slice(filterRowRaw.length - sidebarTextWidth + 1)
599
- : filterRowRaw.slice(0, sidebarTextWidth - 1) + "…"
600
-
601
661
  // While help is open, the `?` key closes the overlay — relabel its hint
602
662
  // so the footer accurately describes what pressing the key will do.
603
663
  // Memoized: `helpVisible` changes rarely; `browserBindings` and
@@ -612,13 +672,17 @@ export const Browser = ({
612
672
  [helpVisible],
613
673
  )
614
674
 
615
- // One sidebar element is reused for inline and drawer rendering; only
616
- // the wrapper differs (flex sibling vs absolute-positioned). The body is
617
- // identical so file rows / filter row don't drift between modes.
675
+ // One sidebar body for both wide-inline and narrow-stack rendering; only
676
+ // the wrapper differs (fixed-width sibling vs flex-grow full-pane).
618
677
  const sidebarBody = (
619
678
  <>
620
679
  {filterRowVisible && (
621
- <text content={filterRowContent} wrapMode="none" style={{ fg: filterRowFg }} />
680
+ <PromptRow
681
+ query={filterQuery}
682
+ editing={filterOpen}
683
+ placeholder="/ to filter…"
684
+ width={sidebarTextWidth}
685
+ />
622
686
  )}
623
687
  {displayedFiles.length === 0 ? (
624
688
  <text
@@ -635,127 +699,168 @@ export const Browser = ({
635
699
  visibleFiles.map((file, idx) => {
636
700
  const realIdx = desiredScroll + idx
637
701
  const isSelected = realIdx === selectedIndex
638
- const display = truncatePath(file.relativePath)
639
- if (!isSelected) {
640
- return (
641
- <text key={file.path} content={display} wrapMode="none" style={{ fg: colors.text }} />
642
- )
643
- }
644
- const bg = sidebarActive ? colors.selectedBg : colors.selectedBgInactive
702
+ const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
703
+ const basenameFg = isSelected ? colors.textStrong : colors.text
704
+ const rowStyle = isSelected
705
+ ? { bg: sidebarActive ? colors.selectedBg : colors.selectedBgInactive }
706
+ : {}
645
707
  return (
646
- <text
647
- key={file.path}
648
- content={display}
649
- wrapMode="none"
650
- style={{ fg: colors.textStrong, bg }}
651
- />
708
+ <text key={file.path} wrapMode="none" style={rowStyle}>
709
+ <span style={{ fg: basenameFg }}>{basename}</span>
710
+ {parent !== "" && (
711
+ <span style={{ fg: colors.textMuted }}>{`${separator}${parent}`}</span>
712
+ )}
713
+ </text>
652
714
  )
653
715
  })
654
716
  )}
655
717
  </>
656
718
  )
657
719
 
720
+ // Pane borders draw a connected frame: each pane's top/bottom edges
721
+ // (the horizontal rules) and the sidebar's right edge (the vertical
722
+ // divider) are rendered by opentui in one pass, so the junctions
723
+ // never get painted over by sibling elements. customBorderChars on
724
+ // the sidebar turns its right-side corners from `┐ ┘` into `┬ ┴` so
725
+ // they connect cleanly with the reader's top/bottom rules.
726
+ const sidebarBorderSides: BorderSides[] = ["top", "bottom", "right"]
727
+ const readerBorderSides: BorderSides[] = ["top", "bottom"]
728
+ const SIDEBAR_BORDER_CHARS = {
729
+ topLeft: "┌",
730
+ topRight: "┬",
731
+ bottomLeft: "└",
732
+ bottomRight: "┴",
733
+ horizontal: "─",
734
+ vertical: "│",
735
+ topT: "┬",
736
+ bottomT: "┴",
737
+ leftT: "├",
738
+ rightT: "┤",
739
+ cross: "┼",
740
+ } as const
741
+
658
742
  return (
659
- <box style={{ width, height, flexDirection: "column", backgroundColor: colors.background }}>
743
+ <box style={{ width, height, flexDirection: "column", backgroundColor: colors.surface }}>
744
+ <Header width={width} currentFile={currentFile} />
660
745
  <box
661
746
  style={{
662
747
  flexDirection: "row",
663
748
  flexGrow: 1,
664
749
  flexShrink: 1,
665
- backgroundColor: colors.background,
750
+ backgroundColor: colors.surface,
666
751
  }}
667
752
  >
668
753
  {sidebarInline && (
669
754
  <box
670
- title={sidebarTitle}
671
- titleAlignment="left"
672
755
  style={{
673
- border: true,
674
- borderColor: sidebarActive ? colors.borderActive : colors.border,
675
- width: sidebarWidth,
676
- flexShrink: 0,
756
+ // Narrow mode runs single-pane: the sidebar fills the area
757
+ // and drops its right divider (no neighbour to abut).
758
+ border: isNarrow ? readerBorderSides : sidebarBorderSides,
759
+ borderColor: colors.textMuted,
760
+ ...(isNarrow
761
+ ? { flexGrow: 1, flexShrink: 1 }
762
+ : { width: sidebarWidth, flexShrink: 0 }),
677
763
  flexDirection: "column",
764
+ // Dim by default. Borders/separators ride on this so they read
765
+ // as a single connected frame regardless of focus; only the
766
+ // active pane's inner body overrides to the raised tint below.
678
767
  backgroundColor: colors.surface,
679
768
  }}
769
+ {...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
680
770
  >
681
- {sidebarBody}
771
+ <box
772
+ style={{
773
+ flexGrow: 1,
774
+ flexShrink: 1,
775
+ flexDirection: "column",
776
+ paddingLeft: 1,
777
+ backgroundColor: sidebarActive ? colors.background : colors.surface,
778
+ }}
779
+ >
780
+ {sidebarBody}
781
+ </box>
682
782
  </box>
683
783
  )}
684
- <box
685
- title={readerTitle}
686
- titleAlignment="left"
687
- style={{
688
- border: true,
689
- borderColor: readerActive ? colors.borderActive : colors.border,
690
- padding: 1,
691
- flexGrow: 1,
692
- flexShrink: 1,
693
- backgroundColor: colors.background,
694
- }}
695
- >
696
- {error ? (
697
- <text content={error} style={{ fg: colors.error }} />
698
- ) : (
699
- <scrollbox
784
+ {readerVisible && (
785
+ <box
786
+ style={{
787
+ border: readerBorderSides,
788
+ borderColor: colors.textMuted,
789
+ flexGrow: 1,
790
+ flexShrink: 1,
791
+ flexDirection: "column",
792
+ // Dim by default (see sidebar note); inner body overrides when active.
793
+ backgroundColor: colors.surface,
794
+ }}
795
+ >
796
+ <box
700
797
  style={{
701
- scrollY: true,
702
- scrollX: false,
703
798
  flexGrow: 1,
704
799
  flexShrink: 1,
705
- backgroundColor: colors.background,
800
+ flexDirection: "column",
801
+ padding: 1,
802
+ backgroundColor: readerActive ? colors.background : colors.surface,
706
803
  }}
707
- // opentui's scrollbox consumes arrow keys at the focused-element
708
- // level *before* useKeyboard fires, so a modal that handles
709
- // arrow keys itself (palette nav, help dismissal) would still
710
- // see the reader scroll alongside its own action. Unfocus the
711
- // scrollbox while any blocking modal is up — useKeyboard's
712
- // modal branches own the keys in that state. Filter is not
713
- // listed because it force-focuses the sidebar (readerActive
714
- // is already false).
715
- focused={readerActive && !paletteOpen && !helpVisible}
716
804
  >
717
- <markdown
718
- key={renderedPath ?? "empty"}
719
- content={content}
720
- syntaxStyle={syntaxStyle}
721
- fg={colors.text}
722
- bg={colors.background}
723
- conceal
724
- style={{ width: maxWidth ?? "100%" }}
725
- />
726
- </scrollbox>
727
- )}
728
- </box>
805
+ {error ? (
806
+ <text content={error} style={{ fg: colors.error }} />
807
+ ) : !renderedPath ? (
808
+ // Reader empty state — no file selected. Brand mark centered as a
809
+ // welcome anchor; in-app tips (#47) will live here too.
810
+ <box
811
+ style={{
812
+ flexGrow: 1,
813
+ flexShrink: 1,
814
+ alignItems: "center",
815
+ justifyContent: "center",
816
+ backgroundColor: readerActive ? colors.background : colors.surface,
817
+ }}
818
+ >
819
+ <text
820
+ content={`${BRAND} ${BRAND_NAME}`}
821
+ wrapMode="none"
822
+ style={{ fg: colors.textMuted }}
823
+ />
824
+ </box>
825
+ ) : (
826
+ <scrollbox
827
+ style={{
828
+ scrollY: true,
829
+ scrollX: false,
830
+ flexGrow: 1,
831
+ flexShrink: 1,
832
+ backgroundColor: readerActive ? colors.background : colors.surface,
833
+ }}
834
+ // opentui's scrollbox consumes arrow keys at the focused-element
835
+ // level *before* useKeyboard fires, so a modal that handles
836
+ // arrow keys itself (palette nav, help dismissal) would still
837
+ // see the reader scroll alongside its own action. Unfocus the
838
+ // scrollbox while any blocking modal is up — useKeyboard's
839
+ // modal branches own the keys in that state. Filter is not
840
+ // listed because it force-focuses the sidebar (readerActive
841
+ // is already false).
842
+ focused={readerActive && !paletteOpen && !helpVisible}
843
+ >
844
+ <markdown
845
+ key={renderedPath ?? "empty"}
846
+ content={content}
847
+ syntaxStyle={syntaxStyle}
848
+ fg={colors.text}
849
+ bg={readerActive ? colors.background : colors.surface}
850
+ conceal
851
+ style={{ width: maxWidth ?? "100%" }}
852
+ />
853
+ </scrollbox>
854
+ )}
855
+ </box>
856
+ </box>
857
+ )}
729
858
  </box>
730
- {sidebarAsDrawer && (
731
- // Offset by 1 row so the reader pane's top border (which carries
732
- // the current file name) stays visible above the drawer. Without
733
- // this, the user loses the only on-screen indicator of which
734
- // file they're reading whenever the drawer is up.
735
- <box
736
- position="absolute"
737
- left={0}
738
- top={drawerTopOffset}
739
- width={sidebarWidth}
740
- height={Math.max(1, height - FOOTER_HEIGHT - drawerTopOffset)}
741
- zIndex={5}
742
- title={sidebarTitle}
743
- titleAlignment="left"
744
- style={{
745
- border: true,
746
- borderColor: sidebarActive ? colors.borderActive : colors.border,
747
- flexDirection: "column",
748
- backgroundColor: colors.surface,
749
- }}
750
- >
751
- {sidebarBody}
752
- </box>
753
- )}
754
859
  <Footer
755
860
  bindings={footerBindings}
756
861
  ctx={ctx}
757
862
  width={width}
758
- notice={footerNotice}
863
+ notice={footerNotice?.text ?? null}
759
864
  discoveryStatus={discoveryStatus}
760
865
  filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
761
866
  />