@carlesandres/house 0.4.14 → 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 (87) hide show
  1. package/CHANGELOG.md +25 -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 -160
  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/rootLabel.ts +0 -18
  24. package/src/discovery/show.ts +0 -48
  25. package/src/discovery/walk.ts +0 -209
  26. package/src/index.tsx +0 -469
  27. package/src/io/clipboard.ts +0 -53
  28. package/src/io/editor.ts +0 -162
  29. package/src/io/readFile.ts +0 -14
  30. package/src/keymap/browser.ts +0 -335
  31. package/src/keymap/displayKey.ts +0 -17
  32. package/src/keymap/keymap.ts +0 -95
  33. package/src/layout/resolve.ts +0 -52
  34. package/src/layout/sidebarEmptyState.ts +0 -7
  35. package/src/layout/sidebarRow.ts +0 -69
  36. package/src/markdown/frontmatter.ts +0 -62
  37. package/src/serve/css.ts +0 -120
  38. package/src/serve/openBrowser.ts +0 -19
  39. package/src/serve/render.ts +0 -56
  40. package/src/serve/server.ts +0 -166
  41. package/src/theme/atom.ts +0 -23
  42. package/src/theme/colors.ts +0 -86
  43. package/src/theme/loader.ts +0 -110
  44. package/src/theme/registry.ts +0 -12
  45. package/src/theme/resolve.ts +0 -168
  46. package/src/theme/themes/aura.json +0 -58
  47. package/src/theme/themes/ayu.json +0 -69
  48. package/src/theme/themes/carbonfox.json +0 -201
  49. package/src/theme/themes/catppuccin-frappe.json +0 -186
  50. package/src/theme/themes/catppuccin-macchiato.json +0 -186
  51. package/src/theme/themes/catppuccin.json +0 -212
  52. package/src/theme/themes/cobalt2.json +0 -181
  53. package/src/theme/themes/cursor.json +0 -202
  54. package/src/theme/themes/dracula.json +0 -172
  55. package/src/theme/themes/everforest.json +0 -194
  56. package/src/theme/themes/flexoki.json +0 -190
  57. package/src/theme/themes/github.json +0 -186
  58. package/src/theme/themes/gruvbox.json +0 -195
  59. package/src/theme/themes/kanagawa.json +0 -180
  60. package/src/theme/themes/lucent-orng.json +0 -186
  61. package/src/theme/themes/material.json +0 -188
  62. package/src/theme/themes/matrix.json +0 -180
  63. package/src/theme/themes/mercury.json +0 -198
  64. package/src/theme/themes/monokai.json +0 -174
  65. package/src/theme/themes/nightowl.json +0 -174
  66. package/src/theme/themes/nord.json +0 -176
  67. package/src/theme/themes/one-dark.json +0 -184
  68. package/src/theme/themes/opencode.json +0 -198
  69. package/src/theme/themes/orng.json +0 -202
  70. package/src/theme/themes/osaka-jade.json +0 -193
  71. package/src/theme/themes/palenight.json +0 -175
  72. package/src/theme/themes/rosepine.json +0 -187
  73. package/src/theme/themes/solarized.json +0 -180
  74. package/src/theme/themes/synthwave84.json +0 -179
  75. package/src/theme/themes/tokyonight.json +0 -196
  76. package/src/theme/themes/vercel.json +0 -198
  77. package/src/theme/themes/vesper.json +0 -171
  78. package/src/theme/themes/zenburn.json +0 -176
  79. package/src/theme/types.ts +0 -115
  80. package/src/tips.ts +0 -88
  81. package/src/ui/middleTruncate.ts +0 -27
  82. package/src/update/cache.ts +0 -77
  83. package/src/update/check.ts +0 -165
  84. package/src/update/compare.ts +0 -41
  85. package/src/update/notice.ts +0 -29
  86. package/src/update/runtime.ts +0 -48
  87. 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,160 +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 discovery root truncates/drops first, then
9
- * the wordmark, then the version, then the filename truncates, leaving the
10
- * brand mark as the irreducible identity element. Always rendered — the row is
11
- * worth one cell on any viewport so the user never loses the filename/root
12
- * indicator (notably, when the sidebar drawer overlays the reader on narrow
13
- * viewports).
14
- */
15
-
16
- import pkg from "../package.json" with { type: "json" }
17
- import { BRAND, BRAND_NAME } from "./brand.ts"
18
- import { colors } from "./theme/colors.ts"
19
- import { middleTruncate } from "./ui/middleTruncate.ts"
20
-
21
- export const HEADER_HEIGHT = 1
22
-
23
- const FILE_SEPARATOR = " · "
24
- const HEADER_HORIZONTAL_PADDING = 2
25
- const HEADER_GROUP_GAP = 1
26
- const MIN_TRUNCATED_ROOT_WIDTH = 5
27
-
28
- export interface HeaderSegment {
29
- readonly id: "brand" | "file" | "root"
30
- readonly text: string
31
- readonly tone: "brand" | "primary" | "muted"
32
- }
33
-
34
- export interface HeaderLayout {
35
- readonly left: readonly HeaderSegment[]
36
- readonly right: string | null
37
- }
38
-
39
- export interface HeaderLayoutInput {
40
- readonly width: number
41
- readonly currentFile?: string | null | undefined
42
- readonly rootLabel?: string | null | undefined
43
- readonly version: string
44
- }
45
-
46
- export const layoutHeaderSegments = ({
47
- width,
48
- currentFile,
49
- rootLabel,
50
- version,
51
- }: HeaderLayoutInput): HeaderLayout => {
52
- const usableWidth = Math.max(0, width - HEADER_HORIZONTAL_PADDING)
53
- const file = currentFile && currentFile.length > 0 ? currentFile : null
54
- const root = rootLabel && rootLabel.length > 0 ? rootLabel : null
55
- const right = `v${version}`
56
- const brand: HeaderSegment = { id: "brand", text: `${BRAND} ${BRAND_NAME}`, tone: "brand" }
57
- const iconBrand: HeaderSegment = { id: "brand", text: BRAND, tone: "brand" }
58
- const fileSegment: HeaderSegment | null =
59
- file === null ? null : { id: "file", text: file, tone: "primary" }
60
- const rootSegment: HeaderSegment | null =
61
- root === null ? null : { id: "root", text: root, tone: "muted" }
62
- const segments = [brand, ...(fileSegment === null ? [] : [fileSegment])]
63
-
64
- if (rootSegment !== null && fits([...segments, rootSegment], right, usableWidth)) {
65
- return { left: [...segments, rootSegment], right }
66
- }
67
-
68
- const rootWidth =
69
- rootSegment === null
70
- ? 0
71
- : usableWidth -
72
- joinedLength(segments) -
73
- FILE_SEPARATOR.length -
74
- HEADER_GROUP_GAP -
75
- right.length
76
- if (root !== null && root.length > rootWidth && rootWidth >= MIN_TRUNCATED_ROOT_WIDTH) {
77
- return {
78
- left: [...segments, { id: "root", text: middleTruncate(root, rootWidth), tone: "muted" }],
79
- right,
80
- }
81
- }
82
-
83
- if (fits(segments, right, usableWidth)) return { left: segments, right }
84
-
85
- const iconSegments = [iconBrand, ...(fileSegment === null ? [] : [fileSegment])]
86
- if (fits(iconSegments, right, usableWidth)) return { left: iconSegments, right }
87
- if (fits(iconSegments, null, usableWidth)) return { left: iconSegments, right: null }
88
- if (file !== null) {
89
- const fileWidth = usableWidth - BRAND.length - FILE_SEPARATOR.length
90
- if (fileWidth > 0) {
91
- return {
92
- left: [iconBrand, { id: "file", text: middleTruncate(file, fileWidth), tone: "primary" }],
93
- right: null,
94
- }
95
- }
96
- }
97
-
98
- return { left: [iconBrand], right: null }
99
- }
100
-
101
- const joinedLength = (segments: readonly HeaderSegment[]): number =>
102
- segments.reduce(
103
- (total, segment, idx) => total + segment.text.length + (idx === 0 ? 0 : FILE_SEPARATOR.length),
104
- 0,
105
- )
106
-
107
- const fits = (
108
- segments: readonly HeaderSegment[],
109
- right: string | null,
110
- usableWidth: number,
111
- ): boolean => {
112
- const rightWidth = right === null ? 0 : HEADER_GROUP_GAP + right.length
113
- return joinedLength(segments) + rightWidth <= usableWidth
114
- }
115
-
116
- export interface HeaderProps {
117
- readonly width: number
118
- /** Currently selected file's relative path. When set, the Header shows
119
- * it next to the brand mark — replaces the per-pane border title that
120
- * used to carry this information. */
121
- readonly currentFile?: string | null
122
- /** Canonical discovery root / scan-scope label. */
123
- readonly rootLabel?: string | null
124
- /** Optional override for the version string (testing). Defaults to
125
- * the running package's version. */
126
- readonly version?: string
127
- }
128
-
129
- export const Header = ({ width, currentFile, rootLabel, version = pkg.version }: HeaderProps) => {
130
- const layout = layoutHeaderSegments({ width, currentFile, rootLabel, version })
131
-
132
- return (
133
- <box
134
- style={{
135
- width,
136
- height: HEADER_HEIGHT,
137
- flexShrink: 0,
138
- flexDirection: "row",
139
- justifyContent: "space-between",
140
- paddingLeft: 1,
141
- paddingRight: 1,
142
- backgroundColor: colors.backgroundPanel,
143
- }}
144
- >
145
- <text wrapMode="none">
146
- {layout.left.map((segment, idx) => (
147
- <span
148
- key={`${segment.id}-${idx}`}
149
- style={{ fg: segment.tone === "muted" ? colors.textMuted : colors.text }}
150
- >
151
- {`${idx === 0 ? "" : FILE_SEPARATOR}${segment.text}`}
152
- </span>
153
- ))}
154
- </text>
155
- {layout.right !== null && (
156
- <text content={layout.right} wrapMode="none" style={{ fg: colors.text }} />
157
- )}
158
- </box>
159
- )
160
- }
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
- }