@meowlynxsea/koi 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 (109) hide show
  1. package/LICENSE +34 -0
  2. package/NOTICE +35 -0
  3. package/README.md +15 -0
  4. package/bin/koi +12 -0
  5. package/dist/highlights-eq9cgrbb.scm +604 -0
  6. package/dist/highlights-ghv9g403.scm +205 -0
  7. package/dist/highlights-hk7bwhj4.scm +284 -0
  8. package/dist/highlights-r812a2qc.scm +150 -0
  9. package/dist/highlights-x6tmsnaa.scm +115 -0
  10. package/dist/injections-73j83es3.scm +27 -0
  11. package/dist/main.js +489918 -0
  12. package/dist/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  13. package/dist/tree-sitter-markdown-411r6y9b.wasm +0 -0
  14. package/dist/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  15. package/dist/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  16. package/dist/tree-sitter-zig-e78zbjpm.wasm +0 -0
  17. package/package.json +51 -0
  18. package/src/agent/check-permissions.ts +239 -0
  19. package/src/agent/hooks/message-utils.ts +305 -0
  20. package/src/agent/hooks/types.ts +32 -0
  21. package/src/agent/hooks.ts +1560 -0
  22. package/src/agent/mode.ts +163 -0
  23. package/src/agent/monitor-registry.ts +308 -0
  24. package/src/agent/permission-ui.ts +71 -0
  25. package/src/agent/plan-ui.ts +74 -0
  26. package/src/agent/question-ui.ts +58 -0
  27. package/src/agent/session-fork.ts +299 -0
  28. package/src/agent/session-snapshots.ts +216 -0
  29. package/src/agent/session-store.ts +649 -0
  30. package/src/agent/session-tasks.ts +305 -0
  31. package/src/agent/session.ts +27 -0
  32. package/src/agent/subagent-registry.ts +176 -0
  33. package/src/agent/subagent.ts +194 -0
  34. package/src/agent/tool-orchestration.ts +55 -0
  35. package/src/agent/tools.ts +8 -0
  36. package/src/cli/args.ts +6 -0
  37. package/src/cli/commands.ts +5 -0
  38. package/src/commands/skills/index.ts +23 -0
  39. package/src/config/models.ts +6 -0
  40. package/src/config/settings.ts +392 -0
  41. package/src/main.tsx +64 -0
  42. package/src/services/mcp/client.ts +194 -0
  43. package/src/services/mcp/config.ts +232 -0
  44. package/src/services/mcp/connection-manager.ts +258 -0
  45. package/src/services/mcp/index.ts +80 -0
  46. package/src/services/mcp/mcp-commands.ts +114 -0
  47. package/src/services/mcp/stdio-transport.ts +246 -0
  48. package/src/services/mcp/types.ts +155 -0
  49. package/src/skills/SkillsMenu.tsx +370 -0
  50. package/src/skills/bundled/batch.ts +106 -0
  51. package/src/skills/bundled/debug.ts +86 -0
  52. package/src/skills/bundled/loremIpsum.ts +101 -0
  53. package/src/skills/bundled/remember.ts +97 -0
  54. package/src/skills/bundled/simplify.ts +100 -0
  55. package/src/skills/bundled/skillify.ts +123 -0
  56. package/src/skills/bundled/stuck.ts +101 -0
  57. package/src/skills/bundled/updateConfig.ts +228 -0
  58. package/src/skills/bundled.ts +46 -0
  59. package/src/skills/frontmatter.ts +179 -0
  60. package/src/skills/index.ts +87 -0
  61. package/src/skills/invoke.ts +231 -0
  62. package/src/skills/loader.ts +710 -0
  63. package/src/skills/substitution.ts +169 -0
  64. package/src/skills/types.ts +201 -0
  65. package/src/tools/agent.ts +143 -0
  66. package/src/tools/ask-user-question.ts +46 -0
  67. package/src/tools/bash.ts +148 -0
  68. package/src/tools/edit.ts +164 -0
  69. package/src/tools/glob.ts +102 -0
  70. package/src/tools/grep.ts +248 -0
  71. package/src/tools/index.ts +73 -0
  72. package/src/tools/list-mcp-resources.ts +74 -0
  73. package/src/tools/ls.ts +85 -0
  74. package/src/tools/mcp.ts +76 -0
  75. package/src/tools/monitor.ts +159 -0
  76. package/src/tools/plan-mode.ts +134 -0
  77. package/src/tools/read-mcp-resource.ts +79 -0
  78. package/src/tools/read.ts +137 -0
  79. package/src/tools/skill.ts +176 -0
  80. package/src/tools/task.ts +349 -0
  81. package/src/tools/types.ts +52 -0
  82. package/src/tools/webfetch-domains.ts +239 -0
  83. package/src/tools/webfetch.ts +533 -0
  84. package/src/tools/write.ts +101 -0
  85. package/src/tui/app.tsx +1178 -0
  86. package/src/tui/components/chat-panel.tsx +1071 -0
  87. package/src/tui/components/command-panel.tsx +261 -0
  88. package/src/tui/components/confirm-modal.tsx +135 -0
  89. package/src/tui/components/connect-modal.tsx +435 -0
  90. package/src/tui/components/connecting-modal.tsx +167 -0
  91. package/src/tui/components/edit-pending-modal.tsx +103 -0
  92. package/src/tui/components/exit-modal.tsx +131 -0
  93. package/src/tui/components/fork-modal.tsx +377 -0
  94. package/src/tui/components/image-preview-modal.tsx +141 -0
  95. package/src/tui/components/image-utils.ts +128 -0
  96. package/src/tui/components/info-bar.tsx +103 -0
  97. package/src/tui/components/input-box.tsx +352 -0
  98. package/src/tui/components/mcp/MCPSettings.tsx +386 -0
  99. package/src/tui/components/mcp/index.ts +7 -0
  100. package/src/tui/components/model-modal.tsx +310 -0
  101. package/src/tui/components/pending-area.tsx +88 -0
  102. package/src/tui/components/rename-modal.tsx +119 -0
  103. package/src/tui/components/session-modal.tsx +233 -0
  104. package/src/tui/components/side-bar.tsx +349 -0
  105. package/src/tui/components/tool-output.ts +6 -0
  106. package/src/tui/hooks/user-prompt-history.ts +114 -0
  107. package/src/tui/theme.ts +63 -0
  108. package/src/types/commands.ts +80 -0
  109. package/src/types/cross-spawn.d.ts +24 -0
@@ -0,0 +1,386 @@
1
+ /**
2
+ * MCP Settings Component
3
+ */
4
+
5
+ import { useState, useEffect, useCallback, useRef } from "react";
6
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
7
+ import { createTextAttributes, type TextareaRenderable } from "@opentui/core";
8
+ import type { McpServerConfig } from "../../../services/mcp/types.js";
9
+ import {
10
+ getAllMcpConfigs,
11
+ getMcpConfig,
12
+ setMcpConfig,
13
+ removeMcpConfig,
14
+ isMcpServerDisabled,
15
+ validateMcpConfig,
16
+ loadMcpConfigs,
17
+ } from "../../../services/mcp/config.js";
18
+ import { connectMcpServer, disconnectMcpServer, toggleMcpServer, getMcpConnections } from "../../../services/mcp/connection-manager.js";
19
+
20
+ interface MCPSettingsProps {
21
+ isActive: boolean;
22
+ onClose: () => void;
23
+ onMcpChange?: () => void;
24
+ }
25
+
26
+ type View = "list" | "add" | "edit";
27
+ type ServerType = "stdio" | "sse" | "http" | "ws";
28
+
29
+ export function MCPSettings({ isActive, onClose, onMcpChange }: MCPSettingsProps) {
30
+ const { width } = useTerminalDimensions();
31
+ const [view, setView] = useState<View>("list");
32
+ const [selectedIndex, setSelectedIndex] = useState(0);
33
+ const [servers, setServers] = useState<string[]>([]);
34
+ const [message, setMessage] = useState<string | null>(null);
35
+
36
+ // Form state
37
+ const [editName, setEditName] = useState("");
38
+ const [editType, setEditType] = useState<ServerType>("stdio");
39
+ const [editCommand, setEditCommand] = useState("");
40
+ const [editArgs, setEditArgs] = useState("");
41
+ const [editUrl, setEditUrl] = useState("");
42
+ const [editHeaders, setEditHeaders] = useState("");
43
+
44
+ // Textarea refs
45
+ const nameRef = useRef<TextareaRenderable>(null);
46
+ const commandRef = useRef<TextareaRenderable>(null);
47
+ const argsRef = useRef<TextareaRenderable>(null);
48
+ const urlRef = useRef<TextareaRenderable>(null);
49
+ const headersRef = useRef<TextareaRenderable>(null);
50
+
51
+ const panelWidth = Math.min(75, Math.max(50, Math.floor(width * 0.8)));
52
+
53
+ const refreshServers = useCallback(() => {
54
+ loadMcpConfigs();
55
+ const configs = getAllMcpConfigs();
56
+ setServers(Array.from(configs.keys()));
57
+ setSelectedIndex(0);
58
+ }, []);
59
+
60
+ useEffect(() => {
61
+ if (isActive) {
62
+ refreshServers();
63
+ setView("list");
64
+ setMessage(null);
65
+ }
66
+ }, [isActive, refreshServers]);
67
+
68
+ const getServerStatus = (name: string) => {
69
+ const connection = getMcpConnections().get(name);
70
+ if (connection) return connection.status;
71
+ if (isMcpServerDisabled(name)) return "disabled";
72
+ return "disconnected";
73
+ };
74
+
75
+ const getServerType = (name: string) => {
76
+ const config = getMcpConfig(name);
77
+ return config?.type ?? "unknown";
78
+ };
79
+
80
+ const handleConnect = async (name: string) => {
81
+ setMessage(`Connecting to ${name}...`);
82
+ const result = await connectMcpServer(name);
83
+ setMessage(result.success ? `Connected to ${name}` : `Failed: ${result.error}`);
84
+ refreshServers();
85
+ onMcpChange?.();
86
+ };
87
+
88
+ const handleToggle = async (name: string) => {
89
+ const connection = getMcpConnections().get(name);
90
+ const status = connection?.status ?? "disconnected";
91
+
92
+ if (status === "connected" || status === "failed") {
93
+ await toggleMcpServer(name, false);
94
+ setMessage(`Disabled ${name}`);
95
+ refreshServers();
96
+ onMcpChange?.();
97
+ } else {
98
+ await toggleMcpServer(name, true);
99
+ setMessage(`Enabled ${name}`);
100
+ refreshServers();
101
+ onMcpChange?.();
102
+ }
103
+ };
104
+
105
+ const handleDelete = (name: string) => {
106
+ removeMcpConfig(name);
107
+ disconnectMcpServer(name).catch(() => {});
108
+ setMessage(`Removed ${name}`);
109
+ refreshServers();
110
+ onMcpChange?.();
111
+ };
112
+
113
+ const handleAddNew = () => {
114
+ setEditName("");
115
+ setEditType("stdio");
116
+ setEditCommand("");
117
+ setEditArgs("");
118
+ setEditUrl("");
119
+ setEditHeaders("");
120
+ setView("add");
121
+ };
122
+
123
+ const handleEdit = (name: string) => {
124
+ const config = getMcpConfig(name);
125
+ if (!config) {
126
+ setMessage(`Config not found for ${name}`);
127
+ return;
128
+ }
129
+
130
+ setEditName(name);
131
+ if (config.type === "stdio" && config.command) {
132
+ setEditType("stdio");
133
+ setEditCommand(config.command);
134
+ setEditArgs(config.args?.join(" ") ?? "");
135
+ } else {
136
+ setEditType((config.type ?? "sse") as ServerType);
137
+ setEditUrl(config.url ?? "");
138
+ setEditHeaders(Object.entries(config.headers ?? {}).map(([k, v]) => `${k}:${v}`).join("\n"));
139
+ }
140
+ setView("edit");
141
+ };
142
+
143
+ const handleSave = () => {
144
+ let config: McpServerConfig;
145
+
146
+ if (editType === "stdio") {
147
+ if (!editCommand.trim()) { setMessage("Command is required for stdio servers"); return; }
148
+ config = { type: "stdio", command: editCommand.trim(), args: editArgs.trim() ? editArgs.trim().split(/\s+/) : [] };
149
+ } else {
150
+ if (!editUrl.trim()) { setMessage("URL is required for remote servers"); return; }
151
+ const headers: Record<string, string> = {};
152
+ for (const line of editHeaders.split("\n")) {
153
+ const [key, ...valueParts] = line.split(":");
154
+ if (key && valueParts.length > 0) headers[key.trim()] = valueParts.join(":").trim();
155
+ }
156
+ config = { type: editType, url: editUrl.trim(), headers: Object.keys(headers).length > 0 ? headers : undefined };
157
+ }
158
+
159
+ const nameToUse = editName.trim() || (editType === "stdio" ? editCommand.trim() : editUrl.trim());
160
+ if (!nameToUse) { setMessage("Could not determine server name"); return; }
161
+
162
+ const validation = validateMcpConfig(nameToUse, config);
163
+ if (!validation.valid) { setMessage(validation.errors?.join(", ") ?? "Invalid config"); return; }
164
+
165
+ if (view === "add" && getMcpConfig(nameToUse)) { setMessage(`Server '${nameToUse}' already exists`); return; }
166
+
167
+ setMcpConfig(nameToUse, config, "user");
168
+ setMessage(`Saved ${nameToUse} - connecting...`);
169
+ setView("list");
170
+ refreshServers();
171
+
172
+ // Auto-connect after saving
173
+ void (async () => {
174
+ const result = await connectMcpServer(nameToUse);
175
+ setMessage(result.success ? `Connected to ${nameToUse}` : `Saved but failed to connect: ${result.error}`);
176
+ refreshServers();
177
+ onMcpChange?.();
178
+ })();
179
+ };
180
+
181
+ const handleBack = () => { setView("list"); setMessage(null); };
182
+
183
+ useKeyboard((key) => {
184
+ if (!isActive) return;
185
+
186
+ if (key.name === "escape") {
187
+ if (view !== "list") { handleBack(); } else { onClose(); }
188
+ return;
189
+ }
190
+
191
+ if (view === "list") {
192
+ if (key.name === "up") { setSelectedIndex(i => Math.max(0, i - 1)); return; }
193
+ if (key.name === "down") { setSelectedIndex(i => Math.min(servers.length - 1, i + 1)); return; }
194
+ if (key.name === "return") {
195
+ const selectedServer = servers[selectedIndex];
196
+ if (selectedServer) { handleEdit(selectedServer); }
197
+ else if (servers.length === 0) { handleAddNew(); }
198
+ return;
199
+ }
200
+ if (key.name === "n" || key.name === "N") { handleAddNew(); return; }
201
+ }
202
+
203
+ if (view === "edit" || view === "add") {
204
+ if (key.name === "return") { handleSave(); return; }
205
+ }
206
+ });
207
+
208
+ if (!isActive) return null;
209
+
210
+ const statusColors: Record<string, string> = {
211
+ connected: "#00ff99", failed: "#ff6b6b", disabled: "#fbbf24", disconnected: "#6c6c7c", pending: "#00d9ff",
212
+ };
213
+
214
+ const TYPE_BUTTON_ENABLED_BG = "#ff6b9d";
215
+ const TYPE_BUTTON_DISABLED_BG = "#4a4a5a";
216
+
217
+ // List View
218
+ if (view === "list") {
219
+ return (
220
+ <box position="absolute" top={0} left={0} width="100%" height="100%" backgroundColor="#00000080" alignItems="center" justifyContent="center">
221
+ <box width={panelWidth} flexDirection="column" borderStyle="rounded" borderColor="#4a4a5a" backgroundColor="#1a1a2e" paddingX={2} paddingY={1}>
222
+ <text attributes={createTextAttributes({ bold: true })} fg="#ff79c6">MCP Servers</text>
223
+
224
+ <box flexDirection="column" flexGrow={1} overflow="hidden" marginTop={1}>
225
+ {servers.length === 0 ? (
226
+ <box height={1}><text fg="#6c6c7c">No MCP servers configured. Press N to add one.</text></box>
227
+ ) : servers.map((name, index) => {
228
+ const status = getServerStatus(name);
229
+ const type = getServerType(name);
230
+ const color = statusColors[status] ?? "#6c6c7c";
231
+ const isSelected = index === selectedIndex;
232
+
233
+ return (
234
+ <box
235
+ key={name}
236
+ height={1}
237
+ backgroundColor={isSelected ? "#44475a" : undefined}
238
+ paddingLeft={2}
239
+ flexDirection="row"
240
+ >
241
+ <text fg={color}>● </text>
242
+ <text width={20} fg={isSelected ? "#ff79c6" : "#f8f8f2"}>{name}</text>
243
+ <text width={8} fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>[{type}]</text>
244
+ <text fg={color}>{status}</text>
245
+ <box flexGrow={1} />
246
+ {(status === "disconnected" || status === "failed") ? (
247
+ <text fg="#2dd4bf" onMouseUp={() => void handleConnect(name)}>Connect</text>
248
+ ) : null}
249
+ <text fg="#fbbf24" marginLeft={1} onMouseUp={() => handleToggle(name)}>
250
+ {status === "disabled" ? "Enable" : "Disable"}
251
+ </text>
252
+ <text fg="#f43f5e" marginLeft={1} onMouseUp={() => handleDelete(name)}>Remove</text>
253
+ </box>
254
+ );
255
+ })}
256
+ </box>
257
+
258
+ <box marginTop={1} flexDirection="row" justifyContent="space-between">
259
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>[N] Add [Enter] Edit [Esc] Close</text>
260
+ {message ? <text fg="#fbbf24">{message}</text> : null}
261
+ </box>
262
+ </box>
263
+ </box>
264
+ );
265
+ }
266
+
267
+ // Edit/Add View
268
+ return (
269
+ <box position="absolute" top={0} left={0} width="100%" height="100%" backgroundColor="#00000080" alignItems="center" justifyContent="center">
270
+ <box width={panelWidth} flexDirection="column" borderStyle="rounded" borderColor="#4a4a5a" backgroundColor="#1a1a2e" paddingX={2} paddingY={1}>
271
+ <text attributes={createTextAttributes({ bold: true })} fg="#ff79c6">
272
+ {view === "add" ? "Add MCP Server" : `Edit: ${editName}`}
273
+ </text>
274
+
275
+ <box flexDirection="column" flexGrow={1} marginTop={1} gap={1}>
276
+ {view === "add" && (
277
+ <box height={1} flexDirection="row" alignItems="center">
278
+ <text width={12} fg="#f8f8f2">Name:</text>
279
+ <textarea
280
+ ref={nameRef}
281
+ initialValue={editName}
282
+ height={1}
283
+ width={30}
284
+ onContentChange={() => {
285
+ const text = nameRef.current?.editBuffer.getText() ?? "";
286
+ setEditName(text);
287
+ }}
288
+ />
289
+ </box>
290
+ )}
291
+
292
+ <box height={1} flexDirection="row" alignItems="center">
293
+ <text width={12} fg="#f8f8f2">Type:</text>
294
+ <box flexDirection="row" gap={1}>
295
+ {(["stdio", "sse", "http", "ws"] as ServerType[]).map((t) => (
296
+ <box
297
+ key={t}
298
+ paddingLeft={1}
299
+ paddingRight={1}
300
+ backgroundColor={editType === t ? TYPE_BUTTON_ENABLED_BG : TYPE_BUTTON_DISABLED_BG}
301
+ onMouseUp={() => setEditType(t)}
302
+ >
303
+ <text fg={editType === t ? "#ffffff" : "#a0a0b0"} attributes={createTextAttributes({ bold: true })}>
304
+ {t.toUpperCase()}
305
+ </text>
306
+ </box>
307
+ ))}
308
+ </box>
309
+ </box>
310
+
311
+ {editType === "stdio" ? (
312
+ <>
313
+ <box height={1} flexDirection="row" alignItems="center">
314
+ <text width={12} fg="#f8f8f2">Command:</text>
315
+ <textarea
316
+ ref={commandRef}
317
+ initialValue={editCommand}
318
+ height={1}
319
+ width={40}
320
+ onContentChange={() => {
321
+ const text = commandRef.current?.editBuffer.getText() ?? "";
322
+ setEditCommand(text);
323
+ }}
324
+ />
325
+ </box>
326
+ <box height={1} flexDirection="row" alignItems="center">
327
+ <text width={12} fg="#f8f8f2">Args:</text>
328
+ <textarea
329
+ ref={argsRef}
330
+ initialValue={editArgs}
331
+ height={1}
332
+ width={40}
333
+ onContentChange={() => {
334
+ const text = argsRef.current?.editBuffer.getText() ?? "";
335
+ setEditArgs(text);
336
+ }}
337
+ />
338
+ </box>
339
+ </>
340
+ ) : (
341
+ <>
342
+ <box height={1} flexDirection="row" alignItems="center">
343
+ <text width={12} fg="#f8f8f2">URL:</text>
344
+ <textarea
345
+ ref={urlRef}
346
+ initialValue={editUrl}
347
+ height={1}
348
+ width={50}
349
+ onContentChange={() => {
350
+ const text = urlRef.current?.editBuffer.getText() ?? "";
351
+ setEditUrl(text);
352
+ }}
353
+ />
354
+ </box>
355
+ <box height={2} flexDirection="row" alignItems="flex-start">
356
+ <text width={12} fg="#f8f8f2">Headers:</text>
357
+ <textarea
358
+ ref={headersRef}
359
+ initialValue={editHeaders}
360
+ height={2}
361
+ width={50}
362
+ onContentChange={() => {
363
+ const text = headersRef.current?.editBuffer.getText() ?? "";
364
+ setEditHeaders(text);
365
+ }}
366
+ />
367
+ </box>
368
+ </>
369
+ )}
370
+ </box>
371
+
372
+ <box marginTop={1} flexDirection="row" justifyContent="space-between" alignItems="center">
373
+ <box flexDirection="row" gap={2}>
374
+ <box paddingX={2} backgroundColor="#2dd4bf" onMouseUp={handleSave}>
375
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>[Save]</text>
376
+ </box>
377
+ <box paddingX={2} backgroundColor="#f43f5e" onMouseUp={handleBack}>
378
+ <text fg="white" attributes={createTextAttributes({ bold: true })}>[Cancel]</text>
379
+ </box>
380
+ </box>
381
+ {message ? <text fg="#fbbf24">{message}</text> : null}
382
+ </box>
383
+ </box>
384
+ </box>
385
+ );
386
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * MCP Components
3
+ *
4
+ * MCP-related UI components for Koi's TUI.
5
+ */
6
+
7
+ export { MCPSettings } from "./MCPSettings.js";
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Model Selection Modal
3
+ *
4
+ * Shows configured providers as section headers with their models as
5
+ * selectable items. Uses Pi SDK model registry.
6
+ *
7
+ * Supports Primary / Auxiliary model selection via tab switcher
8
+ * in the top-right corner. Press Tab to toggle between modes.
9
+ */
10
+
11
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
12
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
13
+ import { createTextAttributes } from "@opentui/core";
14
+ import type { MouseEvent } from "@opentui/core";
15
+ import {
16
+ getConfiguredProviders,
17
+ getCurrentModel,
18
+ getAuxiliaryModel,
19
+ setCurrentModel,
20
+ setAuxiliaryModel,
21
+ getProviderModels,
22
+ type ModelRef,
23
+ } from "../../config/settings.js";
24
+
25
+ interface ModelModalProps {
26
+ isActive: boolean;
27
+ onClose: () => void;
28
+ onSelectPrimary?: (model: ModelRef) => void;
29
+ onSelectAuxiliary?: (model: ModelRef) => void;
30
+ }
31
+
32
+ interface FlatItem {
33
+ type: "header" | "model";
34
+ provider?: string;
35
+ modelId?: string;
36
+ modelName?: string;
37
+ modelIndex?: number;
38
+ }
39
+
40
+ export function ModelModal({
41
+ isActive,
42
+ onClose,
43
+ onSelectPrimary,
44
+ onSelectAuxiliary,
45
+ }: ModelModalProps) {
46
+ const { height } = useTerminalDimensions();
47
+ const configuredProviders = getConfiguredProviders();
48
+ const [selectedModelIndex, setSelectedModelIndex] = useState(0);
49
+ const [activeTab, setActiveTab] = useState<"primary" | "auxiliary">("primary");
50
+ const scrollOffsetRef = useRef(0);
51
+
52
+ const listHeight = Math.min(12, Math.floor(height * 0.4));
53
+ const primaryModel = getCurrentModel();
54
+ const auxiliaryModel = getAuxiliaryModel();
55
+
56
+ // Reset when opened
57
+ useLayoutEffect(() => {
58
+ if (isActive) {
59
+ setSelectedModelIndex(0);
60
+ setActiveTab("primary");
61
+ scrollOffsetRef.current = 0;
62
+ }
63
+ }, [isActive]);
64
+
65
+ // Build flat list of providers + models
66
+ const { flatItems, modelCount } = useMemo(() => {
67
+ const items: FlatItem[] = [];
68
+ let mIdx = 0;
69
+ for (const provider of configuredProviders) {
70
+ items.push({ type: "header", provider });
71
+ const models = getProviderModels(provider);
72
+ for (const model of models) {
73
+ items.push({
74
+ type: "model",
75
+ provider,
76
+ modelId: model.id,
77
+ modelName: model.name || model.id,
78
+ modelIndex: mIdx,
79
+ });
80
+ mIdx++;
81
+ }
82
+ }
83
+ return { flatItems: items, modelCount: mIdx };
84
+ }, [configuredProviders]);
85
+
86
+ // Clamp selected index
87
+ useEffect(() => {
88
+ if (selectedModelIndex >= modelCount && modelCount > 0) {
89
+ setSelectedModelIndex(modelCount - 1);
90
+ }
91
+ }, [modelCount, selectedModelIndex]);
92
+
93
+ // Effective scroll offset
94
+ const effectiveScrollOffset = scrollOffsetRef.current;
95
+
96
+ useKeyboard((key) => {
97
+ if (!isActive) return;
98
+
99
+ if (key.name === "tab" || key.name === "TAB") {
100
+ setActiveTab((prev) => (prev === "primary" ? "auxiliary" : "primary"));
101
+ return;
102
+ }
103
+
104
+ if (key.name === "escape") {
105
+ onClose();
106
+ return;
107
+ }
108
+
109
+ // Navigation with direct scroll calculation
110
+ if (key.name === "up" || key.name === "down") {
111
+ const newIndex = key.name === "up"
112
+ ? Math.max(0, selectedModelIndex - 1)
113
+ : Math.min(modelCount - 1, selectedModelIndex + 1);
114
+
115
+ const newFlatIndex = flatItems.findIndex(
116
+ (i) => i.type === "model" && i.modelIndex === newIndex
117
+ );
118
+
119
+ let newScrollOffset = scrollOffsetRef.current;
120
+ if (newFlatIndex !== -1) {
121
+ if (newFlatIndex < scrollOffsetRef.current) {
122
+ newScrollOffset = newFlatIndex;
123
+ } else if (newFlatIndex > scrollOffsetRef.current + listHeight - 1) {
124
+ newScrollOffset = newFlatIndex - listHeight + 1;
125
+ }
126
+ }
127
+
128
+ scrollOffsetRef.current = newScrollOffset;
129
+ setSelectedModelIndex(newIndex);
130
+ return;
131
+ }
132
+ if (key.name === "return") {
133
+ const selectedItem = flatItems.find(
134
+ (i) => i.type === "model" && i.modelIndex === selectedModelIndex
135
+ );
136
+ if (selectedItem?.provider && selectedItem.modelId) {
137
+ const ref = {
138
+ provider: selectedItem.provider,
139
+ modelId: selectedItem.modelId,
140
+ };
141
+ if (activeTab === "primary") {
142
+ setCurrentModel(ref);
143
+ onSelectPrimary?.(ref);
144
+ } else {
145
+ setAuxiliaryModel(ref);
146
+ onSelectAuxiliary?.(ref);
147
+ }
148
+ onClose();
149
+ }
150
+ return;
151
+ }
152
+ });
153
+
154
+ if (!isActive) return null;
155
+
156
+ const visibleItems = flatItems.slice(effectiveScrollOffset, effectiveScrollOffset + listHeight);
157
+
158
+ const isCurrent = (provider?: string, modelId?: string) => {
159
+ const target = activeTab === "primary" ? primaryModel : auxiliaryModel;
160
+ return target?.provider === provider && target?.modelId === modelId;
161
+ };
162
+
163
+ const handleMouseSelect = (
164
+ e: MouseEvent,
165
+ provider?: string,
166
+ modelId?: string
167
+ ) => {
168
+ e.stopPropagation();
169
+ if (provider && modelId) {
170
+ const ref = { provider, modelId };
171
+ if (activeTab === "primary") {
172
+ setCurrentModel(ref);
173
+ onSelectPrimary?.(ref);
174
+ } else {
175
+ setAuxiliaryModel(ref);
176
+ onSelectAuxiliary?.(ref);
177
+ }
178
+ onClose();
179
+ }
180
+ };
181
+
182
+ return (
183
+ <box
184
+ position="absolute"
185
+ top={0}
186
+ left={0}
187
+ width="100%"
188
+ height="100%"
189
+ backgroundColor="#00000080"
190
+ alignItems="center"
191
+ justifyContent="center"
192
+ >
193
+ <box
194
+ width={60}
195
+ flexDirection="column"
196
+ borderStyle="rounded"
197
+ borderColor="#4a4a5a"
198
+ backgroundColor="#1a1a2e"
199
+ paddingX={2}
200
+ paddingY={1}
201
+ >
202
+ {/* Header row with tabs */}
203
+ <box flexDirection="row" justifyContent="space-between">
204
+ <text attributes={createTextAttributes({ bold: true })} fg="#ff79c6">
205
+ Select Model
206
+ </text>
207
+
208
+ <box flexDirection="row" gap={1}>
209
+ <box
210
+ paddingX={1}
211
+ backgroundColor={
212
+ activeTab === "primary" ? "#44475a" : undefined
213
+ }
214
+ onMouseUp={(e: MouseEvent) => {
215
+ e.stopPropagation();
216
+ setActiveTab("primary");
217
+ }}
218
+ >
219
+ <text
220
+ fg={activeTab === "primary" ? "#ff79c6" : "#6c6c7c"}
221
+ attributes={createTextAttributes({ bold: activeTab === "primary" })}
222
+ >
223
+ Primary
224
+ </text>
225
+ </box>
226
+ <box
227
+ paddingX={1}
228
+ backgroundColor={
229
+ activeTab === "auxiliary" ? "#44475a" : undefined
230
+ }
231
+ onMouseUp={(e: MouseEvent) => {
232
+ e.stopPropagation();
233
+ setActiveTab("auxiliary");
234
+ }}
235
+ >
236
+ <text
237
+ fg={activeTab === "auxiliary" ? "#ff79c6" : "#6c6c7c"}
238
+ attributes={createTextAttributes({ bold: activeTab === "auxiliary" })}
239
+ >
240
+ Auxiliary
241
+ </text>
242
+ </box>
243
+ </box>
244
+ </box>
245
+
246
+ <box
247
+ height={listHeight}
248
+ flexDirection="column"
249
+ overflow="hidden"
250
+ marginTop={1}
251
+ >
252
+ {configuredProviders.length === 0 && (
253
+ <box height={1}>
254
+ <text fg="#6c6c7c">
255
+ No providers configured. Use /connect to add one.
256
+ </text>
257
+ </box>
258
+ )}
259
+ {visibleItems.map((item, idx) => {
260
+ const flatIndex = effectiveScrollOffset + idx;
261
+ if (item.type === "header") {
262
+ return (
263
+ <box
264
+ key={`h-${item.provider}-${flatIndex}`}
265
+ height={1}
266
+ marginTop={1}
267
+ >
268
+ <text
269
+ fg="#ff79c6"
270
+ attributes={createTextAttributes({ bold: true })}
271
+ >
272
+ {item.provider}
273
+ </text>
274
+ </box>
275
+ );
276
+ }
277
+ const isSelected = item.modelIndex === selectedModelIndex;
278
+ const current = isCurrent(item.provider, item.modelId);
279
+ return (
280
+ <box
281
+ key={`m-${item.modelId}-${flatIndex}`}
282
+ height={1}
283
+ backgroundColor={isSelected ? "#44475a" : undefined}
284
+ paddingLeft={2}
285
+ flexDirection="row"
286
+ onMouseUp={(e: MouseEvent) =>
287
+ handleMouseSelect(e, item.provider, item.modelId)
288
+ }
289
+ >
290
+ <text fg={isSelected ? "#ff79c6" : "#f8f8f2"}>
291
+ {current ? "● " : " "}
292
+ {item.modelName}
293
+ </text>
294
+ </box>
295
+ );
296
+ })}
297
+ </box>
298
+
299
+ <box marginTop={1} flexDirection="row" justifyContent="space-between">
300
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
301
+ ↑↓ Navigate Enter Select Esc Cancel
302
+ </text>
303
+ <text fg="#6c6c7c" attributes={createTextAttributes({ dim: true })}>
304
+ Tab Switch
305
+ </text>
306
+ </box>
307
+ </box>
308
+ </box>
309
+ );
310
+ }