@carlesandres/house 0.4.7 → 0.4.8
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 +27 -3
- package/README.md +21 -9
- package/package.json +1 -1
- package/src/Browser.tsx +235 -160
- package/src/CommandPalette.tsx +78 -14
- package/src/Footer.tsx +41 -33
- package/src/Header.tsx +4 -1
- package/src/StatusPopover.tsx +189 -0
- package/src/cli/argv.ts +12 -5
- package/src/commands/buildCommands.ts +22 -33
- package/src/commands/score.ts +1 -1
- package/src/discovery/walk.ts +20 -3
- package/src/index.tsx +120 -155
- package/src/keymap/browser.ts +14 -20
- package/src/keymap/keymap.ts +4 -4
- package/src/layout/sidebarEmptyState.ts +7 -0
- package/src/markdown/frontmatter.ts +62 -0
- package/src/theme/resolve.ts +1 -1
- package/src/theme/themes/aura.json +1 -1
- package/src/theme/themes/carbonfox.json +1 -1
- package/src/theme/themes/lucent-orng.json +4 -4
- package/src/theme/themes/nightowl.json +2 -2
- package/src/theme/themes/orng.json +2 -2
- package/src/theme/themes/solarized.json +6 -2
- package/src/theme/themes/vesper.json +2 -2
- package/src/tips.ts +0 -5
- package/src/update/check.ts +1 -1
- package/src/update/runtime.ts +1 -1
- package/src/HelpOverlay.tsx +0 -148
package/src/CommandPalette.tsx
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CommandPalette — modal overlay for searching and running commands.
|
|
3
3
|
*
|
|
4
|
-
* Render-only: state lives in Browser.tsx alongside
|
|
4
|
+
* Render-only: state lives in Browser.tsx alongside filterOpen
|
|
5
5
|
* (#70 design log §state location). Key handling sits in Browser.tsx's
|
|
6
6
|
* `useKeyboard` palette-branch, mirroring the filterOpen pattern.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Commands are grouped by semantic category while preserving command order
|
|
9
|
+
* inside each group. Browser.tsx owns the query and selected command index;
|
|
10
|
+
* this component turns those commands into render rows and keeps the selected
|
|
11
|
+
* row visible when the list is taller than the modal body.
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
14
|
import { RGBA } from "@opentui/core"
|
|
@@ -29,6 +31,52 @@ export interface CommandPaletteProps {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
const FOOTER_HINT = "↑↓ select enter run esc close"
|
|
34
|
+
const CATEGORY_ORDER = ["Navigation", "View", "File", "Appearance", "App"] as const
|
|
35
|
+
|
|
36
|
+
type PaletteRow =
|
|
37
|
+
| { readonly kind: "spacer"; readonly key: string }
|
|
38
|
+
| { readonly kind: "header"; readonly key: string; readonly text: string }
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: "command"
|
|
41
|
+
readonly key: string
|
|
42
|
+
readonly command: AppCommand
|
|
43
|
+
readonly commandIndex: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const orderCommandsForPalette = (commands: readonly AppCommand[]): readonly AppCommand[] => {
|
|
47
|
+
const grouped = new Map<string, AppCommand[]>()
|
|
48
|
+
for (const command of commands) {
|
|
49
|
+
const category = command.category ?? "Other"
|
|
50
|
+
const list = grouped.get(category)
|
|
51
|
+
if (list) list.push(command)
|
|
52
|
+
else grouped.set(category, [command])
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const orderedCategories = [
|
|
56
|
+
...CATEGORY_ORDER.filter((category) => grouped.has(category)),
|
|
57
|
+
...Array.from(grouped.keys()).filter((category) => !CATEGORY_ORDER.includes(category as never)),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
return orderedCategories.flatMap((category) => grouped.get(category) ?? [])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const buildRows = (commands: readonly AppCommand[]): readonly PaletteRow[] => {
|
|
64
|
+
const orderedCommands = orderCommandsForPalette(commands)
|
|
65
|
+
const rows: PaletteRow[] = []
|
|
66
|
+
let previousCategory: string | null = null
|
|
67
|
+
let commandIndex = 0
|
|
68
|
+
for (const command of orderedCommands) {
|
|
69
|
+
const category = command.category ?? "Other"
|
|
70
|
+
if (category !== previousCategory) {
|
|
71
|
+
if (previousCategory !== null) rows.push({ kind: "spacer", key: `spacer-${category}` })
|
|
72
|
+
rows.push({ kind: "header", key: `header-${category}`, text: category })
|
|
73
|
+
previousCategory = category
|
|
74
|
+
}
|
|
75
|
+
rows.push({ kind: "command", key: command.id, command, commandIndex })
|
|
76
|
+
commandIndex += 1
|
|
77
|
+
}
|
|
78
|
+
return rows
|
|
79
|
+
}
|
|
32
80
|
|
|
33
81
|
export const CommandPalette = ({
|
|
34
82
|
commands,
|
|
@@ -38,11 +86,12 @@ export const CommandPalette = ({
|
|
|
38
86
|
viewportHeight,
|
|
39
87
|
}: CommandPaletteProps) => {
|
|
40
88
|
const overlayWidth = Math.min(viewportWidth - 4, 64)
|
|
89
|
+
const rows = buildRows(commands)
|
|
41
90
|
// Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
|
|
42
91
|
// 1 spacer above footer, 1 footer row. Body gets the rest.
|
|
43
92
|
const chrome = 2 + 1 + 1 + 1 + 1
|
|
44
93
|
const maxBody = Math.max(1, viewportHeight - 4 - chrome)
|
|
45
|
-
const desiredBody = Math.max(1,
|
|
94
|
+
const desiredBody = Math.max(1, rows.length || 1)
|
|
46
95
|
const bodyHeight = Math.min(desiredBody, maxBody)
|
|
47
96
|
const overlayHeight = chrome + bodyHeight
|
|
48
97
|
const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
|
|
@@ -51,17 +100,19 @@ export const CommandPalette = ({
|
|
|
51
100
|
// Inner content width: overlay minus 1-cell border + 1-cell padding on each side.
|
|
52
101
|
const rowWidth = Math.max(4, overlayWidth - 4)
|
|
53
102
|
|
|
54
|
-
// Window the visible slice around the
|
|
55
|
-
//
|
|
56
|
-
// inevitable backlog growth.
|
|
103
|
+
// Window the visible slice around the selected command row. Headers and
|
|
104
|
+
// spacers are render-only rows, so selection still tracks command indexes.
|
|
57
105
|
const scrollTop = (() => {
|
|
58
|
-
if (
|
|
59
|
-
const maxScroll =
|
|
106
|
+
if (rows.length <= bodyHeight) return 0
|
|
107
|
+
const maxScroll = rows.length - bodyHeight
|
|
60
108
|
let s = 0
|
|
61
|
-
|
|
109
|
+
const selectedRowIndex = rows.findIndex(
|
|
110
|
+
(row) => row.kind === "command" && row.commandIndex === selectedIndex,
|
|
111
|
+
)
|
|
112
|
+
if (selectedRowIndex >= bodyHeight) s = selectedRowIndex - bodyHeight + 1
|
|
62
113
|
return Math.max(0, Math.min(s, maxScroll))
|
|
63
114
|
})()
|
|
64
|
-
const visible =
|
|
115
|
+
const visible = rows.slice(scrollTop, scrollTop + bodyHeight)
|
|
65
116
|
|
|
66
117
|
// Shortcut column width — long enough for `shift+t`-style chords but
|
|
67
118
|
// trimmed to prevent the title from being squeezed below ~16 cells.
|
|
@@ -110,9 +161,22 @@ export const CommandPalette = ({
|
|
|
110
161
|
{commands.length === 0 ? (
|
|
111
162
|
<text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
|
|
112
163
|
) : (
|
|
113
|
-
visible.map((
|
|
114
|
-
|
|
115
|
-
|
|
164
|
+
visible.map((row) => {
|
|
165
|
+
if (row.kind === "spacer") {
|
|
166
|
+
return <text key={row.key} content=" " />
|
|
167
|
+
}
|
|
168
|
+
if (row.kind === "header") {
|
|
169
|
+
return (
|
|
170
|
+
<text
|
|
171
|
+
key={row.key}
|
|
172
|
+
wrapMode="none"
|
|
173
|
+
content={fit(row.text, rowWidth)}
|
|
174
|
+
style={{ fg: colors.textMuted, attributes: 1 }}
|
|
175
|
+
/>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
const cmd = row.command
|
|
179
|
+
const isSelected = row.commandIndex === selectedIndex
|
|
116
180
|
const selector = isSelected ? "▸ " : " "
|
|
117
181
|
const titleText = fit(cmd.title, titleWidth)
|
|
118
182
|
const shortcutText = cmd.shortcut
|
package/src/Footer.tsx
CHANGED
|
@@ -40,30 +40,26 @@ export interface FooterProps<C> {
|
|
|
40
40
|
* no TTL, cleared by the caller when the underlying activity finishes.
|
|
41
41
|
* Loses to `notice` when both are set so transient toasts still surface. */
|
|
42
42
|
readonly discoveryStatus?: string | null
|
|
43
|
-
/** When a filter is applied but the input is closed, surface a chip in the
|
|
44
|
-
* hint row so the user remembers `[`/`]` walks the filtered set. Pass null
|
|
45
|
-
* while the filter input is open (the sidebar already shows the query) or
|
|
46
|
-
* when no filter is applied. */
|
|
47
|
-
readonly filterQuery?: string | null
|
|
48
43
|
/** Test seam: override spinner tick speed so tests don't sleep on the full
|
|
49
44
|
* production interval. Ignored when discoveryStatus is null. */
|
|
50
45
|
readonly discoverySpinnerIntervalMs?: number
|
|
51
46
|
readonly discoverySpinnerInitialFrameIndex?: number
|
|
52
47
|
/** Test seam: deterministic footer spinner driver. */
|
|
53
48
|
readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
|
|
49
|
+
/** Optional toggle callback for the discovery-warning popover. */
|
|
50
|
+
readonly onDiscoveryWarningToggle?: () => void
|
|
54
51
|
}
|
|
55
52
|
|
|
53
|
+
const normalizeStatusLine = (status: string): string => status.replace(/\s+/g, " ").trim()
|
|
54
|
+
|
|
56
55
|
const HINT_SEPARATOR = " "
|
|
57
56
|
|
|
58
|
-
/** Hint row entries. `key === null` is a standalone chip (e.g. the filter
|
|
59
|
-
* chip) and renders as muted text without the key/label split. */
|
|
60
57
|
interface Hint {
|
|
61
|
-
readonly key: string
|
|
58
|
+
readonly key: string
|
|
62
59
|
readonly label: string
|
|
63
60
|
}
|
|
64
61
|
|
|
65
|
-
const hintWidth = (h: Hint): number =>
|
|
66
|
-
h.key === null ? h.label.length : h.key.length + 1 + h.label.length // key + " " + label
|
|
62
|
+
const hintWidth = (h: Hint): number => h.key.length + 1 + h.label.length // key + " " + label
|
|
67
63
|
|
|
68
64
|
const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
|
|
69
65
|
if (!b.hint) return null
|
|
@@ -73,8 +69,8 @@ const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
|
|
|
73
69
|
}
|
|
74
70
|
|
|
75
71
|
/** Drop hints from the end until they fit within `width`. If not even the
|
|
76
|
-
* first hint fits, fall back to the bare key
|
|
77
|
-
*
|
|
72
|
+
* first hint fits, fall back to the bare key truncated to width, so the row
|
|
73
|
+
* is never silently blank on tight viewports. */
|
|
78
74
|
const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
|
|
79
75
|
if (width <= 0 || hints.length === 0) return []
|
|
80
76
|
const acc: Hint[] = []
|
|
@@ -87,22 +83,24 @@ const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
|
|
|
87
83
|
}
|
|
88
84
|
if (acc.length > 0) return acc
|
|
89
85
|
const first = hints[0]!
|
|
90
|
-
if (first.key === null) return [{ key: null, label: first.label.slice(0, width) }]
|
|
91
86
|
return [{ key: first.key.slice(0, width), label: "" }]
|
|
92
87
|
}
|
|
93
88
|
|
|
94
89
|
const STATUS_SEPARATOR = " · "
|
|
95
90
|
|
|
91
|
+
const isPartialDiscoveryWarning = (status: string | null): boolean =>
|
|
92
|
+
status?.startsWith("scan incomplete:") ?? false
|
|
93
|
+
|
|
96
94
|
export const Footer = <C,>({
|
|
97
95
|
bindings,
|
|
98
96
|
ctx,
|
|
99
97
|
width,
|
|
100
98
|
notice,
|
|
101
99
|
discoveryStatus,
|
|
102
|
-
filterQuery,
|
|
103
100
|
discoverySpinnerIntervalMs,
|
|
104
101
|
discoverySpinnerInitialFrameIndex,
|
|
105
102
|
discoverySpinnerRegisterTick,
|
|
103
|
+
onDiscoveryWarningToggle,
|
|
106
104
|
}: FooterProps<C>) => {
|
|
107
105
|
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
108
106
|
|
|
@@ -117,13 +115,6 @@ export const Footer = <C,>({
|
|
|
117
115
|
} as const
|
|
118
116
|
|
|
119
117
|
const hints: Hint[] = []
|
|
120
|
-
// The filter chip prepends to the hint row when a filter is applied and the
|
|
121
|
-
// input is closed. Bracketed to avoid looking like a `key:hint` binding —
|
|
122
|
-
// "filter" is not a key. Surfaces the otherwise-invisible invariant that
|
|
123
|
-
// `[`/`]` walks the filtered set. See DESIGN.md §7.1 Q1.
|
|
124
|
-
if (filterQuery && filterQuery.length > 0) {
|
|
125
|
-
hints.push({ key: null, label: `[filter: ${filterQuery}]` })
|
|
126
|
-
}
|
|
127
118
|
for (const b of bindings) {
|
|
128
119
|
// Hint visibility prefers `hintWhen` (binding-specific) over `when`
|
|
129
120
|
// (dispatch gate). Falling back to `when` keeps the original
|
|
@@ -137,14 +128,17 @@ export const Footer = <C,>({
|
|
|
137
128
|
// Discovery status sits left of the hints, separated by " · ". On tight
|
|
138
129
|
// viewports it claims its budget first; hints fit into the remainder so
|
|
139
130
|
// the indicator stays visible while less-essential hints drop off.
|
|
140
|
-
const status =
|
|
141
|
-
|
|
131
|
+
const status =
|
|
132
|
+
discoveryStatus && discoveryStatus.length > 0 ? normalizeStatusLine(discoveryStatus) : null
|
|
133
|
+
const isPartialWarning = isPartialDiscoveryWarning(status)
|
|
134
|
+
const statusBudget = status
|
|
135
|
+
? Math.min((isPartialWarning ? 1 : status.length) + STATUS_SEPARATOR.length, usableWidth)
|
|
136
|
+
: 0
|
|
142
137
|
const hintsWidth = Math.max(0, usableWidth - statusBudget)
|
|
143
138
|
const visibleHints = fitHints(hints, hintsWidth)
|
|
144
139
|
const statusContent = status
|
|
145
140
|
? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
|
|
146
141
|
: ""
|
|
147
|
-
|
|
148
142
|
const noticeContent = notice
|
|
149
143
|
? notice.length > usableWidth
|
|
150
144
|
? notice.slice(0, usableWidth)
|
|
@@ -167,12 +161,6 @@ export const Footer = <C,>({
|
|
|
167
161
|
/>,
|
|
168
162
|
]
|
|
169
163
|
: []
|
|
170
|
-
if (h.key === null) {
|
|
171
|
-
return [
|
|
172
|
-
...sep,
|
|
173
|
-
<text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.secondary }} />,
|
|
174
|
-
]
|
|
175
|
-
}
|
|
176
164
|
return [
|
|
177
165
|
...sep,
|
|
178
166
|
<text key={`k${i}`} content={h.key} wrapMode="none" style={{ fg: colors.text }} />,
|
|
@@ -195,6 +183,20 @@ export const Footer = <C,>({
|
|
|
195
183
|
)
|
|
196
184
|
}
|
|
197
185
|
|
|
186
|
+
const warningTrigger = isPartialWarning ? (
|
|
187
|
+
<box
|
|
188
|
+
{...(onDiscoveryWarningToggle === undefined ? {} : { onMouseUp: onDiscoveryWarningToggle })}
|
|
189
|
+
style={{
|
|
190
|
+
width: 1,
|
|
191
|
+
height: 1,
|
|
192
|
+
flexDirection: "row",
|
|
193
|
+
backgroundColor: colors.backgroundElement,
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
<text content="!" wrapMode="none" style={{ fg: colors.warning, attributes: 1 }} />
|
|
197
|
+
</box>
|
|
198
|
+
) : null
|
|
199
|
+
|
|
198
200
|
if (status !== null) {
|
|
199
201
|
const spinnerProps = {
|
|
200
202
|
fg: colors.secondary,
|
|
@@ -211,9 +213,15 @@ export const Footer = <C,>({
|
|
|
211
213
|
|
|
212
214
|
return (
|
|
213
215
|
<box style={rowStyle}>
|
|
214
|
-
<Spinner {...spinnerProps} />
|
|
215
|
-
|
|
216
|
-
|
|
216
|
+
{isPartialWarning ? null : <Spinner {...spinnerProps} />}
|
|
217
|
+
{isPartialWarning ? null : (
|
|
218
|
+
<text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
219
|
+
)}
|
|
220
|
+
{isPartialWarning ? (
|
|
221
|
+
warningTrigger
|
|
222
|
+
) : (
|
|
223
|
+
<text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
|
|
224
|
+
)}
|
|
217
225
|
<text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
218
226
|
{renderHints()}
|
|
219
227
|
</box>
|
package/src/Header.tsx
CHANGED
|
@@ -61,7 +61,10 @@ export const Header = ({ width, currentFile, version = pkg.version }: HeaderProp
|
|
|
61
61
|
backgroundColor: colors.backgroundPanel,
|
|
62
62
|
}}
|
|
63
63
|
>
|
|
64
|
-
<text
|
|
64
|
+
<text wrapMode="none">
|
|
65
|
+
<span style={{ fg: colors.text }}>{brand}</span>
|
|
66
|
+
{showFile && <span style={{ fg: colors.textMuted }}>{`${FILE_SEPARATOR}${file}`}</span>}
|
|
67
|
+
</text>
|
|
65
68
|
{showRight && <text content={right} wrapMode="none" style={{ fg: colors.text }} />}
|
|
66
69
|
</box>
|
|
67
70
|
)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useTerminalDimensions } from "@opentui/react"
|
|
2
|
+
import { useMemo, useState } from "react"
|
|
3
|
+
import { colors } from "./theme/colors.ts"
|
|
4
|
+
|
|
5
|
+
export type StatusPopoverVariant = "info" | "warning" | "error" | "success"
|
|
6
|
+
|
|
7
|
+
export interface StatusPopoverProps {
|
|
8
|
+
readonly icon: string
|
|
9
|
+
readonly content: string
|
|
10
|
+
readonly variant?: StatusPopoverVariant
|
|
11
|
+
readonly open?: boolean
|
|
12
|
+
readonly defaultOpen?: boolean
|
|
13
|
+
readonly onOpenChange?: (open: boolean) => void
|
|
14
|
+
readonly showPanel?: boolean
|
|
15
|
+
readonly minWidth?: number
|
|
16
|
+
readonly maxWidth?: number
|
|
17
|
+
readonly maxHeight?: number
|
|
18
|
+
readonly zIndex?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StatusPopoverPanelProps {
|
|
22
|
+
readonly content: string
|
|
23
|
+
readonly variant?: StatusPopoverVariant
|
|
24
|
+
readonly minWidth?: number
|
|
25
|
+
readonly maxWidth?: number
|
|
26
|
+
readonly maxHeight?: number
|
|
27
|
+
readonly zIndex?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
31
|
+
|
|
32
|
+
const variantFg = (variant: StatusPopoverVariant): string => {
|
|
33
|
+
switch (variant) {
|
|
34
|
+
case "info":
|
|
35
|
+
return colors.info
|
|
36
|
+
case "warning":
|
|
37
|
+
return colors.warning
|
|
38
|
+
case "error":
|
|
39
|
+
return colors.error
|
|
40
|
+
case "success":
|
|
41
|
+
return colors.success
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const measureLine = (line: string): number => line.length
|
|
46
|
+
|
|
47
|
+
const wrapLine = (line: string, width: number): string[] => {
|
|
48
|
+
if (width <= 0) return [line]
|
|
49
|
+
if (line.length <= width) return [line]
|
|
50
|
+
const out: string[] = []
|
|
51
|
+
let i = 0
|
|
52
|
+
while (i < line.length) {
|
|
53
|
+
out.push(line.slice(i, i + width))
|
|
54
|
+
i += width
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const StatusPopover = ({
|
|
60
|
+
icon,
|
|
61
|
+
content,
|
|
62
|
+
variant = "warning",
|
|
63
|
+
open,
|
|
64
|
+
defaultOpen = false,
|
|
65
|
+
onOpenChange,
|
|
66
|
+
showPanel = true,
|
|
67
|
+
minWidth = 14,
|
|
68
|
+
maxWidth = 40,
|
|
69
|
+
maxHeight = 12,
|
|
70
|
+
zIndex = 30,
|
|
71
|
+
}: StatusPopoverProps) => {
|
|
72
|
+
const { width: viewportWidth, height: viewportHeight } = useTerminalDimensions()
|
|
73
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
|
74
|
+
const isOpen = open ?? uncontrolledOpen
|
|
75
|
+
const setOpen = (next: boolean) => {
|
|
76
|
+
if (open === undefined) setUncontrolledOpen(next)
|
|
77
|
+
onOpenChange?.(next)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lines = useMemo(() => content.split(/\r?\n/), [content])
|
|
81
|
+
const textWidth = useMemo(() => {
|
|
82
|
+
const widest = Math.max(1, ...lines.map(measureLine))
|
|
83
|
+
return clamp(widest, minWidth, Math.min(maxWidth, Math.max(1, viewportWidth - 4)))
|
|
84
|
+
}, [content, lines, maxWidth, minWidth, viewportWidth])
|
|
85
|
+
|
|
86
|
+
const wrapped = useMemo(
|
|
87
|
+
() => lines.flatMap((line) => wrapLine(line, textWidth)),
|
|
88
|
+
[lines, textWidth],
|
|
89
|
+
)
|
|
90
|
+
const bodyHeight = Math.min(maxHeight, Math.max(1, wrapped.length))
|
|
91
|
+
const popoverWidth = Math.min(textWidth + 2, Math.max(1, viewportWidth - 2))
|
|
92
|
+
const popoverHeight = Math.min(bodyHeight + 2, Math.max(3, viewportHeight - 2))
|
|
93
|
+
|
|
94
|
+
const left = 1
|
|
95
|
+
const top = Math.max(0, viewportHeight - popoverHeight - 2)
|
|
96
|
+
|
|
97
|
+
const linesToRender = wrapped.slice(0, Math.max(0, popoverHeight - 2))
|
|
98
|
+
while (linesToRender.length < popoverHeight - 2) linesToRender.push("")
|
|
99
|
+
const triggerFg = variantFg(variant)
|
|
100
|
+
const borderColor = variantFg(variant)
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
<box
|
|
105
|
+
onMouseUp={() => setOpen(!isOpen)}
|
|
106
|
+
style={{
|
|
107
|
+
width: 3,
|
|
108
|
+
height: 1,
|
|
109
|
+
flexDirection: "row",
|
|
110
|
+
backgroundColor: colors.backgroundElement,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<text content={` ${icon} `} wrapMode="none" style={{ fg: triggerFg, attributes: 1 }} />
|
|
114
|
+
</box>
|
|
115
|
+
{showPanel && isOpen && (
|
|
116
|
+
<box
|
|
117
|
+
position="absolute"
|
|
118
|
+
left={left}
|
|
119
|
+
top={top}
|
|
120
|
+
width={popoverWidth}
|
|
121
|
+
height={popoverHeight}
|
|
122
|
+
zIndex={zIndex}
|
|
123
|
+
style={{
|
|
124
|
+
border: true,
|
|
125
|
+
borderColor,
|
|
126
|
+
backgroundColor: colors.backgroundPanel,
|
|
127
|
+
flexDirection: "column",
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<text content="" />
|
|
131
|
+
{linesToRender.map((line, i) => (
|
|
132
|
+
<text key={i} content={line} wrapMode="none" style={{ fg: colors.text }} />
|
|
133
|
+
))}
|
|
134
|
+
<text content="" />
|
|
135
|
+
</box>
|
|
136
|
+
)}
|
|
137
|
+
</>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const StatusPopoverPanel = ({
|
|
142
|
+
content,
|
|
143
|
+
variant = "warning",
|
|
144
|
+
minWidth = 14,
|
|
145
|
+
maxWidth = 40,
|
|
146
|
+
maxHeight = 12,
|
|
147
|
+
zIndex = 30,
|
|
148
|
+
}: StatusPopoverPanelProps) => {
|
|
149
|
+
const { width: viewportWidth, height: viewportHeight } = useTerminalDimensions()
|
|
150
|
+
const lines = useMemo(() => content.split(/\r?\n/), [content])
|
|
151
|
+
const textWidth = useMemo(() => {
|
|
152
|
+
const widest = Math.max(1, ...lines.map(measureLine))
|
|
153
|
+
return clamp(widest, minWidth, Math.min(maxWidth, Math.max(1, viewportWidth - 4)))
|
|
154
|
+
}, [lines, maxWidth, minWidth, viewportWidth])
|
|
155
|
+
const wrapped = useMemo(
|
|
156
|
+
() => lines.flatMap((line) => wrapLine(line, textWidth)),
|
|
157
|
+
[lines, textWidth],
|
|
158
|
+
)
|
|
159
|
+
const bodyHeight = Math.min(maxHeight, Math.max(1, wrapped.length))
|
|
160
|
+
const popoverWidth = Math.min(textWidth + 2, Math.max(1, viewportWidth - 2))
|
|
161
|
+
const popoverHeight = Math.min(bodyHeight + 2, Math.max(3, viewportHeight - 2))
|
|
162
|
+
const linesToRender = wrapped.slice(0, Math.max(0, popoverHeight - 2))
|
|
163
|
+
while (linesToRender.length < popoverHeight - 2) linesToRender.push("")
|
|
164
|
+
|
|
165
|
+
const borderColor = variantFg(variant)
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<box
|
|
169
|
+
position="absolute"
|
|
170
|
+
left={1}
|
|
171
|
+
top={Math.max(0, viewportHeight - popoverHeight - 2)}
|
|
172
|
+
width={popoverWidth}
|
|
173
|
+
height={popoverHeight}
|
|
174
|
+
zIndex={zIndex}
|
|
175
|
+
style={{
|
|
176
|
+
border: true,
|
|
177
|
+
borderColor,
|
|
178
|
+
backgroundColor: colors.backgroundPanel,
|
|
179
|
+
flexDirection: "column",
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<text content="" />
|
|
183
|
+
{linesToRender.map((line, i) => (
|
|
184
|
+
<text key={i} content={line} wrapMode="none" style={{ fg: colors.text }} />
|
|
185
|
+
))}
|
|
186
|
+
<text content="" />
|
|
187
|
+
</box>
|
|
188
|
+
)
|
|
189
|
+
}
|
package/src/cli/argv.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface ParsedArgs {
|
|
|
14
14
|
readonly width: string | null
|
|
15
15
|
/** Value of `--sort <mode>` (`dirs-first` or `files-first`), or null. Validated by the boot layer. */
|
|
16
16
|
readonly sort: string | null
|
|
17
|
-
/** True when `--serve` was passed: serve the
|
|
17
|
+
/** True when `--serve` was passed: serve the positional path as HTML, skip TUI. */
|
|
18
18
|
readonly serve: boolean
|
|
19
19
|
/** Value of `--port <N>`, or null. Validated by the boot layer. */
|
|
20
20
|
readonly port: string | null
|
|
@@ -139,9 +139,11 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
139
139
|
|
|
140
140
|
const themeList = themeDefinitions.map((t) => t.id).join(", ")
|
|
141
141
|
|
|
142
|
-
export const usage = `usage:
|
|
142
|
+
export const usage = `usage:
|
|
143
|
+
house [query] [options]
|
|
144
|
+
house --serve <path> [--port N]
|
|
143
145
|
|
|
144
|
-
|
|
146
|
+
query initial filter query; omit to browse the full discovery root
|
|
145
147
|
|
|
146
148
|
options:
|
|
147
149
|
--theme <id> color theme: ${themeList} (default: opencode)
|
|
@@ -149,11 +151,11 @@ options:
|
|
|
149
151
|
--width <N> cap rendered markdown width at N columns
|
|
150
152
|
--show <list> reveal normally-skipped entries; comma-separated subset of:
|
|
151
153
|
hidden, gitignored. Use --show "" to clear.
|
|
152
|
-
--root <dir> discovery root to walk (overrides defaultRoot config)
|
|
154
|
+
--root <dir> discovery root to walk (overrides defaultRoot config/env)
|
|
153
155
|
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
154
156
|
--sidebar <m> initial sidebar visibility: auto (default), on, or off
|
|
155
157
|
--focus <m> startup focus: sidebar, reader, or filter (default: filter)
|
|
156
|
-
--serve serve the
|
|
158
|
+
--serve serve the positional path as HTML in the browser (skips TUI)
|
|
157
159
|
--port <N> port for --serve (default: OS-assigned)
|
|
158
160
|
-h, --help show this help and exit
|
|
159
161
|
-v, --version print version and exit
|
|
@@ -161,6 +163,11 @@ options:
|
|
|
161
163
|
--no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
|
|
162
164
|
--no-mdx exclude .mdx files from discovery (default: included)
|
|
163
165
|
|
|
166
|
+
examples:
|
|
167
|
+
house README.md
|
|
168
|
+
house --root docs
|
|
169
|
+
house --serve README.md
|
|
170
|
+
|
|
164
171
|
configuration:
|
|
165
172
|
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
166
173
|
keys: theme, tone, mdx, show, focus, defaultRoot
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* Derive palette commands from `browserBindings` plus the annotation map.
|
|
3
3
|
*
|
|
4
4
|
* The annotation map is the *only* hand-written list keyed by binding id:
|
|
5
|
-
* it carries
|
|
6
|
-
*
|
|
7
|
-
* entry rather than a palette command. Every other binding is exposed
|
|
5
|
+
* it carries title rewrites and metadata for bindings whose raw
|
|
6
|
+
* `description` reads awkwardly as a palette command. Every enabled binding is exposed
|
|
8
7
|
* verbatim — its `description` becomes the palette `title`, its first key
|
|
9
8
|
* becomes the `shortcut`.
|
|
10
9
|
*
|
|
@@ -30,49 +29,40 @@ interface Annotation {
|
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* hidden in v1, reconsider if users ask. Title rewrites convert
|
|
37
|
-
* help-overlay phrasing ("Toggle sidebar visibility") into imperative
|
|
38
|
-
* palette phrasing ("Toggle sidebar"). See #70 design log §6.
|
|
32
|
+
* Title rewrites convert keymap phrasing into imperative palette phrasing.
|
|
33
|
+
* With the help overlay removed (#139), the palette is the only in-app full
|
|
34
|
+
* action index, so all currently-enabled actions stay discoverable here.
|
|
39
35
|
*/
|
|
40
36
|
const annotations: Record<string, Annotation> = {
|
|
41
37
|
// --- Keep, with title rewrites where the binding description reads awkwardly as a command ---
|
|
42
38
|
quit: { category: "App" },
|
|
43
39
|
"focus.toggle": { title: "Toggle focus", category: "View" },
|
|
44
40
|
"sidebar.toggle": { title: "Toggle sidebar", category: "View" },
|
|
45
|
-
"
|
|
41
|
+
"sidebar.down": { category: "Navigation" },
|
|
42
|
+
"sidebar.up": { category: "Navigation" },
|
|
43
|
+
"sidebar.jumpDown": { category: "Navigation" },
|
|
44
|
+
"sidebar.jumpUp": { category: "Navigation" },
|
|
45
|
+
"sidebar.pageDown": { category: "Navigation" },
|
|
46
|
+
"sidebar.pageUp": { category: "Navigation" },
|
|
47
|
+
"sidebar.top": { category: "Navigation" },
|
|
48
|
+
"sidebar.bottom": { category: "Navigation" },
|
|
49
|
+
"sidebar.open": { title: "Open file", category: "Navigation" },
|
|
46
50
|
"filter.open": { title: "Filter files…", category: "Navigation" },
|
|
51
|
+
"filter.clearOrOpen": { category: "Navigation" },
|
|
47
52
|
"discovery.toggleAll": {
|
|
48
53
|
title: "Toggle hidden / gitignored files",
|
|
49
54
|
category: "Navigation",
|
|
50
55
|
keywords: ["hidden", "gitignore", "dotfiles", "all"],
|
|
51
56
|
},
|
|
57
|
+
"reader.back": { title: "Back to sidebar", category: "Navigation" },
|
|
58
|
+
"reader.prevFile": { title: "Previous file", category: "Navigation" },
|
|
59
|
+
"reader.nextFile": { title: "Next file", category: "Navigation" },
|
|
52
60
|
"serve.current": { title: "Open in browser", category: "File" },
|
|
53
61
|
"file.edit": { title: "Open in editor", category: "File", keywords: ["editor", "vim", "vscode"] },
|
|
54
62
|
"theme.next": { category: "Appearance" },
|
|
55
63
|
"theme.prev": { category: "Appearance" },
|
|
56
64
|
"theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },
|
|
57
65
|
|
|
58
|
-
// --- Hide: pure keystroke navigation (j/k/space/b/g/G…) ---
|
|
59
|
-
"sidebar.down": { hidden: true },
|
|
60
|
-
"sidebar.up": { hidden: true },
|
|
61
|
-
"sidebar.jumpDown": { hidden: true },
|
|
62
|
-
"sidebar.jumpUp": { hidden: true },
|
|
63
|
-
"sidebar.pageDown": { hidden: true },
|
|
64
|
-
"sidebar.pageUp": { hidden: true },
|
|
65
|
-
"sidebar.top": { hidden: true },
|
|
66
|
-
"sidebar.bottom": { hidden: true },
|
|
67
|
-
|
|
68
|
-
// --- Hide: borderline reader nav. `[`/`]` and Return-to-open feel command-shaped
|
|
69
|
-
// but are pure keystroke navigation under the hood. #70 Q6a — reconsider
|
|
70
|
-
// if user feedback expects them in the palette.
|
|
71
|
-
"sidebar.open": { hidden: true },
|
|
72
|
-
"reader.back": { hidden: true },
|
|
73
|
-
"reader.prevFile": { hidden: true },
|
|
74
|
-
"reader.nextFile": { hidden: true },
|
|
75
|
-
|
|
76
66
|
// --- Hide: the palette opener itself shouldn't appear in the palette ---
|
|
77
67
|
"palette.open": { hidden: true },
|
|
78
68
|
}
|
|
@@ -80,8 +70,8 @@ const annotations: Record<string, Annotation> = {
|
|
|
80
70
|
/**
|
|
81
71
|
* Build the AppCommand list for a given render. Iterates `browserBindings`
|
|
82
72
|
* in array order (the empty-query palette renders in this order, by design
|
|
83
|
-
* — see #70 design log §empty-state ordering), drops hidden entries
|
|
84
|
-
* those whose `when` predicate currently returns false, and resolves
|
|
73
|
+
* — see #70 design log §empty-state ordering), drops explicitly-hidden entries
|
|
74
|
+
* and those whose `when` predicate currently returns false, and resolves
|
|
85
75
|
* annotations to populate title / category / keywords.
|
|
86
76
|
*/
|
|
87
77
|
export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
|
|
@@ -89,9 +79,8 @@ export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
|
|
|
89
79
|
for (const binding of browserBindings) {
|
|
90
80
|
const ann = annotations[binding.id]
|
|
91
81
|
if (ann?.hidden) continue
|
|
92
|
-
// Same gating the keymap dispatcher uses. Disabled bindings
|
|
93
|
-
//
|
|
94
|
-
// reason follow-up after the atom-driven migration).
|
|
82
|
+
// Same gating the keymap dispatcher uses. Disabled bindings stay out of
|
|
83
|
+
// the palette; #96 tracks a future show-with-reason mode.
|
|
95
84
|
if (binding.when && !binding.when(ctx)) continue
|
|
96
85
|
const cmd: AppCommand = {
|
|
97
86
|
id: binding.id,
|
package/src/commands/score.ts
CHANGED
|
@@ -34,7 +34,7 @@ const fuzzyIncludes = (text: string, query: string): boolean => {
|
|
|
34
34
|
|
|
35
35
|
const searchText = (command: AppCommand): string =>
|
|
36
36
|
normalize(
|
|
37
|
-
[command.title, command.category,
|
|
37
|
+
[command.title, command.category, ...(command.keywords ?? [])]
|
|
38
38
|
.filter((s): s is string => Boolean(s))
|
|
39
39
|
.join(" "),
|
|
40
40
|
)
|