@carlesandres/house 0.4.0 → 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,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,7 +21,9 @@ 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"
25
28
  import { readFileText } from "./io/readFile.ts"
26
29
  import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
@@ -41,7 +44,6 @@ export type SidebarMode = "auto" | "on" | "off"
41
44
 
42
45
  export interface BrowserProps {
43
46
  readonly files: readonly FileEntry[]
44
- readonly title?: string
45
47
  readonly initialIndex?: number
46
48
  /** Cap the rendered markdown's width at N columns. Null = fill the pane. */
47
49
  readonly maxWidth?: number | null
@@ -54,6 +56,14 @@ export interface BrowserProps {
54
56
  readonly onQuit?: () => void
55
57
  /** Test seam: replaces the file reader. */
56
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
57
67
  }
58
68
 
59
69
  const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
@@ -74,13 +84,14 @@ const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
74
84
 
75
85
  export const Browser = ({
76
86
  files,
77
- title = "house",
78
87
  initialIndex = 0,
79
88
  maxWidth = null,
80
89
  discoveryStatus = null,
81
90
  sidebarMode = "auto",
82
91
  onQuit,
83
92
  readFile = defaultReadFile,
93
+ updateNotice = null,
94
+ updateNoticeTtlMs = 10000,
84
95
  }: BrowserProps) => {
85
96
  const renderer = useRenderer()
86
97
  const { width, height } = useTerminalDimensions()
@@ -131,10 +142,14 @@ export const Browser = ({
131
142
  const filterOpenRef = useRef(false)
132
143
  const filterQueryRef = useRef("")
133
144
  // 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.
145
+ // keeps them.
136
146
  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])
@@ -239,13 +269,21 @@ export const Browser = ({
239
269
  setFocus,
240
270
  setSelectedIndex,
241
271
  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
272
+ // Two layout shapes, two behaviors:
273
+ // wideflip 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
+ }
249
287
  if (shown) {
250
288
  setShown(false)
251
289
  if (focus === "sidebar") setFocus("reader")
@@ -256,10 +294,11 @@ export const Browser = ({
256
294
  },
257
295
  setHelpVisible,
258
296
  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.
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`.
263
302
  priorFilterQueryRef.current = filterQueryRef.current
264
303
  if (focus !== "sidebar") setFocus("sidebar")
265
304
  filterOpenRef.current = true
@@ -288,9 +327,9 @@ export const Browser = ({
288
327
  handle = startServer({ path: file.path })
289
328
  serverRef.current = handle
290
329
  openInBrowser(handle.url)
291
- setFooterNotice(`serving at ${handle.url}`)
330
+ pushFooterNotice(`serving at ${handle.url}`)
292
331
  } catch (err) {
293
- setFooterNotice(`serve failed: ${String(err)}`)
332
+ pushFooterNotice(`serve failed: ${String(err)}`)
294
333
  }
295
334
  return
296
335
  }
@@ -302,7 +341,7 @@ export const Browser = ({
302
341
  // an existing tab on the same URL when one is open, so this is
303
342
  // idempotent for the common case.
304
343
  openInBrowser(handle.url)
305
- setFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
344
+ pushFooterNotice(`serving ${file.relativePath} at ${handle.url}`)
306
345
  },
307
346
  quit: () => {
308
347
  if (onQuit) {
@@ -361,18 +400,20 @@ export const Browser = ({
361
400
  if (idx >= 0) setSelectedIndex(() => idx)
362
401
  }
363
402
  }
364
- // Where focus lands depends on layout intent (DESIGN.md §7.1):
403
+ // Where focus lands after the filter closes:
365
404
  // commit (Return on a real pick) → reader, always. The user
366
405
  // 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.
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.
372
413
  if (effectiveCommit) {
373
414
  setFocus("reader")
374
415
  } else {
375
- setFocus(shown && canFitInline(width) ? "sidebar" : "reader")
416
+ setFocus(shown ? "sidebar" : "reader")
376
417
  }
377
418
  }
378
419
  if (key.name === "escape") {
@@ -516,42 +557,33 @@ export const Browser = ({
516
557
  const sidebarWidth = resolveSidebarWidth(width, defaultPreferredWidth(width))
517
558
  const sidebarActive = focus === "sidebar"
518
559
  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} `
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
536
572
  const content = loaded?.path === renderedPath ? loaded.content : ""
537
573
 
538
574
  // Sidebar virtualization: render only the visible window. Without this,
539
575
  // every keystroke re-renders all N file rows even though only the bg of
540
576
  // two of them changed (old + new selected). On a 195-file vault that
541
577
  // 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
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
544
580
  // discovery is in flight (allocates the row up front so it doesn't pop
545
581
  // in when the first file arrives).
546
582
  const discoveryActive = discoveryStatus !== null && discoveryStatus.length > 0
547
583
  const filterRowVisible = files.length > 0 || discoveryActive
548
584
  const sidebarBodyHeight = Math.max(
549
585
  1,
550
- height -
551
- 2 -
552
- FOOTER_HEIGHT -
553
- (filterRowVisible ? 1 : 0) -
554
- (sidebarAsDrawer ? drawerTopOffset : 0),
586
+ height - FOOTER_HEIGHT - HEADER_HEIGHT - 2 - (filterRowVisible ? 1 : 0),
555
587
  )
556
588
  const maxScroll = Math.max(0, displayedFiles.length - sidebarBodyHeight)
557
589
  const desiredScroll = (() => {
@@ -564,8 +596,12 @@ export const Browser = ({
564
596
  if (desiredScroll !== sidebarScroll) setSidebarScroll(desiredScroll)
565
597
  }, [desiredScroll, sidebarScroll])
566
598
  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)
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))
569
605
  // Right-anchored truncation: keep the filename visible, lose the prefix
570
606
  // with a leading ellipsis when the path is too long.
571
607
  const truncatePath = useCallback(
@@ -612,9 +648,8 @@ export const Browser = ({
612
648
  [helpVisible],
613
649
  )
614
650
 
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.
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).
618
653
  const sidebarBody = (
619
654
  <>
620
655
  {filterRowVisible && (
@@ -655,107 +690,150 @@ export const Browser = ({
655
690
  </>
656
691
  )
657
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
+
658
715
  return (
659
- <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} />
660
718
  <box
661
719
  style={{
662
720
  flexDirection: "row",
663
721
  flexGrow: 1,
664
722
  flexShrink: 1,
665
- backgroundColor: colors.background,
723
+ backgroundColor: colors.surface,
666
724
  }}
667
725
  >
668
726
  {sidebarInline && (
669
727
  <box
670
- title={sidebarTitle}
671
- titleAlignment="left"
672
728
  style={{
673
- border: true,
674
- borderColor: sidebarActive ? colors.borderActive : colors.border,
675
- width: sidebarWidth,
676
- 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 }),
677
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.
678
740
  backgroundColor: colors.surface,
679
741
  }}
742
+ {...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
680
743
  >
681
- {sidebarBody}
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>
682
755
  </box>
683
756
  )}
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
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
700
770
  style={{
701
- scrollY: true,
702
- scrollX: false,
703
771
  flexGrow: 1,
704
772
  flexShrink: 1,
705
- backgroundColor: colors.background,
773
+ flexDirection: "column",
774
+ padding: 1,
775
+ backgroundColor: readerActive ? colors.background : colors.surface,
706
776
  }}
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
777
  >
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>
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
+ )}
729
831
  </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
832
  <Footer
755
833
  bindings={footerBindings}
756
834
  ctx={ctx}
757
835
  width={width}
758
- notice={footerNotice}
836
+ notice={footerNotice?.text ?? null}
759
837
  discoveryStatus={discoveryStatus}
760
838
  filterQuery={!filterOpen && filterQuery.length > 0 ? filterQuery : null}
761
839
  />
@@ -9,9 +9,16 @@
9
9
  * no mouse, no recency. See #91/#93/#94/#95/#96 for the follow-ups.
10
10
  */
11
11
 
12
+ import { RGBA } from "@opentui/core"
12
13
  import { colors } from "./theme/colors.ts"
13
14
  import type { AppCommand } from "./commands/types.ts"
14
15
 
16
+ // Semi-transparent black scrim painted across the viewport behind the modal.
17
+ // Opentui composites it over the chrome underneath, so the rest of the UI
18
+ // reads as darkened while the modal stays fully opaque. Mirrors opencode's
19
+ // dialog backdrop (cli/cmd/tui/ui/dialog.tsx).
20
+ const SCRIM = RGBA.fromInts(0, 0, 0, 150)
21
+
15
22
  export interface CommandPaletteProps {
16
23
  readonly commands: readonly AppCommand[]
17
24
  readonly query: string
@@ -73,54 +80,71 @@ export const CommandPalette = ({
73
80
  return (
74
81
  <box
75
82
  position="absolute"
76
- left={left}
77
- top={top}
78
- width={overlayWidth}
79
- height={overlayHeight}
83
+ left={0}
84
+ top={0}
85
+ width={viewportWidth}
86
+ height={viewportHeight}
80
87
  zIndex={20}
81
- title=" Commands "
82
- titleAlignment="left"
83
- paddingLeft={1}
84
- paddingRight={1}
85
- style={{
86
- border: true,
87
- borderColor: colors.borderActive,
88
- flexDirection: "column",
89
- backgroundColor: colors.surface,
90
- }}
88
+ style={{ backgroundColor: SCRIM }}
91
89
  >
92
- <text
93
- wrapMode="none"
94
- content={fit(`> ${query}▏`, rowWidth)}
95
- style={{ fg: colors.textStrong }}
96
- />
97
- <text content=" " />
98
- {commands.length === 0 ? (
99
- <text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
100
- ) : (
101
- visible.map((cmd, i) => {
102
- const realIdx = scrollTop + i
103
- const isSelected = realIdx === selectedIndex
104
- const selector = isSelected ? "▸ " : " "
105
- const titleText = fit(cmd.title, titleWidth)
106
- const shortcutText = cmd.shortcut
107
- ? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
108
- : " ".repeat(SHORTCUT_WIDTH)
109
- // Title and shortcut render as separate spans so the shortcut
110
- // can use `textMuted` while the title uses `text`/`textStrong`.
111
- // Same trick opencode pulls with `--text-weak` — the theme
112
- // guarantees the contrast, we just pick the right role.
113
- const titleFg = isSelected ? colors.textStrong : colors.text
114
- return (
115
- <text key={cmd.id} wrapMode="none" style={isSelected ? { bg: colors.selectedBg } : {}}>
116
- <span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
117
- <span style={{ fg: colors.textMuted }}>{shortcutText}</span>
118
- </text>
119
- )
120
- })
121
- )}
122
- <text content=" " />
123
- <text wrapMode="none" content={fit(FOOTER_HINT, rowWidth)} style={{ fg: colors.textMuted }} />
90
+ <box
91
+ position="absolute"
92
+ left={left}
93
+ top={top}
94
+ width={overlayWidth}
95
+ height={overlayHeight}
96
+ title=" Commands "
97
+ titleAlignment="left"
98
+ paddingLeft={1}
99
+ paddingRight={1}
100
+ style={{
101
+ border: true,
102
+ borderColor: colors.textMuted,
103
+ flexDirection: "column",
104
+ backgroundColor: colors.surface,
105
+ }}
106
+ >
107
+ <text
108
+ wrapMode="none"
109
+ content={fit(`> ${query}▏`, rowWidth)}
110
+ style={{ fg: colors.textStrong }}
111
+ />
112
+ <text content=" " />
113
+ {commands.length === 0 ? (
114
+ <text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
115
+ ) : (
116
+ visible.map((cmd, i) => {
117
+ const realIdx = scrollTop + i
118
+ const isSelected = realIdx === selectedIndex
119
+ const selector = isSelected ? "▸ " : " "
120
+ const titleText = fit(cmd.title, titleWidth)
121
+ const shortcutText = cmd.shortcut
122
+ ? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
123
+ : " ".repeat(SHORTCUT_WIDTH)
124
+ // Title and shortcut render as separate spans so the shortcut
125
+ // can use `textMuted` while the title uses `text`/`textStrong`.
126
+ // Same trick opencode pulls with `--text-weak` — the theme
127
+ // guarantees the contrast, we just pick the right role.
128
+ const titleFg = isSelected ? colors.textStrong : colors.text
129
+ return (
130
+ <text
131
+ key={cmd.id}
132
+ wrapMode="none"
133
+ style={isSelected ? { bg: colors.selectedBg } : {}}
134
+ >
135
+ <span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
136
+ <span style={{ fg: colors.textMuted }}>{shortcutText}</span>
137
+ </text>
138
+ )
139
+ })
140
+ )}
141
+ <text content=" " />
142
+ <text
143
+ wrapMode="none"
144
+ content={fit(FOOTER_HINT, rowWidth)}
145
+ style={{ fg: colors.textMuted }}
146
+ />
147
+ </box>
124
148
  </box>
125
149
  )
126
150
  }