acmecode 1.0.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 (131) hide show
  1. package/.acmecode/config.json +6 -0
  2. package/README.md +124 -0
  3. package/dist/agent/index.js +161 -0
  4. package/dist/cli/bin/acmecode.js +3 -0
  5. package/dist/cli/package.json +25 -0
  6. package/dist/cli/src/index.d.ts +1 -0
  7. package/dist/cli/src/index.js +53 -0
  8. package/dist/config/index.js +92 -0
  9. package/dist/context/index.js +30 -0
  10. package/dist/core/src/agent/index.d.ts +52 -0
  11. package/dist/core/src/agent/index.js +476 -0
  12. package/dist/core/src/config/index.d.ts +83 -0
  13. package/dist/core/src/config/index.js +318 -0
  14. package/dist/core/src/context/index.d.ts +1 -0
  15. package/dist/core/src/context/index.js +30 -0
  16. package/dist/core/src/llm/provider.d.ts +27 -0
  17. package/dist/core/src/llm/provider.js +202 -0
  18. package/dist/core/src/llm/vision.d.ts +7 -0
  19. package/dist/core/src/llm/vision.js +37 -0
  20. package/dist/core/src/mcp/index.d.ts +10 -0
  21. package/dist/core/src/mcp/index.js +84 -0
  22. package/dist/core/src/prompt/anthropic.d.ts +1 -0
  23. package/dist/core/src/prompt/anthropic.js +32 -0
  24. package/dist/core/src/prompt/architect.d.ts +1 -0
  25. package/dist/core/src/prompt/architect.js +17 -0
  26. package/dist/core/src/prompt/autopilot.d.ts +1 -0
  27. package/dist/core/src/prompt/autopilot.js +18 -0
  28. package/dist/core/src/prompt/beast.d.ts +1 -0
  29. package/dist/core/src/prompt/beast.js +83 -0
  30. package/dist/core/src/prompt/gemini.d.ts +1 -0
  31. package/dist/core/src/prompt/gemini.js +45 -0
  32. package/dist/core/src/prompt/index.d.ts +18 -0
  33. package/dist/core/src/prompt/index.js +239 -0
  34. package/dist/core/src/prompt/zen.d.ts +1 -0
  35. package/dist/core/src/prompt/zen.js +13 -0
  36. package/dist/core/src/session/index.d.ts +18 -0
  37. package/dist/core/src/session/index.js +97 -0
  38. package/dist/core/src/skills/index.d.ts +6 -0
  39. package/dist/core/src/skills/index.js +72 -0
  40. package/dist/core/src/tools/batch.d.ts +2 -0
  41. package/dist/core/src/tools/batch.js +65 -0
  42. package/dist/core/src/tools/browser.d.ts +7 -0
  43. package/dist/core/src/tools/browser.js +86 -0
  44. package/dist/core/src/tools/edit.d.ts +11 -0
  45. package/dist/core/src/tools/edit.js +312 -0
  46. package/dist/core/src/tools/index.d.ts +13 -0
  47. package/dist/core/src/tools/index.js +980 -0
  48. package/dist/core/src/tools/lsp-client.d.ts +11 -0
  49. package/dist/core/src/tools/lsp-client.js +224 -0
  50. package/dist/index.js +41 -0
  51. package/dist/llm/provider.js +34 -0
  52. package/dist/mcp/index.js +84 -0
  53. package/dist/session/index.js +74 -0
  54. package/dist/skills/index.js +32 -0
  55. package/dist/tools/index.js +96 -0
  56. package/dist/tui/App.js +297 -0
  57. package/dist/tui/Spinner.js +16 -0
  58. package/dist/tui/TextInput.js +98 -0
  59. package/dist/tui/src/App.d.ts +11 -0
  60. package/dist/tui/src/App.js +1211 -0
  61. package/dist/tui/src/CatLogo.d.ts +10 -0
  62. package/dist/tui/src/CatLogo.js +99 -0
  63. package/dist/tui/src/OptionList.d.ts +15 -0
  64. package/dist/tui/src/OptionList.js +60 -0
  65. package/dist/tui/src/Spinner.d.ts +7 -0
  66. package/dist/tui/src/Spinner.js +18 -0
  67. package/dist/tui/src/TextInput.d.ts +28 -0
  68. package/dist/tui/src/TextInput.js +139 -0
  69. package/dist/tui/src/Tips.d.ts +2 -0
  70. package/dist/tui/src/Tips.js +62 -0
  71. package/dist/tui/src/Toast.d.ts +19 -0
  72. package/dist/tui/src/Toast.js +39 -0
  73. package/dist/tui/src/TodoItem.d.ts +7 -0
  74. package/dist/tui/src/TodoItem.js +21 -0
  75. package/dist/tui/src/i18n.d.ts +172 -0
  76. package/dist/tui/src/i18n.js +189 -0
  77. package/dist/tui/src/markdown.d.ts +6 -0
  78. package/dist/tui/src/markdown.js +356 -0
  79. package/dist/tui/src/theme.d.ts +31 -0
  80. package/dist/tui/src/theme.js +239 -0
  81. package/output.txt +0 -0
  82. package/package.json +44 -0
  83. package/packages/cli/package.json +25 -0
  84. package/packages/cli/src/index.ts +59 -0
  85. package/packages/cli/tsconfig.json +26 -0
  86. package/packages/core/package.json +39 -0
  87. package/packages/core/src/agent/index.ts +588 -0
  88. package/packages/core/src/config/index.ts +383 -0
  89. package/packages/core/src/context/index.ts +34 -0
  90. package/packages/core/src/llm/provider.ts +237 -0
  91. package/packages/core/src/llm/vision.ts +43 -0
  92. package/packages/core/src/mcp/index.ts +110 -0
  93. package/packages/core/src/prompt/anthropic.ts +32 -0
  94. package/packages/core/src/prompt/architect.ts +17 -0
  95. package/packages/core/src/prompt/autopilot.ts +18 -0
  96. package/packages/core/src/prompt/beast.ts +83 -0
  97. package/packages/core/src/prompt/gemini.ts +45 -0
  98. package/packages/core/src/prompt/index.ts +267 -0
  99. package/packages/core/src/prompt/zen.ts +13 -0
  100. package/packages/core/src/session/index.ts +129 -0
  101. package/packages/core/src/skills/index.ts +86 -0
  102. package/packages/core/src/tools/batch.ts +73 -0
  103. package/packages/core/src/tools/browser.ts +95 -0
  104. package/packages/core/src/tools/edit.ts +317 -0
  105. package/packages/core/src/tools/index.ts +1112 -0
  106. package/packages/core/src/tools/lsp-client.ts +303 -0
  107. package/packages/core/tsconfig.json +19 -0
  108. package/packages/tui/package.json +24 -0
  109. package/packages/tui/src/App.tsx +1702 -0
  110. package/packages/tui/src/CatLogo.tsx +134 -0
  111. package/packages/tui/src/OptionList.tsx +95 -0
  112. package/packages/tui/src/Spinner.tsx +28 -0
  113. package/packages/tui/src/TextInput.tsx +202 -0
  114. package/packages/tui/src/Tips.tsx +64 -0
  115. package/packages/tui/src/Toast.tsx +60 -0
  116. package/packages/tui/src/TodoItem.tsx +29 -0
  117. package/packages/tui/src/i18n.ts +203 -0
  118. package/packages/tui/src/markdown.ts +403 -0
  119. package/packages/tui/src/theme.ts +287 -0
  120. package/packages/tui/tsconfig.json +24 -0
  121. package/tsconfig.json +18 -0
  122. package/vscode-acmecode/.vscodeignore +11 -0
  123. package/vscode-acmecode/README.md +57 -0
  124. package/vscode-acmecode/esbuild.js +46 -0
  125. package/vscode-acmecode/images/button-dark.svg +5 -0
  126. package/vscode-acmecode/images/button-light.svg +5 -0
  127. package/vscode-acmecode/images/icon.png +1 -0
  128. package/vscode-acmecode/package-lock.json +490 -0
  129. package/vscode-acmecode/package.json +87 -0
  130. package/vscode-acmecode/src/extension.ts +128 -0
  131. package/vscode-acmecode/tsconfig.json +16 -0
@@ -0,0 +1,1702 @@
1
+ import React, { useState, useRef, useEffect } from "react";
2
+ import { Box, Text, Static, useStdout } from "ink";
3
+ import TextInput, { type PastedPart } from "./TextInput.js";
4
+ import Spinner from "./Spinner.js";
5
+ import Tips from "./Tips.js";
6
+ import OptionList from "./OptionList.js";
7
+ import Toast, { useToast } from "./Toast.js";
8
+ import { renderMarkdown } from "./markdown.js";
9
+ import { runAgent, AgentEvent } from "@acmecode/core/agent/index.js";
10
+ import { getSystemPrompt } from "@acmecode/core/prompt/index.js";
11
+ import {
12
+ ProviderType,
13
+ getContextWindow,
14
+ estimateTokens,
15
+ fetchModelsDevCache,
16
+ getAvailableProviders,
17
+ getProviders,
18
+ } from "@acmecode/core/llm/provider.js";
19
+ import { loadSession, saveMessages } from "@acmecode/core/session/index.js";
20
+ import { loadSkills, Skill } from "@acmecode/core/skills/index.js";
21
+ import { getProjectContext } from "@acmecode/core/context/index.js";
22
+ import {
23
+ loadModelConfig,
24
+ loadLangConfig,
25
+ loadThemeConfig,
26
+ loadReasoningLevel,
27
+ loadAgentModeConfig,
28
+ saveProjectModelConfig,
29
+ saveAgentModeConfig,
30
+ saveGlobalModelConfig,
31
+ saveGlobalLangConfig,
32
+ saveGlobalThemeConfig,
33
+ saveGlobalReasoningLevel,
34
+ saveProviderConfig,
35
+ loadCustomProviders,
36
+ getProviderKey,
37
+ getProviderBaseUrl,
38
+ getProviderProtocol,
39
+ ReasoningLevel,
40
+ AgentMode,
41
+ } from "@acmecode/core/config/index.js";
42
+ import {
43
+ theme,
44
+ formatToolIcon,
45
+ formatToolName,
46
+ formatBorder,
47
+ setTheme,
48
+ listThemes,
49
+ getThemeName,
50
+ onThemeChange,
51
+ } from "./theme.js";
52
+ import { t, setLang, listLangs, getLang, Language } from "./i18n.js";
53
+
54
+ export interface ChatMessage {
55
+ role: "user" | "assistant" | "system" | "tool";
56
+ content: any;
57
+ }
58
+
59
+ interface AppProps {
60
+ sessionId: string;
61
+ onExit: () => void;
62
+ }
63
+
64
+ interface DisplayItem {
65
+ id: string;
66
+ role: "user" | "assistant" | "system" | "tool";
67
+ text: string;
68
+ rendered?: React.ReactNode; // Pre-rendered content for Static items
69
+ }
70
+
71
+ let cachedModels: { id: string }[] = [];
72
+ let itemCounter = 0;
73
+
74
+ function windowText(
75
+ text: string,
76
+ maxLines: number,
77
+ ): { content: string; truncated: boolean } {
78
+ const lines = text.split("\n");
79
+ if (lines.length <= maxLines) return { content: text, truncated: false };
80
+
81
+ let content = lines.slice(-maxLines).join("\n");
82
+
83
+ // Check for open code blocks to maintain Markdown integrity
84
+ const codeBlockCount = (content.match(/^```/gm) || []).length;
85
+ if (codeBlockCount % 2 !== 0) {
86
+ // Find if the original text had an opening block that was truncated
87
+ const originalCodeBlocks = (text.match(/^```/gm) || []).length;
88
+ if (originalCodeBlocks % 2 === 0) {
89
+ // We are inside a block that started earlier, prepend a fake opener to satisfy parser
90
+ content = "```\n" + content;
91
+ } else {
92
+ // The block is genuinely open and should be closed for safety
93
+ content = content + "\n```";
94
+ }
95
+ }
96
+
97
+ return { content, truncated: true };
98
+ }
99
+
100
+ // Tools that only read data — show a brief call line, suppress result output
101
+ const READ_ONLY_TOOLS = new Set([
102
+ "read_file",
103
+ "list_dir",
104
+ "grep_search",
105
+ "codesearch",
106
+ "webfetch",
107
+ "websearch",
108
+ "lsp",
109
+ "batch",
110
+ ]);
111
+
112
+ function isReadOnlyTool(name: string): boolean {
113
+ return READ_ONLY_TOOLS.has(name);
114
+ }
115
+
116
+ function truncateToolResult(result: string, maxLen = 500): string {
117
+ // If result contains a diff block, show it fully — that's the whole point
118
+ if (result.includes("```diff") || result.includes("```patch")) return result;
119
+ if (result.length <= maxLen) return result;
120
+ return result.slice(0, maxLen) + `\n${theme.muted("... (truncated)")}`;
121
+ }
122
+
123
+ function extractToolDetail(args: any): string {
124
+ let argsObj = args || {};
125
+ if (typeof argsObj === "string") {
126
+ try {
127
+ argsObj = JSON.parse(argsObj);
128
+ } catch {
129
+ return argsObj ? ` ${argsObj}` : "";
130
+ }
131
+ }
132
+
133
+ // Primary argument — shown inline after tool name
134
+ const primaryKeys = [
135
+ "command",
136
+ "commandLine",
137
+ "CommandLine",
138
+ "cmd",
139
+ "filePath",
140
+ "path",
141
+ "url",
142
+ "query",
143
+ "pattern",
144
+ "prompt",
145
+ ];
146
+ let primary = "";
147
+ const extras: string[] = [];
148
+
149
+ // Special handling for batch tool - check for array format first
150
+ if (Array.isArray(argsObj)) {
151
+ return `${argsObj.length}个工具调用`;
152
+ }
153
+
154
+ // Special handling for batch tool
155
+ if (argsObj.tools || argsObj.calls) {
156
+ let callsArray: any[] = [];
157
+ const toolsData = argsObj.tools || argsObj.calls;
158
+ if (typeof toolsData === "string") {
159
+ try {
160
+ callsArray = JSON.parse(toolsData);
161
+ } catch {
162
+ callsArray = [];
163
+ }
164
+ } else if (Array.isArray(toolsData)) {
165
+ callsArray = toolsData;
166
+ }
167
+ if (callsArray.length > 0) {
168
+ return `${callsArray.length}个工具调用`;
169
+ }
170
+ }
171
+
172
+ for (const key of primaryKeys) {
173
+ if (argsObj[key]) {
174
+ primary = String(argsObj[key]);
175
+ break;
176
+ }
177
+ }
178
+
179
+ // Secondary arguments — shown in [key=val, ...] brackets
180
+ const secondaryKeys = [
181
+ "limit",
182
+ "offset",
183
+ "recursive",
184
+ "replaceAll",
185
+ "include",
186
+ "exclude",
187
+ ];
188
+ for (const key of secondaryKeys) {
189
+ if (argsObj[key] !== undefined && argsObj[key] !== null) {
190
+ extras.push(`${key}=${argsObj[key]}`);
191
+ }
192
+ }
193
+
194
+ // Fallback: if no primary found, use first key's value
195
+ if (!primary && Object.keys(argsObj).length > 0) {
196
+ const firstKey = Object.keys(argsObj)[0]!;
197
+ const firstVal = argsObj[firstKey];
198
+ if (typeof firstVal === "string" || typeof firstVal === "number") {
199
+ primary = String(firstVal);
200
+ }
201
+ }
202
+
203
+ if (!primary && extras.length === 0) return "";
204
+
205
+ const extrasStr = extras.length > 0 ? ` [${extras.join(", ")}]` : "";
206
+ return primary ? ` ${primary}${extrasStr}` : extrasStr;
207
+ }
208
+
209
+ function extractContent(msg: any): string {
210
+ if (typeof msg.content === "string") return msg.content;
211
+ if (Array.isArray(msg.content)) {
212
+ return msg.content
213
+ .map((c: any) => {
214
+ if (c.type === "text") return c.text;
215
+ if (c.type === "tool-call") {
216
+ const icon = formatToolIcon(c.toolName);
217
+ const name = formatToolName(c.toolName);
218
+ const detail = extractToolDetail(c.args);
219
+ return `${icon} ${theme.muted(name)}${detail ? " " + theme.muted("→") + " " + theme.muted(detail.trim()) : ""}\n`;
220
+ }
221
+ if (c.type === "tool-result") {
222
+ const r =
223
+ typeof c.result === "string"
224
+ ? c.result
225
+ : JSON.stringify(c.result, null, 2);
226
+ return `${theme.success("✔")} ${theme.muted(truncateToolResult(r))}\n`;
227
+ }
228
+ return "";
229
+ })
230
+ .join("");
231
+ }
232
+ return "";
233
+ }
234
+
235
+ export default function App({
236
+ sessionId,
237
+ onExit,
238
+ }: AppProps): React.ReactElement | null {
239
+ const [themeName, setThemeName] = useState(() => getThemeName());
240
+
241
+ useEffect(() => {
242
+ onThemeChange((name) => {
243
+ setThemeName(name);
244
+ });
245
+ }, []);
246
+
247
+ const [displayItems, setDisplayItems] = useState<DisplayItem[]>(() => {
248
+ try {
249
+ const saved = loadSession(sessionId) || [];
250
+ return saved
251
+ .filter((m: any) => m.role !== "tool" && m.role !== "system")
252
+ .map((m: any) => {
253
+ const text = extractContent(m);
254
+ if (!text && m.role === "assistant") return null;
255
+ return { id: String(itemCounter++), role: m.role, text };
256
+ })
257
+ .filter(Boolean) as DisplayItem[];
258
+ } catch (e) {
259
+ return [];
260
+ }
261
+ });
262
+
263
+ const pendingToolCallsRef = useRef<
264
+ Map<
265
+ string,
266
+ {
267
+ icon: string;
268
+ humanName: string;
269
+ detail: string;
270
+ timestamp: number;
271
+ }
272
+ >
273
+ >(new Map());
274
+
275
+ // Refresh pre-rendered Markdown when theme changes
276
+ useEffect(() => {
277
+ setDisplayItems((prev) =>
278
+ prev.map((item) => {
279
+ if (item.role === "assistant" || item.role === "user") {
280
+ return {
281
+ ...item,
282
+ rendered: renderMarkdown(item.text),
283
+ };
284
+ }
285
+ return item;
286
+ }),
287
+ );
288
+ }, [themeName]);
289
+
290
+ const [currentInput, setCurrentInput] = useState("");
291
+ // Stores collapsed paste parts: label → full text mapping
292
+ const [pastedParts, setPastedParts] = useState<Map<string, string>>(
293
+ new Map(),
294
+ );
295
+ // Clear paste tokens whenever the input is fully cleared
296
+ useEffect(() => {
297
+ if (currentInput === "") setPastedParts(new Map());
298
+ }, [currentInput]);
299
+ const [isGenerating, setIsGenerating] = useState(false);
300
+ const [streamingText, setStreamingText] = useState("");
301
+ const [statusText, setStatusText] = useState("");
302
+ const [activeSkill, setActiveSkill] = useState<Skill | null>(null);
303
+ const [tempProvider, setTempProvider] = useState<ProviderType | null>(null);
304
+ useEffect(() => {
305
+ // Fetch dynamic model limits from models.dev in the background
306
+ fetchModelsDevCache().catch(() => {});
307
+ }, []);
308
+
309
+ // Support aborting generation
310
+ const abortRef = useRef<AbortController | null>(null);
311
+ const [provider, setProvider] = useState<string>(
312
+ () => loadModelConfig().provider,
313
+ );
314
+ const [modelName, setModelName] = useState<string>(
315
+ () => loadModelConfig().model,
316
+ );
317
+ const [visionProvider, setVisionProvider] = useState<string | undefined>(
318
+ () => loadModelConfig().visionProvider,
319
+ );
320
+ const [visionModel, setVisionModel] = useState<string | undefined>(
321
+ () => loadModelConfig().visionModel,
322
+ );
323
+ const [reasoningLevel, setReasoningLevel] =
324
+ useState<ReasoningLevel>(loadReasoningLevel());
325
+ const [agentMode, setAgentMode] = useState<AgentMode>(
326
+ () => loadAgentModeConfig().mode,
327
+ );
328
+ const [activePlanFile, setActivePlanFile] = useState<string | undefined>(
329
+ () => loadAgentModeConfig().planFile,
330
+ );
331
+
332
+ const [activeSelection, setActiveSelection] = useState<{
333
+ type:
334
+ | "model"
335
+ | "theme"
336
+ | "lang"
337
+ | "reason"
338
+ | "mode"
339
+ | "provider"
340
+ | "config_provider"
341
+ | "config_protocol"
342
+ | "vision_provider"
343
+ | "vision_model";
344
+ options: { id: string; label: string }[];
345
+ title: string;
346
+ currentId?: string;
347
+ } | null>(null);
348
+
349
+ const [setupStep, setSetupStep] = useState<
350
+ "none" | "naming" | "entering_key" | "entering_url" | "entering_protocol"
351
+ >("none");
352
+ const [setupContext, setSetupContext] = useState<{
353
+ providerId: string;
354
+ isCustom: boolean;
355
+ name?: string;
356
+ protocol?: string;
357
+ apiKey?: string;
358
+ baseUrl?: string;
359
+ } | null>(null);
360
+
361
+ // Exact length of the system prompt (including hidden specs/instructions)
362
+ const [activePromptLength, setActivePromptLength] = useState(0);
363
+
364
+ // List of available slash commands for autocomplete
365
+ const slashCommands = [
366
+ { id: "/mode", label: t("command.mode") },
367
+ { id: "/model", label: t("command.model") },
368
+ { id: "/vision", label: "Configure Vision Model (AcmeVision)" },
369
+ { id: "/config", label: t("command.config") },
370
+ { id: "/reason", label: t("command.reason") },
371
+ { id: "/skill", label: t("command.skill") },
372
+ { id: "/theme", label: t("command.theme") },
373
+ { id: "/lang", label: t("command.lang") },
374
+ { id: "/clear", label: t("command.clear") },
375
+ { id: "/exit", label: t("command.exit") },
376
+ ];
377
+
378
+ // Autocomplete state
379
+ const [autocompleteIndex, setAutocompleteIndex] = useState(0);
380
+ const visibleCommands =
381
+ currentInput.startsWith("/") && !currentInput.includes(" ")
382
+ ? slashCommands.filter((cmd) =>
383
+ cmd.id.startsWith(currentInput.toLowerCase()),
384
+ )
385
+ : [];
386
+
387
+ const messagesRef = useRef<any[]>(
388
+ (() => {
389
+ try {
390
+ return loadSession(sessionId) || [];
391
+ } catch {
392
+ return [];
393
+ }
394
+ })(),
395
+ );
396
+
397
+ const addDisplayItem = (
398
+ role: "user" | "assistant" | "tool",
399
+ text: string,
400
+ ) => {
401
+ // Pre-render Markdown for assistant/user messages to prevent re-parsing in Static
402
+ const rendered =
403
+ role === "assistant" || role === "user"
404
+ ? renderMarkdown(text)
405
+ : undefined;
406
+ const item: DisplayItem = {
407
+ id: String(itemCounter++),
408
+ role,
409
+ text,
410
+ rendered,
411
+ };
412
+ setDisplayItems((prev) => [...prev, item]);
413
+ return item;
414
+ };
415
+
416
+ const { toast, show: showToast, dismiss: dismissToast } = useToast();
417
+
418
+ const handleSubmit = async (value: string) => {
419
+ // Expand any collapsed paste tokens before processing
420
+ let expandedValue = value;
421
+ if (pastedParts.size > 0) {
422
+ for (const [label, text] of pastedParts.entries()) {
423
+ expandedValue = expandedValue.split(label).join(text);
424
+ }
425
+ setPastedParts(new Map());
426
+ }
427
+ const input = expandedValue.trim();
428
+
429
+ // If the autocomplete menu is open and the user presses Enter, autocomplete instead of submitting
430
+ // EXCEPT if the input is already an exact match of a command
431
+ if (visibleCommands.length > 0) {
432
+ const selected =
433
+ visibleCommands[
434
+ Math.min(autocompleteIndex, visibleCommands.length - 1)
435
+ ];
436
+ if (selected && selected.id !== input.toLowerCase()) {
437
+ setCurrentInput(selected.id + " ");
438
+ setAutocompleteIndex(0);
439
+ return;
440
+ }
441
+ }
442
+ if (input === "/exit" || input === "/quit") {
443
+ onExit();
444
+ return;
445
+ }
446
+
447
+ if (input === "/clear") {
448
+ setDisplayItems([]);
449
+ setCurrentInput("");
450
+ setActiveSkill(null);
451
+ messagesRef.current = [];
452
+ showToast("info", t("command.clear"));
453
+ return;
454
+ }
455
+
456
+ if (input === "/cancel" && isGenerating) {
457
+ if (abortRef.current) {
458
+ abortRef.current.abort();
459
+ }
460
+ setCurrentInput("");
461
+ return;
462
+ }
463
+
464
+ const [cmdRaw, ...argsParts] = input.split(/\s+/);
465
+ const cmd = cmdRaw.toLowerCase();
466
+ const args = argsParts.join(" ").trim();
467
+
468
+ if (cmd === "/skill") {
469
+ setCurrentInput("");
470
+ if (!args) {
471
+ const skills = await loadSkills();
472
+ addDisplayItem(
473
+ "assistant",
474
+ `${t("command.skill")}:\n${skills.length ? skills.map((s) => `- ${theme.primary(s.name)}: ${s.description}`).join("\n") : t("status.no_skills")}`,
475
+ );
476
+ return;
477
+ }
478
+ const skills = await loadSkills();
479
+ const skill = skills.find((s: any) => s.name === args);
480
+ if (skill) {
481
+ setActiveSkill(skill);
482
+ addDisplayItem(
483
+ "assistant",
484
+ t("status.skill_activated", { name: theme.primary(skill.name) }),
485
+ );
486
+ } else {
487
+ addDisplayItem(
488
+ "assistant",
489
+ t("status.skill_not_found", { name: args }),
490
+ );
491
+ }
492
+ return;
493
+ }
494
+
495
+ if (cmd === "/lang") {
496
+ setCurrentInput("");
497
+ const all = listLangs();
498
+ if (!args) {
499
+ setActiveSelection({
500
+ type: "lang",
501
+ options: all.map((l) => ({ id: l, label: l })),
502
+ title: t("status.available_langs"),
503
+ currentId: getLang(),
504
+ });
505
+ } else if (setLang(args as Language)) {
506
+ showToast(
507
+ "success",
508
+ t("status.switched_lang", { name: args }),
509
+ t("command.lang"),
510
+ );
511
+ addDisplayItem(
512
+ "assistant",
513
+ `${theme.success("✔")} ${t("status.switched_lang", { name: theme.primary(args) })}`,
514
+ );
515
+ } else {
516
+ addDisplayItem("assistant", t("status.lang_not_found", { name: args }));
517
+ }
518
+ return;
519
+ }
520
+
521
+ if (cmd === "/theme") {
522
+ setCurrentInput("");
523
+ const all = listThemes();
524
+ if (!args) {
525
+ setActiveSelection({
526
+ type: "theme",
527
+ options: all.map((tn) => ({ id: tn, label: tn })),
528
+ title: t("status.available_themes"),
529
+ currentId: getThemeName(),
530
+ });
531
+ } else if (setTheme(args)) {
532
+ showToast(
533
+ "success",
534
+ t("status.switched_theme", { name: args }),
535
+ t("command.theme"),
536
+ );
537
+ addDisplayItem(
538
+ "assistant",
539
+ `${theme.success("✔")} ${t("status.switched_theme", { name: theme.primary(args) })}`,
540
+ );
541
+ } else {
542
+ addDisplayItem(
543
+ "assistant",
544
+ t("status.theme_not_found", { name: args }),
545
+ );
546
+ }
547
+ return;
548
+ }
549
+
550
+ if (cmd === "/model") {
551
+ setCurrentInput("");
552
+ const all = getProviders();
553
+ setActiveSelection({
554
+ type: "provider",
555
+ options: all.map((p) => ({ id: p.id, label: p.name })),
556
+ title: t("status.select_provider"),
557
+ currentId: provider,
558
+ });
559
+ return;
560
+ }
561
+
562
+ if (cmd === "/vision") {
563
+ setCurrentInput("");
564
+ const all = getProviders();
565
+ setActiveSelection({
566
+ type: "vision_provider",
567
+ options: all.map((p) => ({ id: p.id, label: p.name })),
568
+ title: "Select Vision Delegate Provider",
569
+ currentId: visionProvider,
570
+ });
571
+ return;
572
+ }
573
+ if (cmd === "/reason") {
574
+ setCurrentInput("");
575
+ const levels: ReasoningLevel[] = [
576
+ "low",
577
+ "medium",
578
+ "high",
579
+ "max",
580
+ "xhigh",
581
+ ];
582
+ if (!args) {
583
+ setActiveSelection({
584
+ type: "reason",
585
+ options: levels.map((l) => ({ id: l, label: l })),
586
+ title: t("status.available_reasoning"),
587
+ currentId: reasoningLevel,
588
+ });
589
+ } else if (levels.includes(args as ReasoningLevel)) {
590
+ setReasoningLevel(args as ReasoningLevel);
591
+ saveGlobalReasoningLevel(args as ReasoningLevel);
592
+ showToast(
593
+ "success",
594
+ t("status.switched_reasoning", { name: args }),
595
+ t("command.reason"),
596
+ );
597
+ addDisplayItem(
598
+ "assistant",
599
+ `${theme.success("✔")} ${t("status.switched_reasoning", { name: theme.primary(args) })}`,
600
+ );
601
+ } else {
602
+ addDisplayItem(
603
+ "assistant",
604
+ t("status.reasoning_not_found", { name: args }),
605
+ );
606
+ }
607
+ return;
608
+ }
609
+
610
+ if (cmd === "/mode") {
611
+ setCurrentInput("");
612
+ const modes: AgentMode[] = ["plan", "code", "agent", "zen"];
613
+ if (!args) {
614
+ setActiveSelection({
615
+ type: "mode",
616
+ options: modes.map((m) => ({ id: m, label: t(`mode.${m}` as any) })),
617
+ title: t("status.available_modes"),
618
+ currentId: agentMode,
619
+ });
620
+ } else if (modes.includes(args as AgentMode)) {
621
+ setAgentMode(args as AgentMode);
622
+ setActivePlanFile(undefined);
623
+ saveAgentModeConfig(args as AgentMode, undefined);
624
+ showToast(
625
+ "success",
626
+ t("status.switched_mode", { name: args }),
627
+ t("command.mode"),
628
+ );
629
+ addDisplayItem(
630
+ "assistant",
631
+ `${theme.success("✔")} ${t("status.switched_mode", { name: theme.primary(args) })}`,
632
+ );
633
+ } else {
634
+ addDisplayItem("assistant", `Invalid mode: ${args}`);
635
+ }
636
+ return;
637
+ }
638
+
639
+ if (cmd === "/config") {
640
+ setCurrentInput("");
641
+ const providers = getProviders();
642
+ setActiveSelection({
643
+ type: "config_provider",
644
+ options: [
645
+ ...providers.map((p) => ({ id: p.id, label: p.name })),
646
+ { id: "ADD_CUSTOM", label: t("ui.add_custom_provider") },
647
+ ],
648
+ title: t("ui.config_title"),
649
+ });
650
+ return;
651
+ }
652
+
653
+ // Handle Setup Wizard Input
654
+ if (setupStep !== "none") {
655
+ const val = input.trim();
656
+ if (!val) return;
657
+ setCurrentInput("");
658
+
659
+ if (setupStep === "naming") {
660
+ setSetupContext((prev) => ({
661
+ ...prev!,
662
+ providerId: val.toLowerCase(),
663
+ name: val,
664
+ isCustom: true,
665
+ }));
666
+
667
+ // Show protocol selection
668
+ setActiveSelection({
669
+ type: "config_protocol",
670
+ options: [
671
+ { id: "openai", label: "OpenAI (Standard)" },
672
+ { id: "anthropic", label: "Anthropic" },
673
+ { id: "google", label: "Google (Gemini)" },
674
+ { id: "mistral", label: "Mistral" },
675
+ { id: "groq", label: "Groq" },
676
+ ],
677
+ title: t("ui.select_protocol", { name: val }),
678
+ });
679
+ setSetupStep("entering_protocol");
680
+ } else if (setupStep === "entering_key") {
681
+ if (setupContext?.isCustom) {
682
+ setSetupContext((prev) => ({ ...prev!, apiKey: val }));
683
+ setSetupStep("entering_url");
684
+ } else {
685
+ // Official provider - save immediately
686
+ saveProviderConfig(setupContext!.providerId, { apiKey: val });
687
+ addDisplayItem(
688
+ "assistant",
689
+ `${theme.success("✔")} ${t("status.config_saved", { name: theme.primary(setupContext!.providerId) })}`,
690
+ );
691
+ setSetupStep("none");
692
+ setSetupContext(null);
693
+ }
694
+ } else if (setupStep === "entering_url") {
695
+ saveProviderConfig(setupContext!.providerId, {
696
+ apiKey: setupContext!.apiKey!,
697
+ baseUrl: val,
698
+ isCustom: true,
699
+ protocol: setupContext!.protocol!,
700
+ });
701
+ addDisplayItem(
702
+ "assistant",
703
+ `${theme.success("✔")} Custom provider ${theme.primary(setupContext!.name!)} configured.`,
704
+ );
705
+ setSetupStep("none");
706
+ setSetupContext(null);
707
+ }
708
+ return;
709
+ }
710
+
711
+ if (value.trim() && !isGenerating) {
712
+ addDisplayItem("user", value);
713
+ setCurrentInput("");
714
+ setIsGenerating(true);
715
+ setStreamingText("");
716
+
717
+ const currentMessages: any[] = Array.isArray(messagesRef.current)
718
+ ? messagesRef.current
719
+ : [];
720
+ const newMessages = [
721
+ ...currentMessages,
722
+ { role: "user", content: value },
723
+ ];
724
+
725
+ try {
726
+ abortRef.current = new AbortController();
727
+
728
+ // Auto-select first model if not set
729
+ let effectiveModelName = modelName;
730
+ if (!effectiveModelName) {
731
+ try {
732
+ const apiKey = getProviderKey(provider);
733
+ const baseUrl = getProviderBaseUrl(provider);
734
+ const res = await fetch(`${baseUrl}/models`, {
735
+ headers: { Authorization: `Bearer ${apiKey}` },
736
+ });
737
+ if (res.ok) {
738
+ const data = await res.json();
739
+ const models = (data.data || data.models || []) as any[];
740
+ if (models.length > 0) {
741
+ effectiveModelName =
742
+ models[0].id ||
743
+ models[0].name?.replace("models/", "") ||
744
+ models[0].model;
745
+ setModelName(effectiveModelName);
746
+ }
747
+ }
748
+ } catch {
749
+ // ignore fetch error
750
+ }
751
+ }
752
+
753
+ if (!effectiveModelName) {
754
+ addDisplayItem(
755
+ "assistant",
756
+ theme.danger(
757
+ "No model available. Please select a provider and model.",
758
+ ),
759
+ );
760
+ return;
761
+ }
762
+
763
+ const generator = runAgent(
764
+ provider as any,
765
+ effectiveModelName,
766
+ newMessages,
767
+ undefined, // Let runAgent build system prompt dynamically
768
+ abortRef.current.signal,
769
+ reasoningLevel,
770
+ agentMode,
771
+ activePlanFile,
772
+ activeSkill?.content,
773
+ );
774
+ let fullResponse = "";
775
+ let lastFlush = Date.now();
776
+ let didGetMessages = false;
777
+ let generatorInstance = generator;
778
+ let nextApproval: boolean | undefined = undefined;
779
+ let lastToolName = "";
780
+
781
+ setStatusText(t("status.thinking"));
782
+
783
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
784
+ const resetIdle = (forceThinking: boolean = true) => {
785
+ if (idleTimer) clearTimeout(idleTimer);
786
+ idleTimer = setTimeout(() => {
787
+ setStreamingText(fullResponse);
788
+ if (forceThinking) setStatusText(t("status.thinking"));
789
+ }, 500);
790
+ };
791
+ resetIdle();
792
+
793
+ while (true) {
794
+ const next = await generatorInstance.next(nextApproval);
795
+ nextApproval = undefined; // Reset
796
+
797
+ if (next.done) {
798
+ break;
799
+ }
800
+
801
+ const event = next.value as AgentEvent;
802
+ resetIdle();
803
+
804
+ if (event.type === "text") {
805
+ setStatusText("");
806
+ fullResponse += event.text;
807
+ const now = Date.now();
808
+ // Throttle streaming updates to 200ms to reduce render-flicker
809
+ if (now - lastFlush > 200) {
810
+ setStreamingText(fullResponse);
811
+ lastFlush = now;
812
+ }
813
+ } else if (event.type === "tool-approval-required") {
814
+ const icon = formatToolIcon(event.name);
815
+ const humanName = formatToolName(event.name);
816
+ const detail = extractToolDetail(event.args);
817
+ const riskLabel = event.riskLevel
818
+ ? t(`ui.risk_${event.riskLevel}` as any)
819
+ : "";
820
+ const localWarning = event.isLocalGuard
821
+ ? `\n${theme.danger("! " + t("ui.local_guard_hit"))}`
822
+ : "";
823
+
824
+ setStatusText(
825
+ `${theme.danger("⚠ ")} ${humanName} ${theme.danger(t("ui.approval_required"))}`,
826
+ );
827
+
828
+ const approved = await new Promise<boolean>((resolve) => {
829
+ setActiveSelection({
830
+ type: "mode",
831
+ title: `${icon} ${theme.danger(t("ui.dangerous_command_warning"))}${localWarning}\n${theme.muted(formatBorder(40))}\n${theme.primary(detail)}\n${theme.muted(formatBorder(40))}\n${theme.highlight(t("ui.risk_level", { level: riskLabel }))}\n${t("ui.approve_execution_q")}`,
832
+ options: [
833
+ { id: "deny", label: theme.danger(t("ui.deny")) },
834
+ { id: "approve", label: theme.success(t("ui.approve")) },
835
+ ],
836
+ currentId: "deny",
837
+ });
838
+
839
+ (globalThis as any)._approvalResolver = (opt: any) => {
840
+ setActiveSelection(null);
841
+ resolve(opt.id === "approve");
842
+ };
843
+ });
844
+ nextApproval = approved;
845
+ } else if (event.type === "tool-call") {
846
+ const icon = formatToolIcon(event.name);
847
+ const humanName = formatToolName(event.name);
848
+ let detail = extractToolDetail(event.args);
849
+ // Default detail for tools without arguments
850
+ if (!detail) {
851
+ if (event.name === "list_dir") detail = ".";
852
+ else if (event.name === "read_file") detail = "(未指定文件)";
853
+ }
854
+ lastToolName = event.name;
855
+
856
+ setStatusText(`${icon} ${humanName}${detail}`);
857
+ if (idleTimer) clearTimeout(idleTimer);
858
+
859
+ if (fullResponse.trim()) {
860
+ addDisplayItem("assistant", fullResponse);
861
+ fullResponse = "";
862
+ setStreamingText("");
863
+ }
864
+ const toolDetail = detail;
865
+ const key = event.toolCallId || `${event.name}:${toolDetail}`;
866
+ pendingToolCallsRef.current.set(key, {
867
+ icon,
868
+ humanName,
869
+ detail: toolDetail,
870
+ timestamp: Date.now(),
871
+ });
872
+ } else if (event.type === "tool-call-delta") {
873
+ const icon = formatToolIcon(event.name);
874
+ const humanName = formatToolName(event.name);
875
+ const detail = extractToolDetail(event.args);
876
+ setStatusText(
877
+ `${icon} ${humanName}${detail} ${theme.muted("(streaming...)")}`,
878
+ );
879
+ } else if (event.type === "tool-generating") {
880
+ const icon = formatToolIcon((event as any).name);
881
+ const humanName = formatToolName((event as any).name);
882
+ const detail = extractToolDetail((event as any).args);
883
+ // Only update the spinner status — tool-call already added the display item
884
+ setStatusText(`${icon} ${humanName}${detail}`);
885
+ } else if (event.type === "tool-result") {
886
+ const resultStr =
887
+ typeof event.result === "string"
888
+ ? event.result
889
+ : JSON.stringify(event.result, null, 2);
890
+
891
+ let matchedEntry: [string, any] | undefined;
892
+ let matchedKey: string | undefined;
893
+
894
+ // 先用 toolCallId 匹配 key
895
+ if (event.toolCallId) {
896
+ const entry = Array.from(
897
+ pendingToolCallsRef.current.entries(),
898
+ ).find(([k]) => k.startsWith(event.toolCallId!));
899
+ if (entry) {
900
+ matchedEntry = entry;
901
+ matchedKey = entry[0];
902
+ }
903
+ }
904
+
905
+ // 否则用工具名匹配 key(key 格式是 "toolName:detail")
906
+ if (!matchedEntry) {
907
+ for (const [
908
+ key,
909
+ value,
910
+ ] of pendingToolCallsRef.current.entries()) {
911
+ if (
912
+ key.startsWith(event.name + ":") ||
913
+ key.startsWith(event.name.toLowerCase() + ":")
914
+ ) {
915
+ matchedEntry = [key, value];
916
+ matchedKey = key;
917
+ break;
918
+ }
919
+ }
920
+ }
921
+
922
+ if (matchedEntry) {
923
+ const { humanName, detail } = matchedEntry[1];
924
+ const isReadOnly = isReadOnlyTool(event.name);
925
+ const isReadFile = event.name === "read_file";
926
+ const isBatch = event.name === "batch";
927
+ const separator = isReadFile ? " ~ " : " → ";
928
+
929
+ if (isBatch) {
930
+ // Batch tool: show summary
931
+ const callCount = (resultStr.match(/\[Call \d+:/g) || [])
932
+ .length;
933
+ const errorCount = (resultStr.match(/Error:/g) || []).length;
934
+ const summary =
935
+ errorCount > 0
936
+ ? `${callCount}个调用, ${errorCount}个失败`
937
+ : `${callCount}个调用成功`;
938
+ addDisplayItem(
939
+ "tool",
940
+ `${theme.muted(humanName)}${detail ? theme.muted(separator) + theme.muted(detail.trim()) : ""} ${theme.success("✔")} ${theme.muted(summary)}`,
941
+ );
942
+ } else if (isReadOnly) {
943
+ addDisplayItem(
944
+ "tool",
945
+ `${theme.muted(humanName)}${detail ? theme.muted(separator) + theme.muted(detail.trim()) : ""} ${theme.success("✔")}`,
946
+ );
947
+ } else {
948
+ addDisplayItem(
949
+ "tool",
950
+ `${theme.muted(humanName)}${detail ? theme.muted(separator) + theme.muted(detail.trim()) : ""}\n${theme.success("✔")} ${theme.muted(truncateToolResult(resultStr || "OK"))}`,
951
+ );
952
+ }
953
+ pendingToolCallsRef.current.delete(matchedKey!);
954
+ } else {
955
+ addDisplayItem(
956
+ "tool",
957
+ `${theme.success("✔")} ${theme.muted(truncateToolResult(resultStr || "OK"))}`,
958
+ );
959
+ }
960
+ setStatusText(t("status.thinking"));
961
+ resetIdle();
962
+ } else if (event.type === "step") {
963
+ setStatusText(
964
+ `${t("status.thinking")} (${event.step}/${event.maxSteps})`,
965
+ );
966
+ } else if (event.type === "mode-changed") {
967
+ // Autonomous mode switch initiated by the agent
968
+ setAgentMode(event.mode);
969
+ setActivePlanFile(event.planFile);
970
+ saveAgentModeConfig(event.mode, event.planFile);
971
+ showToast(
972
+ "success",
973
+ t("status.switched_mode", { name: event.mode }),
974
+ t("command.mode"),
975
+ );
976
+ addDisplayItem(
977
+ "assistant",
978
+ `${theme.success("✔")} ${t("status.switched_mode", { name: theme.primary(event.mode) })}${event.planFile ? ` (${event.planFile})` : ""}`,
979
+ );
980
+ } else if (event.type === "messages") {
981
+ messagesRef.current = event.messages;
982
+ if (event.promptLength !== undefined) {
983
+ setActivePromptLength(event.promptLength);
984
+ }
985
+ saveMessages(sessionId, messagesRef.current);
986
+ didGetMessages = true;
987
+ }
988
+ }
989
+
990
+ if (idleTimer) clearTimeout(idleTimer);
991
+ setStatusText("");
992
+
993
+ if (fullResponse) {
994
+ addDisplayItem("assistant", fullResponse);
995
+ }
996
+
997
+ if (!didGetMessages) {
998
+ const fallback = [
999
+ ...newMessages,
1000
+ { role: "assistant", content: fullResponse },
1001
+ ];
1002
+ messagesRef.current = fallback;
1003
+ saveMessages(sessionId, fallback);
1004
+ }
1005
+
1006
+ setStreamingText("");
1007
+ } catch (err: any) {
1008
+ addDisplayItem("assistant", theme.danger(`Error: ${err.message}`));
1009
+ setStreamingText("");
1010
+ } finally {
1011
+ setIsGenerating(false);
1012
+ abortRef.current = null;
1013
+ }
1014
+ }
1015
+ };
1016
+
1017
+ // ── Context Usage Calculation ──
1018
+ const contextUsage = (() => {
1019
+ const windowSize = getContextWindow(modelName);
1020
+
1021
+ let historyLength = 0;
1022
+ if (messagesRef.current && Array.isArray(messagesRef.current)) {
1023
+ for (const msg of messagesRef.current) {
1024
+ if (typeof msg.content === "string") {
1025
+ historyLength += msg.content.length;
1026
+ } else if (Array.isArray(msg.content)) {
1027
+ historyLength += JSON.stringify(msg.content).length;
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ // Account for hidden system prompts, specs, and environment details
1033
+ // activePromptLength covers selectBasePrompt + mode_instructions + language_preference + environment + specs
1034
+ const totalTextLength =
1035
+ historyLength + streamingText.length + (activePromptLength || 4000);
1036
+
1037
+ // 1 token ≈ 3.0 characters (safer ratio for heavily formatted code/Chinese)
1038
+ const tokens = Math.ceil(totalTextLength / 3.0);
1039
+
1040
+ let windowStr =
1041
+ windowSize >= 1000000
1042
+ ? `${(windowSize / 1000000).toFixed(1).replace(".0", "")}M`
1043
+ : `${Math.round(windowSize / 1000)}k`;
1044
+
1045
+ const percent = Math.min((tokens / windowSize) * 100, 100);
1046
+ const pStr = percent > 0 && percent < 0.01 ? "<0.01" : percent.toFixed(2);
1047
+
1048
+ return `${tokens}/${windowStr} (${pStr}%)`;
1049
+ })();
1050
+
1051
+ return (
1052
+ <Box flexDirection="column" width="100%">
1053
+ {/* Scrollable History Area */}
1054
+ <Static items={displayItems}>
1055
+ {(item) => {
1056
+ const hasText = !!item.text.trim();
1057
+ if (!hasText && item.role !== "user") return null;
1058
+
1059
+ return (
1060
+ <Box
1061
+ key={item.id}
1062
+ flexDirection="column"
1063
+ marginBottom={!hasText || item.role === "tool" ? 0 : 1}
1064
+ width="100%"
1065
+ >
1066
+ {item.role !== "tool" ? (
1067
+ <Box flexDirection="row">
1068
+ <Text bold color={item.role === "user" ? "green" : "blue"}>
1069
+ {item.role === "user" ? "❯ " : "󱨊 "}
1070
+ </Text>
1071
+ <Box paddingLeft={1} flexDirection="column" width="100%">
1072
+ <Text>{item.rendered || renderMarkdown(item.text)}</Text>
1073
+ </Box>
1074
+ </Box>
1075
+ ) : (
1076
+ <Box paddingLeft={3}>
1077
+ <Text>{item.text}</Text>
1078
+ </Box>
1079
+ )}
1080
+ </Box>
1081
+ );
1082
+ }}
1083
+ </Static>
1084
+
1085
+ {/* Live UI Area */}
1086
+ <Box flexDirection="column" marginTop={1} width="100%">
1087
+ {/* Header card */}
1088
+ <Box
1089
+ flexDirection="row"
1090
+ justifyContent="space-between"
1091
+ paddingLeft={2}
1092
+ paddingRight={2}
1093
+ paddingTop={1}
1094
+ paddingBottom={1}
1095
+ marginBottom={1}
1096
+ backgroundColor="#1a1a2e"
1097
+ width="100%"
1098
+ >
1099
+ <Box flexDirection="row" gap={1}>
1100
+ <Text bold color="cyan">
1101
+ AcmeCode
1102
+ </Text>
1103
+ <Text color="gray">·</Text>
1104
+ <Text>
1105
+ {agentMode === "plan"
1106
+ ? theme.highlight(agentMode)
1107
+ : agentMode === "code"
1108
+ ? theme.success(agentMode)
1109
+ : theme.primary(agentMode)}
1110
+ </Text>
1111
+ {activePlanFile && (
1112
+ <>
1113
+ <Text color="gray">·</Text>
1114
+ <Text color="yellow">{activePlanFile}</Text>
1115
+ </>
1116
+ )}
1117
+ <Text color="gray">·</Text>
1118
+ <Text color="white">{modelName}</Text>
1119
+ {activeSkill ? (
1120
+ <>
1121
+ <Text color="gray">·</Text>
1122
+ <Text color="yellow">{activeSkill.name}</Text>
1123
+ </>
1124
+ ) : null}
1125
+ </Box>
1126
+ <Box flexDirection="row" gap={1}>
1127
+ <Text color="gray">{reasoningLevel}</Text>
1128
+ <Text color="gray">·</Text>
1129
+ <Text color="gray">{contextUsage}</Text>
1130
+ </Box>
1131
+ </Box>
1132
+
1133
+ {/* Interactive Selection List (Below Logo, in live area) */}
1134
+ {activeSelection && (
1135
+ <OptionList
1136
+ title={activeSelection.title}
1137
+ options={activeSelection.options}
1138
+ currentId={activeSelection.currentId}
1139
+ onSelect={(opt) => {
1140
+ if (activeSelection.type === "lang") {
1141
+ setLang(opt.id as Language);
1142
+ showToast(
1143
+ "success",
1144
+ t("status.switched_lang", { name: opt.id }),
1145
+ t("command.lang"),
1146
+ );
1147
+ addDisplayItem(
1148
+ "assistant",
1149
+ `${theme.success("✔")} ${t("status.switched_lang", { name: theme.primary(opt.id) })}`,
1150
+ );
1151
+ } else if (activeSelection.type === "theme") {
1152
+ setTheme(opt.id);
1153
+ showToast(
1154
+ "success",
1155
+ t("status.switched_theme", { name: opt.id }),
1156
+ t("command.theme"),
1157
+ );
1158
+ addDisplayItem(
1159
+ "assistant",
1160
+ `${theme.success("✔")} ${t("status.switched_theme", { name: theme.primary(opt.id) })}`,
1161
+ );
1162
+ } else if (activeSelection.type === "reason") {
1163
+ setReasoningLevel(opt.id as ReasoningLevel);
1164
+ saveGlobalReasoningLevel(opt.id as ReasoningLevel);
1165
+ showToast(
1166
+ "success",
1167
+ t("status.switched_reasoning", { name: opt.id }),
1168
+ t("command.reason"),
1169
+ );
1170
+ addDisplayItem(
1171
+ "assistant",
1172
+ `${theme.success("✔")} ${t("status.switched_reasoning", { name: theme.primary(opt.id) })}`,
1173
+ );
1174
+ } else if (activeSelection.type === "mode") {
1175
+ if ((globalThis as any)._approvalResolver) {
1176
+ (globalThis as any)._approvalResolver(opt);
1177
+ delete (globalThis as any)._approvalResolver;
1178
+ return;
1179
+ }
1180
+ setAgentMode(opt.id as AgentMode);
1181
+ setActivePlanFile(undefined);
1182
+ saveAgentModeConfig(opt.id as AgentMode, undefined);
1183
+ showToast(
1184
+ "success",
1185
+ t("status.switched_mode", { name: opt.id }),
1186
+ t("command.mode"),
1187
+ );
1188
+ addDisplayItem(
1189
+ "assistant",
1190
+ `${theme.success("✔")} ${t("status.switched_mode", { name: theme.primary(opt.id) })}`,
1191
+ );
1192
+ } else if (activeSelection.type === "provider") {
1193
+ const selectedProvider = opt.id as ProviderType;
1194
+ setActiveSelection(null);
1195
+ setTempProvider(selectedProvider);
1196
+
1197
+ // Fetch models for the selected provider
1198
+ addDisplayItem(
1199
+ "assistant",
1200
+ `Fetching available models (${selectedProvider})...`,
1201
+ );
1202
+ (async () => {
1203
+ try {
1204
+ const apiKey = getProviderKey(selectedProvider);
1205
+ const baseUrl = getProviderBaseUrl(selectedProvider);
1206
+ const protocol = getProviderProtocol(selectedProvider);
1207
+
1208
+ if (!apiKey) {
1209
+ addDisplayItem(
1210
+ "assistant",
1211
+ theme.danger(
1212
+ `Error: API key for ${selectedProvider} not found.`,
1213
+ ),
1214
+ );
1215
+ return;
1216
+ }
1217
+
1218
+ let models: { id: string }[] = [];
1219
+ let fetchError = "";
1220
+
1221
+ // 1. Try Official Endpoint
1222
+ try {
1223
+ let url = "";
1224
+ let headers: any = {};
1225
+
1226
+ // Special handling for Extralink: fetch from Git repo
1227
+ if (selectedProvider === "extralink") {
1228
+ url =
1229
+ "https://cnb.cool/acmecloud/acmecode-models/-/git/raw/main/v1/models";
1230
+ const res = await fetch(url);
1231
+ if (res.ok) {
1232
+ const data = await res.json();
1233
+ const list = (data.data || []) as any[];
1234
+ models = list.map((m) => ({
1235
+ id: m.id,
1236
+ label: m.name || m.id,
1237
+ }));
1238
+ } else {
1239
+ fetchError = `HTTP ${res.status}`;
1240
+ }
1241
+ } else {
1242
+ // Standard provider model fetching
1243
+ url = `${baseUrl}/models`;
1244
+ headers = { Authorization: `Bearer ${apiKey}` };
1245
+
1246
+ if (protocol === "google") {
1247
+ // Google uses key in query param
1248
+ const cleanBase = baseUrl?.replace(/\/+$/, "");
1249
+ url = `${cleanBase}/models?key=${apiKey}`;
1250
+ headers = {};
1251
+ } else if (protocol === "anthropic") {
1252
+ // Anthropic doesn't have a public model list API usually,
1253
+ // but some proxies might. We'll skip official and hit fallback directly.
1254
+ throw new Error("Anthropic official has no list API");
1255
+ }
1256
+
1257
+ const res = await fetch(url, { headers });
1258
+ if (res.ok) {
1259
+ const data = await res.json();
1260
+ const list = (data.data ||
1261
+ data.models ||
1262
+ []) as any[];
1263
+ models = list.map((m) => ({
1264
+ id:
1265
+ m.id || m.name?.replace("models/", "") || m.model,
1266
+ }));
1267
+ } else {
1268
+ fetchError = `HTTP ${res.status}`;
1269
+ }
1270
+ }
1271
+ } catch (e: any) {
1272
+ fetchError = e.message;
1273
+ }
1274
+
1275
+ // 2. Fallback: Try multiple OpenAI-compatible candidates if first attempt failed or was empty
1276
+ if (
1277
+ models.length === 0 &&
1278
+ selectedProvider !== "extralink"
1279
+ ) {
1280
+ const cleanBase = baseUrl
1281
+ ?.replace(/\/+$/, "")
1282
+ .replace(/\/(v1|v1beta)$/, "");
1283
+ const candidates = [
1284
+ `${cleanBase}/models`,
1285
+ `${cleanBase}/v1/models`,
1286
+ `${cleanBase}/v1beta/models`,
1287
+ ];
1288
+
1289
+ for (const fallbackUrl of candidates) {
1290
+ try {
1291
+ const res = await fetch(fallbackUrl, {
1292
+ headers: { Authorization: `Bearer ${apiKey}` },
1293
+ });
1294
+ if (res.ok) {
1295
+ const data = await res.json();
1296
+ const list = (data.data ||
1297
+ data.models ||
1298
+ []) as any[];
1299
+ if (list.length > 0) {
1300
+ models = list.map((m) => ({
1301
+ id:
1302
+ m.id ||
1303
+ m.name?.replace("models/", "") ||
1304
+ m.model,
1305
+ }));
1306
+ break; // Success!
1307
+ }
1308
+ }
1309
+ } catch (e) {
1310
+ // Continue to next candidate
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ if (models.length === 0) {
1316
+ addDisplayItem(
1317
+ "assistant",
1318
+ theme.danger(
1319
+ `No models found or fetch failed: ${fetchError}`,
1320
+ ),
1321
+ );
1322
+ } else {
1323
+ // Auto-select first model if none selected
1324
+ const currentModelId =
1325
+ selectedProvider === provider
1326
+ ? modelName || models[0]?.id
1327
+ : undefined;
1328
+ setActiveSelection({
1329
+ type: "model",
1330
+ options: models.map((m: any) => ({
1331
+ id: m.id,
1332
+ label: m.label || m.id,
1333
+ })),
1334
+ title: `${t("status.available_models")} (${selectedProvider})`,
1335
+ currentId: currentModelId,
1336
+ });
1337
+ }
1338
+ } catch (err: any) {
1339
+ addDisplayItem(
1340
+ "assistant",
1341
+ theme.danger(`Error fetching models: ${err.message}`),
1342
+ );
1343
+ }
1344
+ })();
1345
+ return; // Don't close selection yet, we're transitioning
1346
+ } else if (activeSelection.type === "vision_provider") {
1347
+ const selectedProvider = opt.id as ProviderType;
1348
+ setActiveSelection(null);
1349
+ setTempProvider(selectedProvider);
1350
+ addDisplayItem(
1351
+ "assistant",
1352
+ `Fetching vision models (${selectedProvider})...`,
1353
+ );
1354
+ (async () => {
1355
+ try {
1356
+ const apiKey = getProviderKey(selectedProvider);
1357
+ const baseUrl = getProviderBaseUrl(selectedProvider);
1358
+ const protocol = getProviderProtocol(selectedProvider);
1359
+ if (!apiKey) {
1360
+ addDisplayItem(
1361
+ "assistant",
1362
+ theme.danger(
1363
+ `Error: API key for ${selectedProvider} not found.`,
1364
+ ),
1365
+ );
1366
+ return;
1367
+ }
1368
+
1369
+ // Reuse model fetching logic (extracted or duplicated for now)
1370
+ let models: { id: string }[] = [];
1371
+ const cleanBase = baseUrl
1372
+ ?.replace(/\/+$/, "")
1373
+ .replace(/\/(v1|v1beta)$/, "");
1374
+ const candidates = [
1375
+ `${cleanBase}/models`,
1376
+ `${cleanBase}/v1/models`,
1377
+ `${cleanBase}/v1beta/models`,
1378
+ ];
1379
+ for (const fallbackUrl of candidates) {
1380
+ try {
1381
+ const res = await fetch(fallbackUrl, {
1382
+ headers: { Authorization: `Bearer ${apiKey}` },
1383
+ });
1384
+ if (res.ok) {
1385
+ const data = await res.json();
1386
+ const list = (data.data ||
1387
+ data.models ||
1388
+ []) as any[];
1389
+ if (list.length > 0) {
1390
+ models = list.map((m) => ({
1391
+ id:
1392
+ m.id ||
1393
+ m.name?.replace("models/", "") ||
1394
+ m.model,
1395
+ }));
1396
+ break;
1397
+ }
1398
+ }
1399
+ } catch {}
1400
+ }
1401
+
1402
+ if (models.length === 0) {
1403
+ addDisplayItem(
1404
+ "assistant",
1405
+ theme.danger(
1406
+ `No vision models found for ${selectedProvider}.`,
1407
+ ),
1408
+ );
1409
+ } else {
1410
+ setActiveSelection({
1411
+ type: "vision_model",
1412
+ options: models.map((m) => ({ id: m.id, label: m.id })),
1413
+ title: `Select Vision Model (${selectedProvider})`,
1414
+ currentId: visionModel,
1415
+ });
1416
+ }
1417
+ } catch (err: any) {
1418
+ addDisplayItem(
1419
+ "assistant",
1420
+ theme.danger(`Error: ${err.message}`),
1421
+ );
1422
+ }
1423
+ })();
1424
+ return;
1425
+ } else if (activeSelection.type === "vision_model") {
1426
+ const p = tempProvider || (provider as any);
1427
+ setVisionProvider(p);
1428
+ setVisionModel(opt.id);
1429
+ saveProjectModelConfig(provider, modelName, p, opt.id);
1430
+ showToast("success", `Vision delegate set to ${opt.id}`);
1431
+ addDisplayItem(
1432
+ "assistant",
1433
+ `${theme.success("✔")} Vision Delegate: ${theme.primary(opt.id)}`,
1434
+ );
1435
+ setActiveSelection(null);
1436
+ setTempProvider(null);
1437
+ return;
1438
+ } else if (activeSelection.type === "model") {
1439
+ const p = tempProvider || provider;
1440
+ setProvider(p);
1441
+ setModelName(opt.id);
1442
+ saveProjectModelConfig(p, opt.id);
1443
+ addDisplayItem(
1444
+ "assistant",
1445
+ `${theme.success("✔")} ${t("status.switched_model", { name: theme.primary(`${p}:${opt.id}`) })}`,
1446
+ );
1447
+ setTempProvider(null);
1448
+ } else if (activeSelection.type === "config_provider") {
1449
+ if (opt.id === "ADD_CUSTOM") {
1450
+ setSetupStep("naming");
1451
+ setSetupContext({ providerId: "", isCustom: true });
1452
+ addDisplayItem("assistant", t("ui.enter_custom_name"));
1453
+ } else {
1454
+ const providerInfo = getProviders().find(
1455
+ (p) => p.id === opt.id,
1456
+ );
1457
+ const isCustom = providerInfo?.envKey === "CUSTOM";
1458
+
1459
+ if (isCustom) {
1460
+ setSetupContext({
1461
+ providerId: opt.id,
1462
+ name: opt.label,
1463
+ isCustom: true,
1464
+ });
1465
+ setActiveSelection({
1466
+ type: "config_protocol",
1467
+ options: [
1468
+ { id: "openai", label: "OpenAI (Standard)" },
1469
+ { id: "anthropic", label: "Anthropic" },
1470
+ { id: "google", label: "Google (Gemini)" },
1471
+ { id: "mistral", label: "Mistral" },
1472
+ { id: "groq", label: "Groq" },
1473
+ ],
1474
+ title: t("ui.select_protocol", { name: opt.label }),
1475
+ });
1476
+ setSetupStep("entering_protocol");
1477
+ return; // Don't close selection, transitioning to protocols
1478
+ } else {
1479
+ setSetupContext({ providerId: opt.id, isCustom: false });
1480
+ setSetupStep("entering_key");
1481
+ addDisplayItem(
1482
+ "assistant",
1483
+ t("ui.enter_api_key", { name: opt.label }),
1484
+ );
1485
+ }
1486
+ }
1487
+ } else if (activeSelection.type === "config_protocol") {
1488
+ setSetupContext((prev) => ({ ...prev!, protocol: opt.id }));
1489
+ setSetupStep("entering_key");
1490
+ addDisplayItem(
1491
+ "assistant",
1492
+ t("ui.enter_api_key", { name: setupContext?.name || "" }),
1493
+ );
1494
+ }
1495
+ setActiveSelection(null);
1496
+ }}
1497
+ onCancel={() => {
1498
+ setActiveSelection(null);
1499
+ setTempProvider(null);
1500
+ setSetupStep("none");
1501
+ setSetupContext(null);
1502
+ }}
1503
+ />
1504
+ )}
1505
+
1506
+ {/* Streaming Assistant Response */}
1507
+ {setupStep !== "none" && (
1508
+ <Box flexDirection="column" marginBottom={1}>
1509
+ {setupStep === "naming" && (
1510
+ <Text color="yellow">󱔗 {t("ui.enter_custom_name")}</Text>
1511
+ )}
1512
+ {setupStep === "entering_key" && (
1513
+ <Text color="yellow">
1514
+ 󱔗{" "}
1515
+ {t("ui.enter_api_key", {
1516
+ name: setupContext?.name || setupContext?.providerId || "",
1517
+ })}
1518
+ </Text>
1519
+ )}
1520
+ {setupStep === "entering_url" && (
1521
+ <Text color="yellow">
1522
+ 󱔗{" "}
1523
+ {t("ui.enter_base_url", {
1524
+ name: setupContext?.name || setupContext?.providerId || "",
1525
+ example:
1526
+ setupContext?.protocol === "anthropic"
1527
+ ? "https://api.anthropic.com"
1528
+ : setupContext?.protocol === "google"
1529
+ ? "https://generativelanguage.googleapis.com"
1530
+ : setupContext?.protocol === "mistral"
1531
+ ? "https://api.mistral.ai"
1532
+ : setupContext?.protocol === "groq"
1533
+ ? "https://api.groq.com/openai/v1"
1534
+ : "https://api.openai.com/v1",
1535
+ })}
1536
+ </Text>
1537
+ )}
1538
+ </Box>
1539
+ )}
1540
+
1541
+ {streamingText ? (
1542
+ <Box flexDirection="column" marginBottom={1}>
1543
+ <Box>
1544
+ <Text bold color="blue">
1545
+ 󱨊{" "}
1546
+ </Text>
1547
+ <Box paddingLeft={1} flexDirection="column">
1548
+ {(() => {
1549
+ const { content, truncated } = windowText(streamingText, 25);
1550
+ return (
1551
+ <>
1552
+ {truncated && (
1553
+ <Text dimColor italic>
1554
+ ... (
1555
+ {t("ui.output_truncated") ||
1556
+ "Previous lines hidden for stability"}
1557
+ )
1558
+ </Text>
1559
+ )}
1560
+ <Text>{renderMarkdown(content)}</Text>
1561
+ </>
1562
+ );
1563
+ })()}
1564
+ </Box>
1565
+ </Box>
1566
+ </Box>
1567
+ ) : null}
1568
+
1569
+ {/* Spinner */}
1570
+ {isGenerating && statusText ? (
1571
+ <Box marginBottom={1} paddingLeft={3}>
1572
+ <Spinner label={statusText} color="cyan" />
1573
+ </Box>
1574
+ ) : null}
1575
+ </Box>
1576
+
1577
+ {/* Input & Footer */}
1578
+ {!activeSelection ? (
1579
+ <Box flexDirection="column">
1580
+ {/* Autocomplete Menu */}
1581
+ {!isGenerating && visibleCommands.length > 0 && (
1582
+ <Box marginBottom={1} paddingX={2} flexDirection="column">
1583
+ {visibleCommands.map((cmd, i) => {
1584
+ const isSelected =
1585
+ i === Math.min(autocompleteIndex, visibleCommands.length - 1);
1586
+ return (
1587
+ <Text key={cmd.id}>
1588
+ {isSelected ? theme.success("❯ ") : " "}
1589
+ {isSelected
1590
+ ? theme.highlight(cmd.id)
1591
+ : theme.primary(cmd.id)}{" "}
1592
+ {theme.muted(`- ${cmd.label}`)}
1593
+ </Text>
1594
+ );
1595
+ })}
1596
+ </Box>
1597
+ )}
1598
+
1599
+ {/* Prompt card */}
1600
+ <Box flexDirection="row" width="100%">
1601
+ {/* Accent bar - full height */}
1602
+ <Box width={1} backgroundColor={isGenerating ? "yellow" : "cyan"} />
1603
+ {/* Card body */}
1604
+ <Box
1605
+ flexDirection="column"
1606
+ flexGrow={1}
1607
+ paddingLeft={2}
1608
+ paddingRight={2}
1609
+ paddingTop={1}
1610
+ paddingBottom={1}
1611
+ backgroundColor="#16213e"
1612
+ >
1613
+ <TextInput
1614
+ value={currentInput}
1615
+ focus={!activeSelection}
1616
+ onChange={(val) => {
1617
+ setCurrentInput(val);
1618
+ setAutocompleteIndex(0);
1619
+ }}
1620
+ onSubmit={handleSubmit}
1621
+ onUp={() =>
1622
+ setAutocompleteIndex((prev) => Math.max(0, prev - 1))
1623
+ }
1624
+ onDown={() =>
1625
+ setAutocompleteIndex((prev) =>
1626
+ Math.min(visibleCommands.length - 1, prev + 1),
1627
+ )
1628
+ }
1629
+ onTab={() => {
1630
+ if (visibleCommands.length > 0) {
1631
+ const selected =
1632
+ visibleCommands[
1633
+ Math.min(autocompleteIndex, visibleCommands.length - 1)
1634
+ ];
1635
+ if (selected) setCurrentInput(selected.id + " ");
1636
+ }
1637
+ }}
1638
+ placeholder={
1639
+ isGenerating ? t("command.cancel") : t("ui.input_placeholder")
1640
+ }
1641
+ onPaste={(part: PastedPart) => {
1642
+ setPastedParts((prev) => {
1643
+ const next = new Map(prev);
1644
+ next.set(part.label, part.text);
1645
+ return next;
1646
+ });
1647
+ }}
1648
+ />
1649
+ {/* Model info row */}
1650
+ <Box flexDirection="row" marginTop={1} gap={1}>
1651
+ <Text color="cyan">{agentMode.toUpperCase()}</Text>
1652
+ <Text color="gray">·</Text>
1653
+ <Text color="white">{modelName}</Text>
1654
+ <Text color="gray">{provider}</Text>
1655
+ {activeSkill && (
1656
+ <>
1657
+ <Text color="gray">·</Text>
1658
+ <Text color="yellow">{activeSkill!.name}</Text>
1659
+ </>
1660
+ )}
1661
+ </Box>
1662
+ </Box>
1663
+ </Box>
1664
+
1665
+ {/* Footer */}
1666
+ <Box
1667
+ flexDirection="row"
1668
+ justifyContent="space-between"
1669
+ marginTop={1}
1670
+ paddingLeft={2}
1671
+ paddingRight={2}
1672
+ >
1673
+ <Text color="gray">{process.cwd()}</Text>
1674
+ <Box flexDirection="row" gap={2}>
1675
+ {isGenerating ? (
1676
+ <Text color="gray">
1677
+ esc <Text color="gray">interrupt</Text>
1678
+ </Text>
1679
+ ) : (
1680
+ <>
1681
+ <Text color="gray">
1682
+ / <Text color="gray">commands</Text>
1683
+ </Text>
1684
+ <Text color="gray">/exit</Text>
1685
+ </>
1686
+ )}
1687
+ </Box>
1688
+ </Box>
1689
+
1690
+ {!isGenerating && (
1691
+ <Box marginTop={1} paddingLeft={2}>
1692
+ <Tips />
1693
+ </Box>
1694
+ )}
1695
+ </Box>
1696
+ ) : null}
1697
+
1698
+ {/* Toast notification */}
1699
+ <Toast toast={toast} onDismiss={dismissToast} />
1700
+ </Box>
1701
+ );
1702
+ }