camelagi 0.5.49 → 0.5.51

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 (44) hide show
  1. package/dist/cli/cmd-chat.js +69 -2
  2. package/dist/cli/cmd-chat.js.map +1 -1
  3. package/dist/core/version.js +1 -1
  4. package/dist/runtime/orchestrate.js +1 -1
  5. package/dist/telegram/admin-bot.js +1 -1
  6. package/package.json +5 -2
  7. package/tui/package.json +23 -0
  8. package/tui/src/App.tsx +161 -0
  9. package/tui/src/agent/parse.ts +184 -0
  10. package/tui/src/agent/types.ts +75 -0
  11. package/tui/src/commands/registry.ts +301 -0
  12. package/tui/src/components/ActivityIndicator.tsx +148 -0
  13. package/tui/src/components/ApprovalPrompt.tsx +58 -0
  14. package/tui/src/components/BottomBar.tsx +74 -0
  15. package/tui/src/components/Chat.tsx +98 -0
  16. package/tui/src/components/Divider.tsx +12 -0
  17. package/tui/src/components/HorizontalRule.tsx +12 -0
  18. package/tui/src/components/Input.tsx +126 -0
  19. package/tui/src/components/Markdown.tsx +290 -0
  20. package/tui/src/components/Message.tsx +77 -0
  21. package/tui/src/components/PermissionBanner.tsx +30 -0
  22. package/tui/src/components/Picker.tsx +127 -0
  23. package/tui/src/components/SlashMenu.tsx +46 -0
  24. package/tui/src/components/SubagentBlock.tsx +16 -0
  25. package/tui/src/components/ToolBlock.tsx +24 -0
  26. package/tui/src/components/Welcome.tsx +75 -0
  27. package/tui/src/components/tools/BashTool.tsx +27 -0
  28. package/tui/src/components/tools/DefaultTool.tsx +38 -0
  29. package/tui/src/components/tools/DiffView.tsx +91 -0
  30. package/tui/src/components/tools/EditGroup.tsx +97 -0
  31. package/tui/src/components/tools/EditTool.tsx +41 -0
  32. package/tui/src/components/tools/ReadTool.tsx +41 -0
  33. package/tui/src/components/tools/SearchTool.tsx +27 -0
  34. package/tui/src/components/tools/ToolHeader.tsx +48 -0
  35. package/tui/src/components/tools/WriteTool.tsx +54 -0
  36. package/tui/src/config.ts +6 -0
  37. package/tui/src/hooks/useAgent.ts +202 -0
  38. package/tui/src/main.tsx +12 -0
  39. package/tui/src/models.ts +26 -0
  40. package/tui/src/state/reducer.ts +290 -0
  41. package/tui/src/theme.ts +28 -0
  42. package/tui/src/util/nativeNotify.ts +47 -0
  43. package/tui/src/util/spinner.ts +11 -0
  44. package/tui/tsconfig.json +19 -0
@@ -0,0 +1,12 @@
1
+ import { useTerminalDimensions } from "@opentui/react"
2
+ import { theme } from "../theme.js"
3
+
4
+ export function HorizontalRule({ marginTop = 0, marginBottom = 0 }: { marginTop?: number; marginBottom?: number }) {
5
+ const { width } = useTerminalDimensions()
6
+ const w = Math.max(20, Math.min(width, 400))
7
+ return (
8
+ <box marginTop={marginTop} marginBottom={marginBottom}>
9
+ <text content={"─".repeat(w)} fg={theme.divider} />
10
+ </box>
11
+ )
12
+ }
@@ -0,0 +1,126 @@
1
+ // Plain text input row. No border (Claude Code style: dividers above/below
2
+ // supplied by the parent). Owns its own buffer; emits onSubmit on Enter.
3
+ // Slash-mode bookkeeping is exposed via onSlashStateChange so the parent
4
+ // can swap the bottom area between BottomBar and SlashMenu.
5
+
6
+ import { useEffect, useMemo, useState } from "react"
7
+ import { useKeyboard } from "@opentui/react"
8
+ import { fg, t, bold } from "@opentui/core"
9
+ import { filterCommands, type SlashCommand } from "../commands/registry.js"
10
+ import { theme } from "../theme.js"
11
+
12
+ export interface InputProps {
13
+ disabled?: boolean
14
+ onSubmit: (text: string) => void
15
+ onSlash: (commandName: string, args: string[]) => void
16
+ onAbort?: () => void
17
+ onCyclePermission?: () => void
18
+ /** Notifies parent of current slash-mode state so it can render the menu in the bottom area. */
19
+ onSlashState?: (state: { matches: SlashCommand[]; selectedIndex: number; argMode?: boolean } | null) => void
20
+ placeholder?: string
21
+ }
22
+
23
+ export function Input({
24
+ disabled,
25
+ onSubmit,
26
+ onSlash,
27
+ onAbort,
28
+ onCyclePermission,
29
+ onSlashState,
30
+ placeholder,
31
+ }: InputProps) {
32
+ const [value, setValue] = useState("")
33
+ const [menuIdx, setMenuIdx] = useState(0)
34
+
35
+ const inSlashMode = value.startsWith("/")
36
+ const slashTokens = inSlashMode ? value.slice(1).split(/\s+/) : []
37
+ const slashQuery = inSlashMode ? slashTokens[0] ?? "" : ""
38
+
39
+ const matches = useMemo<SlashCommand[]>(() => {
40
+ if (!inSlashMode) return []
41
+ if (slashTokens.length === 1) return filterCommands(slashQuery)
42
+ return []
43
+ }, [inSlashMode, slashQuery, slashTokens.length])
44
+
45
+ // Keep menuIdx in range as matches change.
46
+ if (menuIdx >= matches.length && matches.length > 0) {
47
+ setMenuIdx(0)
48
+ }
49
+
50
+ // Notify parent.
51
+ useEffect(() => {
52
+ if (matches.length > 0) onSlashState?.({ matches, selectedIndex: menuIdx })
53
+ else onSlashState?.(null)
54
+ }, [matches, menuIdx, onSlashState])
55
+
56
+ useKeyboard(key => {
57
+ if (key.shift && key.name === "tab") {
58
+ onCyclePermission?.()
59
+ return
60
+ }
61
+ if (disabled) {
62
+ if (key.name === "escape") onAbort?.()
63
+ return
64
+ }
65
+ if (matches.length > 0) {
66
+ if (key.name === "up") { setMenuIdx(i => Math.max(0, i - 1)); return }
67
+ if (key.name === "down") { setMenuIdx(i => Math.min(matches.length - 1, i + 1)); return }
68
+ if (key.name === "tab") {
69
+ const pick = matches[menuIdx]
70
+ if (pick) setValue(`/${pick.name} `)
71
+ return
72
+ }
73
+ if (key.name === "return") {
74
+ const pick = matches[menuIdx]
75
+ if (pick) {
76
+ setValue("")
77
+ setMenuIdx(0)
78
+ onSlash(pick.name, [])
79
+ }
80
+ return
81
+ }
82
+ }
83
+ if (key.name === "return") {
84
+ const trimmed = value.trim()
85
+ setValue("")
86
+ setMenuIdx(0)
87
+ if (!trimmed) return
88
+ if (trimmed.startsWith("/")) {
89
+ const tokens = trimmed.slice(1).split(/\s+/)
90
+ const [name, ...args] = tokens
91
+ onSlash(name, args)
92
+ return
93
+ }
94
+ onSubmit(trimmed)
95
+ return
96
+ }
97
+ if (key.name === "escape") {
98
+ if (value) setValue("")
99
+ else onAbort?.()
100
+ return
101
+ }
102
+ if (key.name === "backspace") {
103
+ setValue(v => v.slice(0, -1))
104
+ return
105
+ }
106
+ if (typeof key.sequence === "string" && key.sequence.length === 1) {
107
+ const ch = key.sequence
108
+ const code = ch.charCodeAt(0)
109
+ if (code >= 32 && code !== 127) setValue(v => v + ch)
110
+ }
111
+ })
112
+
113
+ // Bigger, bolder caret + chunkier block cursor for visibility.
114
+ const cursor = !disabled ? bold(fg(theme.assistant)("█")) : ""
115
+ const prompt = bold(fg(theme.assistant)("❯ "))
116
+ const showPlaceholder = !value && placeholder && !disabled
117
+ const promptContent = showPlaceholder
118
+ ? t`${prompt}${fg(theme.dim)(placeholder)}${cursor}`
119
+ : t`${prompt}${fg(theme.assistant)(value)}${cursor}`
120
+
121
+ return (
122
+ <box paddingLeft={1} paddingRight={1} marginTop={1} marginBottom={1}>
123
+ <text content={promptContent} />
124
+ </box>
125
+ )
126
+ }
@@ -0,0 +1,290 @@
1
+ // Lightweight markdown renderer for assistant text. Handles the patterns
2
+ // LLMs actually emit:
3
+ // - **bold** *italic* `inline code`
4
+ // - ``` fenced code blocks ```
5
+ // - # heading ## heading
6
+ // - "- " / "* " bulleted lists
7
+ // - "1. " numbered lists (cyan number, Codex style)
8
+ // - > blockquote
9
+ //
10
+ // Output is OpenTUI <text>/<box> elements. Inline styling builds StyledText
11
+ // directly from TextChunks (the class OpenTUI's <text content> consumes).
12
+
13
+ import { Fragment, type ReactNode } from "react"
14
+ import { fg, bg, bold, italic, t, StyledText, type TextChunk } from "@opentui/core"
15
+ import { theme } from "../theme.js"
16
+
17
+ export function Markdown({ text, prefix }: { text: string; prefix?: TextChunk }) {
18
+ const blocks = parseBlocks(text)
19
+ // Drop the prefix unless the very first block is a paragraph — bullets
20
+ // glued to a code block or heading look noisy.
21
+ const firstIsParagraph = blocks.length > 0 && blocks[0].kind === "paragraph"
22
+ return (
23
+ <Fragment>
24
+ {blocks.map((b, i) =>
25
+ renderBlock(b, i, i === 0 && firstIsParagraph ? prefix : undefined),
26
+ )}
27
+ </Fragment>
28
+ )
29
+ }
30
+
31
+ // ── block-level parser ─────────────────────────────────────────────
32
+
33
+ type Block =
34
+ | { kind: "paragraph"; lines: string[] }
35
+ | { kind: "code"; lang: string; content: string }
36
+ | { kind: "heading"; level: 1 | 2 | 3; text: string }
37
+ | { kind: "bullet"; items: string[] }
38
+ | { kind: "numbered"; items: string[] }
39
+ | { kind: "quote"; lines: string[] }
40
+
41
+ function parseBlocks(text: string): Block[] {
42
+ const blocks: Block[] = []
43
+ const rawLines = text.split("\n")
44
+ let i = 0
45
+
46
+ while (i < rawLines.length) {
47
+ const line = rawLines[i]
48
+
49
+ const fenceMatch = line.match(/^```(\w*)/)
50
+ if (fenceMatch) {
51
+ const lang = fenceMatch[1] ?? ""
52
+ const buf: string[] = []
53
+ i++
54
+ while (i < rawLines.length && !rawLines[i].startsWith("```")) {
55
+ buf.push(rawLines[i])
56
+ i++
57
+ }
58
+ i++ // skip closing fence
59
+ blocks.push({ kind: "code", lang, content: buf.join("\n") })
60
+ continue
61
+ }
62
+
63
+ const h = line.match(/^(#{1,3})\s+(.*)$/)
64
+ if (h) {
65
+ blocks.push({ kind: "heading", level: h[1].length as 1 | 2 | 3, text: h[2] })
66
+ i++
67
+ continue
68
+ }
69
+
70
+ if (line.match(/^[-*]\s/)) {
71
+ const items: string[] = []
72
+ while (i < rawLines.length && rawLines[i].match(/^[-*]\s/)) {
73
+ items.push(rawLines[i].replace(/^[-*]\s/, ""))
74
+ i++
75
+ }
76
+ blocks.push({ kind: "bullet", items })
77
+ continue
78
+ }
79
+
80
+ if (line.match(/^\d+\.\s/)) {
81
+ const items: string[] = []
82
+ while (i < rawLines.length && rawLines[i].match(/^\d+\.\s/)) {
83
+ items.push(rawLines[i].replace(/^\d+\.\s/, ""))
84
+ i++
85
+ }
86
+ blocks.push({ kind: "numbered", items })
87
+ continue
88
+ }
89
+
90
+ if (line.startsWith("> ")) {
91
+ const buf: string[] = []
92
+ while (i < rawLines.length && rawLines[i].startsWith("> ")) {
93
+ buf.push(rawLines[i].slice(2))
94
+ i++
95
+ }
96
+ blocks.push({ kind: "quote", lines: buf })
97
+ continue
98
+ }
99
+
100
+ if (line.trim() === "") {
101
+ i++
102
+ continue
103
+ }
104
+
105
+ const paraLines: string[] = []
106
+ while (
107
+ i < rawLines.length
108
+ && rawLines[i].trim() !== ""
109
+ && !rawLines[i].match(/^```/)
110
+ && !rawLines[i].match(/^#{1,3}\s/)
111
+ && !rawLines[i].match(/^[-*]\s/)
112
+ && !rawLines[i].match(/^\d+\.\s/)
113
+ && !rawLines[i].startsWith("> ")
114
+ ) {
115
+ paraLines.push(rawLines[i])
116
+ i++
117
+ }
118
+ if (paraLines.length > 0) blocks.push({ kind: "paragraph", lines: paraLines })
119
+ }
120
+
121
+ return blocks
122
+ }
123
+
124
+ // ── block renderer ─────────────────────────────────────────────────
125
+
126
+ function renderBlock(block: Block, key: number, prefix?: TextChunk): ReactNode {
127
+ switch (block.kind) {
128
+ case "paragraph":
129
+ return (
130
+ <Fragment key={key}>
131
+ {block.lines.map((line, i) => {
132
+ const chunks = inlineChunks(line, theme.assistant)
133
+ const withPrefix = i === 0 && prefix ? [prefix, ...chunks] : chunks
134
+ return <text key={i} content={styled(withPrefix)} />
135
+ })}
136
+ </Fragment>
137
+ )
138
+
139
+ case "code":
140
+ return (
141
+ <box key={key} flexDirection="column" marginTop={1} marginBottom={1}>
142
+ {block.lang ? (
143
+ <text content={" " + block.lang} fg={theme.dim} />
144
+ ) : null}
145
+ {block.content.split("\n").map((line, i) => (
146
+ <text
147
+ key={i}
148
+ content={t`${fg(theme.branch)("│ ")}${fg(theme.assistant)(line.length > 0 ? line : " ")}`}
149
+ />
150
+ ))}
151
+ </box>
152
+ )
153
+
154
+ case "heading":
155
+ return (
156
+ <box key={key} marginTop={1}>
157
+ <text content={styled([applyBold(applyFg(block.text, theme.assistant))])} />
158
+ </box>
159
+ )
160
+
161
+ case "bullet":
162
+ return (
163
+ <Fragment key={key}>
164
+ {block.items.map((item, i) => (
165
+ <text
166
+ key={i}
167
+ content={styled([
168
+ applyFg(" • ", theme.bullet),
169
+ ...inlineChunks(item, theme.assistant),
170
+ ])}
171
+ />
172
+ ))}
173
+ </Fragment>
174
+ )
175
+
176
+ case "numbered":
177
+ return (
178
+ <Fragment key={key}>
179
+ {block.items.map((item, i) => (
180
+ <text
181
+ key={i}
182
+ content={styled([
183
+ applyFg(` ${i + 1}. `, theme.number),
184
+ ...inlineChunks(item, theme.assistant),
185
+ ])}
186
+ />
187
+ ))}
188
+ </Fragment>
189
+ )
190
+
191
+ case "quote":
192
+ return (
193
+ <Fragment key={key}>
194
+ {block.lines.map((line, i) => (
195
+ <text
196
+ key={i}
197
+ content={styled([
198
+ applyFg("│ ", theme.dim),
199
+ ...inlineChunks(line, theme.dim),
200
+ ])}
201
+ />
202
+ ))}
203
+ </Fragment>
204
+ )
205
+ }
206
+ }
207
+
208
+ // ── inline parser & styling helpers ────────────────────────────────
209
+
210
+ function renderInline(line: string, defaultColor: string): StyledText {
211
+ return styled(inlineChunks(line, defaultColor))
212
+ }
213
+
214
+ function inlineChunks(line: string, defaultColor: string): TextChunk[] {
215
+ const tokens = tokenizeInline(line)
216
+ return tokens.map(tok => styleToken(tok, defaultColor))
217
+ }
218
+
219
+ type Token =
220
+ | { kind: "text"; v: string }
221
+ | { kind: "bold"; v: string }
222
+ | { kind: "italic"; v: string }
223
+ | { kind: "code"; v: string }
224
+
225
+ function tokenizeInline(line: string): Token[] {
226
+ const out: Token[] = []
227
+ let i = 0
228
+ while (i < line.length) {
229
+ if (line[i] === "`") {
230
+ const end = line.indexOf("`", i + 1)
231
+ if (end > i) {
232
+ out.push({ kind: "code", v: line.slice(i + 1, end) })
233
+ i = end + 1
234
+ continue
235
+ }
236
+ }
237
+ if (line[i] === "*" && line[i + 1] === "*") {
238
+ const end = line.indexOf("**", i + 2)
239
+ if (end > i + 1) {
240
+ out.push({ kind: "bold", v: line.slice(i + 2, end) })
241
+ i = end + 2
242
+ continue
243
+ }
244
+ }
245
+ if ((line[i] === "*" || line[i] === "_") && line[i + 1] && line[i + 1] !== " ") {
246
+ const ch = line[i]
247
+ const end = line.indexOf(ch, i + 1)
248
+ if (end > i + 1 && line[end - 1] !== " ") {
249
+ out.push({ kind: "italic", v: line.slice(i + 1, end) })
250
+ i = end + 1
251
+ continue
252
+ }
253
+ }
254
+ let j = i
255
+ while (j < line.length && line[j] !== "`" && line[j] !== "*" && line[j] !== "_") j++
256
+ if (j === i) j = i + 1
257
+ out.push({ kind: "text", v: line.slice(i, j) })
258
+ i = j
259
+ }
260
+ return out
261
+ }
262
+
263
+ function styleToken(tok: Token, defaultColor: string): TextChunk {
264
+ switch (tok.kind) {
265
+ case "text": return applyFg(tok.v, defaultColor)
266
+ case "bold": return applyBold(applyFg(tok.v, defaultColor))
267
+ case "italic": return applyItalic(applyFg(tok.v, defaultColor))
268
+ case "code": return applyBold(applyFg(tok.v, defaultColor))
269
+ }
270
+ }
271
+
272
+ // applyFg/Bg/Bold/Italic each return a TextChunk. They composes via
273
+ // OpenTUI's chunk-level helpers; we don't go through the t`` template.
274
+
275
+ function applyFg(input: string | TextChunk, color: string): TextChunk {
276
+ return fg(color)(input)
277
+ }
278
+ function applyBg(input: string | TextChunk, color: string): TextChunk {
279
+ return bg(color)(input)
280
+ }
281
+ function applyBold(input: string | TextChunk): TextChunk {
282
+ return bold(input)
283
+ }
284
+ function applyItalic(input: string | TextChunk): TextChunk {
285
+ return italic(input)
286
+ }
287
+
288
+ function styled(chunks: TextChunk[]): StyledText {
289
+ return new StyledText(chunks)
290
+ }
@@ -0,0 +1,77 @@
1
+ // Codex-style messages.
2
+ // User: full-width subtle-highlight bar with › prefix.
3
+ // Assistant: inline text prefixed by • bullet, no border.
4
+ // System: same dim treatment as assistant but with the system tone color.
5
+
6
+ import { fg, t } from "@opentui/core"
7
+ import { theme } from "../theme.js"
8
+ import { Markdown } from "./Markdown.js"
9
+
10
+ export function UserMessage({ text }: { text: string }) {
11
+ return (
12
+ <box
13
+ flexDirection="column"
14
+ marginTop={1}
15
+ paddingLeft={1}
16
+ paddingRight={1}
17
+ backgroundColor={theme.userBg}
18
+ >
19
+ {splitLines(text).map((line, i) => (
20
+ <text
21
+ key={i}
22
+ content={i === 0 ? t`${fg(theme.dim)("› ")}${fg(theme.user)(line)}` : t` ${fg(theme.user)(line)}`}
23
+ bg={theme.userBg}
24
+ />
25
+ ))}
26
+ </box>
27
+ )
28
+ }
29
+
30
+ export function AssistantMessage({
31
+ text,
32
+ thinking,
33
+ streaming,
34
+ }: {
35
+ text: string
36
+ thinking: string
37
+ streaming: boolean
38
+ }) {
39
+ return (
40
+ <box flexDirection="column" marginTop={1}>
41
+ {thinking ? (
42
+ <box flexDirection="column" marginBottom={text ? 1 : 0}>
43
+ <text content={t`${fg(theme.thinking)("● thinking")}${streaming ? fg(theme.dim)("…") : ""}`} />
44
+ {splitLines(thinking).map((line, i) => (
45
+ <text key={i} content={" " + line} fg={theme.dim} />
46
+ ))}
47
+ </box>
48
+ ) : null}
49
+ {text && text.trim() ? (
50
+ <>
51
+ <Markdown text={text} />
52
+ {streaming ? <text content={t`${fg(theme.dim)("▍")}`} /> : null}
53
+ </>
54
+ ) : null}
55
+ {streaming && !(text && text.trim()) ? (
56
+ <text content={t`${fg(theme.dim)("…")}`} />
57
+ ) : null}
58
+ </box>
59
+ )
60
+ }
61
+
62
+ export function SystemMessage({ text, tone }: { text: string; tone?: "info" | "warn" | "error" }) {
63
+ const color = tone === "error" ? theme.toolError : tone === "warn" ? theme.toolRunning : theme.system
64
+ const lines = splitLines(text)
65
+ return (
66
+ <box flexDirection="column" marginTop={1}>
67
+ {lines.map((line, i) => {
68
+ const prefix = i === 0 ? fg(theme.bullet)("● ") : " "
69
+ return <text key={i} content={t`${prefix}${fg(color)(line)}`} />
70
+ })}
71
+ </box>
72
+ )
73
+ }
74
+
75
+ function splitLines(s: string): string[] {
76
+ return s.split("\n")
77
+ }
@@ -0,0 +1,30 @@
1
+ import { fg, t } from "@opentui/core"
2
+ import type { PermissionMode } from "../agent/types.js"
3
+ import { theme } from "../theme.js"
4
+
5
+ const ORDER: PermissionMode[] = ["default", "acceptEdits", "bypassPermissions", "plan"]
6
+
7
+ export function nextMode(current: PermissionMode): PermissionMode {
8
+ const i = ORDER.indexOf(current)
9
+ return ORDER[(i + 1) % ORDER.length]
10
+ }
11
+
12
+ export function PermissionBanner({ mode, busy }: { mode: PermissionMode; busy?: boolean }) {
13
+ if (mode === "default") return null
14
+ const { label, color } = banner(mode)
15
+ const hint = busy
16
+ ? "(shift+tab to cycle · esc to interrupt)"
17
+ : "(shift+tab to cycle)"
18
+ return (
19
+ <text content={t`${fg(color)("▶▶ ")}${fg(color)(label)} ${fg(theme.dim)(hint)}`} />
20
+ )
21
+ }
22
+
23
+ function banner(mode: PermissionMode): { label: string; color: string } {
24
+ switch (mode) {
25
+ case "acceptEdits": return { label: "accept edits on", color: theme.modeAcceptEdits }
26
+ case "bypassPermissions": return { label: "bypass permissions on", color: theme.modeBypass }
27
+ case "plan": return { label: "plan mode on", color: theme.modePlan }
28
+ default: return { label: "", color: theme.dim }
29
+ }
30
+ }
@@ -0,0 +1,127 @@
1
+ // Generic interactive list picker with type-to-filter search.
2
+ // Up/Down navigate, Enter confirms, Esc cancels, printable keys edit query.
3
+
4
+ import { useState, useMemo, useEffect } from "react"
5
+ import { useKeyboard } from "@opentui/react"
6
+ import { fg, t } from "@opentui/core"
7
+ import { theme } from "../theme.js"
8
+
9
+ export interface PickerItem {
10
+ value: string
11
+ label: string
12
+ description?: string
13
+ badge?: string
14
+ }
15
+
16
+ export interface PickerProps {
17
+ title: string
18
+ items: PickerItem[]
19
+ initialIndex?: number
20
+ onSelect: (value: string) => void
21
+ onCancel: () => void
22
+ }
23
+
24
+ const MAX_VISIBLE = 8
25
+
26
+ export function Picker({ title, items, initialIndex = 0, onSelect, onCancel }: PickerProps) {
27
+ const [query, setQuery] = useState("")
28
+ const [idx, setIdx] = useState(Math.min(initialIndex, Math.max(0, items.length - 1)))
29
+
30
+ const filtered = useMemo(() => {
31
+ if (!query) return items
32
+ const q = query.toLowerCase()
33
+ return items.filter(i =>
34
+ i.label.toLowerCase().includes(q)
35
+ || (i.description ?? "").toLowerCase().includes(q)
36
+ || (i.badge ?? "").toLowerCase().includes(q),
37
+ )
38
+ }, [items, query])
39
+
40
+ // Snap selection back into range whenever the filter changes.
41
+ useEffect(() => {
42
+ setIdx(i => Math.min(Math.max(0, i), Math.max(0, filtered.length - 1)))
43
+ }, [filtered.length])
44
+
45
+ useKeyboard(key => {
46
+ if (key.name === "up") {
47
+ setIdx(i => Math.max(0, i - 1))
48
+ } else if (key.name === "down") {
49
+ setIdx(i => Math.min(filtered.length - 1, i + 1))
50
+ } else if (key.name === "return") {
51
+ if (filtered.length > 0) onSelect(filtered[idx].value)
52
+ } else if (key.name === "escape") {
53
+ onCancel()
54
+ } else if (key.name === "backspace") {
55
+ setQuery(q => q.slice(0, -1))
56
+ } else if (
57
+ key.sequence
58
+ && key.sequence.length === 1
59
+ && key.sequence >= " "
60
+ && key.sequence <= "~"
61
+ ) {
62
+ setQuery(q => q + key.sequence)
63
+ }
64
+ })
65
+
66
+ if (items.length === 0) return null
67
+
68
+ const start = Math.max(
69
+ 0,
70
+ Math.min(idx - Math.floor(MAX_VISIBLE / 2), Math.max(0, filtered.length - MAX_VISIBLE)),
71
+ )
72
+ const visible = filtered.slice(start, start + MAX_VISIBLE)
73
+
74
+ // Fixed column widths derived from the full item set so widths don't jiggle as you filter.
75
+ const labelWidth = Math.min(28, Math.max(...items.map(i => i.label.length)))
76
+ const badgeWidth = Math.min(12, Math.max(...items.map(i => (i.badge ?? "").length)))
77
+
78
+ // Total height: title + filter + blank + visible rows + blank + footer + padding(2)
79
+ const height = visible.length + 7
80
+
81
+ return (
82
+ <box
83
+ flexDirection="column"
84
+ flexShrink={0}
85
+ height={height}
86
+ borderStyle="rounded"
87
+ borderColor={theme.borderActive}
88
+ paddingLeft={1}
89
+ paddingRight={1}
90
+ paddingTop={0}
91
+ paddingBottom={0}
92
+ marginTop={1}
93
+ >
94
+ <text content={t`${fg(theme.accent)("› ")}${fg(theme.assistant)(title)}`} />
95
+ <text
96
+ content={t`${fg(theme.dim)(" search ")}${fg(theme.assistant)(query || " ")}${fg(theme.dim)(query ? "" : "(type to filter)")}`}
97
+ />
98
+ <text content="" />
99
+ {visible.length === 0 ? (
100
+ <text content=" no matches" fg={theme.dim} />
101
+ ) : (
102
+ visible.map((item, i) => {
103
+ const realIdx = start + i
104
+ const active = realIdx === idx
105
+ const marker = active ? "› " : " "
106
+ const markerColor = active ? theme.accent : theme.dim
107
+ const labelColor = active ? theme.assistant : theme.dim
108
+ const label = clip(item.label, labelWidth).padEnd(labelWidth)
109
+ const badge = clip(item.badge ?? "", badgeWidth).padEnd(badgeWidth)
110
+ const desc = item.description ?? ""
111
+ return (
112
+ <text
113
+ key={item.value}
114
+ content={t`${fg(markerColor)(marker)}${fg(labelColor)(label)} ${fg(theme.dim)(badge)} ${fg(theme.dim)(desc)}`}
115
+ />
116
+ )
117
+ })
118
+ )}
119
+ <text content="" />
120
+ <text content="↑/↓ choose · enter confirm · esc cancel · type to filter" fg={theme.dim} />
121
+ </box>
122
+ )
123
+ }
124
+
125
+ function clip(s: string, max: number): string {
126
+ return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…"
127
+ }