@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/README.md +10 -8
  3. package/dist/bin.js +92 -0
  4. package/dist/index.js +10005 -0
  5. package/package.json +15 -3
  6. package/src/Browser.tsx +0 -1210
  7. package/src/CommandPalette.tsx +0 -214
  8. package/src/Footer.tsx +0 -258
  9. package/src/Header.tsx +0 -71
  10. package/src/PromptRow.tsx +0 -51
  11. package/src/Spinner.tsx +0 -39
  12. package/src/StatusIndicator.tsx +0 -55
  13. package/src/StatusPopover.tsx +0 -166
  14. package/src/brand.ts +0 -7
  15. package/src/cli/argv.ts +0 -179
  16. package/src/commands/buildCommands.ts +0 -98
  17. package/src/commands/paletteOnlyCommands.ts +0 -27
  18. package/src/commands/score.ts +0 -78
  19. package/src/commands/types.ts +0 -26
  20. package/src/config/load.ts +0 -381
  21. package/src/config/save.ts +0 -95
  22. package/src/discovery/filter.ts +0 -148
  23. package/src/discovery/show.ts +0 -48
  24. package/src/discovery/walk.ts +0 -209
  25. package/src/index.tsx +0 -464
  26. package/src/io/clipboard.ts +0 -53
  27. package/src/io/editor.ts +0 -162
  28. package/src/io/readFile.ts +0 -14
  29. package/src/keymap/browser.ts +0 -335
  30. package/src/keymap/displayKey.ts +0 -17
  31. package/src/keymap/keymap.ts +0 -95
  32. package/src/layout/resolve.ts +0 -52
  33. package/src/layout/sidebarEmptyState.ts +0 -7
  34. package/src/layout/sidebarRow.ts +0 -69
  35. package/src/markdown/frontmatter.ts +0 -62
  36. package/src/serve/css.ts +0 -120
  37. package/src/serve/openBrowser.ts +0 -19
  38. package/src/serve/render.ts +0 -56
  39. package/src/serve/server.ts +0 -166
  40. package/src/theme/atom.ts +0 -23
  41. package/src/theme/colors.ts +0 -86
  42. package/src/theme/loader.ts +0 -110
  43. package/src/theme/registry.ts +0 -12
  44. package/src/theme/resolve.ts +0 -168
  45. package/src/theme/themes/aura.json +0 -58
  46. package/src/theme/themes/ayu.json +0 -69
  47. package/src/theme/themes/carbonfox.json +0 -201
  48. package/src/theme/themes/catppuccin-frappe.json +0 -186
  49. package/src/theme/themes/catppuccin-macchiato.json +0 -186
  50. package/src/theme/themes/catppuccin.json +0 -212
  51. package/src/theme/themes/cobalt2.json +0 -181
  52. package/src/theme/themes/cursor.json +0 -202
  53. package/src/theme/themes/dracula.json +0 -172
  54. package/src/theme/themes/everforest.json +0 -194
  55. package/src/theme/themes/flexoki.json +0 -190
  56. package/src/theme/themes/github.json +0 -186
  57. package/src/theme/themes/gruvbox.json +0 -195
  58. package/src/theme/themes/kanagawa.json +0 -180
  59. package/src/theme/themes/lucent-orng.json +0 -186
  60. package/src/theme/themes/material.json +0 -188
  61. package/src/theme/themes/matrix.json +0 -180
  62. package/src/theme/themes/mercury.json +0 -198
  63. package/src/theme/themes/monokai.json +0 -174
  64. package/src/theme/themes/nightowl.json +0 -174
  65. package/src/theme/themes/nord.json +0 -176
  66. package/src/theme/themes/one-dark.json +0 -184
  67. package/src/theme/themes/opencode.json +0 -198
  68. package/src/theme/themes/orng.json +0 -202
  69. package/src/theme/themes/osaka-jade.json +0 -193
  70. package/src/theme/themes/palenight.json +0 -175
  71. package/src/theme/themes/rosepine.json +0 -187
  72. package/src/theme/themes/solarized.json +0 -180
  73. package/src/theme/themes/synthwave84.json +0 -179
  74. package/src/theme/themes/tokyonight.json +0 -196
  75. package/src/theme/themes/vercel.json +0 -198
  76. package/src/theme/themes/vesper.json +0 -171
  77. package/src/theme/themes/zenburn.json +0 -176
  78. package/src/theme/types.ts +0 -115
  79. package/src/tips.ts +0 -88
  80. package/src/ui/middleTruncate.ts +0 -27
  81. package/src/update/cache.ts +0 -77
  82. package/src/update/check.ts +0 -165
  83. package/src/update/compare.ts +0 -41
  84. package/src/update/notice.ts +0 -29
  85. package/src/update/runtime.ts +0 -48
  86. package/src/update/useUpdateNotice.ts +0 -24
@@ -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
- }
@@ -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
- }