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