@miphamai/cli 0.5.7 → 0.5.9

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,158 @@
1
+ /**
2
+ * Mipham Code — Self-update utilities.
3
+ *
4
+ * Shared between the CLI entry point (bin/mipham.ts) and the TUI slash
5
+ * command handler (commands.ts) so that both `mipham update` and `/upgrade`
6
+ * use the same logic.
7
+ */
8
+
9
+ import { readFileSync, existsSync, copyFileSync, mkdirSync, chmodSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { execSync } from 'node:child_process'
12
+ import { homedir } from 'node:os'
13
+
14
+ const PACKAGE = '@miphamai/cli'
15
+ const HOME = homedir()
16
+ const MIPHAM_HOME = join(HOME, '.mipham')
17
+ const CONFIG_PATH = join(MIPHAM_HOME, 'config.yml')
18
+
19
+ export interface UpdateCheck {
20
+ /** Current installed version */
21
+ current: string
22
+ /** Latest version on npm */
23
+ latest: string
24
+ /** Whether an update is available */
25
+ available: boolean
26
+ }
27
+
28
+ /**
29
+ * Read the currently installed version from package.json.
30
+ */
31
+ export function getCurrentVersion(): string {
32
+ try {
33
+ // Try the CLI's own package.json first
34
+ const cliPkg = join(import.meta.dirname!, '..', '..', 'package.json')
35
+ if (existsSync(cliPkg)) {
36
+ const pkg = JSON.parse(readFileSync(cliPkg, 'utf-8'))
37
+ return pkg.version
38
+ }
39
+ } catch {
40
+ // fall through
41
+ }
42
+ return 'unknown'
43
+ }
44
+
45
+ /**
46
+ * Fetch the latest version from the npm registry.
47
+ * Returns the version string, or throws on failure.
48
+ */
49
+ function fetchLatestVersion(): string {
50
+ const result = execSync(`npm view ${PACKAGE} version --json`, {
51
+ encoding: 'utf-8',
52
+ timeout: 10_000,
53
+ stdio: ['pipe', 'pipe', 'pipe'],
54
+ }).trim()
55
+ return result.replace(/"/g, '')
56
+ }
57
+
58
+ /**
59
+ * Check if an update is available by comparing the local version
60
+ * against the npm registry.
61
+ */
62
+ export function checkForUpdates(): UpdateCheck {
63
+ const current = getCurrentVersion()
64
+ let latest = current
65
+ let available = false
66
+
67
+ try {
68
+ latest = fetchLatestVersion()
69
+ available = compareVersions(latest, current) > 0
70
+ } catch {
71
+ // If we can't reach npm, treat as up-to-date (don't alarm the user)
72
+ }
73
+
74
+ return { current, latest, available }
75
+ }
76
+
77
+ /**
78
+ * Back up the user's config.yml before an update.
79
+ * Returns the backup path, or null if backup wasn't possible.
80
+ */
81
+ export function backupConfig(label: string): string | null {
82
+ if (!existsSync(CONFIG_PATH)) return null
83
+ try {
84
+ mkdirSync(MIPHAM_HOME, { recursive: true, mode: 0o700 })
85
+ const ts = new Date().toISOString().replace(/[:.]/g, '-')
86
+ const backupPath = join(MIPHAM_HOME, `config.pre-${label}-${ts}.yml`)
87
+ copyFileSync(CONFIG_PATH, backupPath)
88
+ chmodSync(backupPath, 0o600) // owner read/write only — contains API keys
89
+ return backupPath
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ /** Validate a semver string before passing it to a shell command. */
96
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$/
97
+
98
+ function isValidSemver(v: string): boolean {
99
+ return SEMVER_RE.test(v)
100
+ }
101
+
102
+ /**
103
+ * Perform the actual update via npm install -g.
104
+ * Validates the version string before shell execution (prevents command injection).
105
+ * Returns true on success.
106
+ */
107
+ export function performUpdate(version: string): boolean {
108
+ if (!isValidSemver(version)) {
109
+ process.stderr.write(`⚠ Refusing to install invalid version: "${version}"\n`)
110
+ return false
111
+ }
112
+
113
+ try {
114
+ execSync(`npm install -g ${PACKAGE}@${version}`, {
115
+ encoding: 'utf-8',
116
+ stdio: 'inherit',
117
+ timeout: 120_000,
118
+ })
119
+ return true
120
+ } catch {
121
+ return false
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Restore config from a backup path.
127
+ */
128
+ export function restoreConfig(backupPath: string): boolean {
129
+ if (!existsSync(backupPath)) return false
130
+ try {
131
+ copyFileSync(backupPath, CONFIG_PATH)
132
+ return true
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Get the config path so callers can verify it survived.
140
+ */
141
+ export function getConfigPath(): string {
142
+ return CONFIG_PATH
143
+ }
144
+
145
+ /**
146
+ * Compare two semver strings. Returns >0 if a > b, <0 if a < b, 0 if equal.
147
+ */
148
+ function compareVersions(a: string, b: string): number {
149
+ const pa = a.split('.').map(Number)
150
+ const pb = b.split('.').map(Number)
151
+ for (let i = 0; i < 3; i++) {
152
+ const da = pa[i] ?? 0
153
+ const db = pb[i] ?? 0
154
+ if (da > db) return 1
155
+ if (da < db) return -1
156
+ }
157
+ return 0
158
+ }
@@ -66,6 +66,19 @@ export class SkillsLoader {
66
66
  return this.skills.has(name)
67
67
  }
68
68
 
69
+ countByType(): { standard: number; mipham: number; total: number } {
70
+ const all = this.list()
71
+ return {
72
+ standard: all.filter((s) => s.type === 'standard').length,
73
+ mipham: all.filter((s) => s.type === 'mipham').length,
74
+ total: all.length,
75
+ }
76
+ }
77
+
78
+ getNamesByType(type: 'standard' | 'mipham'): string[] {
79
+ return this.listByType(type).map((s) => s.name)
80
+ }
81
+
69
82
  private loadDirectory(dir: string, type: 'standard' | 'mipham'): void {
70
83
  try {
71
84
  const entries = readdirSync(dir)
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Mipham Code — Skill Registry & Installer
3
+ *
4
+ * Provides a community skill marketplace: browse, search, and install skills
5
+ * from remote sources (GitHub repos, direct URLs).
6
+ */
7
+
8
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, unlinkSync, chmodSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { homedir } from 'node:os'
11
+ import { spawnSync } from 'node:child_process'
12
+ import { URL } from 'node:url'
13
+
14
+ const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
15
+
16
+ // ═══════════════════════════════════════════════════════════════
17
+ // Community Skill Registry
18
+ // ═══════════════════════════════════════════════════════════════
19
+
20
+ export interface SkillEntry {
21
+ /** Unique skill name (kebab-case) */
22
+ name: string
23
+ /** One-line description */
24
+ description: string
25
+ /** GitHub repo URL (https://github.com/owner/repo) */
26
+ url: string
27
+ /** Skill file path within the repo (e.g. "skill.SKILL.md") */
28
+ file?: string
29
+ /** Category for grouping */
30
+ category: string
31
+ /** Author */
32
+ author: string
33
+ }
34
+
35
+ /**
36
+ * Embedded community skill registry.
37
+ * In the future, this can be fetched from a remote URL.
38
+ */
39
+ const COMMUNITY_SKILLS: SkillEntry[] = [
40
+ {
41
+ name: 'code-review',
42
+ description: 'Automated code review with multiple dimensions',
43
+ url: 'https://github.com/One-Mipham/skill-code-review',
44
+ file: 'code-review.SKILL.md',
45
+ category: 'Development',
46
+ author: 'MiphamAI',
47
+ },
48
+ {
49
+ name: 'systematic-debugging',
50
+ description: 'Systematic debugging workflow — find root cause before fixing',
51
+ url: 'https://github.com/anthropics/skills',
52
+ file: 'systematic-debugging.SKILL.md',
53
+ category: 'Development',
54
+ author: 'Anthropic',
55
+ },
56
+ {
57
+ name: 'test-driven-development',
58
+ description: 'TDD workflow — write failing test first, then implement',
59
+ url: 'https://github.com/anthropics/skills',
60
+ file: 'test-driven-development.SKILL.md',
61
+ category: 'Development',
62
+ author: 'Anthropic',
63
+ },
64
+ {
65
+ name: 'web-access',
66
+ description: 'Web search, scraping, and browser automation',
67
+ url: 'https://github.com/One-Mipham/skill-web-access',
68
+ file: 'web-access.SKILL.md',
69
+ category: 'Network',
70
+ author: 'MiphamAI',
71
+ },
72
+ {
73
+ name: 'doc-generator',
74
+ description: 'Generate API docs, READMEs, and changelogs',
75
+ url: 'https://github.com/One-Mipham/skill-doc-generator',
76
+ file: 'doc-generator.SKILL.md',
77
+ category: 'Documentation',
78
+ author: 'MiphamAI',
79
+ },
80
+ {
81
+ name: 'github-ops',
82
+ description: 'GitHub PR/issue management and automation',
83
+ url: 'https://github.com/One-Mipham/skill-github-ops',
84
+ file: 'github-ops.SKILL.md',
85
+ category: 'DevOps',
86
+ author: 'MiphamAI',
87
+ },
88
+ {
89
+ name: 'security-review',
90
+ description: 'Security audit and vulnerability scanning for code changes',
91
+ url: 'https://github.com/One-Mipham/skill-security-review',
92
+ file: 'security-review.SKILL.md',
93
+ category: 'Security',
94
+ author: 'MiphamAI',
95
+ },
96
+ {
97
+ name: 'frontend-design',
98
+ description: 'Distinctive visual design guidance for UI components',
99
+ url: 'https://github.com/anthropics/skills',
100
+ file: 'frontend-design.SKILL.md',
101
+ category: 'Design',
102
+ author: 'Anthropic',
103
+ },
104
+ ]
105
+
106
+ // ═══════════════════════════════════════════════════════════════
107
+ // Skill Installer
108
+ // ═══════════════════════════════════════════════════════════════
109
+
110
+ export interface InstallResult {
111
+ success: boolean
112
+ name: string
113
+ message: string
114
+ }
115
+
116
+ /**
117
+ * Get the list of available community skills.
118
+ */
119
+ export function getAvailableSkills(): SkillEntry[] {
120
+ return [...COMMUNITY_SKILLS]
121
+ }
122
+
123
+ /**
124
+ * Search for skills by name or description.
125
+ */
126
+ export function searchSkills(query: string): SkillEntry[] {
127
+ const q = query.toLowerCase()
128
+ return COMMUNITY_SKILLS.filter(
129
+ (s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
130
+ )
131
+ }
132
+
133
+ /**
134
+ * Install a skill from a GitHub repository.
135
+ *
136
+ * Clones the repo to a temp directory, copies the skill file(s),
137
+ * and cleans up.
138
+ */
139
+ export function installSkill(skillName: string): InstallResult {
140
+ const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
141
+ if (!entry) {
142
+ return {
143
+ success: false,
144
+ name: skillName,
145
+ message: `Skill "${skillName}" not found in the registry. Use /browse-skills to see available skills.`,
146
+ }
147
+ }
148
+
149
+ const destDir = join(SKILLS_DIR)
150
+ const destPath = join(destDir, `${skillName}.SKILL.md`)
151
+
152
+ // Check if already installed
153
+ if (existsSync(destPath)) {
154
+ return {
155
+ success: false,
156
+ name: skillName,
157
+ message: `Skill "${skillName}" is already installed. Remove it first to reinstall.`,
158
+ }
159
+ }
160
+
161
+ mkdirSync(destDir, { recursive: true })
162
+
163
+ try {
164
+ // Download the skill file from GitHub raw content
165
+ const rawUrl = githubRawUrl(entry.url, entry.file || `${skillName}.SKILL.md`)
166
+ const content = downloadFile(rawUrl)
167
+
168
+ // Validate it's a proper skill file (has frontmatter)
169
+ if (!content.includes('---')) {
170
+ return {
171
+ success: false,
172
+ name: skillName,
173
+ message: `Downloaded file does not appear to be a valid skill (missing frontmatter).`,
174
+ }
175
+ }
176
+
177
+ writeFileSync(destPath, content, 'utf-8')
178
+
179
+ return {
180
+ success: true,
181
+ name: skillName,
182
+ message: `Skill "${skillName}" installed to ${destPath}\nRun /reload-skills to activate it.`,
183
+ }
184
+ } catch (err: unknown) {
185
+ const msg = err instanceof Error ? err.message : String(err)
186
+ return {
187
+ success: false,
188
+ name: skillName,
189
+ message: `Failed to install "${skillName}": ${msg}`,
190
+ }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
196
+ */
197
+ export function installSkillFromUrl(url: string): InstallResult {
198
+ // Derive skill name from URL
199
+ const name =
200
+ url
201
+ .split('/')
202
+ .pop()
203
+ ?.replace(/\.(SKILL\.)?md$/i, '') || 'custom-skill'
204
+
205
+ const destDir = join(SKILLS_DIR)
206
+ const destPath = join(destDir, `${name}.SKILL.md`)
207
+
208
+ mkdirSync(destDir, { recursive: true })
209
+
210
+ try {
211
+ const content = downloadFile(url)
212
+
213
+ if (!content.includes('---')) {
214
+ return {
215
+ success: false,
216
+ name,
217
+ message: `Downloaded file does not appear to be a valid skill (missing frontmatter).`,
218
+ }
219
+ }
220
+
221
+ writeFileSync(destPath, content, 'utf-8')
222
+
223
+ return {
224
+ success: true,
225
+ name,
226
+ message: `Skill "${name}" installed from URL to ${destPath}\nRun /reload-skills to activate it.`,
227
+ }
228
+ } catch (err: unknown) {
229
+ const msg = err instanceof Error ? err.message : String(err)
230
+ return { success: false, name, message: `Failed to install from URL: ${msg}` }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * List installed skills (files in ~/.mipham/skills/).
236
+ */
237
+ export function listInstalledSkills(): string[] {
238
+ if (!existsSync(SKILLS_DIR)) return []
239
+ return readdirSync(SKILLS_DIR).filter(
240
+ (f: string) => f.endsWith('.SKILL.md') || f.endsWith('.mipham-skill.md'),
241
+ )
242
+ }
243
+
244
+ /**
245
+ * Remove an installed skill.
246
+ */
247
+ export function removeSkill(skillName: string): InstallResult {
248
+ const destPath = join(SKILLS_DIR, `${skillName}.SKILL.md`)
249
+ if (!existsSync(destPath)) {
250
+ return { success: false, name: skillName, message: `Skill "${skillName}" is not installed.` }
251
+ }
252
+ try {
253
+ unlinkSync(destPath)
254
+ return {
255
+ success: true,
256
+ name: skillName,
257
+ message: `Skill "${skillName}" removed. Run /reload-skills to refresh.`,
258
+ }
259
+ } catch (err: unknown) {
260
+ const msg = err instanceof Error ? err.message : String(err)
261
+ return { success: false, name: skillName, message: `Failed to remove: ${msg}` }
262
+ }
263
+ }
264
+
265
+ // ═══════════════════════════════════════════════════════════════
266
+ // Helpers
267
+ // ═══════════════════════════════════════════════════════════════
268
+
269
+ /**
270
+ * Convert a GitHub repo URL to a raw content URL.
271
+ * https://github.com/owner/repo → https://raw.githubusercontent.com/owner/repo/main/<file>
272
+ */
273
+ function githubRawUrl(repoUrl: string, file: string): string {
274
+ const base = repoUrl.replace('https://github.com/', 'https://raw.githubusercontent.com/')
275
+ return `${base}/main/${file}`
276
+ }
277
+
278
+ /** Allowed domains for remote skill installation */
279
+ const ALLOWED_DOMAINS = [
280
+ 'raw.githubusercontent.com',
281
+ 'github.com',
282
+ 'gist.githubusercontent.com',
283
+ 'gitlab.com',
284
+ ]
285
+
286
+ /**
287
+ * Download a file from a URL using curl with spawn (no shell injection).
288
+ * Only allows HTTPS URLs from known safe domains.
289
+ */
290
+ function downloadFile(rawUrl: string): string {
291
+ let parsed: URL
292
+ try {
293
+ parsed = new URL(rawUrl)
294
+ } catch {
295
+ throw new Error(`Invalid URL: ${rawUrl}`)
296
+ }
297
+
298
+ // Protocol must be HTTPS
299
+ if (parsed.protocol !== 'https:') {
300
+ throw new Error(`Only HTTPS URLs are allowed (got: ${parsed.protocol})`)
301
+ }
302
+
303
+ // Domain must be in allowlist
304
+ if (!ALLOWED_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith('.' + d))) {
305
+ throw new Error(
306
+ `Domain not allowed: ${parsed.hostname}. Allowed: ${ALLOWED_DOMAINS.join(', ')}`,
307
+ )
308
+ }
309
+
310
+ // Use spawnSync with args array — no shell, no injection
311
+ const result = spawnSync('curl', ['-fsSL', rawUrl], {
312
+ encoding: 'utf-8',
313
+ timeout: 15_000,
314
+ stdio: ['pipe', 'pipe', 'pipe'],
315
+ })
316
+
317
+ if (result.error) {
318
+ throw new Error(`Failed to download: ${result.error.message}`)
319
+ }
320
+ if (result.status !== 0) {
321
+ throw new Error(`Download failed with status ${result.status}`)
322
+ }
323
+
324
+ return result.stdout
325
+ }
package/src/ui/app.tsx CHANGED
@@ -423,7 +423,7 @@ export function App({
423
423
  </Text>
424
424
  <Text dimColor> · ⏵⏵ accept edits on</Text>
425
425
  <Text dimColor> (Shift+Tab to cycle)</Text>
426
- <Text dimColor> · Ctrl+P pick · /help · Esc cancel</Text>
426
+ <Text dimColor> · Ctrl+P pick · /help /commands · Esc cancel</Text>
427
427
  <Text dimColor> · ← agents</Text>
428
428
  </Box>
429
429
  </Box>
package/src/ui/chat.tsx CHANGED
@@ -7,6 +7,16 @@ interface ChatPanelProps {
7
7
  focusMode?: boolean
8
8
  }
9
9
 
10
+ /** Format cwd for display: replace HOME with ~, truncate if too long */
11
+ function displayCwd(): string {
12
+ const cwd = process.cwd()
13
+ const home = process.env.HOME || ''
14
+ if (home && cwd.startsWith(home)) {
15
+ return '~' + cwd.slice(home.length)
16
+ }
17
+ return cwd
18
+ }
19
+
10
20
  export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
11
21
  // In focus mode, show only the last user+assistant exchange
12
22
  const displayMessages = focusMode ? getLastExchange(messages) : messages
@@ -61,7 +71,7 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
61
71
  color={msg.role === 'user' ? 'green' : msg.role === 'system' ? 'yellow' : 'blue'}
62
72
  >
63
73
  {msg.role === 'user'
64
- ? '▸ You'
74
+ ? `▸ ${displayCwd()}`
65
75
  : msg.role === 'assistant'
66
76
  ? 'Mipham Code'
67
77
  : '⚠ System'}
@@ -0,0 +1,145 @@
1
+ import React, { useState, useMemo, useEffect } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+ import TextInput from 'ink-text-input'
4
+ import { getCommandList, type CommandEntry } from './commands.js'
5
+
6
+ interface CommandPickerProps {
7
+ /** Text already typed (e.g. "/", "/age") — used as initial filter */
8
+ initialFilter: string
9
+ /** Called when user selects a command */
10
+ onSelect: (commandName: string) => void
11
+ /** Called when user presses Esc */
12
+ onClose: () => void
13
+ /** Max visible items before scrolling */
14
+ maxVisible?: number
15
+ }
16
+
17
+ const PAGE_SIZE = 12
18
+
19
+ /**
20
+ * CommandPicker — interactive slash-command selector.
21
+ *
22
+ * Reuses the ModelPicker's Ink interaction pattern:
23
+ * ↑/↓ cursor navigation (wrapping), Enter to select, Esc to close.
24
+ * Plus real-time filtering — user types to narrow the command list.
25
+ */
26
+ export function CommandPicker({
27
+ initialFilter,
28
+ onSelect,
29
+ onClose,
30
+ maxVisible = PAGE_SIZE,
31
+ }: CommandPickerProps) {
32
+ const allCommands = useMemo(() => getCommandList(), [])
33
+ const [filter, setFilter] = useState(initialFilter)
34
+ const [cursorIdx, setCursorIdx] = useState(0)
35
+
36
+ // Filter commands based on user input
37
+ const filtered = useMemo(() => {
38
+ const q = filter.startsWith('/') ? filter.slice(1).toLowerCase() : filter.toLowerCase()
39
+ if (!q) return allCommands
40
+ return allCommands.filter(
41
+ (cmd) => cmd.name.toLowerCase().includes(q) || cmd.description.toLowerCase().includes(q),
42
+ )
43
+ }, [filter, allCommands])
44
+
45
+ // Reset cursor when filter changes
46
+ useEffect(() => {
47
+ setCursorIdx(0)
48
+ }, [filter])
49
+
50
+ // Scroll window: keep cursor in the visible range
51
+ const scrollStart = Math.max(
52
+ 0,
53
+ Math.min(cursorIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
54
+ )
55
+ const visible = filtered.slice(scrollStart, scrollStart + maxVisible)
56
+ const adjustedCursor = cursorIdx - scrollStart
57
+
58
+ // Wrap cursor safely
59
+ const safeCursor = (i: number) =>
60
+ filtered.length === 0 ? 0 : ((i % filtered.length) + filtered.length) % filtered.length
61
+
62
+ useInput((_input, key) => {
63
+ if (key.escape) {
64
+ onClose()
65
+ return
66
+ }
67
+
68
+ if (key.return) {
69
+ const selected = filtered[cursorIdx]
70
+ if (selected) {
71
+ onSelect(selected.name)
72
+ }
73
+ return
74
+ }
75
+
76
+ if (key.upArrow) {
77
+ setCursorIdx((prev) => safeCursor(prev - 1))
78
+ return
79
+ }
80
+
81
+ if (key.downArrow) {
82
+ setCursorIdx((prev) => safeCursor(prev + 1))
83
+ return
84
+ }
85
+ })
86
+
87
+ const itemColor = (isCursor: boolean) => (isCursor ? 'cyan' : undefined)
88
+ const itemBold = (isCursor: boolean) => isCursor
89
+
90
+ return (
91
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1}>
92
+ {/* Title */}
93
+ <Box marginBottom={1}>
94
+ <Text bold color="cyan">
95
+ Commands
96
+ </Text>
97
+ <Text dimColor> ↑↓ navigate · Enter select · Esc close</Text>
98
+ </Box>
99
+
100
+ {/* Command list */}
101
+ <Box flexDirection="column" marginBottom={1}>
102
+ {visible.length === 0 && <Text dimColor> No commands match "{filter}"</Text>}
103
+ {visible.map((cmd, i) => {
104
+ const globalIdx = i + scrollStart
105
+ const isCursor = globalIdx === cursorIdx
106
+ return (
107
+ <Box key={cmd.name}>
108
+ <Text color={itemColor(isCursor)} bold={itemBold(isCursor)}>
109
+ {isCursor ? '▶ ' : ' '}
110
+ {cmd.name.padEnd(20)}
111
+ </Text>
112
+ <Text dimColor>{cmd.description}</Text>
113
+ </Box>
114
+ )
115
+ })}
116
+ </Box>
117
+
118
+ {/* Scroll indicator */}
119
+ {filtered.length > maxVisible && (
120
+ <Box marginBottom={1}>
121
+ <Text dimColor>
122
+ {scrollStart + 1}–{Math.min(scrollStart + maxVisible, filtered.length)} of{' '}
123
+ {filtered.length} commands
124
+ </Text>
125
+ </Box>
126
+ )}
127
+
128
+ {/* Filter input */}
129
+ <Box>
130
+ <Text color="cyan">/ </Text>
131
+ <TextInput
132
+ value={filter.startsWith('/') ? filter.slice(1) : filter}
133
+ onChange={(val) => setFilter(`/${val}`)}
134
+ onSubmit={() => {
135
+ const selected = filtered[cursorIdx]
136
+ if (selected) {
137
+ onSelect(selected.name)
138
+ }
139
+ }}
140
+ placeholder="Type to filter commands..."
141
+ />
142
+ </Box>
143
+ </Box>
144
+ )
145
+ }