zerg-ztc 0.1.10 → 0.1.12

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 (151) hide show
  1. package/bin/.gitkeep +0 -0
  2. package/bin/ztc-audio-darwin-arm64 +0 -0
  3. package/dist/App.d.ts.map +1 -1
  4. package/dist/App.js +63 -2
  5. package/dist/App.js.map +1 -1
  6. package/dist/agent/commands/dictation.d.ts +3 -0
  7. package/dist/agent/commands/dictation.d.ts.map +1 -0
  8. package/dist/agent/commands/dictation.js +10 -0
  9. package/dist/agent/commands/dictation.js.map +1 -0
  10. package/dist/agent/commands/index.d.ts.map +1 -1
  11. package/dist/agent/commands/index.js +2 -1
  12. package/dist/agent/commands/index.js.map +1 -1
  13. package/dist/agent/commands/types.d.ts +7 -0
  14. package/dist/agent/commands/types.d.ts.map +1 -1
  15. package/dist/components/InputArea.d.ts +1 -0
  16. package/dist/components/InputArea.d.ts.map +1 -1
  17. package/dist/components/InputArea.js +591 -43
  18. package/dist/components/InputArea.js.map +1 -1
  19. package/dist/components/SingleMessage.d.ts.map +1 -1
  20. package/dist/components/SingleMessage.js +157 -7
  21. package/dist/components/SingleMessage.js.map +1 -1
  22. package/dist/config/types.d.ts +6 -0
  23. package/dist/config/types.d.ts.map +1 -1
  24. package/dist/ui/views/status_bar.js +2 -2
  25. package/dist/ui/views/status_bar.js.map +1 -1
  26. package/dist/utils/dictation.d.ts +46 -0
  27. package/dist/utils/dictation.d.ts.map +1 -0
  28. package/dist/utils/dictation.js +409 -0
  29. package/dist/utils/dictation.js.map +1 -0
  30. package/dist/utils/dictation_native.d.ts +51 -0
  31. package/dist/utils/dictation_native.d.ts.map +1 -0
  32. package/dist/utils/dictation_native.js +236 -0
  33. package/dist/utils/dictation_native.js.map +1 -0
  34. package/dist/utils/path_format.d.ts +20 -0
  35. package/dist/utils/path_format.d.ts.map +1 -0
  36. package/dist/utils/path_format.js +90 -0
  37. package/dist/utils/path_format.js.map +1 -0
  38. package/dist/utils/table.d.ts +38 -0
  39. package/dist/utils/table.d.ts.map +1 -0
  40. package/dist/utils/table.js +133 -0
  41. package/dist/utils/table.js.map +1 -0
  42. package/dist/utils/tool_trace.d.ts +7 -2
  43. package/dist/utils/tool_trace.d.ts.map +1 -1
  44. package/dist/utils/tool_trace.js +156 -51
  45. package/dist/utils/tool_trace.js.map +1 -1
  46. package/package.json +5 -1
  47. package/src/App.tsx +0 -813
  48. package/src/agent/agent.ts +0 -534
  49. package/src/agent/backends/anthropic.ts +0 -86
  50. package/src/agent/backends/gemini.ts +0 -119
  51. package/src/agent/backends/inception.ts +0 -23
  52. package/src/agent/backends/index.ts +0 -17
  53. package/src/agent/backends/openai.ts +0 -23
  54. package/src/agent/backends/openai_compatible.ts +0 -143
  55. package/src/agent/backends/types.ts +0 -83
  56. package/src/agent/commands/clipboard.ts +0 -77
  57. package/src/agent/commands/config.ts +0 -204
  58. package/src/agent/commands/debug.ts +0 -23
  59. package/src/agent/commands/emulation.ts +0 -80
  60. package/src/agent/commands/execution.ts +0 -9
  61. package/src/agent/commands/help.ts +0 -20
  62. package/src/agent/commands/history.ts +0 -13
  63. package/src/agent/commands/index.ts +0 -46
  64. package/src/agent/commands/input_mode.ts +0 -22
  65. package/src/agent/commands/keybindings.ts +0 -40
  66. package/src/agent/commands/model.ts +0 -11
  67. package/src/agent/commands/models.ts +0 -116
  68. package/src/agent/commands/permissions.ts +0 -64
  69. package/src/agent/commands/retry.ts +0 -9
  70. package/src/agent/commands/shell.ts +0 -68
  71. package/src/agent/commands/skills.ts +0 -54
  72. package/src/agent/commands/status.ts +0 -19
  73. package/src/agent/commands/types.ts +0 -80
  74. package/src/agent/commands/update.ts +0 -32
  75. package/src/agent/factory.ts +0 -60
  76. package/src/agent/index.ts +0 -20
  77. package/src/agent/runtime/capabilities.ts +0 -7
  78. package/src/agent/runtime/memory.ts +0 -23
  79. package/src/agent/runtime/policy.ts +0 -48
  80. package/src/agent/runtime/session.ts +0 -18
  81. package/src/agent/runtime/tracing.ts +0 -23
  82. package/src/agent/tools/file.ts +0 -178
  83. package/src/agent/tools/index.ts +0 -52
  84. package/src/agent/tools/screenshot.ts +0 -821
  85. package/src/agent/tools/search.ts +0 -138
  86. package/src/agent/tools/shell.ts +0 -69
  87. package/src/agent/tools/skills.ts +0 -28
  88. package/src/agent/tools/types.ts +0 -14
  89. package/src/agent/tools/zerg.ts +0 -50
  90. package/src/cli.tsx +0 -163
  91. package/src/components/ActivityLine.tsx +0 -23
  92. package/src/components/FullScreen.tsx +0 -79
  93. package/src/components/Header.tsx +0 -27
  94. package/src/components/InputArea.tsx +0 -1096
  95. package/src/components/MessageList.tsx +0 -71
  96. package/src/components/SingleMessage.tsx +0 -59
  97. package/src/components/StatusBar.tsx +0 -55
  98. package/src/components/index.tsx +0 -8
  99. package/src/config/types.ts +0 -12
  100. package/src/config.ts +0 -186
  101. package/src/debug/logger.ts +0 -14
  102. package/src/emulation/README.md +0 -24
  103. package/src/emulation/catalog.ts +0 -82
  104. package/src/emulation/trace_style.ts +0 -8
  105. package/src/emulation/types.ts +0 -7
  106. package/src/skills/index.ts +0 -36
  107. package/src/skills/loader.ts +0 -135
  108. package/src/skills/registry.ts +0 -6
  109. package/src/skills/types.ts +0 -10
  110. package/src/types.ts +0 -84
  111. package/src/ui/README.md +0 -44
  112. package/src/ui/core/factory.ts +0 -9
  113. package/src/ui/core/index.ts +0 -4
  114. package/src/ui/core/input.ts +0 -38
  115. package/src/ui/core/input_segments.ts +0 -410
  116. package/src/ui/core/input_state.ts +0 -17
  117. package/src/ui/core/layout_yoga.ts +0 -122
  118. package/src/ui/core/style.ts +0 -38
  119. package/src/ui/core/types.ts +0 -54
  120. package/src/ui/ink/index.tsx +0 -1
  121. package/src/ui/ink/render.tsx +0 -60
  122. package/src/ui/views/activity_line.ts +0 -33
  123. package/src/ui/views/app.ts +0 -111
  124. package/src/ui/views/header.ts +0 -44
  125. package/src/ui/views/input_area.ts +0 -255
  126. package/src/ui/views/message_list.ts +0 -443
  127. package/src/ui/views/status_bar.ts +0 -114
  128. package/src/ui/vue/index.ts +0 -53
  129. package/src/ui/web/frame_render.tsx +0 -148
  130. package/src/ui/web/index.tsx +0 -1
  131. package/src/ui/web/render.tsx +0 -41
  132. package/src/utils/clipboard.ts +0 -39
  133. package/src/utils/clipboard_image.ts +0 -40
  134. package/src/utils/diff.ts +0 -52
  135. package/src/utils/image_preview.ts +0 -36
  136. package/src/utils/models.ts +0 -98
  137. package/src/utils/path_complete.ts +0 -173
  138. package/src/utils/shell.ts +0 -72
  139. package/src/utils/spinner_frames.ts +0 -1
  140. package/src/utils/spinner_verbs.ts +0 -23
  141. package/src/utils/tool_summary.ts +0 -56
  142. package/src/utils/tool_trace.ts +0 -216
  143. package/src/utils/update.ts +0 -44
  144. package/src/utils/version.ts +0 -15
  145. package/src/web/index.html +0 -352
  146. package/src/web/mirror-favicon.svg +0 -4
  147. package/src/web/mirror.html +0 -641
  148. package/src/web/mirror_hook.ts +0 -25
  149. package/src/web/mirror_server.ts +0 -204
  150. package/tsconfig.json +0 -22
  151. package/vite.config.ts +0 -363
package/src/App.tsx DELETED
@@ -1,813 +0,0 @@
1
- import React, { useState, useCallback, useMemo, useRef } from 'react';
2
- import { Box, useApp, useInput, Static } from 'ink';
3
- import { Header, MessageList, SingleMessage, InputArea, StatusBar, FullScreen, ActivityLine, useScreenSize } from './components/index.js';
4
- import { buildAppView } from './ui/views/app.js';
5
- import { useMirror } from './web/mirror_hook.js';
6
- import { Agent } from './agent/index.js';
7
- import { commands, type CommandContext } from './agent/commands/index.js';
8
- import { Message, AgentState, InputMode } from './types.js';
9
- import { InputState } from './ui/core/input_state.js';
10
- import { estimateInputLines } from './ui/views/input_area.js';
11
- import { configStore } from './config.js';
12
- import { createInputBus } from './ui/core/input.js';
13
- import { loadEmulationProfiles, getEmulationProfile } from './emulation/catalog.js';
14
- import { debugLog } from './debug/logger.js';
15
- import { runShellCommand, resolveWorkingDir } from './utils/shell.js';
16
- import { writeClipboard } from './utils/clipboard.js';
17
- import { listModels } from './utils/models.js';
18
- import { formatToolStart, formatToolEnd, formatToolError, buildToolOutputMessage } from './utils/tool_trace.js';
19
- import { getTraceStyle } from './emulation/trace_style.js';
20
- import { autoActivateSkills, buildSkillPrompt } from './skills/index.js';
21
- import { getSkillRegistry } from './skills/registry.js';
22
- import { Skill } from './skills/types.js';
23
- import { createAgentFromConfig } from './agent/factory.js';
24
- import { checkForUpdate } from './utils/update.js';
25
- import { getVersion } from './utils/version.js';
26
- import { DEFAULT_SPINNER_VERBS } from './utils/spinner_verbs.js';
27
- import { SPINNER_FRAMES } from './utils/spinner_frames.js';
28
-
29
- // --- Utilities ---
30
-
31
- const generateId = () => Math.random().toString(36).slice(2, 11);
32
-
33
- // --- Initial welcome message ---
34
-
35
- function getWelcomeMessage(): Message {
36
- const hasKey = configStore.hasApiKey();
37
- return {
38
- id: generateId(),
39
- role: 'system',
40
- content: hasKey
41
- ? 'Welcome to ZTC - Zerg Terminal Client.\nType a message to begin, or /help for commands.'
42
- : 'Welcome to ZTC - Zerg Terminal Client.\n\n⚠️ No API key configured.\n\nSet your Anthropic API key with:\n /config key sk-ant-your-key-here\n\nOr set the ANTHROPIC_API_KEY environment variable.',
43
- timestamp: new Date()
44
- };
45
- }
46
-
47
- // --- Create agent if possible ---
48
-
49
- function createAgent(): Agent | null {
50
- if (!configStore.hasApiKey()) return null;
51
- const provider = configStore.getProvider();
52
- const apiKey = configStore.getApiKey(provider);
53
- return createAgentFromConfig({
54
- config: configStore.get(),
55
- provider,
56
- apiKey,
57
- openaiCompatibleBaseUrl: configStore.getOpenAICompatibleBaseUrl(),
58
- emulationId: configStore.getEmulationId()
59
- });
60
- }
61
-
62
- // --- Main App ---
63
-
64
- export const App: React.FC = () => {
65
- const renderCount = React.useRef(0);
66
- const { exit } = useApp();
67
- const { rows, columns } = useScreenSize();
68
- const mirrorEnabled = process.env.ZTC_WEB_MIRROR === '1';
69
- const provider = configStore.getProvider();
70
- const model = configStore.get().model;
71
- const emulationId = configStore.getEmulationId();
72
- const shellCwdRef = React.useRef(process.cwd());
73
-
74
- // State
75
- const [messages, setMessages] = useState<Message[]>([getWelcomeMessage()]);
76
- const messagesRef = useRef<Message[]>(messages);
77
- const [agentState, setAgentState] = useState<AgentState>({ status: 'idle' });
78
- const [sessionId] = useState(generateId());
79
- const [agent, setAgent] = useState<Agent | null>(createAgent);
80
- const [expandToolOutputs, setExpandToolOutputs] = useState(false);
81
- const [skills, setSkills] = useState<Skill[]>([]);
82
- const [inputMode, setInputMode] = useState<InputMode>('queue');
83
- const queueRef = useRef<string[]>([]);
84
- const activeRunIdRef = useRef<string | null>(null);
85
- const runCounterRef = useRef(0);
86
- const lastRunDurationRef = useRef<number | null>(null);
87
-
88
- React.useEffect(() => {
89
- let active = true;
90
- configStore.load(true).then(() => {
91
- if (!active) return;
92
- setMessages(prev => {
93
- if (
94
- prev.length === 1 &&
95
- prev[0]?.role === 'system' &&
96
- prev[0].content.startsWith('Welcome to ZTC')
97
- ) {
98
- return [getWelcomeMessage()];
99
- }
100
- return prev;
101
- });
102
- }).catch(() => {});
103
- return () => { active = false; };
104
- }, []);
105
- const [inputSnapshot, setInputSnapshot] = useState<InputState>({
106
- segments: [],
107
- cursor: { index: 0, offset: 0 },
108
- history: [],
109
- historyIdx: -1
110
- });
111
- const lastRequestRef = useRef<{ messages: Message[]; agent: Agent } | null>(null);
112
- const [retryAvailable, setRetryAvailable] = useState(false);
113
- const [toast, setToast] = useState<string | null>(null);
114
- const toastTimerRef = useRef<NodeJS.Timeout | null>(null);
115
- const [spinnerLabel, setSpinnerLabel] = useState<string | null>(null);
116
- const [spinnerFrame, setSpinnerFrame] = useState<string | null>(null);
117
- const streamingMessageId = React.useRef<string | null>(null);
118
- const streamedResponse = React.useRef(false);
119
- const toolStartTimes = React.useRef<Map<string, number[]>>(new Map());
120
- const activeSkillIds = React.useRef<string[]>([]);
121
- const inputBus = useMemo(() => createInputBus(), []);
122
- const fallbackContextLength = useMemo(
123
- () => messages.reduce((sum, msg) => sum + msg.content.length, 0),
124
- [messages]
125
- );
126
- const contextLength = agentState.contextTokens ?? fallbackContextLength;
127
- const contextEstimated = agentState.contextTokens ? agentState.tokensEstimated : true;
128
- const spinnerVerbs = configStore.get().spinnerVerbs || DEFAULT_SPINNER_VERBS;
129
- const spinnerVerbsKey = spinnerVerbs.join('|');
130
-
131
- React.useEffect(() => {
132
- renderCount.current += 1;
133
- debugLog(`App render #${renderCount.current} (messages=${messages.length}, state=${agentState.status})`);
134
- });
135
- React.useEffect(() => {
136
- messagesRef.current = messages;
137
- }, [messages]);
138
- const [debug, setDebug] = useState(false);
139
- const scrollback = process.env.ZTC_SCROLLBACK === '1' || process.env.ZTC_ALT_SCREEN !== '1';
140
-
141
- React.useEffect(() => {
142
- if (agentState.status !== 'thinking' && agentState.status !== 'streaming') {
143
- setSpinnerLabel(null);
144
- setSpinnerFrame(null);
145
- return;
146
- }
147
- if (spinnerVerbs.length === 0) {
148
- setSpinnerLabel(null);
149
- setSpinnerFrame(null);
150
- return;
151
- }
152
- const verb = spinnerVerbs[Math.floor(Math.random() * spinnerVerbs.length)];
153
- setSpinnerLabel(verb);
154
- }, [agentState.status, spinnerVerbsKey]);
155
-
156
- React.useEffect(() => {
157
- if (agentState.status !== 'thinking' && agentState.status !== 'streaming') {
158
- setSpinnerFrame(null);
159
- return;
160
- }
161
- // Animation is now safe in scrollback mode because we use Ink's Static component
162
- // for completed messages - only the live section below Static re-renders
163
- let index = 0;
164
- setSpinnerFrame(SPINNER_FRAMES[index]);
165
- const interval = setInterval(() => {
166
- index = (index + 1) % SPINNER_FRAMES.length;
167
- setSpinnerFrame(SPINNER_FRAMES[index]);
168
- }, 120);
169
- return () => {
170
- clearInterval(interval);
171
- };
172
- }, [agentState.status]);
173
-
174
- const headerHeight = scrollback ? 0 : 4; // border (2) + content (1) + margin (1)
175
- const statusHeight = 1;
176
- const suggestionLines = 4;
177
-
178
- const estimateBadgePreviewLines = useCallback((state: InputState): number => {
179
- const segment = state.segments[state.cursor.index];
180
- if (!segment || segment.type === 'text') return 0;
181
- if (segment.type === 'paste') {
182
- const lines = segment.text.split('\n');
183
- return Math.min(3, lines.length) + (lines.length > 3 ? 1 : 0);
184
- }
185
- if (segment.type === 'image') {
186
- return 16;
187
- }
188
- return 1;
189
- }, []);
190
-
191
- const inputHeight = useMemo(() => {
192
- const inputLineCount = estimateInputLines(inputSnapshot.segments, columns);
193
- const previewLines = estimateBadgePreviewLines(inputSnapshot);
194
- const maxInputLines = Math.max(1, rows - (headerHeight + statusHeight + suggestionLines + 5));
195
- return Math.min(Math.max(1, inputLineCount + previewLines), maxInputLines) + suggestionLines;
196
- }, [columns, estimateBadgePreviewLines, inputSnapshot, rows]);
197
-
198
- // Calculate content height (total - header - input - status)
199
- const contentHeight = useMemo(
200
- () => scrollback ? undefined : Math.max(rows - (headerHeight + inputHeight + statusHeight), 5),
201
- [rows, scrollback, inputHeight]
202
- );
203
-
204
- // Reload agent when config changes
205
- const reloadAgent = useCallback(() => {
206
- setAgent(createAgent());
207
- }, []);
208
-
209
- React.useEffect(() => {
210
- let active = true;
211
- getSkillRegistry()
212
- .then(registry => {
213
- if (!active) return;
214
- setSkills(registry);
215
- })
216
- .catch(() => {});
217
- return () => { active = false; };
218
- }, []);
219
-
220
- // Keyboard shortcuts
221
- useInput((input, key) => {
222
- if (key.ctrl && input === 'c') {
223
- exit();
224
- }
225
- if (key.ctrl && input === 'l') {
226
- setMessages([{
227
- id: generateId(),
228
- role: 'system',
229
- content: 'Screen cleared.',
230
- timestamp: new Date()
231
- }]);
232
- }
233
- if (key.ctrl && input === 'o') {
234
- setExpandToolOutputs(prev => !prev);
235
- }
236
- });
237
-
238
- // Message helpers
239
- const addMessage = useCallback((msg: Omit<Message, 'id' | 'timestamp'>) => {
240
- setMessages(prev => [...prev, {
241
- ...msg,
242
- id: generateId(),
243
- timestamp: new Date()
244
- }]);
245
- }, []);
246
-
247
- React.useEffect(() => {
248
- if (process.env.ZTC_DISABLE_UPDATE === '1') return;
249
- if (process.env.ZTC_WEB_MIRROR === '1') return;
250
- let active = true;
251
- const current = getVersion();
252
- checkForUpdate(current).then(info => {
253
- if (!active || !info.hasUpdate) return;
254
- addMessage({
255
- role: 'system',
256
- content: `Update available: v${info.latest} (current v${info.current}). Run /update to install.`
257
- });
258
- }).catch(() => {});
259
- return () => { active = false; };
260
- }, [addMessage]);
261
-
262
- const clearMessages = useCallback(() => {
263
- setMessages([]);
264
- }, []);
265
-
266
- const getMessages = useCallback(() => messages, [messages]);
267
-
268
- const shellController = useMemo(() => ({
269
- getCwd: () => shellCwdRef.current,
270
- setCwd: async (path: string) => {
271
- const next = await resolveWorkingDir(shellCwdRef.current, path);
272
- shellCwdRef.current = next;
273
- // Also update agent's working directory so tools use it
274
- if (agent) {
275
- agent.setCwd(next);
276
- }
277
- return next;
278
- },
279
- run: async (command: string) => runShellCommand(command, shellCwdRef.current)
280
- }), [agent]);
281
-
282
- const isRetryableError = useCallback((message: string) => {
283
- const lower = message.toLowerCase();
284
- return lower.includes('overloaded') || lower.includes('529') || lower.includes('429') || lower.includes('rate limit');
285
- }, []);
286
-
287
- const runWithRetry = useCallback(async (
288
- requestMessages: Message[],
289
- runAgent: Agent,
290
- isManual = false,
291
- isActive?: () => boolean
292
- ) => {
293
- const maxRetries = 3;
294
- let attempt = 0;
295
- setRetryAvailable(false);
296
- while (attempt < maxRetries) {
297
- attempt += 1;
298
- if (isActive && !isActive()) return;
299
- try {
300
- streamedResponse.current = false;
301
- const result = await runAgent.run(requestMessages);
302
- if (isActive && !isActive()) return;
303
- if (!streamedResponse.current) {
304
- // Only add assistant message if there's actual text content.
305
- // Tool calls are already displayed via tool_start/tool_end events.
306
- // When the model returns empty content after tool execution, don't show empty message.
307
- const hasContent = result.content && result.content.trim().length > 0;
308
- if (hasContent) {
309
- addMessage({
310
- role: 'assistant',
311
- content: result.content,
312
- toolCalls: result.toolCalls.length > 0 ? result.toolCalls : undefined,
313
- metadata: result.usage ? { usage: result.usage } : undefined
314
- });
315
- }
316
- }
317
- setAgentState({
318
- status: 'idle',
319
- tokensUsed: result.usage?.totalTokens || 0,
320
- contextTokens: result.usage?.inputTokens,
321
- tokensEstimated: result.usage?.estimated,
322
- authError: false
323
- });
324
- return;
325
- } catch (err) {
326
- const errorMsg = (err as Error).message || 'Agent error';
327
- if (isActive && !isActive()) return;
328
- if (!isRetryableError(errorMsg) || attempt >= maxRetries) {
329
- throw err;
330
- }
331
- const delayMs = 500 * Math.pow(2, attempt - 1);
332
- addMessage({
333
- role: 'system',
334
- content: `Overloaded. Retrying in ${Math.round(delayMs / 100) / 10}s... (attempt ${attempt + 1}/${maxRetries})`
335
- });
336
- await new Promise(resolve => setTimeout(resolve, delayMs));
337
- if (isManual) {
338
- setAgentState({ status: 'thinking', startedAt: new Date() });
339
- }
340
- }
341
- }
342
- }, [addMessage, isRetryableError, setAgentState]);
343
-
344
- const retryLast = useCallback(() => {
345
- const last = lastRequestRef.current;
346
- if (!last) {
347
- addMessage({ role: 'system', content: 'No previous request to retry.' });
348
- return;
349
- }
350
- const runId = `${Date.now()}_${runCounterRef.current++}`;
351
- activeRunIdRef.current = runId;
352
- const isActive = () => activeRunIdRef.current === runId;
353
- const runStartedAt = Date.now();
354
- setAgentState({ status: 'thinking', startedAt: new Date() });
355
- void runWithRetry(last.messages, last.agent, true, isActive).catch((err) => {
356
- const message = (err as Error).message || 'Agent error';
357
- if (!isActive()) return;
358
- addMessage({ role: 'system', content: `Error: ${message}` });
359
- if (isRetryableError(message)) {
360
- addMessage({ role: 'system', content: 'Retries exhausted. Use /retry to try again.' });
361
- setRetryAvailable(true);
362
- }
363
- setAgentState({ status: 'error', error: message });
364
- });
365
- }, [addMessage, isRetryableError, runWithRetry, setAgentState]);
366
-
367
- // App context for commands
368
- const appContext: CommandContext = useMemo(() => ({
369
- addMessage,
370
- clearMessages,
371
- getMessages,
372
- setAgentState,
373
- reloadAgent,
374
- setDebug,
375
- retry: retryLast,
376
- exit,
377
- config: configStore,
378
- emulation: {
379
- list: loadEmulationProfiles,
380
- get: getEmulationProfile
381
- },
382
- shell: shellController,
383
- clipboard: {
384
- writeText: writeClipboard
385
- },
386
- models: {
387
- list: async (provider?: string) => listModels({
388
- provider: provider || configStore.getProvider(),
389
- apiKey: configStore.getApiKey(provider || configStore.getProvider()),
390
- baseUrl: configStore.getOpenAICompatibleBaseUrl()
391
- })
392
- },
393
- skills: {
394
- list: async () => getSkillRegistry()
395
- },
396
- getInputMode: () => inputMode,
397
- setInputMode: (mode) => setInputMode(mode)
398
- }), [addMessage, clearMessages, getMessages, reloadAgent, exit, shellController, retryLast, inputMode]);
399
-
400
- // Handle commands
401
- const handleCommand = useCallback((cmd: string, args: string[]) => {
402
- const command = commands.find(c => c.name === cmd.toLowerCase());
403
- if (command) {
404
- const result = command.handler(args, appContext);
405
- if (result && typeof (result as Promise<void>).catch === 'function') {
406
- (result as Promise<void>).catch((err) => {
407
- const message = err instanceof Error ? err.message : 'Command failed';
408
- addMessage({ role: 'system', content: `Command failed: ${message}` });
409
- });
410
- }
411
- } else {
412
- addMessage({
413
- role: 'system',
414
- content: `Unknown command: /${cmd}. Type /help for available commands.`
415
- });
416
- }
417
- }, [appContext, addMessage]);
418
-
419
- // Handle message submission
420
- const handleSubmit = useCallback(async (text: string) => {
421
- if (text.startsWith('!')) {
422
- const commandText = text.slice(1).trim();
423
- if (commandText) {
424
- // Intercept 'cd' commands to use the proper /cd handler that persists
425
- if (commandText === 'cd' || commandText.startsWith('cd ')) {
426
- const path = commandText.slice(2).trim();
427
- handleCommand('cd', path ? [path] : []);
428
- } else {
429
- handleCommand('shell', [commandText]);
430
- }
431
- }
432
- return;
433
- }
434
- if (!configStore.hasApiKey()) {
435
- addMessage({ role: 'user', content: text });
436
- addMessage({
437
- role: 'system',
438
- content: '⚠️ No API key configured.\n\nSet your key with: /config key sk-ant-your-key-here'
439
- });
440
- return;
441
- }
442
-
443
- const busy = agentState.status !== 'idle' && agentState.status !== 'error';
444
- if (busy) {
445
- if (inputMode === 'queue') {
446
- queueRef.current.push(text);
447
- addMessage({
448
- role: 'system',
449
- content: `Queued (${queueRef.current.length})`
450
- });
451
- return;
452
- }
453
- addMessage({
454
- role: 'system',
455
- content: 'Interrupting current response...'
456
- });
457
- }
458
-
459
- let currentAgent = agent;
460
-
461
- if (skills.length > 0) {
462
- const activated = autoActivateSkills(text, skills);
463
- const ids = activated.map(skill => skill.id).sort();
464
- const previous = activeSkillIds.current.join(',');
465
- const next = ids.join(',');
466
- if (previous !== next) {
467
- activeSkillIds.current = ids;
468
- const prompt = buildSkillPrompt(activated);
469
- const nextAgent = createAgentFromConfig({
470
- config: configStore.get(),
471
- provider: configStore.getProvider(),
472
- apiKey: configStore.getApiKey(configStore.getProvider()),
473
- openaiCompatibleBaseUrl: configStore.getOpenAICompatibleBaseUrl(),
474
- emulationId: configStore.getEmulationId(),
475
- skillPrompt: prompt
476
- });
477
- if (nextAgent) {
478
- setAgent(nextAgent);
479
- currentAgent = nextAgent;
480
- }
481
- if (ids.length > 0) {
482
- addMessage({
483
- role: 'system',
484
- content: `Skills activated: ${activated.map(skill => skill.name).join(', ')}`
485
- });
486
- }
487
- }
488
- }
489
-
490
- if (!currentAgent) {
491
- currentAgent = createAgent();
492
- setAgent(currentAgent);
493
- }
494
-
495
- if (!currentAgent) {
496
- addMessage({
497
- role: 'system',
498
- content: 'Failed to initialize agent. Please check your configuration.'
499
- });
500
- return;
501
- }
502
-
503
- const userMsg: Message = {
504
- id: generateId(),
505
- role: 'user',
506
- content: text,
507
- timestamp: new Date()
508
- };
509
- setMessages(prev => [...prev, userMsg]);
510
- const requestMessages = [...messagesRef.current, userMsg];
511
- lastRequestRef.current = { messages: requestMessages, agent: currentAgent };
512
-
513
- const runId = `${Date.now()}_${runCounterRef.current++}`;
514
- activeRunIdRef.current = runId;
515
- const isActive = () => activeRunIdRef.current === runId;
516
- const runStartedAt = Date.now();
517
- setAgentState({ status: 'thinking', startedAt: new Date() });
518
- streamingMessageId.current = null;
519
- streamedResponse.current = false;
520
-
521
- const cleanup = currentAgent.on((event) => {
522
- if (!isActive()) return;
523
- switch (event.type) {
524
- case 'thinking_start':
525
- setAgentState(s => ({ ...s, status: 'thinking' }));
526
- break;
527
- case 'tool_start':
528
- setAgentState(s => ({ ...s, status: 'tool_use', currentTool: event.tool }));
529
- {
530
- const key = event.tool;
531
- const bucket = toolStartTimes.current.get(key) || [];
532
- bucket.push(Date.now());
533
- toolStartTimes.current.set(key, bucket);
534
- addMessage({
535
- role: 'tool',
536
- content: formatToolStart(event.tool, event.args, configStore.getEmulationId())
537
- });
538
- }
539
- break;
540
- case 'stream_start':
541
- streamedResponse.current = true;
542
- setAgentState(s => ({ ...s, status: 'streaming' }));
543
- if (!streamingMessageId.current) {
544
- const id = generateId();
545
- streamingMessageId.current = id;
546
- setMessages(prev => [...prev, {
547
- id,
548
- role: 'assistant',
549
- content: '',
550
- timestamp: new Date(),
551
- isStreaming: true
552
- }]);
553
- }
554
- break;
555
- case 'stream_delta':
556
- setAgentState(s => ({ ...s, status: 'streaming' }));
557
- if (streamingMessageId.current) {
558
- setMessages(prev => prev.map(msg => {
559
- if (msg.id !== streamingMessageId.current) return msg;
560
- return { ...msg, content: `${msg.content}${event.content}`, isStreaming: true };
561
- }));
562
- }
563
- break;
564
- case 'stream_end':
565
- if (streamingMessageId.current) {
566
- setMessages(prev => {
567
- // Remove the streaming message if it ended up empty (tool-only response)
568
- const streamingMsg = prev.find(m => m.id === streamingMessageId.current);
569
- if (streamingMsg && (!streamingMsg.content || streamingMsg.content.trim() === '')) {
570
- return prev.filter(m => m.id !== streamingMessageId.current);
571
- }
572
- // Otherwise mark it as no longer streaming
573
- return prev.map(msg => {
574
- if (msg.id !== streamingMessageId.current) return msg;
575
- return { ...msg, isStreaming: false };
576
- });
577
- });
578
- }
579
- streamingMessageId.current = null;
580
- break;
581
- case 'tool_end': {
582
- const key = event.tool;
583
- const bucket = toolStartTimes.current.get(key) || [];
584
- const started = bucket.shift();
585
- toolStartTimes.current.set(key, bucket);
586
- const duration = started ? Date.now() - started : undefined;
587
- const emulationId = configStore.getEmulationId();
588
- if (getTraceStyle(emulationId) === 'claude_code' || getTraceStyle(emulationId) === 'codex') {
589
- const output = buildToolOutputMessage(event.tool, event.result, duration, emulationId);
590
- addMessage({
591
- role: 'tool',
592
- content: output.preview,
593
- metadata: {
594
- toolOutput: output
595
- }
596
- });
597
- } else {
598
- addMessage({
599
- role: 'tool',
600
- content: formatToolEnd(event.tool, event.result, duration, emulationId)
601
- });
602
- }
603
- break;
604
- }
605
- case 'tool_error': {
606
- addMessage({
607
- role: 'tool',
608
- content: formatToolError(event.tool, event.error, configStore.getEmulationId())
609
- });
610
- break;
611
- }
612
- case 'token_usage':
613
- setAgentState(s => ({
614
- ...s,
615
- tokensUsed: event.usage.totalTokens,
616
- contextTokens: event.usage.inputTokens,
617
- tokensEstimated: event.usage.estimated
618
- }));
619
- break;
620
- case 'error':
621
- setAgentState({ status: 'error', error: event.error });
622
- break;
623
- }
624
- });
625
-
626
- try {
627
- await runWithRetry(requestMessages, currentAgent, false, isActive);
628
- } catch (err) {
629
- const errorMsg = (err as Error).message;
630
- if (!isActive()) return;
631
-
632
- const isAuthError = errorMsg.includes('401') || errorMsg.includes('authentication');
633
- if (isAuthError) {
634
- addMessage({
635
- role: 'system',
636
- content: `Authentication failed. Your API key may be invalid.\n\nUpdate it with: /config key sk-ant-your-key-here`
637
- });
638
- } else {
639
- addMessage({
640
- role: 'system',
641
- content: `Error: ${errorMsg}`
642
- });
643
- if (isRetryableError(errorMsg)) {
644
- addMessage({
645
- role: 'system',
646
- content: 'Retries exhausted. Use /retry to try again.'
647
- });
648
- setRetryAvailable(true);
649
- }
650
- }
651
- setAgentState({ status: 'error', error: errorMsg, authError: isAuthError });
652
-
653
- setTimeout(() => {
654
- setAgentState({ status: 'idle' });
655
- }, 3000);
656
- } finally {
657
- if (isActive()) {
658
- streamedResponse.current = false;
659
- streamingMessageId.current = null;
660
- activeRunIdRef.current = null;
661
- lastRunDurationRef.current = Date.now() - runStartedAt;
662
- if (getTraceStyle(configStore.getEmulationId()) === 'codex') {
663
- const seconds = Math.max(0, Math.round((lastRunDurationRef.current || 0) / 1000));
664
- addMessage({
665
- role: 'tool',
666
- content: `✻ Worked for ${seconds}s`
667
- });
668
- }
669
- cleanup();
670
- if (inputMode === 'queue' && queueRef.current.length > 0) {
671
- const next = queueRef.current.shift();
672
- if (next) {
673
- void handleSubmit(next);
674
- }
675
- }
676
- } else {
677
- cleanup();
678
- }
679
- }
680
- }, [agent, agentState.status, addMessage, handleCommand, inputMode, isRetryableError, runWithRetry]);
681
-
682
- const layoutTree = useMemo(() => {
683
- if (!mirrorEnabled) return null;
684
- return buildAppView({
685
- messages,
686
- agentState,
687
- inputState: inputSnapshot,
688
- sessionId,
689
- cols: columns,
690
- rows,
691
- commands,
692
- hasApiKey: configStore.hasApiKey(),
693
- version: getVersion(),
694
- contextLength,
695
- contextEstimated,
696
- provider,
697
- model,
698
- emulationId,
699
- inputMode,
700
- toast,
701
- spinnerLabel,
702
- spinnerFrame,
703
- debug,
704
- expandToolOutputs
705
- });
706
- }, [mirrorEnabled, messages, agentState, inputSnapshot, sessionId, rows, contextLength, contextEstimated, provider, model, emulationId, inputMode, toast, spinnerLabel, spinnerFrame, debug, expandToolOutputs]);
707
-
708
- const showToast = useCallback((message: string) => {
709
- setToast(message);
710
- if (toastTimerRef.current) {
711
- clearTimeout(toastTimerRef.current);
712
- }
713
- toastTimerRef.current = setTimeout(() => {
714
- setToast(null);
715
- }, 2500);
716
- }, []);
717
-
718
- useMirror(layoutTree, inputBus);
719
-
720
- // Compute messages for Static (completed) vs live (streaming)
721
- // Static component handles deduplication by key - it only renders new items
722
- const { staticMessages, streamingMessage } = useMemo(() => {
723
- if (!scrollback) {
724
- return { staticMessages: [], streamingMessage: null };
725
- }
726
-
727
- // Completed messages go to Static (rendered once, kept in scrollback)
728
- const completed = messages.filter(m => !m.isStreaming);
729
- // Streaming message stays in live section (can update)
730
- const streaming = messages.find(m => m.isStreaming) || null;
731
-
732
- return {
733
- staticMessages: completed,
734
- streamingMessage: streaming
735
- };
736
- }, [messages, scrollback]);
737
-
738
- return (
739
- <FullScreen debug={debug} scrollback={scrollback}>
740
- {!scrollback && <Header version={getVersion()} debug={debug} />}
741
-
742
- {scrollback ? (
743
- <Box flexDirection="column">
744
- {/* Static: each message rendered ONCE, stays in scrollback buffer */}
745
- <Static items={staticMessages}>
746
- {(msg: Message) => (
747
- <Box key={msg.id} flexDirection="column">
748
- <SingleMessage message={msg} expandToolOutputs={expandToolOutputs} />
749
- </Box>
750
- )}
751
- </Static>
752
-
753
- {/* Live section: streaming message can update */}
754
- {streamingMessage && (
755
- <SingleMessage message={streamingMessage} expandToolOutputs={expandToolOutputs} />
756
- )}
757
- </Box>
758
- ) : (
759
- <MessageList
760
- messages={messages}
761
- height={contentHeight}
762
- debug={debug}
763
- expandToolOutputs={expandToolOutputs}
764
- scrollback={scrollback}
765
- />
766
- )}
767
-
768
- <ActivityLine
769
- state={agentState}
770
- spinnerLabel={spinnerLabel}
771
- spinnerFrame={spinnerFrame}
772
- inputMode={inputMode}
773
- />
774
-
775
- <InputArea
776
- onSubmit={handleSubmit}
777
- onCommand={handleCommand}
778
- commands={commands}
779
- onStateChange={setInputSnapshot}
780
- onToast={showToast}
781
- cols={columns}
782
- inputBus={inputBus}
783
- disabled={false}
784
- debug={debug}
785
- cwd={shellCwdRef.current}
786
- placeholder={
787
- !configStore.hasApiKey() ? 'Set API key with /config key <key>' :
788
- agentState.status === 'thinking' ? 'Thinking...' :
789
- agentState.status === 'tool_use' ? `Running ${agentState.currentTool}...` :
790
- agentState.status === 'streaming' ? 'Streaming response...' :
791
- 'Type a message or /help for commands...'
792
- }
793
- />
794
-
795
- <StatusBar
796
- state={agentState}
797
- sessionId={sessionId}
798
- version={getVersion()}
799
- connectionStatus={configStore.hasApiKey() ? 'connected' : 'disconnected'}
800
- contextLength={contextLength}
801
- contextEstimated={contextEstimated}
802
- provider={provider}
803
- model={model}
804
- emulationId={emulationId}
805
- inputMode={inputMode}
806
- toast={toast}
807
- debug={debug}
808
- />
809
- </FullScreen>
810
- );
811
- };
812
-
813
- export default App;