@vladimirven/openswe 0.1.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 (117) hide show
  1. package/AGENTS.md +203 -0
  2. package/CLAUDE.md +203 -0
  3. package/README.md +166 -0
  4. package/bun.lock +447 -0
  5. package/bunfig.toml +4 -0
  6. package/package.json +42 -0
  7. package/src/app.tsx +84 -0
  8. package/src/components/App.tsx +526 -0
  9. package/src/components/ConfirmDialog.tsx +88 -0
  10. package/src/components/Footer.tsx +50 -0
  11. package/src/components/HelpModal.tsx +136 -0
  12. package/src/components/IssueSelectorModal.tsx +701 -0
  13. package/src/components/ManualSessionModal.tsx +191 -0
  14. package/src/components/PhaseProgress.tsx +45 -0
  15. package/src/components/Preview.tsx +249 -0
  16. package/src/components/ProviderSwitcherModal.tsx +156 -0
  17. package/src/components/ScrollableText.tsx +120 -0
  18. package/src/components/SessionCard.tsx +60 -0
  19. package/src/components/SessionList.tsx +79 -0
  20. package/src/components/SessionTerminal.tsx +89 -0
  21. package/src/components/StatusBar.tsx +84 -0
  22. package/src/components/ThemeSwitcherModal.tsx +237 -0
  23. package/src/components/index.ts +58 -0
  24. package/src/components/session-utils.ts +337 -0
  25. package/src/components/theme.ts +206 -0
  26. package/src/components/types.ts +215 -0
  27. package/src/config/defaults.ts +44 -0
  28. package/src/config/env.ts +67 -0
  29. package/src/config/global.ts +252 -0
  30. package/src/config/index.ts +171 -0
  31. package/src/config/types.ts +131 -0
  32. package/src/core/.gitkeep +0 -0
  33. package/src/core/index.ts +5 -0
  34. package/src/core/parser.ts +62 -0
  35. package/src/core/process-manager.ts +52 -0
  36. package/src/core/session.ts +423 -0
  37. package/src/core/tmux.ts +206 -0
  38. package/src/git/.gitkeep +0 -0
  39. package/src/git/index.ts +8 -0
  40. package/src/git/repo.ts +443 -0
  41. package/src/git/worktree.ts +317 -0
  42. package/src/github/.gitkeep +0 -0
  43. package/src/github/client.ts +208 -0
  44. package/src/github/index.ts +8 -0
  45. package/src/github/issues.ts +351 -0
  46. package/src/index.ts +369 -0
  47. package/src/prompts/.gitkeep +0 -0
  48. package/src/prompts/index.ts +1 -0
  49. package/src/prompts/swe-system.ts +22 -0
  50. package/src/providers/claude.ts +103 -0
  51. package/src/providers/index.ts +21 -0
  52. package/src/providers/opencode.ts +98 -0
  53. package/src/providers/registry.ts +53 -0
  54. package/src/providers/types.ts +117 -0
  55. package/src/store/buffers.ts +234 -0
  56. package/src/store/db.test.ts +579 -0
  57. package/src/store/db.ts +249 -0
  58. package/src/store/index.ts +101 -0
  59. package/src/store/project.ts +119 -0
  60. package/src/store/schema.sql +71 -0
  61. package/src/store/sessions.ts +454 -0
  62. package/src/store/types.ts +194 -0
  63. package/src/theme/context.tsx +170 -0
  64. package/src/theme/custom.ts +134 -0
  65. package/src/theme/index.ts +58 -0
  66. package/src/theme/loader.ts +264 -0
  67. package/src/theme/themes/aura.json +69 -0
  68. package/src/theme/themes/ayu.json +80 -0
  69. package/src/theme/themes/carbonfox.json +248 -0
  70. package/src/theme/themes/catppuccin-frappe.json +233 -0
  71. package/src/theme/themes/catppuccin-macchiato.json +233 -0
  72. package/src/theme/themes/catppuccin.json +112 -0
  73. package/src/theme/themes/cobalt2.json +228 -0
  74. package/src/theme/themes/cursor.json +249 -0
  75. package/src/theme/themes/dracula.json +219 -0
  76. package/src/theme/themes/everforest.json +241 -0
  77. package/src/theme/themes/flexoki.json +237 -0
  78. package/src/theme/themes/github.json +233 -0
  79. package/src/theme/themes/gruvbox.json +242 -0
  80. package/src/theme/themes/kanagawa.json +77 -0
  81. package/src/theme/themes/lucent-orng.json +237 -0
  82. package/src/theme/themes/material.json +235 -0
  83. package/src/theme/themes/matrix.json +77 -0
  84. package/src/theme/themes/mercury.json +252 -0
  85. package/src/theme/themes/monokai.json +221 -0
  86. package/src/theme/themes/nightowl.json +221 -0
  87. package/src/theme/themes/nord.json +223 -0
  88. package/src/theme/themes/one-dark.json +84 -0
  89. package/src/theme/themes/opencode.json +245 -0
  90. package/src/theme/themes/orng.json +249 -0
  91. package/src/theme/themes/osaka-jade.json +93 -0
  92. package/src/theme/themes/palenight.json +222 -0
  93. package/src/theme/themes/rosepine.json +234 -0
  94. package/src/theme/themes/solarized.json +223 -0
  95. package/src/theme/themes/synthwave84.json +226 -0
  96. package/src/theme/themes/tokyonight.json +243 -0
  97. package/src/theme/themes/vercel.json +245 -0
  98. package/src/theme/themes/vesper.json +218 -0
  99. package/src/theme/themes/zenburn.json +223 -0
  100. package/src/theme/types.ts +225 -0
  101. package/src/types/sql.d.ts +4 -0
  102. package/src/utils/ansi-parser.ts +225 -0
  103. package/src/utils/format.ts +46 -0
  104. package/src/utils/id.ts +15 -0
  105. package/src/utils/logger.ts +112 -0
  106. package/src/utils/prerequisites.ts +118 -0
  107. package/src/utils/shell.ts +9 -0
  108. package/src/wizard/flows.ts +419 -0
  109. package/src/wizard/index.ts +37 -0
  110. package/src/wizard/prompts.ts +190 -0
  111. package/src/workspace/detect.test.ts +51 -0
  112. package/src/workspace/detect.ts +223 -0
  113. package/src/workspace/index.ts +71 -0
  114. package/src/workspace/init.ts +131 -0
  115. package/src/workspace/paths.ts +143 -0
  116. package/src/workspace/project.ts +164 -0
  117. package/tsconfig.json +22 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * ManualSessionModal component - Create a new manual session
3
+ *
4
+ * Collects a session name and creates a worktree + session entry.
5
+ */
6
+
7
+ import { createSignal, createEffect, onCleanup, Show } from "solid-js"
8
+ import { useKeyboard } from "@opentui/solid"
9
+ import type { ManualSessionModalProps } from "./types"
10
+ import { createManualSession } from "./session-utils"
11
+ import { Footer } from "./Footer"
12
+ import { useColors } from "./theme"
13
+ import { logger } from "../utils/logger"
14
+
15
+ // Bold attribute constant
16
+ const BOLD = 1
17
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
18
+
19
+ export function ManualSessionModal(props: ManualSessionModalProps) {
20
+ const colors = useColors()
21
+ const modalWidth = 50
22
+ const maxNameLength = 40
23
+
24
+ const [name, setName] = createSignal("")
25
+ const [error, setError] = createSignal<string | null>(null)
26
+ const [creating, setCreating] = createSignal(false)
27
+ const [spinnerFrame, setSpinnerFrame] = createSignal(0)
28
+
29
+ // Spinner animation
30
+ createEffect(() => {
31
+ if (creating()) {
32
+ const interval = setInterval(() => {
33
+ setSpinnerFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
34
+ }, 80)
35
+ onCleanup(() => clearInterval(interval))
36
+ }
37
+ })
38
+
39
+ // Dynamic height based on error/creating state
40
+ const modalHeight = () => {
41
+ let height = 8 // Base: header(1) + label(1) + input(1) + footer(1) + borders(2) + padding(2)
42
+ if (error()) height += 1
43
+ if (creating()) height += 1
44
+ return height
45
+ }
46
+
47
+ const appendChar = (char: string) => {
48
+ setName((prev) => {
49
+ if (prev.length >= maxNameLength) return prev
50
+ return prev + char
51
+ })
52
+ }
53
+
54
+ const handleSubmit = async () => {
55
+ const trimmed = name().trim()
56
+ if (!trimmed) {
57
+ setError("Session name is required")
58
+ return
59
+ }
60
+
61
+ setCreating(true)
62
+ setError(null)
63
+ logger.debug("Creating manual session", { name: trimmed })
64
+
65
+ const result = await createManualSession(props.projectRoot, trimmed, {
66
+ aiSessionData: { backend: props.currentBackend }
67
+ })
68
+
69
+ if (!result.success || !result.session) {
70
+ const message = result.error ?? "Failed to create session"
71
+ logger.warn("Manual session creation failed", message)
72
+ setError(message)
73
+ setCreating(false)
74
+ return
75
+ }
76
+
77
+ logger.debug("Manual session created", { sessionId: result.session.id })
78
+
79
+ // Start the session (spawns opencode in tmux)
80
+ try {
81
+ await props.sessionManager.startSession({
82
+ sessionId: result.session.id,
83
+ // No prompt - launches interactive opencode TUI
84
+ })
85
+ logger.debug("Manual session started", { sessionId: result.session.id })
86
+ } catch (err) {
87
+ // Session created but failed to start - still close modal
88
+ // User can try to start it manually by pressing Enter
89
+ logger.error("Failed to start session:", err)
90
+ }
91
+
92
+ props.onSessionCreated()
93
+ props.onClose()
94
+ }
95
+
96
+ const extractChar = (event: { name?: string; sequence?: string }) => {
97
+ if (event.sequence && event.sequence.length === 1) {
98
+ return event.sequence
99
+ }
100
+ if (event.name === "space") return " "
101
+ if (event.name && event.name.length === 1) return event.name
102
+ return null
103
+ }
104
+
105
+ useKeyboard((event) => {
106
+ if (creating()) return
107
+
108
+ switch (event.name) {
109
+ case "escape":
110
+ props.onClose()
111
+ return
112
+ case "backspace":
113
+ setName((prev) => prev.slice(0, -1))
114
+ return
115
+ case "return":
116
+ handleSubmit()
117
+ return
118
+ }
119
+
120
+ const char = extractChar(event)
121
+ if (char) {
122
+ appendChar(char)
123
+ setError(null)
124
+ }
125
+ })
126
+
127
+ return (
128
+ <box
129
+ position="absolute"
130
+ top={0}
131
+ left={0}
132
+ width="100%"
133
+ height="100%"
134
+ justifyContent="center"
135
+ alignItems="center"
136
+ >
137
+ <box
138
+ flexDirection="column"
139
+ width={modalWidth}
140
+ height={modalHeight()}
141
+ backgroundColor={colors().bg.secondary}
142
+ borderStyle="rounded"
143
+ borderColor={colors().border.accent}
144
+ overflow="hidden"
145
+ >
146
+ {/* Header */}
147
+ <box height={1} justifyContent="center" paddingLeft={1} paddingRight={1}>
148
+ <text fg={colors().text.primary} attributes={BOLD}>
149
+ New Manual Session
150
+ </text>
151
+ </box>
152
+
153
+ {/* Content area */}
154
+ <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1}>
155
+ <box height={1}>
156
+ <text fg={colors().text.secondary}>Name:</text>
157
+ </box>
158
+
159
+ <box height={1}>
160
+ <text fg={colors().text.primary}>
161
+ {name() || "Type a session name"}
162
+ </text>
163
+ </box>
164
+
165
+ <Show when={error()}>
166
+ <box height={1}>
167
+ <text fg={colors().accent.error}>{error()}</text>
168
+ </box>
169
+ </Show>
170
+
171
+ <Show when={creating()}>
172
+ <box height={1} justifyContent="center">
173
+ <text fg={colors().accent.primary}>
174
+ {SPINNER_FRAMES[spinnerFrame()]} Creating session...
175
+ </text>
176
+ </box>
177
+ </Show>
178
+ </box>
179
+
180
+ {/* Footer */}
181
+ <Footer
182
+ actions={[
183
+ { key: "Enter", label: "Create" },
184
+ { key: "Esc", label: "Cancel" },
185
+ ]}
186
+ bgColor={colors().bg.primary}
187
+ />
188
+ </box>
189
+ </box>
190
+ )
191
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * PhaseProgress component - displays workflow phase progress
3
+ *
4
+ * Two variants:
5
+ * - inline: Just the phase name text (for session cards)
6
+ * - full: Progress bar + phase name (for preview pane)
7
+ */
8
+
9
+ import { Show } from "solid-js"
10
+ import type { PhaseProgressProps } from "./types"
11
+ import {
12
+ PHASE_DISPLAY_NAMES,
13
+ getPhaseProgress,
14
+ generateProgressBar,
15
+ } from "./types"
16
+ import { useColors } from "./theme"
17
+
18
+ export function PhaseProgress(props: PhaseProgressProps) {
19
+ const colors = useColors()
20
+ const displayName = () => PHASE_DISPLAY_NAMES[props.phase]
21
+ const progress = () => getPhaseProgress(props.phase)
22
+ const progressBar = () => generateProgressBar(progress(), 10)
23
+
24
+ // Color based on phase
25
+ const phaseColor = () => {
26
+ if (props.phase === "completed") return colors().accent.success
27
+ if (props.phase === "failed") return colors().accent.error
28
+ return colors().progress.text
29
+ }
30
+
31
+ return (
32
+ <Show
33
+ when={props.variant === "full"}
34
+ fallback={
35
+ <text fg={phaseColor()}>{displayName()}</text>
36
+ }
37
+ >
38
+ <box flexDirection="row" gap={1}>
39
+ <text fg={colors().progress.filled}>{progressBar()}</text>
40
+ <text fg={phaseColor()}>{displayName()}</text>
41
+ <text fg={colors().text.muted}>({progress()}%)</text>
42
+ </box>
43
+ </Show>
44
+ )
45
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Preview component - right pane showing session terminal output
3
+ *
4
+ * Layout:
5
+ * - Header: Session name + PhaseProgress (full variant)
6
+ * - Separator
7
+ * - Terminal Output
8
+ * - Footer: Session duration + attach hint
9
+ */
10
+
11
+ import { Show, createMemo, For } from "solid-js"
12
+ import type { PreviewProps } from "./types"
13
+ import type { Session } from "../store"
14
+ import { getPhaseProgress } from "./types"
15
+ import { useColors, type Colors } from "./theme"
16
+ import { Footer } from "./Footer"
17
+ import { truncate } from "../utils/format"
18
+ import { parseAnsiLine } from "../utils/ansi-parser"
19
+ import { getProvider, isProviderSupported } from "../providers"
20
+
21
+ import { formatTokens } from "../utils/format"
22
+
23
+ // Bold attribute constant
24
+ const BOLD = 1
25
+ const VERSION = "v0.1.0" // TODO: Get from package.json or config
26
+
27
+ /**
28
+ * Format duration in human-readable form
29
+ */
30
+ function formatDuration(startedAt: Date | undefined): string {
31
+ if (!startedAt) return ""
32
+
33
+ const diff = Date.now() - startedAt.getTime()
34
+ const seconds = Math.floor(diff / 1000)
35
+
36
+ if (seconds < 60) return `${seconds}s`
37
+
38
+ const minutes = Math.floor(seconds / 60)
39
+ const remainingSeconds = seconds % 60
40
+
41
+ if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
42
+
43
+ const hours = Math.floor(minutes / 60)
44
+ const remainingMinutes = minutes % 60
45
+
46
+ return `${hours}h ${remainingMinutes}m`
47
+ }
48
+
49
+ /**
50
+ * StatusBanner component - shows attention status with icon
51
+ * Displays colored banner when session needs attention due to questions/errors
52
+ */
53
+ function StatusBanner(props: { session: Session }) {
54
+ const colors = useColors()
55
+ const bannerInfo = createMemo(() => {
56
+ // General needs_attention (e.g., push failed)
57
+ return {
58
+ icon: "!",
59
+ text: props.session.attentionReason || "Needs attention",
60
+ color: colors().accent.warning,
61
+ bgColor: colors().accent.warning,
62
+ }
63
+ })
64
+
65
+ return (
66
+ <box
67
+ width="100%"
68
+ paddingLeft={1}
69
+ paddingRight={1}
70
+ paddingTop={0}
71
+ paddingBottom={0}
72
+ >
73
+ <box
74
+ flexDirection="row"
75
+ gap={1}
76
+ alignItems="center"
77
+ >
78
+ <text fg={bannerInfo().color}>{bannerInfo().icon}</text>
79
+ <text fg={bannerInfo().color}>{bannerInfo().text}</text>
80
+ </box>
81
+ </box>
82
+ )
83
+ }
84
+
85
+ export function Preview(props: PreviewProps) {
86
+ const colors = useColors()
87
+ const duration = createMemo(() => formatDuration(props.startedAt))
88
+ const isActive = createMemo(() => props.session?.status === "active")
89
+
90
+ // Resolve provider branding from session data if available, otherwise fall back to global prop
91
+ const resolvedBranding = createMemo(() => {
92
+ const sessionBackend = props.session?.aiSessionData?.backend
93
+ if (sessionBackend && isProviderSupported(sessionBackend)) {
94
+ try {
95
+ return getProvider(sessionBackend).branding
96
+ } catch {
97
+ // Fallback if provider lookup fails
98
+ }
99
+ }
100
+ return props.providerBranding
101
+ })
102
+
103
+ const borderColor = createMemo(() => resolvedBranding()?.accentColor ?? colors().border.primary)
104
+ const providerName = createMemo(() => resolvedBranding()?.displayName ?? "Terminal")
105
+ const terminalBackground = createMemo(() => resolvedBranding()?.terminalBackground)
106
+
107
+ return (
108
+ <box
109
+ flexDirection="column"
110
+ width="70%"
111
+ height="100%"
112
+ borderStyle="rounded"
113
+ borderColor={borderColor()}
114
+ title={` Preview [${providerName()}] `}
115
+ >
116
+ <Show
117
+ when={props.session}
118
+ fallback={<NoSelectionState />}
119
+ >
120
+ {(session) => (
121
+ /* Terminal View - Full Height */
122
+ <box flexDirection="column" width="100%" height="100%">
123
+ {/* Header with session info */}
124
+ <box
125
+ flexDirection="row"
126
+ width="100%"
127
+ paddingLeft={1}
128
+ paddingRight={1}
129
+ paddingTop={0}
130
+ paddingBottom={0}
131
+ justifyContent="space-between"
132
+ alignItems="flex-start"
133
+ overflow="hidden"
134
+ >
135
+ <box flexDirection="column" overflow="hidden" flexGrow={1} marginRight={1}>
136
+ <text fg={colors().text.primary} attributes={BOLD}>
137
+ {truncate(session().name, 60)}
138
+ </text>
139
+ <Show when={session().issueNumber}>
140
+ <text fg={colors().text.secondary}>
141
+ Issue #{session().issueNumber}
142
+ </text>
143
+ </Show>
144
+ </box>
145
+
146
+ {/* Right side metadata */}
147
+ <box flexDirection="column" alignItems="flex-end" marginRight={1}>
148
+ <box flexDirection="row" gap={1}>
149
+ <text fg={colors().text.muted}>
150
+ {session().tokensUsed ? session().tokensUsed.toLocaleString() : "0"}
151
+ </text>
152
+ <text fg={colors().text.muted}>
153
+ {getPhaseProgress(session().phase)}%
154
+ </text>
155
+ <text fg={colors().text.muted}>($0.00)</text>
156
+ <text fg={colors().text.muted}>{VERSION}</text>
157
+ </box>
158
+ </box>
159
+ </box>
160
+
161
+ {/* Status Banner for needs_attention */}
162
+ <Show when={session().status === "needs_attention"}>
163
+ <StatusBanner session={session()} />
164
+ </Show>
165
+
166
+ {/* Separator */}
167
+ <box width="100%" height={1} overflow="hidden">
168
+ <text fg={colors().border.secondary}>
169
+ {"─".repeat(500)}
170
+ </text>
171
+ </box>
172
+
173
+ <box
174
+ flexDirection="column"
175
+ flexGrow={1}
176
+ width="100%"
177
+ paddingLeft={1}
178
+ paddingRight={1}
179
+ paddingTop={0}
180
+ justifyContent="flex-end"
181
+ overflow="hidden"
182
+ backgroundColor={terminalBackground()}
183
+ >
184
+ <Show
185
+ when={props.session?.status === "active" && props.snapshotLines && props.snapshotLines.length > 0}
186
+ fallback={
187
+ <box height="100%" justifyContent="center" alignItems="center">
188
+ <text fg={colors().text.muted}>Waiting for output...</text>
189
+ </box>
190
+ }
191
+ >
192
+ <For each={props.snapshotLines}>
193
+ {(line) => {
194
+ const segments = parseAnsiLine(line)
195
+ return (
196
+ <box flexDirection="row">
197
+ <For each={segments}>
198
+ {(segment) => (
199
+ <text
200
+ fg={segment.fg || colors().text.primary}
201
+ bg={segment.bg}
202
+ attributes={segment.bold ? BOLD : undefined}
203
+ >
204
+ {segment.text}
205
+ </text>
206
+ )}
207
+ </For>
208
+ </box>
209
+ )
210
+ }}
211
+ </For>
212
+ </Show>
213
+ </box>
214
+
215
+ {/* Footer */}
216
+ <Show when={isActive()}>
217
+ <Footer
218
+ actions={[
219
+ { key: "Enter", label: "Attach" },
220
+ { key: "tmux-prefix+d", label: "Detach" }
221
+ ]}
222
+ message={duration() ? `Running for ${duration()}` : undefined}
223
+ />
224
+ </Show>
225
+ </box>
226
+ )}
227
+ </Show>
228
+ </box>
229
+ )
230
+ }
231
+
232
+ function NoSelectionState() {
233
+ const colors = useColors()
234
+ return (
235
+ <box
236
+ flexDirection="column"
237
+ width="100%"
238
+ height="100%"
239
+ justifyContent="center"
240
+ alignItems="center"
241
+ gap={1}
242
+ >
243
+ <text fg={colors().text.muted}>No session selected</text>
244
+ <text fg={colors().text.secondary}>
245
+ Select a session to view terminal output
246
+ </text>
247
+ </box>
248
+ )
249
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * ProviderSwitcherModal component - Allows switching AI backends
3
+ *
4
+ * Displays a list of available AI providers and lets the user select one.
5
+ */
6
+
7
+ import { createSignal, onMount, For } from "solid-js"
8
+ import { useKeyboard } from "@opentui/solid"
9
+ import type { ProviderSwitcherModalProps } from "./types"
10
+ import { getAllProviders, type Provider } from "../providers"
11
+ import { Footer } from "./Footer"
12
+ import { useColors } from "./theme"
13
+
14
+ // Bold attribute constant
15
+ const BOLD = 1
16
+
17
+ export function ProviderSwitcherModal(props: ProviderSwitcherModalProps) {
18
+ const colors = useColors()
19
+ const [providers, setProviders] = createSignal<Provider[]>([])
20
+ const [focusedIndex, setFocusedIndex] = createSignal(0)
21
+
22
+ onMount(() => {
23
+ const all = getAllProviders()
24
+ setProviders(all)
25
+
26
+ // Set initial focus to current backend
27
+ const idx = all.findIndex(p => p.id === props.currentBackend)
28
+ if (idx !== -1) {
29
+ setFocusedIndex(idx)
30
+ }
31
+ })
32
+
33
+ useKeyboard((event) => {
34
+ const list = providers()
35
+
36
+ switch (event.name) {
37
+ case "escape":
38
+ props.onClose()
39
+ break
40
+
41
+ case "j":
42
+ case "down":
43
+ setFocusedIndex(prev =>
44
+ Math.min(prev + 1, list.length - 1)
45
+ )
46
+ break
47
+
48
+ case "k":
49
+ case "up":
50
+ setFocusedIndex(prev =>
51
+ Math.max(prev - 1, 0)
52
+ )
53
+ break
54
+
55
+ case "enter":
56
+ case "return": {
57
+ const selected = list[focusedIndex()]
58
+ if (selected) {
59
+ props.onSelect(selected.id)
60
+ }
61
+ break
62
+ }
63
+ }
64
+ })
65
+
66
+ const modalWidth = 50
67
+ const modalHeight = 12
68
+
69
+ return (
70
+ <box
71
+ position="absolute"
72
+ top={0}
73
+ left={0}
74
+ width="100%"
75
+ height="100%"
76
+ justifyContent="center"
77
+ alignItems="center"
78
+ >
79
+ <box
80
+ flexDirection="column"
81
+ width={modalWidth}
82
+ height={modalHeight}
83
+ backgroundColor={colors().bg.secondary}
84
+ borderStyle="rounded"
85
+ borderColor={colors().border.accent}
86
+ overflow="hidden"
87
+ >
88
+ {/* Header */}
89
+ <box
90
+ height={1}
91
+ justifyContent="center"
92
+ paddingLeft={1}
93
+ paddingRight={1}
94
+ marginBottom={1}
95
+ >
96
+ <text fg={colors().text.primary} attributes={BOLD}>
97
+ Select AI Provider
98
+ </text>
99
+ </box>
100
+
101
+ {/* List */}
102
+ <box flexDirection="column" flexGrow={1} paddingLeft={2} paddingRight={2}>
103
+ <For each={providers()}>
104
+ {(provider, i) => {
105
+ const isFocused = () => i() === focusedIndex()
106
+ const isCurrent = () => provider.id === props.currentBackend
107
+
108
+ return (
109
+ <box
110
+ height={1}
111
+ flexDirection="row"
112
+ alignItems="center"
113
+ backgroundColor={isFocused() ? colors().bg.cardSelected : undefined}
114
+ >
115
+ {/* Focus Indicator */}
116
+ <box width={2}>
117
+ <text fg={colors().accent.primary}>
118
+ {isFocused() ? "▍" : " "}
119
+ </text>
120
+ </box>
121
+
122
+ {/* Selection Indicator */}
123
+ <box width={4}>
124
+ <text
125
+ fg={isCurrent() ? colors().accent.success : colors().text.muted}
126
+ attributes={isCurrent() ? BOLD : 0}
127
+ >
128
+ {isCurrent() ? "[✓]" : "[ ]"}
129
+ </text>
130
+ </box>
131
+
132
+ {/* Provider Name */}
133
+ <text
134
+ fg={isFocused() ? colors().text.primary : colors().text.secondary}
135
+ attributes={isFocused() ? BOLD : 0}
136
+ >
137
+ {provider.branding.displayName}
138
+ </text>
139
+ </box>
140
+ )
141
+ }}
142
+ </For>
143
+ </box>
144
+
145
+ {/* Footer */}
146
+ <Footer
147
+ actions={[
148
+ { key: "Enter", label: "Select" },
149
+ { key: "Esc", label: "Cancel" }
150
+ ]}
151
+ bgColor={colors().bg.primary}
152
+ />
153
+ </box>
154
+ </box>
155
+ )
156
+ }