@carlesandres/house 0.4.12 → 0.4.14
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 +25 -1
- package/README.md +14 -8
- package/package.json +1 -1
- package/src/Browser.tsx +65 -43
- package/src/CommandPalette.tsx +5 -2
- package/src/Footer.tsx +55 -28
- package/src/Header.tsx +113 -24
- package/src/StatusIndicator.tsx +55 -0
- package/src/StatusPopover.tsx +4 -27
- package/src/cli/argv.ts +19 -11
- package/src/commands/buildCommands.ts +1 -0
- package/src/commands/paletteOnlyCommands.ts +12 -1
- package/src/config/load.ts +108 -40
- package/src/discovery/rootLabel.ts +18 -0
- package/src/index.tsx +51 -37
- package/src/io/clipboard.ts +53 -0
- package/src/keymap/browser.ts +15 -0
- package/src/layout/resolve.ts +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ The publish workflow (`.github/workflows/publish.yml`) runs on the `release: pub
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.14] — 2026-06-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Browser header now shows the active discovery root, and empty states reuse the same canonical root label for consistent launch context.
|
|
14
|
+
|
|
15
|
+
## [0.4.13] — 2026-06-15
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Command palette now includes a `Copy file contents` action for the selected markdown file, copying the exact on-disk text to the system clipboard on macOS and Linux.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Reader wrapping is now a session mode toggled with `w`: `width` / `--width` defines the fixed wrap width, while `wrap` / `--wrap` / `--no-wrap` controls startup mode. The footer now keeps a persistent `W` indicator for wrap state.
|
|
24
|
+
- The built-in startup focus now defaults to `sidebar` instead of opening the filter prompt immediately; use `--focus filter`, `HOUSE_FOCUS=filter`, or `focus = "filter"` to keep the previous behavior.
|
|
25
|
+
- Removed `--sidebar` as a startup mode; the sidebar now starts visible and remains controlled interactively with `s`, `tab`, and `/` during the session.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Footer status chrome, palette sizing, and reader wrap width calculations now stay within the visible pane.
|
|
30
|
+
|
|
9
31
|
## [0.4.12] — 2026-06-14
|
|
10
32
|
|
|
11
33
|
### Fixed
|
|
@@ -317,7 +339,9 @@ The v1 MVP, published as `@carlesandres/openmdr` on npm.
|
|
|
317
339
|
|
|
318
340
|
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.
|
|
319
341
|
|
|
320
|
-
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.
|
|
342
|
+
[Unreleased]: https://github.com/carlesandres/house/compare/v0.4.14...HEAD
|
|
343
|
+
[0.4.14]: https://github.com/carlesandres/house/compare/v0.4.13...v0.4.14
|
|
344
|
+
[0.4.13]: https://github.com/carlesandres/house/compare/v0.4.12...v0.4.13
|
|
321
345
|
[0.4.12]: https://github.com/carlesandres/house/compare/v0.4.11...v0.4.12
|
|
322
346
|
[0.4.11]: https://github.com/carlesandres/house/compare/v0.4.10...v0.4.11
|
|
323
347
|
[0.4.10]: https://github.com/carlesandres/house/compare/v0.4.9...v0.4.10
|
package/README.md
CHANGED
|
@@ -64,11 +64,12 @@ house --serve README.md
|
|
|
64
64
|
| -------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
65
65
|
| `--theme <name>` | `opencode` | Starting theme (see list below) |
|
|
66
66
|
| `--tone dark\|light` | `dark` | Starting tone |
|
|
67
|
-
| `--width <N>` |
|
|
67
|
+
| `--width <N>` | `80` | Reader wrap width used when wrapping is enabled |
|
|
68
|
+
| `--wrap` | off | Start with reader wrapping enabled |
|
|
69
|
+
| `--no-wrap` | off | Start with reader wrapping disabled |
|
|
68
70
|
| `--show <list>` | `""` | Reveal normally-skipped entries; comma-separated subset of `hidden`, `gitignored`. Use `--show ""` to clear. |
|
|
69
71
|
| `--root <dir>` | current directory | Discovery root to walk; overrides `defaultRoot` config/env |
|
|
70
|
-
| `--
|
|
71
|
-
| `--focus <mode>` | `filter` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
|
|
72
|
+
| `--focus <mode>` | `sidebar` | Startup focus: `sidebar`, `reader`, or `filter`. `filter` opens the sidebar filter prompt immediately. |
|
|
72
73
|
| `--serve` | off | Serve the positional path as HTML in the browser (skips TUI) |
|
|
73
74
|
| `--port <N>` | OS-assigned | Port for `--serve` |
|
|
74
75
|
| `--ext <list>` | none | Include extra file extensions (comma-separated) |
|
|
@@ -92,24 +93,28 @@ Run `house --config-path` to print the exact location.
|
|
|
92
93
|
theme = "tokyonight"
|
|
93
94
|
tone = "dark"
|
|
94
95
|
extensions = []
|
|
96
|
+
width = 80
|
|
97
|
+
wrap = false
|
|
95
98
|
show = ["hidden", "gitignored"]
|
|
96
|
-
focus = "
|
|
99
|
+
focus = "sidebar"
|
|
97
100
|
defaultRoot = "cwd" # or "git"
|
|
98
101
|
```
|
|
99
102
|
|
|
100
|
-
Supported keys: `theme`, `tone`, `extensions`, `show`, `focus`, `defaultRoot`.
|
|
103
|
+
Supported keys: `theme`, `tone`, `extensions`, `width`, `wrap`, `show`, `focus`, `defaultRoot`.
|
|
101
104
|
|
|
102
105
|
`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.
|
|
103
106
|
|
|
104
107
|
Precedence, highest to lowest:
|
|
105
108
|
|
|
106
|
-
1. CLI flags (`--theme`, `--tone`, `--ext`, `--show`, `--focus`, `--root`)
|
|
107
|
-
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
|
|
109
|
+
1. CLI flags (`--theme`, `--tone`, `--ext`, `--width`, `--wrap`, `--no-wrap`, `--show`, `--focus`, `--root`)
|
|
110
|
+
2. Env vars (`HOUSE_THEME`, `HOUSE_TONE`, `HOUSE_EXTENSIONS`, `HOUSE_WIDTH`, `HOUSE_WRAP`, `HOUSE_SHOW`, `HOUSE_FOCUS`, `HOUSE_DEFAULT_ROOT`)
|
|
108
111
|
3. Config file
|
|
109
|
-
4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `show = []` / `focus = "
|
|
112
|
+
4. Built-in defaults (`opencode` / `dark` / `extensions = []` / `width = 80` / `wrap = false` / `show = []` / `focus = "sidebar"` / `defaultRoot = "cwd"`)
|
|
110
113
|
|
|
111
114
|
`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.
|
|
112
115
|
|
|
116
|
+
`width` is the fixed reader width used when wrapping is enabled. `wrap` controls startup mode; press `w` in the TUI to toggle reader wrapping for the current session without editing config.
|
|
117
|
+
|
|
113
118
|
The file is optional — a missing file is fine. Invalid keys, unknown themes, or malformed TOML fail loudly with a one-line error. Per-project config (`.house/config.toml`) and additional keys are deferred.
|
|
114
119
|
|
|
115
120
|
## Keys
|
|
@@ -122,6 +127,7 @@ The file is optional — a missing file is fine. Invalid keys, unknown themes, o
|
|
|
122
127
|
| `tab` | Toggle focus (sidebar ↔ reader) |
|
|
123
128
|
| `s` | Toggle sidebar visibility |
|
|
124
129
|
| `ctrl+p` | Command palette |
|
|
130
|
+
| `w` | Toggle reader wrap |
|
|
125
131
|
| `O` | Open current file in browser as HTML |
|
|
126
132
|
| `E` | Open current file in `$EDITOR` (`$VISUAL` takes precedence) |
|
|
127
133
|
| `t` | Next theme |
|
package/package.json
CHANGED
package/src/Browser.tsx
CHANGED
|
@@ -25,16 +25,12 @@ import { parseFrontmatter } from "./markdown/frontmatter.ts"
|
|
|
25
25
|
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
26
26
|
import { Footer, FOOTER_HEIGHT, type FooterProps } from "./Footer.tsx"
|
|
27
27
|
import { Header, HEADER_HEIGHT } from "./Header.tsx"
|
|
28
|
+
import { copyTextToClipboard } from "./io/clipboard.ts"
|
|
28
29
|
import { openInEditor, resolveEditor } from "./io/editor.ts"
|
|
29
30
|
import { readFileText } from "./io/readFile.ts"
|
|
30
31
|
import { browserBindings, type BrowserCtx } from "./keymap/browser.ts"
|
|
31
32
|
import { dispatch } from "./keymap/keymap.ts"
|
|
32
|
-
import {
|
|
33
|
-
canFitInline,
|
|
34
|
-
defaultPreferredWidth,
|
|
35
|
-
initialShownForAuto,
|
|
36
|
-
resolveSidebarWidth,
|
|
37
|
-
} from "./layout/resolve.ts"
|
|
33
|
+
import { canFitInline, defaultPreferredWidth, resolveSidebarWidth } from "./layout/resolve.ts"
|
|
38
34
|
import { fitSidebarEmptyValue } from "./layout/sidebarEmptyState.ts"
|
|
39
35
|
import { formatSidebarRow } from "./layout/sidebarRow.ts"
|
|
40
36
|
import { PromptRow } from "./PromptRow.tsx"
|
|
@@ -48,7 +44,6 @@ import { themeDefinitions, getThemeDefinition } from "./theme/registry.ts"
|
|
|
48
44
|
import { saveThemePreference } from "./config/save.ts"
|
|
49
45
|
import { middleTruncate } from "./ui/middleTruncate.ts"
|
|
50
46
|
|
|
51
|
-
export type SidebarMode = "auto" | "on" | "off"
|
|
52
47
|
export type StartupFocus = "sidebar" | "reader" | "filter"
|
|
53
48
|
|
|
54
49
|
export interface BrowserProps {
|
|
@@ -56,10 +51,12 @@ export interface BrowserProps {
|
|
|
56
51
|
readonly initialIndex?: number
|
|
57
52
|
/** Initial applied filter query seeded from the CLI positional. */
|
|
58
53
|
readonly initialQuery?: string
|
|
59
|
-
/**
|
|
60
|
-
readonly
|
|
61
|
-
/**
|
|
62
|
-
readonly
|
|
54
|
+
/** Reader wrap width used when wrapping is enabled. */
|
|
55
|
+
readonly wrapWidth?: number
|
|
56
|
+
/** Initial reader wrap mode. Runtime toggles are session-only. */
|
|
57
|
+
readonly initialWrap?: boolean
|
|
58
|
+
/** Canonical discovery root label used anywhere the UI names the scan scope. */
|
|
59
|
+
readonly rootLabel?: string
|
|
63
60
|
/** Persistent footer indicator (e.g. "indexing… 42"). Pass null/undefined
|
|
64
61
|
* when discovery has finished; the indicator clears. */
|
|
65
62
|
readonly discoveryStatus?: string | null
|
|
@@ -74,12 +71,11 @@ export interface BrowserProps {
|
|
|
74
71
|
readonly renderedPathDebounceMs?: number
|
|
75
72
|
/** Test seam: disable reader-empty-state tip rotation effect. */
|
|
76
73
|
readonly disableReaderEmptyStateRotation?: boolean
|
|
77
|
-
/** Initial sidebar visibility (`--sidebar` flag). `auto` consults the
|
|
78
|
-
* launch viewport bucket once; subsequent visibility goes through `s`. */
|
|
79
|
-
readonly sidebarMode?: SidebarMode
|
|
80
74
|
readonly onQuit?: () => void
|
|
81
75
|
/** Test seam: replaces the file reader. */
|
|
82
76
|
readonly readFile?: (path: string) => Promise<string>
|
|
77
|
+
/** Test seam: replaces the system clipboard writer. */
|
|
78
|
+
readonly copyToClipboard?: (text: string) => Promise<void>
|
|
83
79
|
/** Optional one-shot footer toast surfaced on first appearance (e.g. the
|
|
84
80
|
* "update available" nudge). Shown with an extended TTL so the user has
|
|
85
81
|
* time to read it; subsequent transient toasts (theme cycle, etc.)
|
|
@@ -189,8 +185,9 @@ export const Browser = ({
|
|
|
189
185
|
files,
|
|
190
186
|
initialIndex = 0,
|
|
191
187
|
initialQuery = "",
|
|
192
|
-
|
|
193
|
-
|
|
188
|
+
wrapWidth = 80,
|
|
189
|
+
initialWrap = false,
|
|
190
|
+
rootLabel = "current root",
|
|
194
191
|
discoveryStatus = null,
|
|
195
192
|
discoverySpinnerIntervalMs,
|
|
196
193
|
discoverySpinnerInitialFrameIndex,
|
|
@@ -198,9 +195,9 @@ export const Browser = ({
|
|
|
198
195
|
filterDebounceMs = FILTER_DEBOUNCE_MS,
|
|
199
196
|
renderedPathDebounceMs = RENDERED_PATH_DEBOUNCE_MS,
|
|
200
197
|
disableReaderEmptyStateRotation = false,
|
|
201
|
-
sidebarMode = "auto",
|
|
202
198
|
onQuit,
|
|
203
199
|
readFile = defaultReadFile,
|
|
200
|
+
copyToClipboard = copyTextToClipboard,
|
|
204
201
|
updateNotice = null,
|
|
205
202
|
updateNoticeTtlMs = 10000,
|
|
206
203
|
disableFooterNoticeAutoClear = false,
|
|
@@ -216,24 +213,13 @@ export const Browser = ({
|
|
|
216
213
|
const [selectedIndex, setSelectedIndex] = useState(() =>
|
|
217
214
|
clamp(initialIndex, 0, Math.max(0, files.length - 1)),
|
|
218
215
|
)
|
|
216
|
+
const [wrapEnabled, setWrapEnabled] = useState(initialWrap)
|
|
219
217
|
const [loaded, setLoaded] = useState<{ path: string; content: string } | null>(null)
|
|
220
218
|
const [error, setError] = useState<string | null>(null)
|
|
221
|
-
// `shown` is the user's sticky preference.
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
// useState initializer pins this to the first render — buckets are
|
|
226
|
-
// launch-only by design, so resize must NOT re-evaluate.
|
|
227
|
-
const [shown, setShown] = useState<boolean>(() => {
|
|
228
|
-
switch (sidebarMode) {
|
|
229
|
-
case "on":
|
|
230
|
-
return true
|
|
231
|
-
case "off":
|
|
232
|
-
return false
|
|
233
|
-
case "auto":
|
|
234
|
-
return initialShownForAuto(width)
|
|
235
|
-
}
|
|
236
|
-
})
|
|
219
|
+
// `shown` is the user's sticky interactive preference. The sidebar starts
|
|
220
|
+
// visible; after launch, `s`/`tab`/`/` may hide or reveal it. Visibility is
|
|
221
|
+
// derived: `visible = shown || focus === "sidebar"`. See DESIGN.md §7.1.
|
|
222
|
+
const [shown, setShown] = useState<boolean>(true)
|
|
237
223
|
const startInFilter = startupFocus === "filter"
|
|
238
224
|
const initialFocus: "sidebar" | "reader" =
|
|
239
225
|
startupFocus === null
|
|
@@ -245,13 +231,12 @@ export const Browser = ({
|
|
|
245
231
|
: "sidebar"
|
|
246
232
|
// `filter` mirrors `openFilter`'s focus rule: the filter input lives in
|
|
247
233
|
// the sidebar, so opening it on mount also forces sidebar focus regardless
|
|
248
|
-
// of
|
|
249
|
-
// focus even when `shown` is false). Plain
|
|
250
|
-
// same pane focus without opening the prompt.
|
|
251
|
-
// legacy Browser behavior: initial focus follows
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
234
|
+
// of the current interactive visibility state (§7.1's visibility derivation
|
|
235
|
+
// surfaces the sidebar via focus even when `shown` is false). Plain
|
|
236
|
+
// `sidebar` startup shares the same pane focus without opening the prompt.
|
|
237
|
+
// When omitted, preserve the legacy Browser behavior: initial focus follows
|
|
238
|
+
// visibility.
|
|
239
|
+
const [focus, setFocus] = useState<"sidebar" | "reader">(() => initialFocus)
|
|
255
240
|
const [sidebarScroll, setSidebarScroll] = useState<number>(0)
|
|
256
241
|
const [filterOpen, setFilterOpen] = useState<boolean>(startInFilter)
|
|
257
242
|
const [filterInput, setFilterInput] = useState<string>(initialQuery)
|
|
@@ -492,6 +477,7 @@ export const Browser = ({
|
|
|
492
477
|
restoreFilterOnSidebarFocus: restoreFilterOnSidebarFocusRef.current,
|
|
493
478
|
filterQuery: filterInput,
|
|
494
479
|
paletteOpen,
|
|
480
|
+
wrapEnabled,
|
|
495
481
|
setFocus,
|
|
496
482
|
// Wrapped so any keymap-driven selection move (j/k/g/G/[/], reader
|
|
497
483
|
// prev/next) clears the pending-restore ref from #145. Internal
|
|
@@ -565,6 +551,7 @@ export const Browser = ({
|
|
|
565
551
|
setPaletteIndex(0)
|
|
566
552
|
dispatchFloatingOverlay({ type: "open-command-palette" })
|
|
567
553
|
},
|
|
554
|
+
toggleWrap: () => setWrapEnabled((prev) => !prev),
|
|
568
555
|
cycleTheme,
|
|
569
556
|
toggleTone,
|
|
570
557
|
toggleAll: () => {
|
|
@@ -674,6 +661,26 @@ export const Browser = ({
|
|
|
674
661
|
}
|
|
675
662
|
})()
|
|
676
663
|
},
|
|
664
|
+
copyCurrentContents: () => {
|
|
665
|
+
const file = displayedFiles[selectedIndex]
|
|
666
|
+
if (!file) return
|
|
667
|
+
void (async () => {
|
|
668
|
+
let text: string
|
|
669
|
+
try {
|
|
670
|
+
text = await readFile(file.path)
|
|
671
|
+
} catch {
|
|
672
|
+
pushFooterNotice(`copy failed: cannot read ${file.relativePath}`)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
await copyToClipboard(text)
|
|
677
|
+
pushFooterNotice(`copied ${file.relativePath}`)
|
|
678
|
+
} catch (err) {
|
|
679
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
680
|
+
pushFooterNotice(`copy failed: ${message}`)
|
|
681
|
+
}
|
|
682
|
+
})()
|
|
683
|
+
},
|
|
677
684
|
}
|
|
678
685
|
|
|
679
686
|
useKeyboard((key) => {
|
|
@@ -860,6 +867,12 @@ export const Browser = ({
|
|
|
860
867
|
const isNarrow = !canFitInline(width)
|
|
861
868
|
const sidebarInline = isNarrow ? sidebarActive : shown || sidebarActive
|
|
862
869
|
const readerVisible = isNarrow ? readerActive : true
|
|
870
|
+
// When fixed-width wrapping is enabled, never size markdown wider than the
|
|
871
|
+
// visible reader body. The scrollbox deliberately disables horizontal
|
|
872
|
+
// scrolling, so an over-wide markdown node clips instead of wrapping.
|
|
873
|
+
const readerPaneWidth = isNarrow || !sidebarInline ? width : Math.max(1, width - sidebarWidth)
|
|
874
|
+
const readerBodyWidth = Math.max(1, readerPaneWidth - 2) // reader padding: 1 left + 1 right
|
|
875
|
+
const markdownWidth = wrapEnabled ? Math.min(wrapWidth, readerBodyWidth) : "100%"
|
|
863
876
|
// Currently-selected file shown in the Header (which replaced the
|
|
864
877
|
// per-pane border title that used to carry this information).
|
|
865
878
|
const currentFile = selected?.relativePath ?? null
|
|
@@ -924,6 +937,15 @@ export const Browser = ({
|
|
|
924
937
|
width,
|
|
925
938
|
notice: footerNotice?.text ?? null,
|
|
926
939
|
discoveryStatus,
|
|
940
|
+
indicators: [
|
|
941
|
+
{
|
|
942
|
+
id: "wrap",
|
|
943
|
+
icon: "W",
|
|
944
|
+
variant: "info",
|
|
945
|
+
active: wrapEnabled,
|
|
946
|
+
onMouseUp: () => setWrapEnabled((prev) => !prev),
|
|
947
|
+
},
|
|
948
|
+
],
|
|
927
949
|
...(discoverySpinnerIntervalMs === undefined ? {} : { discoverySpinnerIntervalMs }),
|
|
928
950
|
...(discoverySpinnerInitialFrameIndex === undefined
|
|
929
951
|
? {}
|
|
@@ -968,7 +990,7 @@ export const Browser = ({
|
|
|
968
990
|
: "No markdown files in"
|
|
969
991
|
: "No files match"
|
|
970
992
|
}
|
|
971
|
-
value={files.length === 0 ? (discoveryActive ? "…" :
|
|
993
|
+
value={files.length === 0 ? (discoveryActive ? "…" : rootLabel) : filterApplied}
|
|
972
994
|
/>
|
|
973
995
|
) : (
|
|
974
996
|
visibleFiles.map((file, idx) => {
|
|
@@ -1022,7 +1044,7 @@ export const Browser = ({
|
|
|
1022
1044
|
<box
|
|
1023
1045
|
style={{ width, height, flexDirection: "column", backgroundColor: colors.backgroundPanel }}
|
|
1024
1046
|
>
|
|
1025
|
-
<Header width={width} currentFile={currentFile} />
|
|
1047
|
+
<Header width={width} currentFile={currentFile} rootLabel={rootLabel} />
|
|
1026
1048
|
<box
|
|
1027
1049
|
style={{
|
|
1028
1050
|
flexDirection: "row",
|
|
@@ -1162,7 +1184,7 @@ export const Browser = ({
|
|
|
1162
1184
|
fg={colors.text}
|
|
1163
1185
|
bg={readerActive ? colors.background : colors.backgroundPanel}
|
|
1164
1186
|
conceal
|
|
1165
|
-
style={{ width:
|
|
1187
|
+
style={{ width: markdownWidth }}
|
|
1166
1188
|
/>
|
|
1167
1189
|
</scrollbox>
|
|
1168
1190
|
)}
|
package/src/CommandPalette.tsx
CHANGED
|
@@ -88,9 +88,12 @@ export const CommandPalette = ({
|
|
|
88
88
|
const overlayWidth = Math.min(viewportWidth - 4, 64)
|
|
89
89
|
const rows = buildRows(commands)
|
|
90
90
|
// Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
|
|
91
|
-
// 1 spacer above footer, 1 footer row. Body gets the rest.
|
|
91
|
+
// 1 spacer above footer, 1 footer row. Body gets the rest. Keep a one-row
|
|
92
|
+
// viewport margin above and below when possible; the modal itself already
|
|
93
|
+
// owns a border and clips its body, so a larger fixed margin needlessly hides
|
|
94
|
+
// the final command on medium-height terminals.
|
|
92
95
|
const chrome = 2 + 1 + 1 + 1 + 1
|
|
93
|
-
const maxBody = Math.max(1, viewportHeight -
|
|
96
|
+
const maxBody = Math.max(1, viewportHeight - 2 - chrome)
|
|
94
97
|
const desiredBody = Math.max(1, rows.length || 1)
|
|
95
98
|
const bodyHeight = Math.min(desiredBody, maxBody)
|
|
96
99
|
const overlayHeight = chrome + bodyHeight
|
package/src/Footer.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import type React from "react"
|
|
|
24
24
|
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
25
25
|
import { displayKey } from "./keymap/displayKey.ts"
|
|
26
26
|
import { Spinner } from "./Spinner.tsx"
|
|
27
|
+
import { StatusIndicator, type StatusIndicatorProps } from "./StatusIndicator.tsx"
|
|
27
28
|
import { colors } from "./theme/colors.ts"
|
|
28
29
|
|
|
29
30
|
/** Rows the Footer occupies. Importers use it for layout math so a future
|
|
@@ -39,6 +40,8 @@ export interface FooterProps<C> {
|
|
|
39
40
|
* no TTL, cleared by the caller when the underlying activity finishes.
|
|
40
41
|
* Loses to `notice` when both are set so transient toasts still surface. */
|
|
41
42
|
readonly discoveryStatus?: string | null
|
|
43
|
+
/** Persistent compact state/status indicators, rendered after built-in warning indicators. */
|
|
44
|
+
readonly indicators?: readonly (StatusIndicatorProps & { readonly id: string })[]
|
|
42
45
|
/** Test seam: override spinner tick speed so tests don't sleep on the full
|
|
43
46
|
* production interval. Ignored when discoveryStatus is null. */
|
|
44
47
|
readonly discoverySpinnerIntervalMs?: number
|
|
@@ -86,6 +89,7 @@ const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
|
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
const STATUS_SEPARATOR = " · "
|
|
92
|
+
const NON_WARNING_STATUS_PREFIX_WIDTH = 2 // 1-cell spinner + 1-cell spacer
|
|
89
93
|
|
|
90
94
|
const isPartialDiscoveryWarning = (status: string | null): boolean =>
|
|
91
95
|
status?.startsWith("scan incomplete:") ?? false
|
|
@@ -96,6 +100,7 @@ export const Footer = <C,>({
|
|
|
96
100
|
width,
|
|
97
101
|
notice,
|
|
98
102
|
discoveryStatus,
|
|
103
|
+
indicators = [],
|
|
99
104
|
discoverySpinnerIntervalMs,
|
|
100
105
|
discoverySpinnerInitialFrameIndex,
|
|
101
106
|
discoverySpinnerRegisterTick,
|
|
@@ -130,19 +135,48 @@ export const Footer = <C,>({
|
|
|
130
135
|
const status =
|
|
131
136
|
discoveryStatus && discoveryStatus.length > 0 ? normalizeStatusLine(discoveryStatus) : null
|
|
132
137
|
const isPartialWarning = isPartialDiscoveryWarning(status)
|
|
138
|
+
const renderedIndicators = [
|
|
139
|
+
...(isPartialWarning
|
|
140
|
+
? [
|
|
141
|
+
{
|
|
142
|
+
id: "discovery-warning",
|
|
143
|
+
icon: "!",
|
|
144
|
+
variant: "warning" as const,
|
|
145
|
+
active: true,
|
|
146
|
+
...(onDiscoveryWarningToggle === undefined
|
|
147
|
+
? {}
|
|
148
|
+
: { onMouseUp: onDiscoveryWarningToggle }),
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
: []),
|
|
152
|
+
...indicators,
|
|
153
|
+
]
|
|
154
|
+
const indicatorBudget = Math.min(usableWidth, renderedIndicators.length * 3)
|
|
155
|
+
const contentBudget = Math.max(0, usableWidth - indicatorBudget)
|
|
156
|
+
const statusChromeWidth = status
|
|
157
|
+
? (isPartialWarning ? 0 : NON_WARNING_STATUS_PREFIX_WIDTH) + STATUS_SEPARATOR.length
|
|
158
|
+
: 0
|
|
133
159
|
const statusBudget = status
|
|
134
|
-
? Math.min((isPartialWarning ?
|
|
160
|
+
? Math.min((isPartialWarning ? 0 : status.length) + statusChromeWidth, contentBudget)
|
|
135
161
|
: 0
|
|
136
|
-
const hintsWidth = Math.max(0,
|
|
162
|
+
const hintsWidth = Math.max(0, contentBudget - statusBudget)
|
|
137
163
|
const visibleHints = fitHints(hints, hintsWidth)
|
|
138
|
-
const
|
|
139
|
-
?
|
|
164
|
+
const nonWarningStatusPrefixBudget =
|
|
165
|
+
status && !isPartialWarning ? Math.min(NON_WARNING_STATUS_PREFIX_WIDTH, statusBudget) : 0
|
|
166
|
+
const statusContent = status ? status.slice(0, Math.max(0, statusBudget - statusChromeWidth)) : ""
|
|
167
|
+
const statusSeparatorContent = status
|
|
168
|
+
? STATUS_SEPARATOR.slice(
|
|
169
|
+
0,
|
|
170
|
+
Math.max(0, statusBudget - nonWarningStatusPrefixBudget - statusContent.length),
|
|
171
|
+
)
|
|
140
172
|
: ""
|
|
141
173
|
const noticeContent = notice
|
|
142
|
-
? notice.length >
|
|
143
|
-
? notice.slice(0,
|
|
174
|
+
? notice.length > contentBudget
|
|
175
|
+
? notice.slice(0, contentBudget)
|
|
144
176
|
: notice
|
|
145
177
|
: null
|
|
178
|
+
const renderIndicators = () =>
|
|
179
|
+
renderedIndicators.map(({ id, ...props }) => <StatusIndicator key={id} {...props} />)
|
|
146
180
|
|
|
147
181
|
// Two-tone hint row: keys render in `text` (foreground-strength), the
|
|
148
182
|
// `:label` portion in `textMuted`. Matches ghui's footer treatment so
|
|
@@ -177,25 +211,12 @@ export const Footer = <C,>({
|
|
|
177
211
|
if (noticeContent !== null) {
|
|
178
212
|
return (
|
|
179
213
|
<box style={rowStyle}>
|
|
214
|
+
{renderIndicators()}
|
|
180
215
|
<text content={noticeContent} wrapMode="none" style={{ fg: colors.primary }} />
|
|
181
216
|
</box>
|
|
182
217
|
)
|
|
183
218
|
}
|
|
184
219
|
|
|
185
|
-
const warningTrigger = isPartialWarning ? (
|
|
186
|
-
<box
|
|
187
|
-
{...(onDiscoveryWarningToggle === undefined ? {} : { onMouseUp: onDiscoveryWarningToggle })}
|
|
188
|
-
style={{
|
|
189
|
-
width: 1,
|
|
190
|
-
height: 1,
|
|
191
|
-
flexDirection: "row",
|
|
192
|
-
backgroundColor: colors.backgroundElement,
|
|
193
|
-
}}
|
|
194
|
-
>
|
|
195
|
-
<text content="!" wrapMode="none" style={{ fg: colors.warning, attributes: 1 }} />
|
|
196
|
-
</box>
|
|
197
|
-
) : null
|
|
198
|
-
|
|
199
220
|
if (status !== null) {
|
|
200
221
|
const spinnerProps = {
|
|
201
222
|
fg: colors.secondary,
|
|
@@ -212,20 +233,26 @@ export const Footer = <C,>({
|
|
|
212
233
|
|
|
213
234
|
return (
|
|
214
235
|
<box style={rowStyle}>
|
|
215
|
-
{
|
|
216
|
-
{
|
|
236
|
+
{renderIndicators()}
|
|
237
|
+
{nonWarningStatusPrefixBudget >= 1 ? <Spinner {...spinnerProps} /> : null}
|
|
238
|
+
{nonWarningStatusPrefixBudget >= 2 ? (
|
|
217
239
|
<text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
218
|
-
)}
|
|
219
|
-
{isPartialWarning ? (
|
|
220
|
-
warningTrigger
|
|
221
|
-
) : (
|
|
240
|
+
) : null}
|
|
241
|
+
{isPartialWarning ? null : (
|
|
222
242
|
<text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
|
|
223
243
|
)}
|
|
224
|
-
|
|
244
|
+
{statusSeparatorContent.length > 0 && (
|
|
245
|
+
<text content={statusSeparatorContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
246
|
+
)}
|
|
225
247
|
{renderHints()}
|
|
226
248
|
</box>
|
|
227
249
|
)
|
|
228
250
|
}
|
|
229
251
|
|
|
230
|
-
return
|
|
252
|
+
return (
|
|
253
|
+
<box style={rowStyle}>
|
|
254
|
+
{renderIndicators()}
|
|
255
|
+
{renderHints()}
|
|
256
|
+
</box>
|
|
257
|
+
)
|
|
231
258
|
}
|