@carlesandres/house 0.4.5 → 0.4.6

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/CHANGELOG.md CHANGED
@@ -6,6 +6,34 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.6] — 2026-05-25
10
+
11
+ ### Added
12
+
13
+ - Reader empty states now show one tip at a time and rotate through a relevance-ordered set of workflow hints.
14
+
15
+ ### Changed
16
+
17
+ - Startup behavior now uses `--focus <sidebar|reader|filter>`, `HOUSE_FOCUS`, and TOML `focus = "..."`; the built-in startup default is now `filter`.
18
+ - Active filter/status metadata now uses the secondary theme token, matching opencode's use of `secondary` for active contextual metadata.
19
+ - UI theme consumers now use the opencode-aligned `primary` token directly for strong emphasis and selected foreground.
20
+ - UI chrome no longer uses house-only aliases (`surface`, `selectedBg`, `selectedBgInactive`), and background/backgroundPanel are no longer reordered by luminance.
21
+ - Sidebar filtering now ranks basename matches above folder-only matches and softly prefers current-folder files over deeper nested paths, while keeping empty-query tree order unchanged.
22
+
23
+ ### Fixed
24
+
25
+ - Tabbing away from the sidebar filter now lands on the reader without dropping filter mode, and the next `Tab` restores focus to the filter input instead of the sidebar list.
26
+ - Batched key input no longer clears active filter focus unexpectedly during tab cycling.
27
+
28
+ ### Docs
29
+
30
+ - Added semantic token usage guidance for supported theme tokens.
31
+ - DESIGN now documents the sidebar filter's basename-first, pure-function ranking model.
32
+
33
+ ### Tests
34
+
35
+ - Added headless and PTY regression coverage for filter tab-cycle focus restoration.
36
+
9
37
  ## [0.4.5] — 2026-05-24
10
38
 
11
39
  ### Changed
@@ -26,7 +54,6 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
26
54
 
27
55
  - Discovery visibility controls: new `--show <list>` CLI flag, `HOUSE_SHOW` env var, and TOML `show = ["..."]` config, replacing boolean discovery toggles with explicit categories (`hidden`, `gitignored`).
28
56
  - Session visibility toggle: `shift+a` flips discovery between the configured visibility set and showing all categories, preserving selection across the re-walk.
29
- - Launch-in-filter option: `--start-in-filter` CLI flag, `HOUSE_START_IN_FILTER` env var, and TOML `start_in_filter = true` open the sidebar filter prompt focused at startup.
30
57
 
31
58
  ### Changed
32
59
 
@@ -203,7 +230,8 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
203
230
 
204
231
  Search, stdin, URL fetching, cross-file link following, `$EDITOR` hand-off, syntax highlighting, persistent config, OS-appearance auto-detect, single-binary distribution (issue [#2](https://github.com/carlesandres/openmdr/issues/2)), Homebrew tap. All tracked.
205
232
 
206
- [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.5...HEAD
233
+ [Unreleased]: https://github.com/carlesandres/house/compare/v0.4.6...HEAD
234
+ [0.4.6]: https://github.com/carlesandres/house/compare/v0.4.5...v0.4.6
207
235
  [0.4.5]: https://github.com/carlesandres/house/compare/v0.4.4...v0.4.5
208
236
  [0.4.4]: https://github.com/carlesandres/house/compare/v0.4.3...v0.4.4
209
237
  [0.4.3]: https://github.com/carlesandres/house/compare/v0.4.2...v0.4.3
package/README.md CHANGED
@@ -57,10 +57,10 @@ house [options] <path>
57
57
  | `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
58
58
  | `--sort <mode>` | `dirs-first` | Sidebar order: `dirs-first` or `files-first` |
59
59
  | `--sidebar <mode>` | `auto` | Initial sidebar visibility: `auto`, `on`, or `off` |
60
+ | `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
60
61
  | `--serve` | off | Serve the given file as HTML in the browser (skips TUI) |
61
62
  | `--port <N>` | OS-assigned | Port for `--serve` |
62
63
  | `--no-mdx` | off | Exclude `.mdx` files from discovery |
63
- | `--start-in-filter` | off | Open the sidebar filter prompt on launch so you can type a query immediately. Press Esc to dismiss. |
64
64
  | `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
65
65
  | `--config-path` | — | Print the resolved config-file path and exit |
66
66
  | `-h`, `--help` | — | Show help and exit |
@@ -82,19 +82,19 @@ theme = "tokyonight"
82
82
  tone = "dark"
83
83
  mdx = true
84
84
  show = ["hidden", "gitignored"]
85
- start_in_filter = false
85
+ focus = "filter"
86
86
  ```
87
87
 
88
- Supported keys: `theme`, `tone`, `mdx`, `show`, `start_in_filter`.
88
+ Supported keys: `theme`, `tone`, `mdx`, `show`, `focus`.
89
89
 
90
90
  `show` is a list of normally-skipped categories to opt into. Known categories: `hidden` (dot-prefixed entries), `gitignored` (entries matched by a `.gitignore`). Default is the empty list. Hard skips (`node_modules`, `.git`, `.venv`) always apply.
91
91
 
92
92
  Precedence, highest to lowest:
93
93
 
94
- 1. CLI flags (`--theme`, `--tone`, `--no-mdx`, `--show`, `--start-in-filter`)
95
- 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`, `HOUSE_SHOW`, `HOUSE_START_IN_FILTER`)
94
+ 1. CLI flags (`--theme`, `--tone`, `--no-mdx`, `--show`, `--focus`)
95
+ 2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`, `HOUSE_SHOW`, `HOUSE_FOCUS`)
96
96
  3. Config file
97
- 4. Built-in defaults (`opencode` / `dark` / `mdx = true` / `show = []` / `start_in_filter = false`)
97
+ 4. Built-in defaults (`opencode` / `dark` / `mdx = true` / `show = []` / `focus = "filter"`)
98
98
 
99
99
  `HOUSE_SHOW` takes a comma-separated list (`HOUSE_SHOW=hidden,gitignored`). For `show` specifically, each source completely replaces the next — categories don't merge across layers. Press `shift+a` in the TUI to round-trip between the configured set and the full vocabulary without editing config.
100
100
 
@@ -149,6 +149,9 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
149
149
 
150
150
  Each theme supports dark and light tones. Cycle with `t` / `T`; toggle tone with `L`.
151
151
 
152
+ For the supported semantic theme tokens and their intended usage, see
153
+ [DESIGN.md §7.5](https://github.com/carlesandres/house/blob/main/DESIGN.md#75-theming).
154
+
152
155
  ## Inspiration
153
156
 
154
157
  - [glow](https://github.com/charmbracelet/glow) — render markdown on the CLI, with pizzazz
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlesandres/house",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "TUI-first markdown reader on opentui",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,6 +56,7 @@
56
56
  "@effect/atom-react": "4.0.0-beta.70",
57
57
  "@opentui/core": "0.2.15",
58
58
  "@opentui/react": "0.2.15",
59
+ "commander": "^14.0.3",
59
60
  "effect": "4.0.0-beta.70",
60
61
  "ignore": "^7.0.5",
61
62
  "marked": "^18.0.4",
package/src/Browser.tsx CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  } from "./layout/resolve.ts"
38
38
  import { formatSidebarRow } from "./layout/sidebarRow.ts"
39
39
  import { PromptRow } from "./PromptRow.tsx"
40
+ import { buildReaderEmptyStateTips, pickTipByRotation } from "./tips.ts"
40
41
  import { openInBrowser } from "./serve/openBrowser.ts"
41
42
  import { startServer, type ServerHandle } from "./serve/server.ts"
42
43
  import { colors, setActiveTheme } from "./theme/colors.ts"
@@ -44,6 +45,7 @@ import { themeAtom } from "./theme/atom.ts"
44
45
  import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
45
46
 
46
47
  export type SidebarMode = "auto" | "on" | "off"
48
+ export type StartupFocus = "sidebar" | "reader" | "filter"
47
49
 
48
50
  export interface BrowserProps {
49
51
  readonly files: readonly FileEntry[]
@@ -72,10 +74,9 @@ export interface BrowserProps {
72
74
  * from this side; we just snapshot the selected path so it can be
73
75
  * restored across the re-walk the parent triggers. */
74
76
  readonly onToggleAll?: () => void
75
- /** Open the sidebar filter prompt on mount so the user can type
76
- * immediately. Esc closes it through the normal close path no
77
- * special "first close" behavior. */
78
- readonly startInFilter?: boolean
77
+ /** Startup pane/input target. `filter` opens the sidebar filter prompt on
78
+ * mount so the user can type immediately. */
79
+ readonly startupFocus?: StartupFocus | null
79
80
  }
80
81
 
81
82
  const defaultReadFile = (path: string): Promise<string> => Effect.runPromise(readFileText(path))
@@ -94,6 +95,16 @@ const HELP_ALLOWED_IDS: ReadonlySet<string> = new Set([
94
95
  "palette.open",
95
96
  ])
96
97
 
98
+ let nextReaderEmptyStateTipRotation = 0
99
+
100
+ export const resetReaderEmptyStateTipRotationForTests = () => {
101
+ nextReaderEmptyStateTipRotation = 0
102
+ }
103
+
104
+ export const setReaderEmptyStateTipRotationForTests = (next: number) => {
105
+ nextReaderEmptyStateTipRotation = next
106
+ }
107
+
97
108
  export const Browser = ({
98
109
  files,
99
110
  initialIndex = 0,
@@ -105,7 +116,7 @@ export const Browser = ({
105
116
  updateNotice = null,
106
117
  updateNoticeTtlMs = 10000,
107
118
  onToggleAll,
108
- startInFilter = false,
119
+ startupFocus = null,
109
120
  }: BrowserProps) => {
110
121
  const renderer = useRenderer()
111
122
  const { width, height } = useTerminalDimensions()
@@ -134,12 +145,23 @@ export const Browser = ({
134
145
  return initialShownForAuto(width)
135
146
  }
136
147
  })
137
- // startInFilter mirrors `openFilter`'s focus rule: the filter input lives
138
- // in the sidebar, so opening it on mount also forces sidebar focus
139
- // regardless of `--sidebar=off` (§7.1's visibility derivation surfaces
140
- // the sidebar via focus even when `shown` is false).
148
+ const startInFilter = startupFocus === "filter"
149
+ const initialFocus: "sidebar" | "reader" =
150
+ startupFocus === null
151
+ ? shown
152
+ ? "sidebar"
153
+ : "reader"
154
+ : startupFocus === "reader"
155
+ ? "reader"
156
+ : "sidebar"
157
+ // `filter` mirrors `openFilter`'s focus rule: the filter input lives in
158
+ // the sidebar, so opening it on mount also forces sidebar focus regardless
159
+ // of `--sidebar=off` (§7.1's visibility derivation surfaces the sidebar via
160
+ // focus even when `shown` is false). Plain `sidebar` startup shares the
161
+ // same pane focus without opening the prompt. When omitted, preserve the
162
+ // legacy Browser behavior: initial focus follows visibility.
141
163
  const [focus, setFocus] = useState<"sidebar" | "reader">(() =>
142
- shown || startInFilter ? "sidebar" : "reader",
164
+ shown || initialFocus === "sidebar" ? "sidebar" : "reader",
143
165
  )
144
166
  const [sidebarScroll, setSidebarScroll] = useState<number>(0)
145
167
  const [helpVisible, setHelpVisible] = useState<boolean>(false)
@@ -155,12 +177,18 @@ export const Browser = ({
155
177
  const paletteOpenRef = useRef(false)
156
178
  const paletteQueryRef = useRef("")
157
179
  const paletteIndexRef = useRef(0)
180
+ const [readerEmptyStateTipRotation, setReaderEmptyStateTipRotation] = useState(
181
+ () => nextReaderEmptyStateTipRotation,
182
+ )
183
+ const readerEmptyStateVisibleRef = useRef(false)
158
184
  // Mirror filter state into refs so the keyboard handler sees synchronous
159
185
  // updates even when multiple keys arrive in a single React batch (the
160
186
  // first key opens the filter; subsequent keys in the same tick would
161
187
  // otherwise still observe filterOpen=false through closure).
162
188
  const filterOpenRef = useRef(startInFilter)
163
189
  const filterQueryRef = useRef("")
190
+ const focusRef = useRef<"sidebar" | "reader">(focus)
191
+ const restoreFilterOnSidebarFocusRef = useRef(startInFilter)
164
192
  const [footerNotice, setFooterNoticeState] = useState<{
165
193
  readonly text: string
166
194
  readonly ttlMs: number
@@ -201,6 +229,10 @@ export const Browser = ({
201
229
  // user time to read it before it auto-clears; the quit-time stderr print
202
230
  // is the durable record they can copy from scrollback.
203
231
  const updateNoticeSeenRef = useRef<string | null>(null)
232
+ useEffect(() => {
233
+ focusRef.current = focus
234
+ }, [focus])
235
+
204
236
  useEffect(() => {
205
237
  if (!updateNotice) return
206
238
  if (updateNoticeSeenRef.current === updateNotice) return
@@ -226,6 +258,7 @@ export const Browser = ({
226
258
  }
227
259
 
228
260
  const displayedFiles = useMemo(() => filterFiles(files, filterQuery), [files, filterQuery])
261
+ const filterHasNoMatches = filterQuery.length > 0 && displayedFiles.length === 0
229
262
  // When the filtered list shrinks, keep selectedIndex valid. The reset to 0
230
263
  // on every query change happens in the keystroke handler, not here, so a
231
264
  // no-op rerender doesn't snap the cursor back to the top.
@@ -306,6 +339,7 @@ export const Browser = ({
306
339
  sidebarShown: shown,
307
340
  helpVisible,
308
341
  filterOpen,
342
+ restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
309
343
  filterQuery,
310
344
  paletteOpen,
311
345
  setFocus,
@@ -349,7 +383,9 @@ export const Browser = ({
349
383
  // the inline sidebar back on screen if it was hidden. In narrow,
350
384
  // focusing the sidebar swaps to the sidebar screen. Either way
351
385
  // no need to mutate `shown`.
386
+ focusRef.current = "sidebar"
352
387
  if (focus !== "sidebar") setFocus("sidebar")
388
+ restoreFilterOnSidebarFocusRef.current = true
353
389
  filterOpenRef.current = true
354
390
  setFilterOpen(true)
355
391
  },
@@ -360,7 +396,9 @@ export const Browser = ({
360
396
  filterQueryRef.current = ""
361
397
  setFilterQuery("")
362
398
  setSelectedIndex(() => 0)
399
+ focusRef.current = "sidebar"
363
400
  if (focus !== "sidebar") setFocus("sidebar")
401
+ restoreFilterOnSidebarFocusRef.current = true
364
402
  filterOpenRef.current = true
365
403
  setFilterOpen(true)
366
404
  },
@@ -500,13 +538,14 @@ export const Browser = ({
500
538
  // so normal bindings (j/k as nav, `s`, `t`, …) don't fire while
501
539
  // the user is typing. This sits outside the data-driven keymap
502
540
  // for the same reason the help branch does — see DESIGN.md §12.
503
- if (filterOpenRef.current) {
541
+ if (filterOpenRef.current && focusRef.current === "sidebar") {
504
542
  // One close path used by both Esc and Return. `commit=true` is
505
543
  // the Return semantic (open the match in the reader); false is
506
544
  // Esc (stop typing, keep the applied filter, stay in sidebar).
507
545
  const closeFilter = (commit: boolean) => {
508
546
  const picked = displayedFiles[selectedIndex] ?? null
509
547
  const effectiveCommit = commit && picked !== null
548
+ restoreFilterOnSidebarFocusRef.current = false
510
549
  filterOpenRef.current = false
511
550
  setFilterOpen(false)
512
551
  // Where focus lands after the filter closes:
@@ -515,9 +554,12 @@ export const Browser = ({
515
554
  // otherwise → sidebar if it's up so j/k keeps walking the
516
555
  // filtered list; reader if the sidebar was hidden.
517
556
  if (effectiveCommit) {
557
+ focusRef.current = "reader"
518
558
  setFocus("reader")
519
559
  } else {
520
- setFocus(shown ? "sidebar" : "reader")
560
+ const nextFocus = shown ? "sidebar" : "reader"
561
+ focusRef.current = nextFocus
562
+ setFocus(nextFocus)
521
563
  }
522
564
  }
523
565
  if (key.name === "escape") {
@@ -528,6 +570,14 @@ export const Browser = ({
528
570
  closeFilter(true)
529
571
  return
530
572
  }
573
+ if (key.name === "tab" || (key.ctrl && key.name === "i" && !key.shift && !key.meta)) {
574
+ focusRef.current = "reader"
575
+ restoreFilterOnSidebarFocusRef.current = true
576
+ filterOpenRef.current = false
577
+ setFilterOpen(false)
578
+ setFocus("reader")
579
+ return
580
+ }
531
581
  if (key.ctrl && key.name === "\\") {
532
582
  // Same action as the `filter.clearOrOpen` binding fires from
533
583
  // outside the modal: clear the query, reset selection. The
@@ -682,6 +732,21 @@ export const Browser = ({
682
732
  // per-pane border title that used to carry this information).
683
733
  const currentFile = selected?.relativePath ?? null
684
734
  const content = loaded?.path === renderedPath ? loaded.content : ""
735
+ const readerEmptyStateTitle = filterHasNoMatches
736
+ ? `No files match: ${filterQuery}`
737
+ : `${BRAND} ${BRAND_NAME}`
738
+ const readerEmptyStateVisible = error == null && renderedPath == null
739
+
740
+ useEffect(() => {
741
+ if (readerEmptyStateVisible) {
742
+ if (!readerEmptyStateVisibleRef.current) {
743
+ readerEmptyStateVisibleRef.current = true
744
+ setReaderEmptyStateTipRotation(nextReaderEmptyStateTipRotation++)
745
+ }
746
+ return
747
+ }
748
+ readerEmptyStateVisibleRef.current = false
749
+ }, [readerEmptyStateVisible])
685
750
 
686
751
  // Sidebar virtualization: render only the visible window. Without this,
687
752
  // every keystroke re-renders all N file rows even though only the bg of
@@ -732,6 +797,11 @@ export const Browser = ({
732
797
  : browserBindings,
733
798
  [helpVisible],
734
799
  )
800
+ const readerEmptyStateTips = useMemo(() => buildReaderEmptyStateTips(browserBindings, ctx), [ctx])
801
+ const readerEmptyStateTip = useMemo(
802
+ () => pickTipByRotation(readerEmptyStateTips, readerEmptyStateTipRotation),
803
+ [readerEmptyStateTipRotation, readerEmptyStateTips],
804
+ )
735
805
 
736
806
  // One sidebar body for both wide-inline and narrow-stack rendering; only
737
807
  // the wrapper differs (fixed-width sibling vs flex-grow full-pane).
@@ -761,9 +831,17 @@ export const Browser = ({
761
831
  const realIdx = desiredScroll + idx
762
832
  const isSelected = realIdx === selectedIndex
763
833
  const { basename, separator, parent } = layoutSidebarRow(file.relativePath)
764
- const basenameFg = isSelected ? colors.textStrong : colors.text
834
+ const selectedFg =
835
+ colors.selectedListItemText === colors.background
836
+ ? colors.primary
837
+ : colors.selectedListItemText
838
+ const basenameFg = isSelected
839
+ ? sidebarActive
840
+ ? selectedFg
841
+ : colors.primary
842
+ : colors.text
765
843
  const rowStyle = isSelected
766
- ? { bg: sidebarActive ? colors.selectedBg : colors.selectedBgInactive }
844
+ ? { bg: sidebarActive ? colors.backgroundElement : colors.borderSubtle }
767
845
  : {}
768
846
  return (
769
847
  <text key={file.path} wrapMode="none" style={rowStyle}>
@@ -801,14 +879,16 @@ export const Browser = ({
801
879
  } as const
802
880
 
803
881
  return (
804
- <box style={{ width, height, flexDirection: "column", backgroundColor: colors.surface }}>
882
+ <box
883
+ style={{ width, height, flexDirection: "column", backgroundColor: colors.backgroundPanel }}
884
+ >
805
885
  <Header width={width} currentFile={currentFile} />
806
886
  <box
807
887
  style={{
808
888
  flexDirection: "row",
809
889
  flexGrow: 1,
810
890
  flexShrink: 1,
811
- backgroundColor: colors.surface,
891
+ backgroundColor: colors.backgroundPanel,
812
892
  }}
813
893
  >
814
894
  {sidebarInline && (
@@ -825,7 +905,7 @@ export const Browser = ({
825
905
  // Dim by default. Borders/separators ride on this so they read
826
906
  // as a single connected frame regardless of focus; only the
827
907
  // active pane's inner body overrides to the raised tint below.
828
- backgroundColor: colors.surface,
908
+ backgroundColor: colors.backgroundPanel,
829
909
  }}
830
910
  {...(isNarrow ? {} : { customBorderChars: SIDEBAR_BORDER_CHARS })}
831
911
  >
@@ -835,7 +915,7 @@ export const Browser = ({
835
915
  flexShrink: 1,
836
916
  flexDirection: "column",
837
917
  paddingLeft: 1,
838
- backgroundColor: sidebarActive ? colors.background : colors.surface,
918
+ backgroundColor: sidebarActive ? colors.background : colors.backgroundPanel,
839
919
  }}
840
920
  >
841
921
  {sidebarBody}
@@ -851,7 +931,7 @@ export const Browser = ({
851
931
  flexShrink: 1,
852
932
  flexDirection: "column",
853
933
  // Dim by default (see sidebar note); inner body overrides when active.
854
- backgroundColor: colors.surface,
934
+ backgroundColor: colors.backgroundPanel,
855
935
  }}
856
936
  >
857
937
  <box
@@ -860,28 +940,38 @@ export const Browser = ({
860
940
  flexShrink: 1,
861
941
  flexDirection: "column",
862
942
  padding: 1,
863
- backgroundColor: readerActive ? colors.background : colors.surface,
943
+ backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
864
944
  }}
865
945
  >
866
946
  {error ? (
867
947
  <text content={error} style={{ fg: colors.error }} />
868
948
  ) : !renderedPath ? (
869
949
  // Reader empty state — no file selected. Brand mark centered as a
870
- // welcome anchor; in-app tips (#47) will live here too.
950
+ // welcome anchor; reusable tips live here too.
871
951
  <box
872
952
  style={{
873
953
  flexGrow: 1,
874
954
  flexShrink: 1,
875
955
  alignItems: "center",
876
956
  justifyContent: "center",
877
- backgroundColor: readerActive ? colors.background : colors.surface,
957
+ backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
878
958
  }}
879
959
  >
880
- <text
881
- content={`${BRAND} ${BRAND_NAME}`}
882
- wrapMode="none"
883
- style={{ fg: colors.textMuted }}
884
- />
960
+ <box style={{ flexDirection: "column", gap: 1, alignItems: "center" }}>
961
+ <text
962
+ content={readerEmptyStateTitle}
963
+ wrapMode="none"
964
+ style={{ fg: colors.textMuted }}
965
+ />
966
+ {readerEmptyStateTip && (
967
+ <text
968
+ key={readerEmptyStateTip.id}
969
+ content={readerEmptyStateTip.text}
970
+ wrapMode="none"
971
+ style={{ fg: colors.textMuted }}
972
+ />
973
+ )}
974
+ </box>
885
975
  </box>
886
976
  ) : (
887
977
  <scrollbox
@@ -890,7 +980,7 @@ export const Browser = ({
890
980
  scrollX: false,
891
981
  flexGrow: 1,
892
982
  flexShrink: 1,
893
- backgroundColor: readerActive ? colors.background : colors.surface,
983
+ backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
894
984
  }}
895
985
  // opentui's scrollbox consumes arrow keys at the focused-element
896
986
  // level *before* useKeyboard fires, so a modal that handles
@@ -907,7 +997,7 @@ export const Browser = ({
907
997
  content={content}
908
998
  syntaxStyle={syntaxStyle}
909
999
  fg={colors.text}
910
- bg={readerActive ? colors.background : colors.surface}
1000
+ bg={readerActive ? colors.background : colors.backgroundPanel}
911
1001
  conceal
912
1002
  style={{ width: maxWidth ?? "100%" }}
913
1003
  />
@@ -102,7 +102,7 @@ export const CommandPalette = ({
102
102
  border: true,
103
103
  borderColor: colors.textMuted,
104
104
  flexDirection: "column",
105
- backgroundColor: colors.surface,
105
+ backgroundColor: colors.backgroundPanel,
106
106
  }}
107
107
  >
108
108
  <PromptRow query={query} editing={true} width={rowWidth} />
@@ -119,15 +119,15 @@ export const CommandPalette = ({
119
119
  ? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
120
120
  : " ".repeat(SHORTCUT_WIDTH)
121
121
  // Title and shortcut render as separate spans so the shortcut
122
- // can use `textMuted` while the title uses `text`/`textStrong`.
122
+ // can use `textMuted` while the title uses `text`/`primary`.
123
123
  // Same trick opencode pulls with `--text-weak` — the theme
124
124
  // guarantees the contrast, we just pick the right role.
125
- const titleFg = isSelected ? colors.textStrong : colors.text
125
+ const titleFg = isSelected ? colors.primary : colors.text
126
126
  return (
127
127
  <text
128
128
  key={cmd.id}
129
129
  wrapMode="none"
130
- style={isSelected ? { bg: colors.selectedBg } : {}}
130
+ style={isSelected ? { bg: colors.backgroundElement } : {}}
131
131
  >
132
132
  <span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
133
133
  <span style={{ fg: colors.textMuted }}>{shortcutText}</span>
package/src/Footer.tsx CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { KeyBinding } from "./keymap/keymap.ts"
25
+ import { displayKey } from "./keymap/displayKey.ts"
25
26
  import { colors } from "./theme/colors.ts"
26
27
 
27
28
  /** Rows the Footer occupies. Importers use it for layout math so a future
@@ -46,31 +47,6 @@ export interface FooterProps<C> {
46
47
 
47
48
  const HINT_SEPARATOR = " "
48
49
 
49
- /** Display form for the first key of a binding. Picks the first chord and
50
- * rewrites a few names to terminal-friendly shorthands.
51
- *
52
- * Footer policy: only the first key is shown, even when a binding has
53
- * aliases (e.g. `sidebar.open` accepts `return`/`right`/`l`). The footer
54
- * is a narrow real-estate budget, and listing every alias would push out
55
- * other bindings on tight viewports. The full alias list lives in the
56
- * help overlay (`?`). */
57
- const displayKey = (raw: string): string => {
58
- switch (raw) {
59
- case "return":
60
- return "↵"
61
- case "escape":
62
- return "esc"
63
- case "space":
64
- return "␣"
65
- case "pageup":
66
- return "pgup"
67
- case "pagedown":
68
- return "pgdn"
69
- default:
70
- return raw
71
- }
72
- }
73
-
74
50
  /** Hint row entries. `key === null` is a standalone chip (e.g. the filter
75
51
  * chip) and renders as muted text without the key/label split. */
76
52
  interface Hint {
@@ -126,7 +102,7 @@ export const Footer = <C,>({
126
102
  flexDirection: "row",
127
103
  paddingLeft: 1,
128
104
  paddingRight: 1,
129
- backgroundColor: colors.surface,
105
+ backgroundColor: colors.backgroundPanel,
130
106
  } as const
131
107
 
132
108
  const hints: Hint[] = []
@@ -183,7 +159,7 @@ export const Footer = <C,>({
183
159
  if (h.key === null) {
184
160
  return [
185
161
  ...sep,
186
- <text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.textMuted }} />,
162
+ <text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.secondary }} />,
187
163
  ]
188
164
  }
189
165
  return [
@@ -198,12 +174,12 @@ export const Footer = <C,>({
198
174
  ]
199
175
  })
200
176
 
201
- // Priority: notice > (status + hints). Notice fg is strong; status sits
202
- // at the muted level so it reads as ambient state, not an event.
177
+ // Priority: notice > (status + hints). Discovery status uses the secondary
178
+ // token: active metadata, but not a warning/error event.
203
179
  if (noticeContent !== null) {
204
180
  return (
205
181
  <box style={rowStyle}>
206
- <text content={noticeContent} wrapMode="none" style={{ fg: colors.textStrong }} />
182
+ <text content={noticeContent} wrapMode="none" style={{ fg: colors.primary }} />
207
183
  </box>
208
184
  )
209
185
  }
@@ -211,7 +187,7 @@ export const Footer = <C,>({
211
187
  if (status !== null) {
212
188
  return (
213
189
  <box style={rowStyle}>
214
- <text content={statusContent} wrapMode="none" style={{ fg: colors.textMuted }} />
190
+ <text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
215
191
  <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
216
192
  {renderHints()}
217
193
  </box>
package/src/Header.tsx CHANGED
@@ -58,7 +58,7 @@ export const Header = ({ width, currentFile, version = pkg.version }: HeaderProp
58
58
  justifyContent: "space-between",
59
59
  paddingLeft: 1,
60
60
  paddingRight: 1,
61
- backgroundColor: colors.surface,
61
+ backgroundColor: colors.backgroundPanel,
62
62
  }}
63
63
  >
64
64
  <text content={left} wrapMode="none" style={{ fg: colors.text }} />
@@ -106,7 +106,7 @@ export const HelpOverlay = <C,>({
106
106
  borderColor: colors.textMuted,
107
107
  padding: 1,
108
108
  flexDirection: "column",
109
- backgroundColor: colors.surface,
109
+ backgroundColor: colors.backgroundPanel,
110
110
  }}
111
111
  >
112
112
  {rows.map((row) => {
package/src/PromptRow.tsx CHANGED
@@ -3,9 +3,9 @@
3
3
  * the command palette query input.
4
4
  *
5
5
  * Render-only: the parent owns query state and the focus/editing flag.
6
- * The `> ` prefix always renders in `textStrong` regardless of state so it
6
+ * The `> ` prefix renders in `primary` or `secondary` so it
7
7
  * reads as chrome, not placeholder text — only the body span shifts color
8
- * (textStrong while editing, text when applied, textMuted as placeholder).
8
+ * (primary while editing, text when applied, textMuted as placeholder).
9
9
  *
10
10
  * Overflow: when editing, an overflowing body anchors its right edge with a
11
11
  * leading `…` so the cursor stays on screen; otherwise it anchors the left
@@ -16,7 +16,7 @@ import { colors } from "./theme/colors.ts"
16
16
 
17
17
  export interface PromptRowProps {
18
18
  readonly query: string
19
- /** True while the input is focused — shows a cursor and uses textStrong fg. */
19
+ /** True while the input is focused — shows a cursor and uses primary fg. */
20
20
  readonly editing: boolean
21
21
  /** Body fallback when !editing && query === "". Pass without the `> ` prefix. */
22
22
  readonly placeholder?: string
@@ -31,7 +31,7 @@ export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRow
31
31
  const bodyBudget = Math.max(1, width - PREFIX.length)
32
32
 
33
33
  const rawBody = editing ? `${query}${CURSOR}` : query.length > 0 ? query : placeholder
34
- const bodyFg = editing ? colors.textStrong : query.length > 0 ? colors.text : colors.textMuted
34
+ const bodyFg = editing ? colors.primary : query.length > 0 ? colors.text : colors.textMuted
35
35
 
36
36
  const body =
37
37
  rawBody.length <= bodyBudget
@@ -40,9 +40,11 @@ export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRow
40
40
  ? "…" + rawBody.slice(rawBody.length - bodyBudget + 1)
41
41
  : rawBody.slice(0, bodyBudget - 1) + "…"
42
42
 
43
+ const prefixFg = editing ? colors.secondary : colors.primary
44
+
43
45
  return (
44
46
  <text wrapMode="none">
45
- <span style={{ fg: colors.textStrong }}>{PREFIX}</span>
47
+ <span style={{ fg: prefixFg }}>{PREFIX}</span>
46
48
  <span style={{ fg: bodyFg }}>{body}</span>
47
49
  </text>
48
50
  )