@carlesandres/house 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -128
- package/README.md +40 -12
- package/package.json +3 -2
- package/src/Browser.tsx +482 -109
- package/src/CommandPalette.tsx +150 -0
- package/src/Footer.tsx +122 -55
- package/src/Header.tsx +68 -0
- package/src/HelpOverlay.tsx +59 -41
- package/src/brand.ts +7 -0
- package/src/cli/argv.ts +61 -2
- package/src/commands/buildCommands.ts +102 -0
- package/src/commands/paletteOnlyCommands.ts +16 -0
- package/src/commands/score.ts +78 -0
- package/src/commands/types.ts +26 -0
- package/src/config/load.ts +231 -0
- package/src/discovery/walk.ts +67 -23
- package/src/index.tsx +163 -32
- package/src/keymap/browser.ts +26 -11
- package/src/layout/resolve.ts +60 -0
- package/src/theme/colors.ts +51 -15
- package/src/theme/types.ts +7 -0
- package/src/update/cache.ts +77 -0
- package/src/update/check.ts +165 -0
- package/src/update/compare.ts +41 -0
- package/src/update/notice.ts +29 -0
- package/src/update/runtime.ts +48 -0
- package/src/update/useUpdateNotice.ts +24 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandPalette — modal overlay for searching and running commands.
|
|
3
|
+
*
|
|
4
|
+
* Render-only: state lives in Browser.tsx alongside helpVisible / filterOpen
|
|
5
|
+
* (#70 design log §state location). Key handling sits in Browser.tsx's
|
|
6
|
+
* `useKeyboard` palette-branch, mirroring the filterOpen pattern.
|
|
7
|
+
*
|
|
8
|
+
* V1 ships a flat list in `browserBindings` order — no category headers,
|
|
9
|
+
* no mouse, no recency. See #91/#93/#94/#95/#96 for the follow-ups.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { RGBA } from "@opentui/core"
|
|
13
|
+
import { colors } from "./theme/colors.ts"
|
|
14
|
+
import type { AppCommand } from "./commands/types.ts"
|
|
15
|
+
|
|
16
|
+
// Semi-transparent black scrim painted across the viewport behind the modal.
|
|
17
|
+
// Opentui composites it over the chrome underneath, so the rest of the UI
|
|
18
|
+
// reads as darkened while the modal stays fully opaque. Mirrors opencode's
|
|
19
|
+
// dialog backdrop (cli/cmd/tui/ui/dialog.tsx).
|
|
20
|
+
const SCRIM = RGBA.fromInts(0, 0, 0, 150)
|
|
21
|
+
|
|
22
|
+
export interface CommandPaletteProps {
|
|
23
|
+
readonly commands: readonly AppCommand[]
|
|
24
|
+
readonly query: string
|
|
25
|
+
readonly selectedIndex: number
|
|
26
|
+
readonly viewportWidth: number
|
|
27
|
+
readonly viewportHeight: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const FOOTER_HINT = "↑↓ select enter run esc close"
|
|
31
|
+
|
|
32
|
+
export const CommandPalette = ({
|
|
33
|
+
commands,
|
|
34
|
+
query,
|
|
35
|
+
selectedIndex,
|
|
36
|
+
viewportWidth,
|
|
37
|
+
viewportHeight,
|
|
38
|
+
}: CommandPaletteProps) => {
|
|
39
|
+
const overlayWidth = Math.min(viewportWidth - 4, 64)
|
|
40
|
+
// Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
|
|
41
|
+
// 1 spacer above footer, 1 footer row. Body gets the rest.
|
|
42
|
+
const chrome = 2 + 1 + 1 + 1 + 1
|
|
43
|
+
const maxBody = Math.max(1, viewportHeight - 4 - chrome)
|
|
44
|
+
const desiredBody = Math.max(1, commands.length || 1)
|
|
45
|
+
const bodyHeight = Math.min(desiredBody, maxBody)
|
|
46
|
+
const overlayHeight = chrome + bodyHeight
|
|
47
|
+
const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
|
|
48
|
+
const top = Math.max(0, Math.floor((viewportHeight - overlayHeight) / 2))
|
|
49
|
+
|
|
50
|
+
// Inner content width: overlay minus 1-cell border + 1-cell padding on each side.
|
|
51
|
+
const rowWidth = Math.max(4, overlayWidth - 4)
|
|
52
|
+
|
|
53
|
+
// Window the visible slice around the selection. With 9 commands in v1
|
|
54
|
+
// this is usually a no-op (list fits), but the math is in place for the
|
|
55
|
+
// inevitable backlog growth.
|
|
56
|
+
const scrollTop = (() => {
|
|
57
|
+
if (commands.length <= bodyHeight) return 0
|
|
58
|
+
const maxScroll = commands.length - bodyHeight
|
|
59
|
+
let s = 0
|
|
60
|
+
if (selectedIndex >= bodyHeight) s = selectedIndex - bodyHeight + 1
|
|
61
|
+
return Math.max(0, Math.min(s, maxScroll))
|
|
62
|
+
})()
|
|
63
|
+
const visible = commands.slice(scrollTop, scrollTop + bodyHeight)
|
|
64
|
+
|
|
65
|
+
// Shortcut column width — long enough for `shift+t`-style chords but
|
|
66
|
+
// trimmed to prevent the title from being squeezed below ~16 cells.
|
|
67
|
+
const SHORTCUT_WIDTH = 10
|
|
68
|
+
const titleWidth = Math.max(8, rowWidth - 2 /* selector */ - SHORTCUT_WIDTH - 1 /* gap */)
|
|
69
|
+
|
|
70
|
+
const fit = (s: string, width: number): string =>
|
|
71
|
+
s.length === width
|
|
72
|
+
? s
|
|
73
|
+
: s.length > width
|
|
74
|
+
? s.slice(0, Math.max(0, width - 1)) + "…"
|
|
75
|
+
: s + " ".repeat(width - s.length)
|
|
76
|
+
|
|
77
|
+
const fitRight = (s: string, width: number): string =>
|
|
78
|
+
s.length >= width ? s.slice(0, width) : " ".repeat(width - s.length) + s
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<box
|
|
82
|
+
position="absolute"
|
|
83
|
+
left={0}
|
|
84
|
+
top={0}
|
|
85
|
+
width={viewportWidth}
|
|
86
|
+
height={viewportHeight}
|
|
87
|
+
zIndex={20}
|
|
88
|
+
style={{ backgroundColor: SCRIM }}
|
|
89
|
+
>
|
|
90
|
+
<box
|
|
91
|
+
position="absolute"
|
|
92
|
+
left={left}
|
|
93
|
+
top={top}
|
|
94
|
+
width={overlayWidth}
|
|
95
|
+
height={overlayHeight}
|
|
96
|
+
title=" Commands "
|
|
97
|
+
titleAlignment="left"
|
|
98
|
+
paddingLeft={1}
|
|
99
|
+
paddingRight={1}
|
|
100
|
+
style={{
|
|
101
|
+
border: true,
|
|
102
|
+
borderColor: colors.textMuted,
|
|
103
|
+
flexDirection: "column",
|
|
104
|
+
backgroundColor: colors.surface,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<text
|
|
108
|
+
wrapMode="none"
|
|
109
|
+
content={fit(`> ${query}▏`, rowWidth)}
|
|
110
|
+
style={{ fg: colors.textStrong }}
|
|
111
|
+
/>
|
|
112
|
+
<text content=" " />
|
|
113
|
+
{commands.length === 0 ? (
|
|
114
|
+
<text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
|
|
115
|
+
) : (
|
|
116
|
+
visible.map((cmd, i) => {
|
|
117
|
+
const realIdx = scrollTop + i
|
|
118
|
+
const isSelected = realIdx === selectedIndex
|
|
119
|
+
const selector = isSelected ? "▸ " : " "
|
|
120
|
+
const titleText = fit(cmd.title, titleWidth)
|
|
121
|
+
const shortcutText = cmd.shortcut
|
|
122
|
+
? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
|
|
123
|
+
: " ".repeat(SHORTCUT_WIDTH)
|
|
124
|
+
// Title and shortcut render as separate spans so the shortcut
|
|
125
|
+
// can use `textMuted` while the title uses `text`/`textStrong`.
|
|
126
|
+
// Same trick opencode pulls with `--text-weak` — the theme
|
|
127
|
+
// guarantees the contrast, we just pick the right role.
|
|
128
|
+
const titleFg = isSelected ? colors.textStrong : colors.text
|
|
129
|
+
return (
|
|
130
|
+
<text
|
|
131
|
+
key={cmd.id}
|
|
132
|
+
wrapMode="none"
|
|
133
|
+
style={isSelected ? { bg: colors.selectedBg } : {}}
|
|
134
|
+
>
|
|
135
|
+
<span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
|
|
136
|
+
<span style={{ fg: colors.textMuted }}>{shortcutText}</span>
|
|
137
|
+
</text>
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
)}
|
|
141
|
+
<text content=" " />
|
|
142
|
+
<text
|
|
143
|
+
wrapMode="none"
|
|
144
|
+
content={fit(FOOTER_HINT, rowWidth)}
|
|
145
|
+
style={{ fg: colors.textMuted }}
|
|
146
|
+
/>
|
|
147
|
+
</box>
|
|
148
|
+
</box>
|
|
149
|
+
)
|
|
150
|
+
}
|
package/src/Footer.tsx
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* were discovered. Intentional — `q:quit` and `?:help` are exactly what an
|
|
12
12
|
* empty-vault user needs as an exit and discoverability anchor.
|
|
13
13
|
*
|
|
14
|
+
* The filter input does not live here — it renders as a row inside the
|
|
15
|
+
* sidebar (see Browser.tsx). The pattern mirrors ghui's PR list, where the
|
|
16
|
+
* filter is part of the list it filters.
|
|
17
|
+
*
|
|
14
18
|
* Width math assumes hint labels are ASCII plus a small set of single-cell
|
|
15
19
|
* BMP glyphs (see `displayKey`). `fitHints` and notice clipping use string
|
|
16
20
|
* length as a proxy for cell count; introducing a CJK or emoji label would
|
|
@@ -29,10 +33,15 @@ export interface FooterProps<C> {
|
|
|
29
33
|
readonly ctx: C
|
|
30
34
|
readonly width: number
|
|
31
35
|
readonly notice?: string | null
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
readonly
|
|
36
|
+
/** Persistent status line (e.g. "indexing… 42"). Distinct from `notice`:
|
|
37
|
+
* no TTL, cleared by the caller when the underlying activity finishes.
|
|
38
|
+
* Loses to `notice` when both are set so transient toasts still surface. */
|
|
39
|
+
readonly discoveryStatus?: string | null
|
|
40
|
+
/** When a filter is applied but the input is closed, surface a chip in the
|
|
41
|
+
* hint row so the user remembers `[`/`]` walks the filtered set. Pass null
|
|
42
|
+
* while the filter input is open (the sidebar already shows the query) or
|
|
43
|
+
* when no filter is applied. */
|
|
44
|
+
readonly filterQuery?: string | null
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
const HINT_SEPARATOR = " "
|
|
@@ -62,39 +71,52 @@ const displayKey = (raw: string): string => {
|
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
|
|
74
|
+
/** Hint row entries. `key === null` is a standalone chip (e.g. the filter
|
|
75
|
+
* chip) and renders as muted text without the key/label split. */
|
|
76
|
+
interface Hint {
|
|
77
|
+
readonly key: string | null
|
|
78
|
+
readonly label: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hintWidth = (h: Hint): number =>
|
|
82
|
+
h.key === null ? h.label.length : h.key.length + 1 + h.label.length // key + " " + label
|
|
83
|
+
|
|
84
|
+
const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
|
|
66
85
|
if (!b.hint) return null
|
|
67
86
|
const first = b.keys[0]
|
|
68
87
|
if (!first) return null
|
|
69
|
-
return
|
|
88
|
+
return { key: displayKey(first), label: b.hint }
|
|
70
89
|
}
|
|
71
90
|
|
|
72
|
-
/** Drop hints from the end until
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
let
|
|
91
|
+
/** Drop hints from the end until they fit within `width`. If not even the
|
|
92
|
+
* first hint fits, fall back to the bare key (or label chip) truncated to
|
|
93
|
+
* width, so the row is never silently blank on tight viewports. */
|
|
94
|
+
const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
|
|
95
|
+
if (width <= 0 || hints.length === 0) return []
|
|
96
|
+
const acc: Hint[] = []
|
|
97
|
+
let used = 0
|
|
79
98
|
for (const h of hints) {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
acc
|
|
99
|
+
const add = acc.length === 0 ? hintWidth(h) : HINT_SEPARATOR.length + hintWidth(h)
|
|
100
|
+
if (used + add > width) break
|
|
101
|
+
acc.push(h)
|
|
102
|
+
used += add
|
|
83
103
|
}
|
|
84
104
|
if (acc.length > 0) return acc
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return firstKey.slice(0, width)
|
|
105
|
+
const first = hints[0]!
|
|
106
|
+
if (first.key === null) return [{ key: null, label: first.label.slice(0, width) }]
|
|
107
|
+
return [{ key: first.key.slice(0, width), label: "" }]
|
|
89
108
|
}
|
|
90
109
|
|
|
91
|
-
|
|
92
|
-
* handler in `Browser.tsx`; if those bindings change, update both. */
|
|
93
|
-
const FILTER_HINTS = "↵:open esc:cancel"
|
|
94
|
-
/** Minimum gap between the filter input and its hints on the same row. */
|
|
95
|
-
const FILTER_HINT_GAP = 2
|
|
110
|
+
const STATUS_SEPARATOR = " · "
|
|
96
111
|
|
|
97
|
-
export const Footer = <C,>({
|
|
112
|
+
export const Footer = <C,>({
|
|
113
|
+
bindings,
|
|
114
|
+
ctx,
|
|
115
|
+
width,
|
|
116
|
+
notice,
|
|
117
|
+
discoveryStatus,
|
|
118
|
+
filterQuery,
|
|
119
|
+
}: FooterProps<C>) => {
|
|
98
120
|
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
99
121
|
|
|
100
122
|
const rowStyle = {
|
|
@@ -104,48 +126,93 @@ export const Footer = <C,>({ bindings, ctx, width, notice, filter }: FooterProps
|
|
|
104
126
|
flexDirection: "row",
|
|
105
127
|
paddingLeft: 1,
|
|
106
128
|
paddingRight: 1,
|
|
107
|
-
backgroundColor: colors.
|
|
129
|
+
backgroundColor: colors.surface,
|
|
108
130
|
} as const
|
|
109
131
|
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
<box style={rowStyle}>
|
|
118
|
-
<text content={input} wrapMode="none" style={{ fg: colors.textStrong }} />
|
|
119
|
-
{showHints && (
|
|
120
|
-
<>
|
|
121
|
-
<box style={{ flexGrow: 1 }} />
|
|
122
|
-
<text content={FILTER_HINTS} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
123
|
-
</>
|
|
124
|
-
)}
|
|
125
|
-
</box>
|
|
126
|
-
)
|
|
132
|
+
const hints: Hint[] = []
|
|
133
|
+
// The filter chip prepends to the hint row when a filter is applied and the
|
|
134
|
+
// input is closed. Bracketed to avoid looking like a `key:hint` binding —
|
|
135
|
+
// "filter" is not a key. Surfaces the otherwise-invisible invariant that
|
|
136
|
+
// `[`/`]` walks the filtered set. See DESIGN.md §7.1 Q1.
|
|
137
|
+
if (filterQuery && filterQuery.length > 0) {
|
|
138
|
+
hints.push({ key: null, label: `[filter: ${filterQuery}]` })
|
|
127
139
|
}
|
|
128
|
-
|
|
129
|
-
const hints: string[] = []
|
|
130
140
|
for (const b of bindings) {
|
|
131
141
|
if (b.when && !b.when(ctx)) continue
|
|
132
142
|
const h = formatHint(b)
|
|
133
143
|
if (h !== null) hints.push(h)
|
|
134
144
|
}
|
|
135
|
-
|
|
145
|
+
|
|
146
|
+
// Discovery status sits left of the hints, separated by " · ". On tight
|
|
147
|
+
// viewports it claims its budget first; hints fit into the remainder so
|
|
148
|
+
// the indicator stays visible while less-essential hints drop off.
|
|
149
|
+
const status = discoveryStatus && discoveryStatus.length > 0 ? discoveryStatus : null
|
|
150
|
+
const statusBudget = status ? Math.min(status.length + STATUS_SEPARATOR.length, usableWidth) : 0
|
|
151
|
+
const hintsWidth = Math.max(0, usableWidth - statusBudget)
|
|
152
|
+
const visibleHints = fitHints(hints, hintsWidth)
|
|
153
|
+
const statusContent = status
|
|
154
|
+
? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
|
|
155
|
+
: ""
|
|
156
|
+
|
|
136
157
|
const noticeContent = notice
|
|
137
158
|
? notice.length > usableWidth
|
|
138
159
|
? notice.slice(0, usableWidth)
|
|
139
160
|
: notice
|
|
140
161
|
: null
|
|
141
162
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
163
|
+
// Two-tone hint row: keys render in `text` (foreground-strength), the
|
|
164
|
+
// `:label` portion in `textMuted`. Matches ghui's footer treatment so
|
|
165
|
+
// the key — the actionable token — visually leads each hint.
|
|
166
|
+
const renderHints = () =>
|
|
167
|
+
visibleHints.flatMap((h, i) => {
|
|
168
|
+
const sep =
|
|
169
|
+
i > 0
|
|
170
|
+
? [
|
|
171
|
+
<text
|
|
172
|
+
key={`s${i}`}
|
|
173
|
+
content={HINT_SEPARATOR}
|
|
174
|
+
wrapMode="none"
|
|
175
|
+
style={{ fg: colors.textMuted }}
|
|
176
|
+
/>,
|
|
177
|
+
]
|
|
178
|
+
: []
|
|
179
|
+
if (h.key === null) {
|
|
180
|
+
return [
|
|
181
|
+
...sep,
|
|
182
|
+
<text key={`l${i}`} content={h.label} wrapMode="none" style={{ fg: colors.textMuted }} />,
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
return [
|
|
186
|
+
...sep,
|
|
187
|
+
<text key={`k${i}`} content={h.key} wrapMode="none" style={{ fg: colors.text }} />,
|
|
188
|
+
<text
|
|
189
|
+
key={`l${i}`}
|
|
190
|
+
content={` ${h.label}`}
|
|
191
|
+
wrapMode="none"
|
|
192
|
+
style={{ fg: colors.textMuted }}
|
|
193
|
+
/>,
|
|
194
|
+
]
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Priority: notice > (status + hints). Notice fg is strong; status sits
|
|
198
|
+
// at the muted level so it reads as ambient state, not an event.
|
|
199
|
+
if (noticeContent !== null) {
|
|
200
|
+
return (
|
|
201
|
+
<box style={rowStyle}>
|
|
202
|
+
<text content={noticeContent} wrapMode="none" style={{ fg: colors.textStrong }} />
|
|
203
|
+
</box>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (status !== null) {
|
|
208
|
+
return (
|
|
209
|
+
<box style={rowStyle}>
|
|
210
|
+
<text content={statusContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
211
|
+
<text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
212
|
+
{renderHints()}
|
|
213
|
+
</box>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
145
216
|
|
|
146
|
-
return (
|
|
147
|
-
<box style={rowStyle}>
|
|
148
|
-
<text content={content} wrapMode="none" style={{ fg }} />
|
|
149
|
-
</box>
|
|
150
|
-
)
|
|
217
|
+
return <box style={rowStyle}>{renderHints()}</box>
|
|
151
218
|
}
|
package/src/Header.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header — single-row chrome above the two-pane area.
|
|
3
|
+
*
|
|
4
|
+
* Borderless single line modeled on ghui's PlainLine header: brand and
|
|
5
|
+
* current filename on the left, version on the right. The row is
|
|
6
|
+
* informational, not interactive — see issue #38 for the design discussion.
|
|
7
|
+
*
|
|
8
|
+
* Width degrades gracefully: the version drops first when the row gets
|
|
9
|
+
* tight, then the filename, leaving the brand mark as the irreducible
|
|
10
|
+
* identity element. Always rendered — the row is worth one cell on any
|
|
11
|
+
* viewport so the user never loses the filename indicator (notably, when
|
|
12
|
+
* the sidebar drawer overlays the reader on narrow viewports).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import pkg from "../package.json" with { type: "json" }
|
|
16
|
+
import { BRAND, BRAND_NAME } from "./brand.ts"
|
|
17
|
+
import { colors } from "./theme/colors.ts"
|
|
18
|
+
|
|
19
|
+
export const HEADER_HEIGHT = 1
|
|
20
|
+
|
|
21
|
+
const FILE_SEPARATOR = " · "
|
|
22
|
+
|
|
23
|
+
export interface HeaderProps {
|
|
24
|
+
readonly width: number
|
|
25
|
+
/** Currently selected file's relative path. When set, the Header shows
|
|
26
|
+
* it next to the brand mark — replaces the per-pane border title that
|
|
27
|
+
* used to carry this information. */
|
|
28
|
+
readonly currentFile?: string | null
|
|
29
|
+
/** Optional override for the version string (testing). Defaults to
|
|
30
|
+
* the running package's version. */
|
|
31
|
+
readonly version?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const Header = ({ width, currentFile, version = pkg.version }: HeaderProps) => {
|
|
35
|
+
const brand = `${BRAND} ${BRAND_NAME}`
|
|
36
|
+
const right = `v${version}`
|
|
37
|
+
const file = currentFile && currentFile.length > 0 ? currentFile : null
|
|
38
|
+
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
39
|
+
|
|
40
|
+
// Priority: brand > filename > version. Brand is the irreducible identity
|
|
41
|
+
// anchor. Filename is per-selection useful info — keep it before the
|
|
42
|
+
// largely-static version string. `1` is the minimum gap between left and
|
|
43
|
+
// right groups so they never visually collide.
|
|
44
|
+
const leftWithFile = file !== null ? `${brand}${FILE_SEPARATOR}${file}` : brand
|
|
45
|
+
const showFileWithVersion = leftWithFile.length + 1 + right.length <= usableWidth
|
|
46
|
+
const showFileWithoutVersion = leftWithFile.length <= usableWidth
|
|
47
|
+
const showFile = file !== null && (showFileWithVersion || showFileWithoutVersion)
|
|
48
|
+
const left = showFile ? leftWithFile : brand
|
|
49
|
+
const showRight = left.length + 1 + right.length <= usableWidth
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<box
|
|
53
|
+
style={{
|
|
54
|
+
width,
|
|
55
|
+
height: HEADER_HEIGHT,
|
|
56
|
+
flexShrink: 0,
|
|
57
|
+
flexDirection: "row",
|
|
58
|
+
justifyContent: "space-between",
|
|
59
|
+
paddingLeft: 1,
|
|
60
|
+
paddingRight: 1,
|
|
61
|
+
backgroundColor: colors.surface,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<text content={left} wrapMode="none" style={{ fg: colors.text }} />
|
|
65
|
+
{showRight && <text content={right} wrapMode="none" style={{ fg: colors.text }} />}
|
|
66
|
+
</box>
|
|
67
|
+
)
|
|
68
|
+
}
|
package/src/HelpOverlay.tsx
CHANGED
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
* keys — the dispatcher and the help text are the same source of truth.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { RGBA } from "@opentui/core"
|
|
9
10
|
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
10
11
|
import { colors } from "./theme/colors.ts"
|
|
11
12
|
|
|
13
|
+
// See CommandPalette.tsx for the scrim rationale.
|
|
14
|
+
const SCRIM = RGBA.fromInts(0, 0, 0, 150)
|
|
15
|
+
|
|
12
16
|
export interface HelpOverlayProps<C> {
|
|
13
17
|
readonly bindings: readonly KeyBinding<C>[]
|
|
14
18
|
readonly viewportWidth: number
|
|
@@ -82,49 +86,63 @@ export const HelpOverlay = <C,>({
|
|
|
82
86
|
return (
|
|
83
87
|
<box
|
|
84
88
|
position="absolute"
|
|
85
|
-
left={
|
|
86
|
-
top={
|
|
87
|
-
width={
|
|
88
|
-
height={
|
|
89
|
+
left={0}
|
|
90
|
+
top={0}
|
|
91
|
+
width={viewportWidth}
|
|
92
|
+
height={viewportHeight}
|
|
89
93
|
zIndex={10}
|
|
90
|
-
|
|
91
|
-
titleAlignment="left"
|
|
92
|
-
style={{
|
|
93
|
-
border: true,
|
|
94
|
-
borderColor: colors.borderActive,
|
|
95
|
-
padding: 1,
|
|
96
|
-
flexDirection: "column",
|
|
97
|
-
backgroundColor: colors.surface,
|
|
98
|
-
}}
|
|
94
|
+
style={{ backgroundColor: SCRIM }}
|
|
99
95
|
>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
96
|
+
<box
|
|
97
|
+
position="absolute"
|
|
98
|
+
left={left}
|
|
99
|
+
top={top}
|
|
100
|
+
width={overlayWidth}
|
|
101
|
+
height={overlayHeight}
|
|
102
|
+
title=" Help "
|
|
103
|
+
titleAlignment="left"
|
|
104
|
+
style={{
|
|
105
|
+
border: true,
|
|
106
|
+
borderColor: colors.textMuted,
|
|
107
|
+
padding: 1,
|
|
108
|
+
flexDirection: "column",
|
|
109
|
+
backgroundColor: colors.surface,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{rows.map((row) => {
|
|
113
|
+
switch (row.kind) {
|
|
114
|
+
case "header":
|
|
115
|
+
return (
|
|
116
|
+
<text
|
|
117
|
+
key={row.key}
|
|
118
|
+
wrapMode="none"
|
|
119
|
+
content={row.text}
|
|
120
|
+
style={{ fg: colors.borderActive, attributes: 1 /* bold */ }}
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
case "footer":
|
|
124
|
+
return (
|
|
125
|
+
<text
|
|
126
|
+
key={row.key}
|
|
127
|
+
wrapMode="none"
|
|
128
|
+
content={row.text}
|
|
129
|
+
style={{ fg: colors.textMuted }}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
case "spacer":
|
|
133
|
+
return <text key={row.key} content=" " />
|
|
134
|
+
case "binding":
|
|
135
|
+
return (
|
|
136
|
+
<text
|
|
137
|
+
key={row.key}
|
|
138
|
+
wrapMode="none"
|
|
139
|
+
content={row.text}
|
|
140
|
+
style={{ fg: colors.text }}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
})}
|
|
145
|
+
</box>
|
|
128
146
|
</box>
|
|
129
147
|
)
|
|
130
148
|
}
|
package/src/brand.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand mark. U+2302 HOUSE is a single-cell glyph in every monospace font
|
|
3
|
+
* we care about; falls back to a tofu box on rare fonts that lack it.
|
|
4
|
+
* Single import point so future placements (#76) stay in sync.
|
|
5
|
+
*/
|
|
6
|
+
export const BRAND = "⌂"
|
|
7
|
+
export const BRAND_NAME = "house"
|
package/src/cli/argv.ts
CHANGED
|
@@ -21,6 +21,16 @@ export interface ParsedArgs {
|
|
|
21
21
|
readonly help: boolean
|
|
22
22
|
/** True when `--version` was passed. */
|
|
23
23
|
readonly version: boolean
|
|
24
|
+
/** True when `--config-path` was passed: print resolved config path and exit. */
|
|
25
|
+
readonly configPath: boolean
|
|
26
|
+
/** Value of `--sidebar <mode>` (`auto`, `on`, `off`), or null. Validated by the boot layer. */
|
|
27
|
+
readonly sidebar: string | null
|
|
28
|
+
/** True when `--no-update-check` was passed: suppress the npm-registry
|
|
29
|
+
* probe and the "update available" notice. Mirrors the
|
|
30
|
+
* `NO_UPDATE_NOTIFIER` env var so opt-out is reachable without env state. */
|
|
31
|
+
readonly noUpdateCheck: boolean
|
|
32
|
+
/** True when `--no-mdx` was passed: exclude `.mdx` files from discovery. */
|
|
33
|
+
readonly noMdx: boolean
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
/**
|
|
@@ -41,6 +51,10 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
41
51
|
let port: string | null = null
|
|
42
52
|
let help = false
|
|
43
53
|
let version = false
|
|
54
|
+
let configPath = false
|
|
55
|
+
let sidebar: string | null = null
|
|
56
|
+
let noUpdateCheck = false
|
|
57
|
+
let noMdx = false
|
|
44
58
|
|
|
45
59
|
for (let i = 0; i < argv.length; i++) {
|
|
46
60
|
const arg = argv[i]!
|
|
@@ -79,13 +93,48 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
|
|
|
79
93
|
case "-v":
|
|
80
94
|
version = true
|
|
81
95
|
continue
|
|
96
|
+
case "--config-path":
|
|
97
|
+
configPath = true
|
|
98
|
+
continue
|
|
99
|
+
case "--sidebar": {
|
|
100
|
+
// Don't swallow the following flag as the sidebar value.
|
|
101
|
+
// `--sidebar --width 80` should leave sidebar=null (the boot
|
|
102
|
+
// layer reports a missing value) without losing --width.
|
|
103
|
+
const next = argv[i + 1]
|
|
104
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
105
|
+
sidebar = next
|
|
106
|
+
i++
|
|
107
|
+
}
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
case "--no-update-check":
|
|
111
|
+
noUpdateCheck = true
|
|
112
|
+
continue
|
|
113
|
+
case "--no-mdx":
|
|
114
|
+
noMdx = true
|
|
115
|
+
continue
|
|
82
116
|
}
|
|
83
117
|
if (path === null && !arg.startsWith("-")) {
|
|
84
118
|
path = arg
|
|
85
119
|
}
|
|
86
120
|
}
|
|
87
121
|
|
|
88
|
-
return {
|
|
122
|
+
return {
|
|
123
|
+
path,
|
|
124
|
+
theme,
|
|
125
|
+
tone,
|
|
126
|
+
width,
|
|
127
|
+
all,
|
|
128
|
+
sort,
|
|
129
|
+
serve,
|
|
130
|
+
port,
|
|
131
|
+
help,
|
|
132
|
+
version,
|
|
133
|
+
configPath,
|
|
134
|
+
sidebar,
|
|
135
|
+
noUpdateCheck,
|
|
136
|
+
noMdx,
|
|
137
|
+
}
|
|
89
138
|
}
|
|
90
139
|
|
|
91
140
|
const themeList = themeDefinitions.map((t) => t.id).join(", ")
|
|
@@ -100,7 +149,17 @@ options:
|
|
|
100
149
|
--width <N> cap rendered markdown width at N columns
|
|
101
150
|
--all include hidden and gitignored files in discovery
|
|
102
151
|
--sort <mode> sidebar order: dirs-first (default) or files-first
|
|
152
|
+
--sidebar <m> initial sidebar visibility: auto (default), on, or off
|
|
103
153
|
--serve serve the given file as HTML in the browser (skips TUI)
|
|
104
154
|
--port <N> port for --serve (default: OS-assigned)
|
|
105
155
|
-h, --help show this help and exit
|
|
106
|
-
-v, --version print version and exit
|
|
156
|
+
-v, --version print version and exit
|
|
157
|
+
--config-path print path to the config file and exit
|
|
158
|
+
--no-update-check suppress the "newer version available" check (also via NO_UPDATE_NOTIFIER=1)
|
|
159
|
+
--no-mdx exclude .mdx files from discovery (default: included)
|
|
160
|
+
|
|
161
|
+
configuration:
|
|
162
|
+
file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
|
|
163
|
+
keys: theme, tone, mdx
|
|
164
|
+
env: HOUSE_THEME, HOUSE_TONE, HOUSE_MDX
|
|
165
|
+
precedence (high → low): flags → env → file → defaults`
|