@carlesandres/house 0.4.13 → 0.5.0
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 +32 -2
- package/README.md +10 -8
- package/dist/bin.js +92 -0
- package/dist/index.js +10005 -0
- package/package.json +15 -3
- package/src/Browser.tsx +0 -1210
- package/src/CommandPalette.tsx +0 -214
- package/src/Footer.tsx +0 -258
- package/src/Header.tsx +0 -71
- package/src/PromptRow.tsx +0 -51
- package/src/Spinner.tsx +0 -39
- package/src/StatusIndicator.tsx +0 -55
- package/src/StatusPopover.tsx +0 -166
- package/src/brand.ts +0 -7
- package/src/cli/argv.ts +0 -179
- package/src/commands/buildCommands.ts +0 -98
- package/src/commands/paletteOnlyCommands.ts +0 -27
- package/src/commands/score.ts +0 -78
- package/src/commands/types.ts +0 -26
- package/src/config/load.ts +0 -381
- package/src/config/save.ts +0 -95
- package/src/discovery/filter.ts +0 -148
- package/src/discovery/show.ts +0 -48
- package/src/discovery/walk.ts +0 -209
- package/src/index.tsx +0 -464
- package/src/io/clipboard.ts +0 -53
- package/src/io/editor.ts +0 -162
- package/src/io/readFile.ts +0 -14
- package/src/keymap/browser.ts +0 -335
- package/src/keymap/displayKey.ts +0 -17
- package/src/keymap/keymap.ts +0 -95
- package/src/layout/resolve.ts +0 -52
- package/src/layout/sidebarEmptyState.ts +0 -7
- package/src/layout/sidebarRow.ts +0 -69
- package/src/markdown/frontmatter.ts +0 -62
- package/src/serve/css.ts +0 -120
- package/src/serve/openBrowser.ts +0 -19
- package/src/serve/render.ts +0 -56
- package/src/serve/server.ts +0 -166
- package/src/theme/atom.ts +0 -23
- package/src/theme/colors.ts +0 -86
- package/src/theme/loader.ts +0 -110
- package/src/theme/registry.ts +0 -12
- package/src/theme/resolve.ts +0 -168
- package/src/theme/themes/aura.json +0 -58
- package/src/theme/themes/ayu.json +0 -69
- package/src/theme/themes/carbonfox.json +0 -201
- package/src/theme/themes/catppuccin-frappe.json +0 -186
- package/src/theme/themes/catppuccin-macchiato.json +0 -186
- package/src/theme/themes/catppuccin.json +0 -212
- package/src/theme/themes/cobalt2.json +0 -181
- package/src/theme/themes/cursor.json +0 -202
- package/src/theme/themes/dracula.json +0 -172
- package/src/theme/themes/everforest.json +0 -194
- package/src/theme/themes/flexoki.json +0 -190
- package/src/theme/themes/github.json +0 -186
- package/src/theme/themes/gruvbox.json +0 -195
- package/src/theme/themes/kanagawa.json +0 -180
- package/src/theme/themes/lucent-orng.json +0 -186
- package/src/theme/themes/material.json +0 -188
- package/src/theme/themes/matrix.json +0 -180
- package/src/theme/themes/mercury.json +0 -198
- package/src/theme/themes/monokai.json +0 -174
- package/src/theme/themes/nightowl.json +0 -174
- package/src/theme/themes/nord.json +0 -176
- package/src/theme/themes/one-dark.json +0 -184
- package/src/theme/themes/opencode.json +0 -198
- package/src/theme/themes/orng.json +0 -202
- package/src/theme/themes/osaka-jade.json +0 -193
- package/src/theme/themes/palenight.json +0 -175
- package/src/theme/themes/rosepine.json +0 -187
- package/src/theme/themes/solarized.json +0 -180
- package/src/theme/themes/synthwave84.json +0 -179
- package/src/theme/themes/tokyonight.json +0 -196
- package/src/theme/themes/vercel.json +0 -198
- package/src/theme/themes/vesper.json +0 -171
- package/src/theme/themes/zenburn.json +0 -176
- package/src/theme/types.ts +0 -115
- package/src/tips.ts +0 -88
- package/src/ui/middleTruncate.ts +0 -27
- package/src/update/cache.ts +0 -77
- package/src/update/check.ts +0 -165
- package/src/update/compare.ts +0 -41
- package/src/update/notice.ts +0 -29
- package/src/update/runtime.ts +0 -48
- package/src/update/useUpdateNotice.ts +0 -24
package/src/CommandPalette.tsx
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CommandPalette — modal overlay for searching and running commands.
|
|
3
|
-
*
|
|
4
|
-
* Render-only: state lives in Browser.tsx alongside filterOpen
|
|
5
|
-
* (#70 design log §state location). Key handling sits in Browser.tsx's
|
|
6
|
-
* `useKeyboard` palette-branch, mirroring the filterOpen pattern.
|
|
7
|
-
*
|
|
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.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { RGBA } from "@opentui/core"
|
|
15
|
-
import { colors } from "./theme/colors.ts"
|
|
16
|
-
import { PromptRow } from "./PromptRow.tsx"
|
|
17
|
-
import type { AppCommand } from "./commands/types.ts"
|
|
18
|
-
|
|
19
|
-
// Semi-transparent black scrim painted across the viewport behind the modal.
|
|
20
|
-
// Opentui composites it over the chrome underneath, so the rest of the UI
|
|
21
|
-
// reads as darkened while the modal stays fully opaque. Mirrors opencode's
|
|
22
|
-
// dialog backdrop (cli/cmd/tui/ui/dialog.tsx).
|
|
23
|
-
const SCRIM = RGBA.fromInts(0, 0, 0, 150)
|
|
24
|
-
|
|
25
|
-
export interface CommandPaletteProps {
|
|
26
|
-
readonly commands: readonly AppCommand[]
|
|
27
|
-
readonly query: string
|
|
28
|
-
readonly selectedIndex: number
|
|
29
|
-
readonly viewportWidth: number
|
|
30
|
-
readonly viewportHeight: number
|
|
31
|
-
}
|
|
32
|
-
|
|
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
|
-
}
|
|
80
|
-
|
|
81
|
-
export const CommandPalette = ({
|
|
82
|
-
commands,
|
|
83
|
-
query,
|
|
84
|
-
selectedIndex,
|
|
85
|
-
viewportWidth,
|
|
86
|
-
viewportHeight,
|
|
87
|
-
}: CommandPaletteProps) => {
|
|
88
|
-
const overlayWidth = Math.min(viewportWidth - 4, 64)
|
|
89
|
-
const rows = buildRows(commands)
|
|
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. 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.
|
|
95
|
-
const chrome = 2 + 1 + 1 + 1 + 1
|
|
96
|
-
const maxBody = Math.max(1, viewportHeight - 2 - chrome)
|
|
97
|
-
const desiredBody = Math.max(1, rows.length || 1)
|
|
98
|
-
const bodyHeight = Math.min(desiredBody, maxBody)
|
|
99
|
-
const overlayHeight = chrome + bodyHeight
|
|
100
|
-
const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
|
|
101
|
-
const top = Math.max(0, Math.floor((viewportHeight - overlayHeight) / 2))
|
|
102
|
-
|
|
103
|
-
// Inner content width: overlay minus 1-cell border + 1-cell padding on each side.
|
|
104
|
-
const rowWidth = Math.max(4, overlayWidth - 4)
|
|
105
|
-
|
|
106
|
-
// Window the visible slice around the selected command row. Headers and
|
|
107
|
-
// spacers are render-only rows, so selection still tracks command indexes.
|
|
108
|
-
const scrollTop = (() => {
|
|
109
|
-
if (rows.length <= bodyHeight) return 0
|
|
110
|
-
const maxScroll = rows.length - bodyHeight
|
|
111
|
-
let s = 0
|
|
112
|
-
const selectedRowIndex = rows.findIndex(
|
|
113
|
-
(row) => row.kind === "command" && row.commandIndex === selectedIndex,
|
|
114
|
-
)
|
|
115
|
-
if (selectedRowIndex >= bodyHeight) s = selectedRowIndex - bodyHeight + 1
|
|
116
|
-
return Math.max(0, Math.min(s, maxScroll))
|
|
117
|
-
})()
|
|
118
|
-
const visible = rows.slice(scrollTop, scrollTop + bodyHeight)
|
|
119
|
-
|
|
120
|
-
// Shortcut column width — long enough for `shift+t`-style chords but
|
|
121
|
-
// trimmed to prevent the title from being squeezed below ~16 cells.
|
|
122
|
-
const SHORTCUT_WIDTH = 10
|
|
123
|
-
const titleWidth = Math.max(8, rowWidth - 2 /* selector */ - SHORTCUT_WIDTH - 1 /* gap */)
|
|
124
|
-
|
|
125
|
-
const fit = (s: string, width: number): string =>
|
|
126
|
-
s.length === width
|
|
127
|
-
? s
|
|
128
|
-
: s.length > width
|
|
129
|
-
? s.slice(0, Math.max(0, width - 1)) + "…"
|
|
130
|
-
: s + " ".repeat(width - s.length)
|
|
131
|
-
|
|
132
|
-
const fitRight = (s: string, width: number): string =>
|
|
133
|
-
s.length >= width ? s.slice(0, width) : " ".repeat(width - s.length) + s
|
|
134
|
-
|
|
135
|
-
return (
|
|
136
|
-
<box
|
|
137
|
-
position="absolute"
|
|
138
|
-
left={0}
|
|
139
|
-
top={0}
|
|
140
|
-
width={viewportWidth}
|
|
141
|
-
height={viewportHeight}
|
|
142
|
-
zIndex={20}
|
|
143
|
-
style={{ backgroundColor: SCRIM }}
|
|
144
|
-
>
|
|
145
|
-
<box
|
|
146
|
-
position="absolute"
|
|
147
|
-
left={left}
|
|
148
|
-
top={top}
|
|
149
|
-
width={overlayWidth}
|
|
150
|
-
height={overlayHeight}
|
|
151
|
-
title=" Commands "
|
|
152
|
-
titleAlignment="left"
|
|
153
|
-
paddingLeft={1}
|
|
154
|
-
paddingRight={1}
|
|
155
|
-
style={{
|
|
156
|
-
border: true,
|
|
157
|
-
borderColor: colors.textMuted,
|
|
158
|
-
flexDirection: "column",
|
|
159
|
-
backgroundColor: colors.backgroundPanel,
|
|
160
|
-
}}
|
|
161
|
-
>
|
|
162
|
-
<PromptRow query={query} editing={true} width={rowWidth} />
|
|
163
|
-
<text content=" " />
|
|
164
|
-
{commands.length === 0 ? (
|
|
165
|
-
<text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
|
|
166
|
-
) : (
|
|
167
|
-
visible.map((row) => {
|
|
168
|
-
if (row.kind === "spacer") {
|
|
169
|
-
return <text key={row.key} content=" " />
|
|
170
|
-
}
|
|
171
|
-
if (row.kind === "header") {
|
|
172
|
-
return (
|
|
173
|
-
<text
|
|
174
|
-
key={row.key}
|
|
175
|
-
wrapMode="none"
|
|
176
|
-
content={fit(row.text, rowWidth)}
|
|
177
|
-
style={{ fg: colors.textMuted, attributes: 1 }}
|
|
178
|
-
/>
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
const cmd = row.command
|
|
182
|
-
const isSelected = row.commandIndex === selectedIndex
|
|
183
|
-
const selector = isSelected ? "▸ " : " "
|
|
184
|
-
const titleText = fit(cmd.title, titleWidth)
|
|
185
|
-
const shortcutText = cmd.shortcut
|
|
186
|
-
? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
|
|
187
|
-
: " ".repeat(SHORTCUT_WIDTH)
|
|
188
|
-
// Title and shortcut render as separate spans so the shortcut
|
|
189
|
-
// can use `textMuted` while the title uses `text`/`primary`.
|
|
190
|
-
// Same trick opencode pulls with `--text-weak` — the theme
|
|
191
|
-
// guarantees the contrast, we just pick the right role.
|
|
192
|
-
const titleFg = isSelected ? colors.primary : colors.text
|
|
193
|
-
return (
|
|
194
|
-
<text
|
|
195
|
-
key={cmd.id}
|
|
196
|
-
wrapMode="none"
|
|
197
|
-
style={isSelected ? { bg: colors.backgroundElement } : {}}
|
|
198
|
-
>
|
|
199
|
-
<span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
|
|
200
|
-
<span style={{ fg: colors.textMuted }}>{shortcutText}</span>
|
|
201
|
-
</text>
|
|
202
|
-
)
|
|
203
|
-
})
|
|
204
|
-
)}
|
|
205
|
-
<text content=" " />
|
|
206
|
-
<text
|
|
207
|
-
wrapMode="none"
|
|
208
|
-
content={fit(FOOTER_HINT, rowWidth)}
|
|
209
|
-
style={{ fg: colors.textMuted }}
|
|
210
|
-
/>
|
|
211
|
-
</box>
|
|
212
|
-
</box>
|
|
213
|
-
)
|
|
214
|
-
}
|
package/src/Footer.tsx
DELETED
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Footer — single-row chrome under the two-pane area.
|
|
3
|
-
*
|
|
4
|
-
* Renders either a notice line (when one is active) or a compact hint row
|
|
5
|
-
* derived from the keymap. Hints are filtered by each binding's `when`
|
|
6
|
-
* against the current context, so the row reflects what the user can
|
|
7
|
-
* actually do right now. Overflow is handled by truncating from the right
|
|
8
|
-
* (later-in-array bindings drop off first).
|
|
9
|
-
*
|
|
10
|
-
* Empty vault: the footer renders normally even when no markdown files
|
|
11
|
-
* were discovered. Intentional — `q:quit` and `?:help` are exactly what an
|
|
12
|
-
* empty-vault user needs as an exit and discoverability anchor.
|
|
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
|
-
*
|
|
18
|
-
* Width math uses terminal-style string width instead of raw string length so
|
|
19
|
-
* ambiguous glyphs such as `↵` don't steal spacing from the label beside them.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import stringWidth from "string-width"
|
|
23
|
-
import type React from "react"
|
|
24
|
-
import type { KeyBinding } from "./keymap/keymap.ts"
|
|
25
|
-
import { displayKey } from "./keymap/displayKey.ts"
|
|
26
|
-
import { Spinner } from "./Spinner.tsx"
|
|
27
|
-
import { StatusIndicator, type StatusIndicatorProps } from "./StatusIndicator.tsx"
|
|
28
|
-
import { colors } from "./theme/colors.ts"
|
|
29
|
-
|
|
30
|
-
/** Rows the Footer occupies. Importers use it for layout math so a future
|
|
31
|
-
* taller footer doesn't require touching two files. */
|
|
32
|
-
export const FOOTER_HEIGHT = 1
|
|
33
|
-
|
|
34
|
-
export interface FooterProps<C> {
|
|
35
|
-
readonly bindings: readonly KeyBinding<C>[]
|
|
36
|
-
readonly ctx: C
|
|
37
|
-
readonly width: number
|
|
38
|
-
readonly notice?: string | null
|
|
39
|
-
/** Persistent status line (e.g. "indexing… 42"). Distinct from `notice`:
|
|
40
|
-
* no TTL, cleared by the caller when the underlying activity finishes.
|
|
41
|
-
* Loses to `notice` when both are set so transient toasts still surface. */
|
|
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 })[]
|
|
45
|
-
/** Test seam: override spinner tick speed so tests don't sleep on the full
|
|
46
|
-
* production interval. Ignored when discoveryStatus is null. */
|
|
47
|
-
readonly discoverySpinnerIntervalMs?: number
|
|
48
|
-
readonly discoverySpinnerInitialFrameIndex?: number
|
|
49
|
-
/** Test seam: deterministic footer spinner driver. */
|
|
50
|
-
readonly discoverySpinnerRegisterTick?: ((tick: () => void) => void) | null
|
|
51
|
-
/** Optional toggle callback for the discovery-warning popover. */
|
|
52
|
-
readonly onDiscoveryWarningToggle?: () => void
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const normalizeStatusLine = (status: string): string => status.replace(/\s+/g, " ").trim()
|
|
56
|
-
|
|
57
|
-
const HINT_SEPARATOR = " "
|
|
58
|
-
|
|
59
|
-
interface Hint {
|
|
60
|
-
readonly key: string
|
|
61
|
-
readonly label: string
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const hintWidth = (h: Hint): number => stringWidth(h.key) + 1 + stringWidth(h.label) // key + " " + label
|
|
65
|
-
|
|
66
|
-
const formatHint = <C,>(b: KeyBinding<C>): Hint | null => {
|
|
67
|
-
if (!b.hint) return null
|
|
68
|
-
const first = b.keys[0]
|
|
69
|
-
if (!first) return null
|
|
70
|
-
return { key: displayKey(first), label: b.hint }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Drop hints from the end until they fit within `width`. If not even the
|
|
74
|
-
* first hint fits, fall back to the bare key truncated to width, so the row
|
|
75
|
-
* is never silently blank on tight viewports. */
|
|
76
|
-
const fitHints = (hints: readonly Hint[], width: number): Hint[] => {
|
|
77
|
-
if (width <= 0 || hints.length === 0) return []
|
|
78
|
-
const acc: Hint[] = []
|
|
79
|
-
let used = 0
|
|
80
|
-
for (const h of hints) {
|
|
81
|
-
const add = acc.length === 0 ? hintWidth(h) : HINT_SEPARATOR.length + hintWidth(h)
|
|
82
|
-
if (used + add > width) break
|
|
83
|
-
acc.push(h)
|
|
84
|
-
used += add
|
|
85
|
-
}
|
|
86
|
-
if (acc.length > 0) return acc
|
|
87
|
-
const first = hints[0]!
|
|
88
|
-
return [{ key: first.key.slice(0, width), label: "" }]
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const STATUS_SEPARATOR = " · "
|
|
92
|
-
const NON_WARNING_STATUS_PREFIX_WIDTH = 2 // 1-cell spinner + 1-cell spacer
|
|
93
|
-
|
|
94
|
-
const isPartialDiscoveryWarning = (status: string | null): boolean =>
|
|
95
|
-
status?.startsWith("scan incomplete:") ?? false
|
|
96
|
-
|
|
97
|
-
export const Footer = <C,>({
|
|
98
|
-
bindings,
|
|
99
|
-
ctx,
|
|
100
|
-
width,
|
|
101
|
-
notice,
|
|
102
|
-
discoveryStatus,
|
|
103
|
-
indicators = [],
|
|
104
|
-
discoverySpinnerIntervalMs,
|
|
105
|
-
discoverySpinnerInitialFrameIndex,
|
|
106
|
-
discoverySpinnerRegisterTick,
|
|
107
|
-
onDiscoveryWarningToggle,
|
|
108
|
-
}: FooterProps<C>) => {
|
|
109
|
-
const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
|
|
110
|
-
|
|
111
|
-
const rowStyle = {
|
|
112
|
-
width,
|
|
113
|
-
height: FOOTER_HEIGHT,
|
|
114
|
-
flexShrink: 0,
|
|
115
|
-
flexDirection: "row",
|
|
116
|
-
paddingLeft: 1,
|
|
117
|
-
paddingRight: 1,
|
|
118
|
-
backgroundColor: colors.backgroundPanel,
|
|
119
|
-
} as const
|
|
120
|
-
|
|
121
|
-
const hints: Hint[] = []
|
|
122
|
-
for (const b of bindings) {
|
|
123
|
-
// Hint visibility prefers `hintWhen` (binding-specific) over `when`
|
|
124
|
-
// (dispatch gate). Falling back to `when` keeps the original
|
|
125
|
-
// "hint shows when binding is enabled" behavior for the common case.
|
|
126
|
-
const visibleGate = b.hintWhen ?? b.when
|
|
127
|
-
if (visibleGate && !visibleGate(ctx)) continue
|
|
128
|
-
const h = formatHint(b)
|
|
129
|
-
if (h !== null) hints.push(h)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Discovery status sits left of the hints, separated by " · ". On tight
|
|
133
|
-
// viewports it claims its budget first; hints fit into the remainder so
|
|
134
|
-
// the indicator stays visible while less-essential hints drop off.
|
|
135
|
-
const status =
|
|
136
|
-
discoveryStatus && discoveryStatus.length > 0 ? normalizeStatusLine(discoveryStatus) : null
|
|
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
|
|
159
|
-
const statusBudget = status
|
|
160
|
-
? Math.min((isPartialWarning ? 0 : status.length) + statusChromeWidth, contentBudget)
|
|
161
|
-
: 0
|
|
162
|
-
const hintsWidth = Math.max(0, contentBudget - statusBudget)
|
|
163
|
-
const visibleHints = fitHints(hints, hintsWidth)
|
|
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
|
-
)
|
|
172
|
-
: ""
|
|
173
|
-
const noticeContent = notice
|
|
174
|
-
? notice.length > contentBudget
|
|
175
|
-
? notice.slice(0, contentBudget)
|
|
176
|
-
: notice
|
|
177
|
-
: null
|
|
178
|
-
const renderIndicators = () =>
|
|
179
|
-
renderedIndicators.map(({ id, ...props }) => <StatusIndicator key={id} {...props} />)
|
|
180
|
-
|
|
181
|
-
// Two-tone hint row: keys render in `text` (foreground-strength), the
|
|
182
|
-
// `:label` portion in `textMuted`. Matches ghui's footer treatment so
|
|
183
|
-
// the key — the actionable token — visually leads each hint.
|
|
184
|
-
const renderHints = () =>
|
|
185
|
-
visibleHints.flatMap((h, i) => {
|
|
186
|
-
const sep =
|
|
187
|
-
i > 0
|
|
188
|
-
? [
|
|
189
|
-
<text
|
|
190
|
-
key={`s${i}`}
|
|
191
|
-
content={HINT_SEPARATOR}
|
|
192
|
-
wrapMode="none"
|
|
193
|
-
style={{ fg: colors.textMuted }}
|
|
194
|
-
/>,
|
|
195
|
-
]
|
|
196
|
-
: []
|
|
197
|
-
return [
|
|
198
|
-
...sep,
|
|
199
|
-
<text key={`k${i}`} content={h.key} wrapMode="none" style={{ fg: colors.text }} />,
|
|
200
|
-
<text
|
|
201
|
-
key={`l${i}`}
|
|
202
|
-
content={` ${h.label}`}
|
|
203
|
-
wrapMode="none"
|
|
204
|
-
style={{ fg: colors.textMuted }}
|
|
205
|
-
/>,
|
|
206
|
-
]
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
// Priority: notice > (status + hints). Discovery status uses the secondary
|
|
210
|
-
// token: active metadata, but not a warning/error event.
|
|
211
|
-
if (noticeContent !== null) {
|
|
212
|
-
return (
|
|
213
|
-
<box style={rowStyle}>
|
|
214
|
-
{renderIndicators()}
|
|
215
|
-
<text content={noticeContent} wrapMode="none" style={{ fg: colors.primary }} />
|
|
216
|
-
</box>
|
|
217
|
-
)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (status !== null) {
|
|
221
|
-
const spinnerProps = {
|
|
222
|
-
fg: colors.secondary,
|
|
223
|
-
...(discoverySpinnerIntervalMs === undefined
|
|
224
|
-
? {}
|
|
225
|
-
: { intervalMs: discoverySpinnerIntervalMs }),
|
|
226
|
-
...(discoverySpinnerInitialFrameIndex === undefined
|
|
227
|
-
? {}
|
|
228
|
-
: { initialFrameIndex: discoverySpinnerInitialFrameIndex }),
|
|
229
|
-
...(discoverySpinnerRegisterTick === undefined
|
|
230
|
-
? {}
|
|
231
|
-
: { registerTick: discoverySpinnerRegisterTick }),
|
|
232
|
-
} satisfies React.ComponentProps<typeof Spinner>
|
|
233
|
-
|
|
234
|
-
return (
|
|
235
|
-
<box style={rowStyle}>
|
|
236
|
-
{renderIndicators()}
|
|
237
|
-
{nonWarningStatusPrefixBudget >= 1 ? <Spinner {...spinnerProps} /> : null}
|
|
238
|
-
{nonWarningStatusPrefixBudget >= 2 ? (
|
|
239
|
-
<text content=" " wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
240
|
-
) : null}
|
|
241
|
-
{isPartialWarning ? null : (
|
|
242
|
-
<text content={statusContent} wrapMode="none" style={{ fg: colors.secondary }} />
|
|
243
|
-
)}
|
|
244
|
-
{statusSeparatorContent.length > 0 && (
|
|
245
|
-
<text content={statusSeparatorContent} wrapMode="none" style={{ fg: colors.textMuted }} />
|
|
246
|
-
)}
|
|
247
|
-
{renderHints()}
|
|
248
|
-
</box>
|
|
249
|
-
)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return (
|
|
253
|
-
<box style={rowStyle}>
|
|
254
|
-
{renderIndicators()}
|
|
255
|
-
{renderHints()}
|
|
256
|
-
</box>
|
|
257
|
-
)
|
|
258
|
-
}
|
package/src/Header.tsx
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
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.backgroundPanel,
|
|
62
|
-
}}
|
|
63
|
-
>
|
|
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>
|
|
68
|
-
{showRight && <text content={right} wrapMode="none" style={{ fg: colors.text }} />}
|
|
69
|
-
</box>
|
|
70
|
-
)
|
|
71
|
-
}
|
package/src/PromptRow.tsx
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PromptRow — single-line `> query` row shared by the sidebar filter and
|
|
3
|
-
* the command palette query input.
|
|
4
|
-
*
|
|
5
|
-
* Render-only: the parent owns query state and the focus/editing flag.
|
|
6
|
-
* The `> ` prefix renders in `primary` or `secondary` so it
|
|
7
|
-
* reads as chrome, not placeholder text — only the body span shifts color
|
|
8
|
-
* (primary while editing, text when applied, textMuted as placeholder).
|
|
9
|
-
*
|
|
10
|
-
* Overflow: when editing, an overflowing body anchors its right edge with a
|
|
11
|
-
* leading `…` so the cursor stays on screen; otherwise it anchors the left
|
|
12
|
-
* edge with a trailing `…`.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { colors } from "./theme/colors.ts"
|
|
16
|
-
|
|
17
|
-
export interface PromptRowProps {
|
|
18
|
-
readonly query: string
|
|
19
|
-
/** True while the input is focused — shows a cursor and uses primary fg. */
|
|
20
|
-
readonly editing: boolean
|
|
21
|
-
/** Body fallback when !editing && query === "". Pass without the `> ` prefix. */
|
|
22
|
-
readonly placeholder?: string
|
|
23
|
-
/** Total cell width available for the row (prefix + body). */
|
|
24
|
-
readonly width: number
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const PREFIX = "> "
|
|
28
|
-
const CURSOR = "▏"
|
|
29
|
-
|
|
30
|
-
export const PromptRow = ({ query, editing, placeholder = "", width }: PromptRowProps) => {
|
|
31
|
-
const bodyBudget = Math.max(1, width - PREFIX.length)
|
|
32
|
-
|
|
33
|
-
const rawBody = editing ? `${query}${CURSOR}` : query.length > 0 ? query : placeholder
|
|
34
|
-
const bodyFg = editing ? colors.primary : query.length > 0 ? colors.text : colors.textMuted
|
|
35
|
-
|
|
36
|
-
const body =
|
|
37
|
-
rawBody.length <= bodyBudget
|
|
38
|
-
? rawBody
|
|
39
|
-
: editing
|
|
40
|
-
? "…" + rawBody.slice(rawBody.length - bodyBudget + 1)
|
|
41
|
-
: rawBody.slice(0, bodyBudget - 1) + "…"
|
|
42
|
-
|
|
43
|
-
const prefixFg = editing ? colors.secondary : colors.primary
|
|
44
|
-
|
|
45
|
-
return (
|
|
46
|
-
<text wrapMode="none">
|
|
47
|
-
<span style={{ fg: prefixFg }}>{PREFIX}</span>
|
|
48
|
-
<span style={{ fg: bodyFg }}>{body}</span>
|
|
49
|
-
</text>
|
|
50
|
-
)
|
|
51
|
-
}
|
package/src/Spinner.tsx
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react"
|
|
2
|
-
import { colors } from "./theme/colors.ts"
|
|
3
|
-
|
|
4
|
-
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const
|
|
5
|
-
const FRAME_COUNT = FRAMES.length
|
|
6
|
-
const normalizeFrameIndex = (index: number) => ((index % FRAME_COUNT) + FRAME_COUNT) % FRAME_COUNT
|
|
7
|
-
|
|
8
|
-
export interface SpinnerProps {
|
|
9
|
-
readonly fg?: string
|
|
10
|
-
readonly intervalMs?: number
|
|
11
|
-
readonly initialFrameIndex?: number
|
|
12
|
-
/** Test seam: deterministic driver for frame advancement. When present,
|
|
13
|
-
* Spinner registers its tick callback here instead of starting an interval. */
|
|
14
|
-
readonly registerTick?: ((tick: () => void) => void) | null
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const Spinner = ({
|
|
18
|
-
fg = colors.textMuted,
|
|
19
|
-
intervalMs = 100,
|
|
20
|
-
initialFrameIndex = 0,
|
|
21
|
-
registerTick = null,
|
|
22
|
-
}: SpinnerProps) => {
|
|
23
|
-
const [index, setIndex] = useState(() => normalizeFrameIndex(initialFrameIndex))
|
|
24
|
-
const tickRef = useRef<() => void>(() => undefined)
|
|
25
|
-
tickRef.current = () => setIndex((prev) => (prev + 1) % FRAME_COUNT)
|
|
26
|
-
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
if (registerTick) {
|
|
29
|
-
registerTick(() => tickRef.current())
|
|
30
|
-
return
|
|
31
|
-
}
|
|
32
|
-
const id = setInterval(() => {
|
|
33
|
-
tickRef.current()
|
|
34
|
-
}, intervalMs)
|
|
35
|
-
return () => clearInterval(id)
|
|
36
|
-
}, [intervalMs, registerTick])
|
|
37
|
-
|
|
38
|
-
return <text content={FRAMES[index] ?? FRAMES[0]} wrapMode="none" style={{ fg }} />
|
|
39
|
-
}
|
package/src/StatusIndicator.tsx
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { colors } from "./theme/colors.ts"
|
|
2
|
-
|
|
3
|
-
export type StatusIndicatorVariant = "info" | "warning" | "error" | "success"
|
|
4
|
-
|
|
5
|
-
export interface StatusIndicatorProps {
|
|
6
|
-
readonly icon: string
|
|
7
|
-
readonly variant?: StatusIndicatorVariant
|
|
8
|
-
readonly active?: boolean
|
|
9
|
-
readonly onMouseUp?: () => void
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export const statusIndicatorFg = (variant: StatusIndicatorVariant): string => {
|
|
13
|
-
switch (variant) {
|
|
14
|
-
case "info":
|
|
15
|
-
return colors.info
|
|
16
|
-
case "warning":
|
|
17
|
-
return colors.warning
|
|
18
|
-
case "error":
|
|
19
|
-
return colors.error
|
|
20
|
-
case "success":
|
|
21
|
-
return colors.success
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const StatusIndicator = ({
|
|
26
|
-
icon,
|
|
27
|
-
variant = "info",
|
|
28
|
-
active = true,
|
|
29
|
-
onMouseUp,
|
|
30
|
-
}: StatusIndicatorProps) => {
|
|
31
|
-
const activeColor = statusIndicatorFg(variant)
|
|
32
|
-
const backgroundColor = active ? activeColor : colors.backgroundElement
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<box
|
|
36
|
-
{...(onMouseUp === undefined ? {} : { onMouseUp })}
|
|
37
|
-
style={{
|
|
38
|
-
width: 3,
|
|
39
|
-
height: 1,
|
|
40
|
-
flexDirection: "row",
|
|
41
|
-
backgroundColor,
|
|
42
|
-
}}
|
|
43
|
-
>
|
|
44
|
-
<text
|
|
45
|
-
content={` ${icon} `}
|
|
46
|
-
wrapMode="none"
|
|
47
|
-
style={{
|
|
48
|
-
fg: active ? colors.backgroundPanel : colors.textMuted,
|
|
49
|
-
bg: backgroundColor,
|
|
50
|
-
attributes: active ? 1 : 0,
|
|
51
|
-
}}
|
|
52
|
-
/>
|
|
53
|
-
</box>
|
|
54
|
-
)
|
|
55
|
-
}
|