@carlesandres/house 0.3.1 → 0.4.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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * CommandPalette — modal overlay for searching and running commands.
3
+ *
4
+ * Render-only: state lives in Browser.tsx alongside helpVisible / filterOpen
5
+ * (#70 design log §state location). Key handling sits in Browser.tsx's
6
+ * `useKeyboard` palette-branch, mirroring the filterOpen pattern.
7
+ *
8
+ * V1 ships a flat list in `browserBindings` order — no category headers,
9
+ * no mouse, no recency. See #91/#93/#94/#95/#96 for the follow-ups.
10
+ */
11
+
12
+ import { colors } from "./theme/colors.ts"
13
+ import type { AppCommand } from "./commands/types.ts"
14
+
15
+ export interface CommandPaletteProps {
16
+ readonly commands: readonly AppCommand[]
17
+ readonly query: string
18
+ readonly selectedIndex: number
19
+ readonly viewportWidth: number
20
+ readonly viewportHeight: number
21
+ }
22
+
23
+ const FOOTER_HINT = "↑↓ select enter run esc close"
24
+
25
+ export const CommandPalette = ({
26
+ commands,
27
+ query,
28
+ selectedIndex,
29
+ viewportWidth,
30
+ viewportHeight,
31
+ }: CommandPaletteProps) => {
32
+ const overlayWidth = Math.min(viewportWidth - 4, 64)
33
+ // Reserve: 2 for border (top+bottom), 1 query row, 1 spacer below query,
34
+ // 1 spacer above footer, 1 footer row. Body gets the rest.
35
+ const chrome = 2 + 1 + 1 + 1 + 1
36
+ const maxBody = Math.max(1, viewportHeight - 4 - chrome)
37
+ const desiredBody = Math.max(1, commands.length || 1)
38
+ const bodyHeight = Math.min(desiredBody, maxBody)
39
+ const overlayHeight = chrome + bodyHeight
40
+ const left = Math.max(0, Math.floor((viewportWidth - overlayWidth) / 2))
41
+ const top = Math.max(0, Math.floor((viewportHeight - overlayHeight) / 2))
42
+
43
+ // Inner content width: overlay minus 1-cell border + 1-cell padding on each side.
44
+ const rowWidth = Math.max(4, overlayWidth - 4)
45
+
46
+ // Window the visible slice around the selection. With 9 commands in v1
47
+ // this is usually a no-op (list fits), but the math is in place for the
48
+ // inevitable backlog growth.
49
+ const scrollTop = (() => {
50
+ if (commands.length <= bodyHeight) return 0
51
+ const maxScroll = commands.length - bodyHeight
52
+ let s = 0
53
+ if (selectedIndex >= bodyHeight) s = selectedIndex - bodyHeight + 1
54
+ return Math.max(0, Math.min(s, maxScroll))
55
+ })()
56
+ const visible = commands.slice(scrollTop, scrollTop + bodyHeight)
57
+
58
+ // Shortcut column width — long enough for `shift+t`-style chords but
59
+ // trimmed to prevent the title from being squeezed below ~16 cells.
60
+ const SHORTCUT_WIDTH = 10
61
+ const titleWidth = Math.max(8, rowWidth - 2 /* selector */ - SHORTCUT_WIDTH - 1 /* gap */)
62
+
63
+ const fit = (s: string, width: number): string =>
64
+ s.length === width
65
+ ? s
66
+ : s.length > width
67
+ ? s.slice(0, Math.max(0, width - 1)) + "…"
68
+ : s + " ".repeat(width - s.length)
69
+
70
+ const fitRight = (s: string, width: number): string =>
71
+ s.length >= width ? s.slice(0, width) : " ".repeat(width - s.length) + s
72
+
73
+ return (
74
+ <box
75
+ position="absolute"
76
+ left={left}
77
+ top={top}
78
+ width={overlayWidth}
79
+ height={overlayHeight}
80
+ zIndex={20}
81
+ title=" Commands "
82
+ titleAlignment="left"
83
+ paddingLeft={1}
84
+ paddingRight={1}
85
+ style={{
86
+ border: true,
87
+ borderColor: colors.borderActive,
88
+ flexDirection: "column",
89
+ backgroundColor: colors.surface,
90
+ }}
91
+ >
92
+ <text
93
+ wrapMode="none"
94
+ content={fit(`> ${query}▏`, rowWidth)}
95
+ style={{ fg: colors.textStrong }}
96
+ />
97
+ <text content=" " />
98
+ {commands.length === 0 ? (
99
+ <text wrapMode="none" content=" (no matches)" style={{ fg: colors.textMuted }} />
100
+ ) : (
101
+ visible.map((cmd, i) => {
102
+ const realIdx = scrollTop + i
103
+ const isSelected = realIdx === selectedIndex
104
+ const selector = isSelected ? "▸ " : " "
105
+ const titleText = fit(cmd.title, titleWidth)
106
+ const shortcutText = cmd.shortcut
107
+ ? fitRight(cmd.shortcut, SHORTCUT_WIDTH)
108
+ : " ".repeat(SHORTCUT_WIDTH)
109
+ // Title and shortcut render as separate spans so the shortcut
110
+ // can use `textMuted` while the title uses `text`/`textStrong`.
111
+ // Same trick opencode pulls with `--text-weak` — the theme
112
+ // guarantees the contrast, we just pick the right role.
113
+ const titleFg = isSelected ? colors.textStrong : colors.text
114
+ return (
115
+ <text key={cmd.id} wrapMode="none" style={isSelected ? { bg: colors.selectedBg } : {}}>
116
+ <span style={{ fg: titleFg }}>{`${selector}${titleText} `}</span>
117
+ <span style={{ fg: colors.textMuted }}>{shortcutText}</span>
118
+ </text>
119
+ )
120
+ })
121
+ )}
122
+ <text content=" " />
123
+ <text wrapMode="none" content={fit(FOOTER_HINT, rowWidth)} style={{ fg: colors.textMuted }} />
124
+ </box>
125
+ )
126
+ }
package/src/Footer.tsx CHANGED
@@ -11,6 +11,10 @@
11
11
  * were discovered. Intentional — `q:quit` and `?:help` are exactly what an
12
12
  * empty-vault user needs as an exit and discoverability anchor.
13
13
  *
14
+ * The filter input does not live here — it renders as a row inside the
15
+ * sidebar (see Browser.tsx). The pattern mirrors ghui's PR list, where the
16
+ * filter is part of the list it filters.
17
+ *
14
18
  * Width math assumes hint labels are ASCII plus a small set of single-cell
15
19
  * BMP glyphs (see `displayKey`). `fitHints` and notice clipping use string
16
20
  * length as a proxy for cell count; introducing a CJK or emoji label would
@@ -29,10 +33,15 @@ export interface FooterProps<C> {
29
33
  readonly ctx: C
30
34
  readonly width: number
31
35
  readonly notice?: string | null
32
- /** When set, the footer row turns into the filter input — `/<query>▏` —
33
- * and suppresses both the hint row and the notice. Mirrors hunk's
34
- * StatusBar: one row of chrome, content swaps by state. */
35
- readonly filter?: { readonly query: string } | null
36
+ /** Persistent status line (e.g. "indexing… 42"). Distinct from `notice`:
37
+ * no TTL, cleared by the caller when the underlying activity finishes.
38
+ * Loses to `notice` when both are set so transient toasts still surface. */
39
+ readonly discoveryStatus?: string | null
40
+ /** When a filter is applied but the input is closed, surface a chip in the
41
+ * hint row so the user remembers `[`/`]` walks the filtered set. Pass null
42
+ * while the filter input is open (the sidebar already shows the query) or
43
+ * when no filter is applied. */
44
+ readonly filterQuery?: string | null
36
45
  }
37
46
 
38
47
  const HINT_SEPARATOR = " "
@@ -88,13 +97,16 @@ const fitHints = (hints: readonly string[], width: number): string => {
88
97
  return firstKey.slice(0, width)
89
98
  }
90
99
 
91
- /** Hints shown alongside the filter input. Mirrors the modal-mode key
92
- * handler in `Browser.tsx`; if those bindings change, update both. */
93
- const FILTER_HINTS = "↵:open esc:cancel"
94
- /** Minimum gap between the filter input and its hints on the same row. */
95
- const FILTER_HINT_GAP = 2
100
+ const STATUS_SEPARATOR = " · "
96
101
 
97
- export const Footer = <C,>({ bindings, ctx, width, notice, filter }: FooterProps<C>) => {
102
+ export const Footer = <C,>({
103
+ bindings,
104
+ ctx,
105
+ width,
106
+ notice,
107
+ discoveryStatus,
108
+ filterQuery,
109
+ }: FooterProps<C>) => {
98
110
  const usableWidth = Math.max(0, width - 2) // 1-cell horizontal padding each side
99
111
 
100
112
  const rowStyle = {
@@ -107,45 +119,60 @@ export const Footer = <C,>({ bindings, ctx, width, notice, filter }: FooterProps
107
119
  backgroundColor: colors.background,
108
120
  } as const
109
121
 
110
- // Filter mode is two-column: input left, hints right, separated by a
111
- // flex-grow spacer. Hints drop entirely on narrow viewports rather than
112
- // pushing the input off-screen — the input is the primary surface.
113
- if (filter) {
114
- const input = `/${filter.query}▏`
115
- const showHints = input.length + FILTER_HINT_GAP + FILTER_HINTS.length <= usableWidth
116
- return (
117
- <box style={rowStyle}>
118
- <text content={input} wrapMode="none" style={{ fg: colors.textStrong }} />
119
- {showHints && (
120
- <>
121
- <box style={{ flexGrow: 1 }} />
122
- <text content={FILTER_HINTS} wrapMode="none" style={{ fg: colors.textMuted }} />
123
- </>
124
- )}
125
- </box>
126
- )
127
- }
128
-
129
122
  const hints: string[] = []
123
+ // The filter chip prepends to the hint row when a filter is applied and the
124
+ // input is closed. Bracketed to avoid looking like a `key:hint` binding —
125
+ // "filter" is not a key. Surfaces the otherwise-invisible invariant that
126
+ // `[`/`]` walks the filtered set. See DESIGN.md §7.1 Q1.
127
+ if (filterQuery && filterQuery.length > 0) {
128
+ hints.push(`[filter: ${filterQuery}]`)
129
+ }
130
130
  for (const b of bindings) {
131
131
  if (b.when && !b.when(ctx)) continue
132
132
  const h = formatHint(b)
133
133
  if (h !== null) hints.push(h)
134
134
  }
135
- const hintContent = fitHints(hints, usableWidth)
135
+
136
+ // Discovery status sits left of the hints, separated by " · ". On tight
137
+ // viewports it claims its budget first; hints fit into the remainder so
138
+ // the indicator stays visible while less-essential hints drop off.
139
+ const status = discoveryStatus && discoveryStatus.length > 0 ? discoveryStatus : null
140
+ const statusBudget = status ? Math.min(status.length + STATUS_SEPARATOR.length, usableWidth) : 0
141
+ const hintsWidth = Math.max(0, usableWidth - statusBudget)
142
+ const hintContent = fitHints(hints, hintsWidth)
143
+ const statusContent = status
144
+ ? status.slice(0, Math.max(0, statusBudget - STATUS_SEPARATOR.length))
145
+ : ""
146
+
136
147
  const noticeContent = notice
137
148
  ? notice.length > usableWidth
138
149
  ? notice.slice(0, usableWidth)
139
150
  : notice
140
151
  : null
141
152
 
142
- // Notice > hints. Notice fg is strong; hints are muted.
143
- const content = noticeContent ?? hintContent
144
- const fg = noticeContent ? colors.textStrong : colors.textMuted
153
+ // Priority: notice > (status + hints). Notice fg is strong; status sits
154
+ // at the muted level so it reads as ambient state, not an event.
155
+ if (noticeContent !== null) {
156
+ return (
157
+ <box style={rowStyle}>
158
+ <text content={noticeContent} wrapMode="none" style={{ fg: colors.textStrong }} />
159
+ </box>
160
+ )
161
+ }
162
+
163
+ if (status !== null) {
164
+ return (
165
+ <box style={rowStyle}>
166
+ <text content={statusContent} wrapMode="none" style={{ fg: colors.textMuted }} />
167
+ <text content={STATUS_SEPARATOR} wrapMode="none" style={{ fg: colors.textMuted }} />
168
+ <text content={hintContent} wrapMode="none" style={{ fg: colors.textMuted }} />
169
+ </box>
170
+ )
171
+ }
145
172
 
146
173
  return (
147
174
  <box style={rowStyle}>
148
- <text content={content} wrapMode="none" style={{ fg }} />
175
+ <text content={hintContent} wrapMode="none" style={{ fg: colors.textMuted }} />
149
176
  </box>
150
177
  )
151
178
  }
package/src/cli/argv.ts CHANGED
@@ -21,6 +21,10 @@ export interface ParsedArgs {
21
21
  readonly help: boolean
22
22
  /** True when `--version` was passed. */
23
23
  readonly version: boolean
24
+ /** True when `--config-path` was passed: print resolved config path and exit. */
25
+ readonly configPath: boolean
26
+ /** Value of `--sidebar <mode>` (`auto`, `on`, `off`), or null. Validated by the boot layer. */
27
+ readonly sidebar: string | null
24
28
  }
25
29
 
26
30
  /**
@@ -41,6 +45,8 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
41
45
  let port: string | null = null
42
46
  let help = false
43
47
  let version = false
48
+ let configPath = false
49
+ let sidebar: string | null = null
44
50
 
45
51
  for (let i = 0; i < argv.length; i++) {
46
52
  const arg = argv[i]!
@@ -79,13 +85,27 @@ export const parseArgv = (argv: readonly string[]): ParsedArgs => {
79
85
  case "-v":
80
86
  version = true
81
87
  continue
88
+ case "--config-path":
89
+ configPath = true
90
+ continue
91
+ case "--sidebar": {
92
+ // Don't swallow the following flag as the sidebar value.
93
+ // `--sidebar --width 80` should leave sidebar=null (the boot
94
+ // layer reports a missing value) without losing --width.
95
+ const next = argv[i + 1]
96
+ if (next !== undefined && !next.startsWith("-")) {
97
+ sidebar = next
98
+ i++
99
+ }
100
+ continue
101
+ }
82
102
  }
83
103
  if (path === null && !arg.startsWith("-")) {
84
104
  path = arg
85
105
  }
86
106
  }
87
107
 
88
- return { path, theme, tone, width, all, sort, serve, port, help, version }
108
+ return { path, theme, tone, width, all, sort, serve, port, help, version, configPath, sidebar }
89
109
  }
90
110
 
91
111
  const themeList = themeDefinitions.map((t) => t.id).join(", ")
@@ -100,7 +120,15 @@ options:
100
120
  --width <N> cap rendered markdown width at N columns
101
121
  --all include hidden and gitignored files in discovery
102
122
  --sort <mode> sidebar order: dirs-first (default) or files-first
123
+ --sidebar <m> initial sidebar visibility: auto (default), on, or off
103
124
  --serve serve the given file as HTML in the browser (skips TUI)
104
125
  --port <N> port for --serve (default: OS-assigned)
105
126
  -h, --help show this help and exit
106
- -v, --version print version and exit`
127
+ -v, --version print version and exit
128
+ --config-path print path to the config file and exit
129
+
130
+ configuration:
131
+ file: $XDG_CONFIG_HOME/house/config.toml (default ~/.config/house/config.toml)
132
+ keys: theme, tone
133
+ env: HOUSE_THEME, HOUSE_TONE
134
+ precedence (high → low): flags → env → file → defaults`
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Derive palette commands from `browserBindings` plus the annotation map.
3
+ *
4
+ * The annotation map is the *only* hand-written list keyed by binding id:
5
+ * it carries (a) hide flags for bindings that are pure keystroke nav, and
6
+ * (b) title rewrites for bindings whose `description` reads as a help-row
7
+ * entry rather than a palette command. Every other binding is exposed
8
+ * verbatim — its `description` becomes the palette `title`, its first key
9
+ * becomes the `shortcut`.
10
+ *
11
+ * Commands close over the per-render `BrowserCtx`, same shape the keymap
12
+ * dispatcher uses, so the palette and the keymap fire the same action via
13
+ * different surfaces. See #70 design log §list construction (option 3b)
14
+ * for why this beat (a) a fully hand-written palette list and (b) deriving
15
+ * everything via #92's atom-driven registry.
16
+ */
17
+
18
+ import { browserBindings, type BrowserCtx } from "../keymap/browser.ts"
19
+ import { paletteOnlyCommands } from "./paletteOnlyCommands.ts"
20
+ import type { AppCommand } from "./types.ts"
21
+
22
+ interface Annotation {
23
+ /** Override the binding's `description` for palette display. */
24
+ readonly title?: string
25
+ /** Carried for #91's category headers; unused in v1's flat list. */
26
+ readonly category?: string
27
+ /** If true, the binding does not appear in the palette. */
28
+ readonly hidden?: boolean
29
+ readonly keywords?: readonly string[]
30
+ }
31
+
32
+ /**
33
+ * Pure-keystroke nav (j/k/space/b/[/]…) is intentionally hidden — those
34
+ * bindings have no command-shaped meaning. Reader prev/next file (`[`/`]`)
35
+ * and sidebar `open` (Return/l) are the borderline cases from #70 Q6a;
36
+ * hidden in v1, reconsider if users ask. Title rewrites convert
37
+ * help-overlay phrasing ("Toggle sidebar visibility") into imperative
38
+ * palette phrasing ("Toggle sidebar"). See #70 design log §6.
39
+ */
40
+ const annotations: Record<string, Annotation> = {
41
+ // --- Keep, with title rewrites where the binding description reads awkwardly as a command ---
42
+ quit: { category: "App" },
43
+ "focus.toggle": { title: "Toggle focus", category: "View" },
44
+ "sidebar.toggle": { title: "Toggle sidebar", category: "View" },
45
+ "help.toggle": { title: "Show help", category: "App" },
46
+ "filter.open": { title: "Filter files…", category: "Navigation" },
47
+ "serve.current": { title: "Open in browser", category: "File" },
48
+ "theme.next": { category: "Appearance" },
49
+ "theme.prev": { category: "Appearance" },
50
+ "theme.toneToggle": { title: "Toggle dark/light tone", category: "Appearance" },
51
+
52
+ // --- Hide: pure keystroke navigation (j/k/space/b/g/G…) ---
53
+ "sidebar.down": { hidden: true },
54
+ "sidebar.up": { hidden: true },
55
+ "sidebar.jumpDown": { hidden: true },
56
+ "sidebar.jumpUp": { hidden: true },
57
+ "sidebar.pageDown": { hidden: true },
58
+ "sidebar.pageUp": { hidden: true },
59
+ "sidebar.top": { hidden: true },
60
+ "sidebar.bottom": { hidden: true },
61
+
62
+ // --- Hide: borderline reader nav. `[`/`]` and Return-to-open feel command-shaped
63
+ // but are pure keystroke navigation under the hood. #70 Q6a — reconsider
64
+ // if user feedback expects them in the palette.
65
+ "sidebar.open": { hidden: true },
66
+ "reader.back": { hidden: true },
67
+ "reader.prevFile": { hidden: true },
68
+ "reader.nextFile": { hidden: true },
69
+
70
+ // --- Hide: the palette opener itself shouldn't appear in the palette ---
71
+ "palette.open": { hidden: true },
72
+ }
73
+
74
+ /**
75
+ * Build the AppCommand list for a given render. Iterates `browserBindings`
76
+ * in array order (the empty-query palette renders in this order, by design
77
+ * — see #70 design log §empty-state ordering), drops hidden entries and
78
+ * those whose `when` predicate currently returns false, and resolves
79
+ * annotations to populate title / category / keywords.
80
+ */
81
+ export const buildCommands = (ctx: BrowserCtx): readonly AppCommand[] => {
82
+ const out: AppCommand[] = []
83
+ for (const binding of browserBindings) {
84
+ const ann = annotations[binding.id]
85
+ if (ann?.hidden) continue
86
+ // Same gating the keymap dispatcher uses. Disabled bindings get
87
+ // hidden from the palette (per #70 Q5b — see #96 for the show-with-
88
+ // reason follow-up after the atom-driven migration).
89
+ if (binding.when && !binding.when(ctx)) continue
90
+ const cmd: AppCommand = {
91
+ id: binding.id,
92
+ title: ann?.title ?? binding.description,
93
+ ...(ann?.category !== undefined && { category: ann.category }),
94
+ ...(ann?.keywords !== undefined && { keywords: ann.keywords }),
95
+ ...(binding.keys[0] !== undefined && { shortcut: binding.keys[0] }),
96
+ run: () => binding.run(ctx),
97
+ }
98
+ out.push(cmd)
99
+ }
100
+ for (const cmd of paletteOnlyCommands(ctx)) out.push(cmd)
101
+ return out
102
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Commands that exist *only* in the palette — no `browserBindings` entry.
3
+ *
4
+ * Empty in v1 by design (#70 Q6c). This file exists as scaffolding so
5
+ * future palette-only commands (#93 reveal-in-OS, #94 copy-path, …) have
6
+ * an obvious home that's already wired into `buildCommands.ts`.
7
+ *
8
+ * Commands here follow the same shape as keymap-derived ones: they take
9
+ * the per-render `BrowserCtx` so they can read selection state, mutate
10
+ * focus, surface notices, etc.
11
+ */
12
+
13
+ import type { BrowserCtx } from "../keymap/browser.ts"
14
+ import type { AppCommand } from "./types.ts"
15
+
16
+ export const paletteOnlyCommands = (_ctx: BrowserCtx): readonly AppCommand[] => []
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Command palette scorer — ported from ghui's pre-May `commands.ts`.
3
+ *
4
+ * Tiered ranking: title-prefix → text-prefix → title-includes →
5
+ * text-includes → acronym → fuzzy-includes. Lower score wins. Ties broken
6
+ * by `browserBindings` array order (the `index` field) so the empty-query
7
+ * palette renders in the keymap's natural order. See #70 design log for
8
+ * why we didn't reach for `fuzzysort`.
9
+ */
10
+
11
+ import type { AppCommand } from "./types.ts"
12
+
13
+ const normalize = (text: string): string =>
14
+ text
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9#]+/g, " ")
17
+ .trim()
18
+
19
+ const acronym = (text: string): string =>
20
+ normalize(text)
21
+ .split(" ")
22
+ .filter(Boolean)
23
+ .map((word) => word[0])
24
+ .join("")
25
+
26
+ const fuzzyIncludes = (text: string, query: string): boolean => {
27
+ let index = 0
28
+ for (const char of text) {
29
+ if (char === query[index]) index++
30
+ if (index >= query.length) return true
31
+ }
32
+ return query.length === 0
33
+ }
34
+
35
+ const searchText = (command: AppCommand): string =>
36
+ normalize(
37
+ [command.title, command.category, command.shortcut, ...(command.keywords ?? [])]
38
+ .filter((s): s is string => Boolean(s))
39
+ .join(" "),
40
+ )
41
+
42
+ /** Returns the tier (lower = better) or `null` if the query doesn't match. */
43
+ const score = (command: AppCommand, query: string): number | null => {
44
+ const q = normalize(query)
45
+ if (q.length === 0) return 0
46
+ const title = normalize(command.title)
47
+ const text = searchText(command)
48
+ if (title.startsWith(q)) return 0
49
+ if (text.startsWith(q)) return 1
50
+ if (title.includes(q)) return 2
51
+ if (text.includes(q)) return 3
52
+ if (acronym(command.title).startsWith(q)) return 4
53
+ if (fuzzyIncludes(text, q.replaceAll(" ", ""))) return 5
54
+ return null
55
+ }
56
+
57
+ /**
58
+ * Filter and sort commands against a query. With an empty query, every
59
+ * command tiers to 0 and the original index breaks ties — so the result is
60
+ * the input list in its original order (which v1 uses as `browserBindings`
61
+ * order). See #70 design log §empty-state ordering.
62
+ */
63
+ export const filterCommands = (
64
+ commands: readonly AppCommand[],
65
+ query: string,
66
+ ): readonly AppCommand[] =>
67
+ commands
68
+ .flatMap((command, index) => {
69
+ const tier = score(command, query)
70
+ return tier === null ? [] : [{ command, index, tier }]
71
+ })
72
+ .sort((a, b) => a.tier - b.tier || a.index - b.index)
73
+ .map(({ command }) => command)
74
+
75
+ export const clampSelectedIndex = (index: number, commands: readonly AppCommand[]): number => {
76
+ if (commands.length === 0) return 0
77
+ return Math.max(0, Math.min(commands.length - 1, index))
78
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Palette command shape.
3
+ *
4
+ * Commands are derived at render time from `browserBindings` via the
5
+ * annotation map (`buildCommands.ts`). The `run` field closes over the
6
+ * per-render `BrowserCtx` so dispatching a command is equivalent to firing
7
+ * the corresponding key binding — single source of truth for the action,
8
+ * two surfaces (keymap + palette) that can invoke it.
9
+ *
10
+ * See issue #70 (and its design-log comments) for the rejected alternatives
11
+ * — atom-driven registry (#92), fully hand-written list, fuzzysort dep, etc.
12
+ */
13
+
14
+ export interface AppCommand {
15
+ /** Stable id; matches the binding's id for keymap-derived commands. */
16
+ readonly id: string
17
+ /** Imperative-phrased label shown in the palette row. */
18
+ readonly title: string
19
+ /** Optional category for #91's grouped headers. Carried but unused in v1. */
20
+ readonly category?: string
21
+ /** Extra match terms (synonyms, alt phrasings). */
22
+ readonly keywords?: readonly string[]
23
+ /** Display-only shortcut hint (first key of the binding, if any). */
24
+ readonly shortcut?: string
25
+ readonly run: () => void
26
+ }