@carlesandres/house 0.4.4 → 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 +45 -2
- package/README.md +17 -7
- package/package.json +5 -1
- package/src/Browser.tsx +119 -29
- package/src/CommandPalette.tsx +4 -4
- package/src/Footer.tsx +7 -31
- package/src/Header.tsx +1 -1
- package/src/HelpOverlay.tsx +1 -1
- package/src/PromptRow.tsx +7 -5
- package/src/cli/argv.ts +86 -108
- package/src/config/load.ts +13 -21
- package/src/discovery/filter.ts +44 -12
- package/src/index.tsx +21 -11
- package/src/keymap/browser.ts +8 -1
- package/src/keymap/displayKey.ts +17 -0
- package/src/keymap/keymap.ts +3 -0
- package/src/theme/colors.ts +5 -37
- package/src/theme/types.ts +7 -8
- package/src/tips.ts +93 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,13 +6,54 @@ 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
|
+
|
|
37
|
+
## [0.4.5] — 2026-05-24
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- npm install/upgrade runtime baseline is now explicit: Node `>=22.22.2` is required when installing `@carlesandres/house` via npm, matching transitive engine requirements.
|
|
42
|
+
|
|
43
|
+
### Docs
|
|
44
|
+
|
|
45
|
+
- README now calls out the Node `>=22.22.2` requirement for npm-based install/upgrade.
|
|
46
|
+
|
|
47
|
+
### Tests
|
|
48
|
+
|
|
49
|
+
- CI now includes an npm global-install smoke matrix on Node `22.22.2` and Node `24` with `engine-strict=true`, catching runtime-floor regressions before release.
|
|
50
|
+
|
|
9
51
|
## [0.4.4] — 2026-05-24
|
|
10
52
|
|
|
11
53
|
### Added
|
|
12
54
|
|
|
13
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`).
|
|
14
56
|
- Session visibility toggle: `shift+a` flips discovery between the configured visibility set and showing all categories, preserving selection across the re-walk.
|
|
15
|
-
- 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.
|
|
16
57
|
|
|
17
58
|
### Changed
|
|
18
59
|
|
|
@@ -189,7 +230,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
189
230
|
|
|
190
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.
|
|
191
232
|
|
|
192
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
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
|
|
235
|
+
[0.4.5]: https://github.com/carlesandres/house/compare/v0.4.4...v0.4.5
|
|
193
236
|
[0.4.4]: https://github.com/carlesandres/house/compare/v0.4.3...v0.4.4
|
|
194
237
|
[0.4.3]: https://github.com/carlesandres/house/compare/v0.4.2...v0.4.3
|
|
195
238
|
[0.4.2]: https://github.com/carlesandres/house/compare/v0.4.1...v0.4.2
|
package/README.md
CHANGED
|
@@ -14,7 +14,14 @@ A terminal markdown reader and navigator — themable and configurable, with a k
|
|
|
14
14
|
- **Open in browser**
|
|
15
15
|
- **Open in `$EDITOR`**
|
|
16
16
|
|
|
17
|
-
Requires [Bun](https://bun.sh) on `PATH`.
|
|
17
|
+
Requires [Bun](https://bun.sh) on `PATH`.
|
|
18
|
+
|
|
19
|
+
## Runtime requirements
|
|
20
|
+
|
|
21
|
+
- Bun: required to run `house`.
|
|
22
|
+
- npm install/upgrade path: Node `>=22.22.2` (Node 24 LTS recommended).
|
|
23
|
+
|
|
24
|
+
Supported on macOS and Linux; Windows is unsupported and unvalidated (see [#129](https://github.com/carlesandres/house/issues/129)).
|
|
18
25
|
|
|
19
26
|
## Install
|
|
20
27
|
|
|
@@ -50,10 +57,10 @@ house [options] <path>
|
|
|
50
57
|
| `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
|
|
51
58
|
| `--sort <mode>` | `dirs-first` | Sidebar order: `dirs-first` or `files-first` |
|
|
52
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. |
|
|
53
61
|
| `--serve` | off | Serve the given file as HTML in the browser (skips TUI) |
|
|
54
62
|
| `--port <N>` | OS-assigned | Port for `--serve` |
|
|
55
63
|
| `--no-mdx` | off | Exclude `.mdx` files from discovery |
|
|
56
|
-
| `--start-in-filter` | off | Open the sidebar filter prompt on launch so you can type a query immediately. Press Esc to dismiss. |
|
|
57
64
|
| `--no-update-check` | off | Suppress the "newer version available" check (also via `NO_UPDATE_NOTIFIER=1`) |
|
|
58
65
|
| `--config-path` | — | Print the resolved config-file path and exit |
|
|
59
66
|
| `-h`, `--help` | — | Show help and exit |
|
|
@@ -75,19 +82,19 @@ theme = "tokyonight"
|
|
|
75
82
|
tone = "dark"
|
|
76
83
|
mdx = true
|
|
77
84
|
show = ["hidden", "gitignored"]
|
|
78
|
-
|
|
85
|
+
focus = "filter"
|
|
79
86
|
```
|
|
80
87
|
|
|
81
|
-
Supported keys: `theme`, `tone`, `mdx`, `show`, `
|
|
88
|
+
Supported keys: `theme`, `tone`, `mdx`, `show`, `focus`.
|
|
82
89
|
|
|
83
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.
|
|
84
91
|
|
|
85
92
|
Precedence, highest to lowest:
|
|
86
93
|
|
|
87
|
-
1. CLI flags (`--theme`, `--tone`, `--no-mdx`, `--show`, `--
|
|
88
|
-
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`, `HOUSE_SHOW`, `
|
|
94
|
+
1. CLI flags (`--theme`, `--tone`, `--no-mdx`, `--show`, `--focus`)
|
|
95
|
+
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_MDX`, `HOUSE_SHOW`, `HOUSE_FOCUS`)
|
|
89
96
|
3. Config file
|
|
90
|
-
4. Built-in defaults (`opencode` / `dark` / `mdx = true` / `show = []` / `
|
|
97
|
+
4. Built-in defaults (`opencode` / `dark` / `mdx = true` / `show = []` / `focus = "filter"`)
|
|
91
98
|
|
|
92
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.
|
|
93
100
|
|
|
@@ -142,6 +149,9 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
|
|
|
142
149
|
|
|
143
150
|
Each theme supports dark and light tones. Cycle with `t` / `T`; toggle tone with `L`.
|
|
144
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
|
+
|
|
145
155
|
## Inspiration
|
|
146
156
|
|
|
147
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.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "TUI-first markdown reader on opentui",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"url": "https://github.com/carlesandres/house/issues"
|
|
13
13
|
},
|
|
14
14
|
"homepage": "https://github.com/carlesandres/house#readme",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.22.2"
|
|
17
|
+
},
|
|
15
18
|
"keywords": [
|
|
16
19
|
"markdown",
|
|
17
20
|
"reader",
|
|
@@ -53,6 +56,7 @@
|
|
|
53
56
|
"@effect/atom-react": "4.0.0-beta.70",
|
|
54
57
|
"@opentui/core": "0.2.15",
|
|
55
58
|
"@opentui/react": "0.2.15",
|
|
59
|
+
"commander": "^14.0.3",
|
|
56
60
|
"effect": "4.0.0-beta.70",
|
|
57
61
|
"ignore": "^7.0.5",
|
|
58
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
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|
|
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.
|
|
957
|
+
backgroundColor: readerActive ? colors.background : colors.backgroundPanel,
|
|
878
958
|
}}
|
|
879
959
|
>
|
|
880
|
-
<
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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.
|
|
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.
|
|
1000
|
+
bg={readerActive ? colors.background : colors.backgroundPanel}
|
|
911
1001
|
conceal
|
|
912
1002
|
style={{ width: maxWidth ?? "100%" }}
|
|
913
1003
|
/>
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -102,7 +102,7 @@ export const CommandPalette = ({
|
|
|
102
102
|
border: true,
|
|
103
103
|
borderColor: colors.textMuted,
|
|
104
104
|
flexDirection: "column",
|
|
105
|
-
backgroundColor: colors.
|
|
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`/`
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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).
|
|
202
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
61
|
+
backgroundColor: colors.backgroundPanel,
|
|
62
62
|
}}
|
|
63
63
|
>
|
|
64
64
|
<text content={left} wrapMode="none" style={{ fg: colors.text }} />
|
package/src/HelpOverlay.tsx
CHANGED