@benzsiangco/jarvis 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +5 -0
  2. package/bin/{jarvis.js → jarvis} +1 -1
  3. package/dist/cli.js +476 -350
  4. package/dist/electron/main.js +160 -0
  5. package/dist/electron/preload.js +19 -0
  6. package/package.json +21 -8
  7. package/skills.md +147 -0
  8. package/src/agents/index.ts +248 -0
  9. package/src/brain/loader.ts +136 -0
  10. package/src/cli.ts +411 -0
  11. package/src/config/index.ts +363 -0
  12. package/src/core/executor.ts +222 -0
  13. package/src/core/plugins.ts +148 -0
  14. package/src/core/types.ts +217 -0
  15. package/src/electron/main.ts +192 -0
  16. package/src/electron/preload.ts +25 -0
  17. package/src/electron/types.d.ts +20 -0
  18. package/src/index.ts +12 -0
  19. package/src/providers/antigravity-loader.ts +233 -0
  20. package/src/providers/antigravity.ts +585 -0
  21. package/src/providers/index.ts +523 -0
  22. package/src/sessions/index.ts +194 -0
  23. package/src/tools/index.ts +436 -0
  24. package/src/tui/index.tsx +784 -0
  25. package/src/utils/auth-prompt.ts +394 -0
  26. package/src/utils/index.ts +180 -0
  27. package/src/utils/native-picker.ts +71 -0
  28. package/src/utils/skills.ts +99 -0
  29. package/src/utils/table-integration-examples.ts +617 -0
  30. package/src/utils/table-utils.ts +401 -0
  31. package/src/web/build-ui.ts +27 -0
  32. package/src/web/server.ts +674 -0
  33. package/src/web/ui/dist/.gitkeep +0 -0
  34. package/src/web/ui/dist/main.css +1 -0
  35. package/src/web/ui/dist/main.js +320 -0
  36. package/src/web/ui/dist/main.js.map +20 -0
  37. package/src/web/ui/index.html +46 -0
  38. package/src/web/ui/src/App.tsx +143 -0
  39. package/src/web/ui/src/Modules/Safety/GuardianModal.tsx +83 -0
  40. package/src/web/ui/src/components/Layout/ContextPanel.tsx +243 -0
  41. package/src/web/ui/src/components/Layout/Header.tsx +91 -0
  42. package/src/web/ui/src/components/Layout/ModelSelector.tsx +235 -0
  43. package/src/web/ui/src/components/Layout/SessionStats.tsx +369 -0
  44. package/src/web/ui/src/components/Layout/Sidebar.tsx +895 -0
  45. package/src/web/ui/src/components/Modules/Chat/ChatStage.tsx +620 -0
  46. package/src/web/ui/src/components/Modules/Chat/MessageItem.tsx +446 -0
  47. package/src/web/ui/src/components/Modules/Editor/CommandInspector.tsx +71 -0
  48. package/src/web/ui/src/components/Modules/Editor/DiffViewer.tsx +83 -0
  49. package/src/web/ui/src/components/Modules/Terminal/TabbedTerminal.tsx +202 -0
  50. package/src/web/ui/src/components/Settings/SettingsModal.tsx +935 -0
  51. package/src/web/ui/src/config/models.ts +70 -0
  52. package/src/web/ui/src/main.tsx +13 -0
  53. package/src/web/ui/src/store/agentStore.ts +41 -0
  54. package/src/web/ui/src/store/uiStore.ts +64 -0
  55. package/src/web/ui/src/types/index.ts +54 -0
@@ -0,0 +1,784 @@
1
+ /** @jsxImportSource @opentui/react */
2
+ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
3
+ import { createRoot, useKeyboard, useTerminalDimensions } from '@opentui/react';
4
+ import { createCliRenderer, TextAttributes } from '@opentui/core';
5
+ import { marked } from 'marked';
6
+ import TerminalRenderer from 'marked-terminal';
7
+ import { exec } from 'child_process';
8
+ import type { Session, AgentConfig } from '../core/types.js';
9
+ import { createSession, getSession } from '../sessions/index.js';
10
+ import { getAgent, getPrimaryAgents, initializeAgents } from '../agents/index.js';
11
+ import { executeAgent } from '../core/executor.js';
12
+ import { loadConfig, getModelConfig, saveGlobalConfig, type JarvisConfig } from '../config/index.js';
13
+ import { initializeProviders, getAvailableModels, getProvidersWithStatus } from '../providers/index.js';
14
+ import { getActiveAccount, getAccountStats, startAntigravityLogin } from '../providers/antigravity.js';
15
+ import { runInteractiveAuth, providers as authProviders, saveApiKey, removeApiKey, getApiKeyStatus } from '../utils/auth-prompt.js';
16
+ import { loadSkills, addSkill, hasSkillsFile, type Skill } from '../utils/skills.js';
17
+ import { initializeTools } from '../tools/index.js';
18
+
19
+ // Configure marked to use terminal renderer
20
+ marked.setOptions({
21
+ renderer: new (TerminalRenderer as any)()
22
+ });
23
+
24
+ interface AppProps {
25
+ initialPrompt?: string;
26
+ model?: string;
27
+ variant?: string;
28
+ runInteractive?: (task: () => Promise<void>) => Promise<void>;
29
+ }
30
+
31
+ interface ToolCall {
32
+ name: string;
33
+ description?: string;
34
+ command?: string;
35
+ output?: string;
36
+ expanded?: boolean;
37
+ }
38
+
39
+ interface Message {
40
+ role: 'user' | 'assistant';
41
+ content: string;
42
+ thinking?: string;
43
+ agentName?: string;
44
+ modelName?: string;
45
+ duration?: number;
46
+ toolCalls?: ToolCall[];
47
+ usage?: {
48
+ promptTokens?: number;
49
+ completionTokens?: number;
50
+ totalTokens?: number;
51
+ };
52
+ }
53
+
54
+ interface Command {
55
+ id: string;
56
+ name: string;
57
+ description?: string;
58
+ shortcut?: string;
59
+ category: string;
60
+ action: () => void;
61
+ }
62
+
63
+ interface ModelWithVariants {
64
+ id: string;
65
+ name: string;
66
+ provider?: string;
67
+ variants?: Record<string, any>;
68
+ }
69
+
70
+ // Markdown Text Component - Fixed line-by-line rendering to prevent overlap
71
+ function MarkdownText({ content, fg = "#ffffff" }: { content: string, fg?: string }) {
72
+ const { width } = useTerminalDimensions();
73
+ const lines = useMemo(() => {
74
+ try {
75
+ const renderer = new (TerminalRenderer as any)({
76
+ width: Math.max(20, width - 15),
77
+ reflowText: true,
78
+ showSectionPrefix: false,
79
+ });
80
+ const formatted = (marked.parse(content, { renderer }) as string).trim();
81
+ return formatted.split('\n');
82
+ } catch (e) {
83
+ return [content];
84
+ }
85
+ }, [content, width]);
86
+
87
+ return (
88
+ <box style={{ flexDirection: 'column' }}>{lines.map((line, i) => (<text key={i} fg={fg}>{String(line)}</text>))}</box>
89
+ );
90
+ }
91
+
92
+ // Thinking Block - OpenCode style
93
+ function ThinkingBlock({ thinking }: { thinking: string }) {
94
+ const lines = useMemo(() => thinking.split('\n'), [thinking]);
95
+ return (
96
+ <box style={{ marginBottom: 1, flexDirection: 'column' }}><box style={{ border: ['left'], borderStyle: 'single', borderColor: '#d97706', paddingLeft: 1, flexDirection: 'column' }}><text fg="#d97706" style={{ attributes: TextAttributes.ITALIC }}>Thinking:</text>{lines.map((line, i) => (
97
+ <text key={i} fg="#888888">{String(line)}</text>
98
+ ))}</box></box>
99
+ );
100
+ }
101
+
102
+ // Tool Block - Expandable terminal
103
+ function ToolBlock({ tool, isExpanded, isHovered, onHover, onToggle }: { tool: ToolCall; isExpanded: boolean; isHovered: boolean; onHover: () => void; onToggle: () => void; }) {
104
+ const outputLines = tool.output?.split('\n') || [];
105
+ const maxLines = 10;
106
+ const hasMore = outputLines.length > maxLines;
107
+ const displayLines = isExpanded ? outputLines : outputLines.slice(0, maxLines);
108
+
109
+ return (
110
+ <box
111
+ style={{ marginBottom: 1, backgroundColor: isHovered ? '#1a1a1a' : '#0d1117', flexDirection: 'column' }}
112
+ onMouseMove={onHover}
113
+ onMouseDown={onToggle}
114
+ ><box style={{ flexDirection: 'column', border: ['left'], borderStyle: 'single', borderColor: isHovered ? '#22d3ee' : '#06b6d4', paddingLeft: 1 }}>{tool.description ? <text fg="#666666"># {String(tool.description)}</text> : null}<box style={{ flexDirection: 'row' }}><text fg="#22c55e">$ </text><text fg="#ffffff">{String(tool.command || tool.name)}</text></box>{displayLines.length > 0 ? (
115
+ <box style={{ flexDirection: 'column', marginTop: 1 }}>{displayLines.map((line, i) => (
116
+ <text key={i} fg="#888888">{String(line)}</text>
117
+ ))}{!isExpanded && hasMore ? <text fg="#666666">...</text> : null}</box>
118
+ ) : null}{hasMore ? (
119
+ <box style={{ marginTop: 1 }}><text fg="#06b6d4">{isExpanded ? 'Click to collapse' : 'Click to expand'}</text></box>
120
+ ) : null}</box></box>
121
+ );
122
+ }
123
+
124
+ // User Message - Cyan border text
125
+ function UserMessage({ content }: { content: string }) {
126
+ return (
127
+ <box style={{ marginBottom: 1 }}><box style={{ border: ['left'], borderStyle: 'single', borderColor: '#06b6d4', paddingLeft: 1 }}><MarkdownText content={content} /></box></box>
128
+ );
129
+ }
130
+
131
+ // Assistant Message - Metadata and Content
132
+ function AssistantMessage({ message, expandedTools, onToggleTool, hoveredToolIndex, onHoverTool }: { message: Message; expandedTools: Set<number>; onToggleTool: (index: number) => void; hoveredToolIndex: number | null; onHoverTool: (index: number | null) => void; }) {
133
+ return (
134
+ <box style={{ flexDirection: 'column', marginBottom: 1 }}>
135
+ {message.thinking ? <ThinkingBlock thinking={message.thinking} /> : null}
136
+ {message.toolCalls ? message.toolCalls.map((tool, i) => (
137
+ <ToolBlock key={i} tool={tool} isExpanded={expandedTools.has(i)} isHovered={hoveredToolIndex === i} onHover={() => onHoverTool(i)} onToggle={() => onToggleTool(i)} />
138
+ )) : null}
139
+ <MarkdownText content={message.content} />
140
+ <box style={{ marginTop: 1, flexDirection: 'row' }}>
141
+ <text fg="#22c55e">■ </text>
142
+ <text fg="#22c55e">{String(message.agentName || 'Jarvis')} </text>
143
+ <text fg="#666666">· {String(message.modelName || 'unknown')}</text>
144
+ {message.duration ? <text fg="#666666"> · {String((message.duration / 1000).toFixed(1))}s</text> : null}
145
+ </box>
146
+ </box>
147
+ );
148
+ }
149
+
150
+ // Modal Component - Clean OpenCode layout
151
+ function Modal({ title, width, height, onClose, children }: { title: string; width: number; height: number; onClose: () => void; children: React.ReactNode }) {
152
+ const modalWidth = Math.min(60, width - 8);
153
+ const modalLeft = Math.floor((width - modalWidth) / 2);
154
+ const modalHeight = Math.min(24, height - 4);
155
+ const modalTop = Math.floor((height - modalHeight) / 2);
156
+
157
+ return (
158
+ <>
159
+ <box style={{ position: 'absolute', top: 0, left: 0, width, height, backgroundColor: '#000000aa' }} onMouseDown={onClose} />
160
+ <box style={{ position: 'absolute', top: modalTop, left: modalLeft, width: modalWidth, height: modalHeight, flexDirection: 'column', backgroundColor: '#1c1c1c', border: ['top', 'bottom', 'left', 'right'], borderStyle: 'single', borderColor: '#3a3a3a' }}><box style={{ flexDirection: 'row', paddingLeft: 1, paddingRight: 1, height: 1, borderStyle: 'single', border: ['bottom'], borderColor: '#3a3a3a' }}><box style={{ flexGrow: 1 }}><text fg="#ffffff" style={{ attributes: TextAttributes.BOLD }}>{String(title)}</text></box><box><text fg="#666666">esc</text></box></box><box style={{ flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }}>{children}</box></box>
161
+ </>
162
+ );
163
+ }
164
+
165
+ // Command Item - Same line name and shortcut
166
+ function CommandItem({ name, description, shortcut, isSelected, onHover, onClick }: { name: string; description?: string; shortcut?: string; isSelected: boolean; onHover: () => void; onClick: () => void }) {
167
+ return (
168
+ <box onMouseMove={onHover} onMouseDown={onClick} style={{ flexDirection: 'row', backgroundColor: isSelected ? '#f97316' : 'transparent', paddingLeft: 1, paddingRight: 1 }}><box style={{ flexGrow: 1, flexDirection: 'row' }}><text fg={isSelected ? '#000000' : '#ffffff'} style={{ attributes: isSelected ? TextAttributes.BOLD : 0 }}>{String(name)}</text>{description ? <text fg={isSelected ? '#000000' : '#888888'}> {String(description)}</text> : null}</box><box style={{ flexShrink: 0 }}>{shortcut ? <text fg={isSelected ? '#000000' : '#666666'}>{String(shortcut)}</text> : null}</box></box>
169
+ );
170
+ }
171
+
172
+ // Helper to read clipboard
173
+ function getClipboard(): Promise<string> {
174
+ return new Promise((resolve) => {
175
+ const platform = process.platform;
176
+ let command = '';
177
+ if (platform === 'win32') command = 'powershell -command "Get-Clipboard"';
178
+ else if (platform === 'darwin') command = 'pbpaste';
179
+ else command = 'xclip -o -selection clipboard';
180
+
181
+ exec(command, (err, stdout) => {
182
+ if (err) resolve('');
183
+ else resolve(stdout.trim());
184
+ });
185
+ });
186
+ }
187
+
188
+ function App({ initialPrompt, variant: initialVariant, runInteractive }: AppProps) {
189
+ const [config, setConfig] = useState<JarvisConfig | null>(null);
190
+ const [currentSession, setCurrentSession] = useState<Session | null>(null);
191
+ const [agent, setAgent] = useState<AgentConfig | null>(null);
192
+ const [inputValue, setInputValue] = useState('');
193
+ const [variant, setVariant] = useState<string | undefined>(initialVariant);
194
+ const [pastedContent, setPastedContent] = useState<string | null>(null);
195
+ const [isLoading, setIsLoading] = useState(false);
196
+ const [history, setHistory] = useState<string[]>([]);
197
+ const [historyIndex, setHistoryIndex] = useState<number>(-1);
198
+ const [tempInput, setTempInput] = useState('');
199
+ const [streamingText, setStreamingText] = useState('');
200
+ const [streamingThinking, setStreamingThinking] = useState('');
201
+ const [error, setError] = useState<string | null>(null);
202
+ const [mode, setMode] = useState<'chat' | 'commands' | 'models' | 'agents' | 'variants' | 'providers' | 'auth' | 'settings' | 'skills'>('chat');
203
+ const [authStep, setAuthStep] = useState<'list' | 'method' | 'apikey'>('list');
204
+ const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
205
+ const [selectionIndex, setSelectionIndex] = useState(0);
206
+ const [skills, setSkills] = useState<Skill[]>([]);
207
+ const [skillStep, setSkillStep] = useState<'list' | 'create'>('list');
208
+ const [newSkillName, setNewSkillName] = useState('');
209
+ const [newSkillDesc, setNewSkillDesc] = useState('');
210
+ const [messages, setMessages] = useState<Message[]>([]);
211
+ const [totalTokens, setTotalTokens] = useState(0);
212
+ const [sessionName, setSessionName] = useState('New session');
213
+ const [hoveredToolIndex, setHoveredToolIndex] = useState<number | null>(null);
214
+ const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
215
+ const [searchValue, setSearchValue] = useState('');
216
+ const [accountStats, setAccountStats] = useState({ active: '', count: 0, total: 0 });
217
+ const scrollRef = useRef<any>(null);
218
+ const appStartTime = useRef(Date.now());
219
+ const [shutdownState, setShutdownState] = useState<'none' | 'summary' | 'closing'>('none');
220
+
221
+ const { width, height } = useTerminalDimensions();
222
+
223
+ const settingsList = useMemo(() => [
224
+ { id: 'bash', name: 'Execute Commands' },
225
+ { id: 'read', name: 'Read Files' },
226
+ { id: 'edit', name: 'Edit Files' },
227
+ { id: 'webfetch', name: 'Web Access' },
228
+ ], []);
229
+
230
+ const togglePermission = useCallback((id: string) => {
231
+ if (!config) return;
232
+ // @ts-ignore
233
+ const current = config.permission?.[id] || 'ask';
234
+ const next = current === 'ask' ? 'allow' : current === 'allow' ? 'deny' : 'ask';
235
+
236
+ const newConfig = {
237
+ ...config,
238
+ permission: {
239
+ ...config.permission,
240
+ [id]: next
241
+ }
242
+ };
243
+ setConfig(newConfig);
244
+ saveGlobalConfig(newConfig);
245
+ }, [config]);
246
+
247
+ useEffect(() => {
248
+ if (shutdownState === 'summary') {
249
+ const timer = setTimeout(() => setShutdownState('closing'), 3500);
250
+ return () => clearTimeout(timer);
251
+ }
252
+ if (shutdownState === 'closing') {
253
+ const timer = setTimeout(() => {
254
+ process.stdout.write('\u001b[?25h');
255
+ process.stdout.write('\u001b[2J\u001b[3J\u001b[H');
256
+ process.exit(0);
257
+ }, 1500);
258
+ return () => clearTimeout(timer);
259
+ }
260
+ }, [shutdownState]);
261
+
262
+ useEffect(() => {
263
+ const updateStats = () => {
264
+ try {
265
+ const active = getActiveAccount();
266
+ const stats = getAccountStats();
267
+ setAccountStats({
268
+ active: active?.email || '',
269
+ count: stats.available,
270
+ total: stats.total
271
+ });
272
+ setSkills(loadSkills());
273
+ } catch (e) {
274
+ // Ignore errors
275
+ }
276
+ };
277
+
278
+ updateStats();
279
+ const interval = setInterval(updateStats, 2000);
280
+ return () => clearInterval(interval);
281
+ }, []);
282
+
283
+ const buildCommands = useCallback((): Command[] => [
284
+ { id: 'login', name: 'Jarvis Auth Login', category: 'Account', action: () => { setMode('auth'); setAuthStep('list'); setSelectionIndex(0); } },
285
+ { id: 'skills', name: 'Skills', category: 'General', action: () => { setMode('skills'); setSkillStep('list'); setSelectionIndex(0); } },
286
+ { id: 'settings', name: 'Settings', category: 'General', action: () => { setMode('settings'); setSelectionIndex(0); } },
287
+ { id: 'show-providers', name: 'Show Providers', category: 'System', action: () => { setMode('providers'); setSelectionIndex(0); } },
288
+ { id: 'switch-model', name: 'Switch model', shortcut: 'ctrl+x m', category: 'Model', action: () => { setMode('models'); setSelectionIndex(0); } },
289
+ { id: 'open-editor', name: 'Open editor', shortcut: 'ctrl+x e', category: 'Session', action: () => {} },
290
+ { id: 'rename-session', name: 'Rename session', shortcut: 'ctrl+r', category: 'Session', action: () => {} },
291
+ { id: 'undo-previous', name: 'Undo previous message', shortcut: 'ctrl+x u', category: 'Session', action: () => {} },
292
+ { id: 'clear', name: 'Clear chat', shortcut: 'ctrl+l', category: 'Quick', action: () => { setMessages([]); setTotalTokens(0); setMode('chat'); } },
293
+ { id: 'new-session', name: 'New session', shortcut: 'ctrl+n', category: 'Quick', action: () => { setCurrentSession(null); setMessages([]); setTotalTokens(0); setSessionName('New session'); setMode('chat'); } },
294
+ { id: 'exit', name: 'Exit', shortcut: 'ctrl+c', category: 'System', action: () => setShutdownState('summary') },
295
+ ], []);
296
+
297
+ useEffect(() => {
298
+ const init = async () => {
299
+ try {
300
+ const cfg = loadConfig(); setConfig(cfg);
301
+ await initializeProviders(cfg); initializeAgents(cfg.agent); initializeTools();
302
+ const primaryAgents = getPrimaryAgents(); const initialAgent = primaryAgents[0] || getAgent('build');
303
+ if (initialAgent) setAgent(initialAgent);
304
+ if (initialPrompt) setInputValue(initialPrompt);
305
+ } catch (err) { setError(`Init Error: ${(err as Error).message}`); }
306
+ };
307
+ init();
308
+ }, [initialPrompt]);
309
+
310
+ const availableModels: ModelWithVariants[] = config ? getAvailableModels(config).map(m => ({ ...m, variants: getModelConfig(config, m.id)?.variants })) : [];
311
+ const providersList = getProvidersWithStatus();
312
+ const primaryAgents = getPrimaryAgents();
313
+ const commands = buildCommands();
314
+ const filteredCommands = commands;
315
+
316
+ const currentVariants = useMemo(() => {
317
+ if (!config?.model) return [];
318
+ const modelCfg = getModelConfig(config, config.model);
319
+ return modelCfg?.variants ? Object.keys(modelCfg.variants) : [];
320
+ }, [config]);
321
+
322
+ const getModelDisplayName = useCallback((modelId: string) => {
323
+ if (!config) return modelId;
324
+ return getModelConfig(config, modelId)?.name || modelId.split('/').pop() || 'unknown';
325
+ }, [config]);
326
+
327
+ const getProviderDisplayName = useCallback((modelId: string) => {
328
+ if (modelId.includes('google')) return 'Google';
329
+ if (modelId.includes('anthropic')) return 'Antigravity';
330
+ return 'OpenAI';
331
+ }, []);
332
+
333
+ useKeyboard((key) => {
334
+ if (key.ctrl && key.name === 'c') {
335
+ setShutdownState('summary');
336
+ return;
337
+ }
338
+ if (key.ctrl && key.name === 'v') { handlePaste(); return; }
339
+ if (mode === 'chat') {
340
+ if (key.ctrl && key.name === 'p') { setMode('commands'); setSelectionIndex(0); setSearchValue(''); return; }
341
+ if (key.ctrl && key.name === 'm') { setMode('models'); setSelectionIndex(0); return; }
342
+ if (key.ctrl && key.name === 't') { setMode('variants'); setSelectionIndex(0); return; }
343
+ if (key.name === 'tab') { const next = agent?.id === 'build' ? getAgent('plan') : getAgent('build'); if (next) setAgent(next); return; }
344
+
345
+ if (key.name === 'up') {
346
+ if (history.length === 0) return;
347
+ const newIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
348
+ if (historyIndex === -1) setTempInput(inputValue);
349
+ setHistoryIndex(newIndex);
350
+ setInputValue(history[newIndex] || '');
351
+ return;
352
+ }
353
+ if (key.name === 'down') {
354
+ if (historyIndex === -1) return;
355
+ const newIndex = historyIndex + 1;
356
+ if (newIndex >= history.length) {
357
+ setHistoryIndex(-1);
358
+ setInputValue(tempInput);
359
+ } else {
360
+ setHistoryIndex(newIndex);
361
+ setInputValue(history[newIndex] || '');
362
+ }
363
+ return;
364
+ }
365
+ }
366
+ if (mode === 'commands' || mode === 'models' || mode === 'agents' || mode === 'variants' || mode === 'providers' || mode === 'auth' || mode === 'settings' || mode === 'skills') {
367
+ if (key.name === 'escape') { setMode('chat'); setSearchValue(''); return; }
368
+ if (key.name === 'up') { setSelectionIndex((i) => Math.max(0, i - 1)); return; }
369
+ if (key.name === 'down') {
370
+ const max = mode === 'commands' ? filteredCommands.length - 1
371
+ : mode === 'models' ? availableModels.length - 1
372
+ : mode === 'agents' ? primaryAgents.length - 1
373
+ : mode === 'variants' ? currentVariants.length - 1
374
+ : mode === 'providers' ? providersList.length - 1
375
+ : mode === 'auth' ? (authStep === 'list' ? authProviders.length : authStep === 'method' ? 1 : 0)
376
+ : mode === 'settings' ? 3
377
+ : mode === 'skills' ? (skillStep === 'list' ? skills.length : 0)
378
+ : 0;
379
+ setSelectionIndex((i) => Math.min(max, i + 1)); return;
380
+ }
381
+ if (key.name === 'return') {
382
+ if (mode === 'commands') { const cmd = filteredCommands[selectionIndex]; if (cmd) cmd.action(); }
383
+ else if (mode === 'models') { const m = availableModels[selectionIndex]; if (m && config) { config.model = m.id; setConfig({ ...config }); setMode('chat'); } }
384
+ else if (mode === 'agents') { const a = primaryAgents[selectionIndex]; if (a) { setAgent(a); setMode('chat'); } }
385
+ else if (mode === 'variants') { const v = currentVariants[selectionIndex]; if (v) { setVariant(v); setMode('chat'); } }
386
+ else if (mode === 'providers') { setMode('chat'); }
387
+ else if (mode === 'settings') {
388
+ const s = settingsList[selectionIndex];
389
+ if (s) togglePermission(s.id);
390
+ }
391
+ else if (mode === 'skills') {
392
+ if (skillStep === 'list') {
393
+ if (selectionIndex === 0) { // Create New
394
+ setSkillStep('create');
395
+ setNewSkillName('');
396
+ setNewSkillDesc('');
397
+ setInputValue('');
398
+ } else {
399
+ const s = skills[selectionIndex - 1];
400
+ if (s) {
401
+ setInputValue((prev) => prev + (prev ? '\n' : '') + `Use skill: ${s.name}`);
402
+ setMode('chat');
403
+ }
404
+ }
405
+ }
406
+ }
407
+ else if (mode === 'auth') {
408
+ if (authStep === 'list') {
409
+ const p = authProviders[selectionIndex];
410
+ if (p) {
411
+ setSelectedProviderId(p.id);
412
+ if (p.id === 'google') {
413
+ setAuthStep('method');
414
+ setSelectionIndex(0);
415
+ } else {
416
+ setAuthStep('apikey');
417
+ setInputValue('');
418
+ }
419
+ }
420
+ } else if (authStep === 'method') {
421
+ if (selectionIndex === 0) {
422
+ setMode('chat');
423
+ startAntigravityLogin().catch(e => setError(e.message));
424
+ } else {
425
+ setAuthStep('apikey');
426
+ setInputValue('');
427
+ }
428
+ }
429
+ }
430
+ setSearchValue('');
431
+ }
432
+ }
433
+ });
434
+
435
+ const handlePaste = useCallback(async () => {
436
+ const text = await getClipboard();
437
+ if (!text) return;
438
+ const lines = text.split('\n');
439
+ if (lines.length > 1 || text.length > 100) {
440
+ setPastedContent(text);
441
+ setInputValue(`Pasted ${lines.length} lines (${text.length} chars). Press Enter to send.`);
442
+ } else {
443
+ setInputValue((prev) => prev + text);
444
+ }
445
+ }, []);
446
+
447
+ const handleSubmit = useCallback(async (text: string) => {
448
+ if (mode === 'skills' && skillStep === 'create') {
449
+ if (!newSkillName) {
450
+ setNewSkillName(text);
451
+ setInputValue('');
452
+ return;
453
+ }
454
+ addSkill(newSkillName, text);
455
+ setSkills(loadSkills());
456
+ setMode('chat');
457
+ setInputValue('');
458
+ setSkillStep('list');
459
+ return;
460
+ }
461
+
462
+ if (mode === 'auth' && authStep === 'apikey' && selectedProviderId) {
463
+ saveApiKey(selectedProviderId, text.trim());
464
+ setMode('chat');
465
+ setInputValue('');
466
+ setAuthStep('list');
467
+ return;
468
+ }
469
+
470
+ let contentToSend = text;
471
+ if (pastedContent && text.startsWith('Pasted ')) {
472
+ contentToSend = pastedContent;
473
+ setPastedContent(null);
474
+ }
475
+ if (!agent || isLoading || !contentToSend.trim()) return;
476
+ setHistory(prev => [...prev, contentToSend]);
477
+ setHistoryIndex(-1);
478
+ setTempInput('');
479
+ let sess = currentSession; if (!sess) { sess = createSession(agent.id); setCurrentSession(sess); setSessionName(contentToSend.split('\n')[0]?.slice(0, 50) || 'New session'); }
480
+ setMessages((prev) => [...prev, { role: 'user', content: contentToSend }]); setInputValue(''); setIsLoading(true); setStreamingText(''); setStreamingThinking(''); setError(null);
481
+ const startTime = Date.now();
482
+ try {
483
+ const result = await executeAgent(contentToSend, { session: sess, agent: { ...agent, model: config?.model }, modelOptions: { variant }, onText: (chunk) => setStreamingText((prev) => prev + chunk), onThinking: (thought) => setStreamingThinking((prev) => prev + thought) });
484
+ const updated = getSession(sess.id);
485
+ if (updated) {
486
+ setCurrentSession({ ...updated }); const lastMsg = updated.messages.filter((m) => m.role === 'assistant').pop();
487
+ if (lastMsg) {
488
+ setMessages((prev) => [...prev, { role: 'assistant', content: lastMsg.content as string, thinking: streamingThinking || undefined, agentName: agent.name, modelName: config?.model?.split('/').pop() || 'unknown', duration: Date.now() - startTime, usage: result.usage }]);
489
+ if (result.usage?.totalTokens) setTotalTokens(prev => prev + result.usage.totalTokens);
490
+ }
491
+ }
492
+ } catch (err) { setError((err as Error).message); } finally { setIsLoading(false); setStreamingText(''); setStreamingThinking(''); }
493
+ }, [agent, isLoading, currentSession, config, streamingThinking, pastedContent, variant]);
494
+
495
+ useEffect(() => {
496
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
497
+ }, [messages.length, streamingText, streamingThinking, expandedTools]);
498
+
499
+ const costEstimate = (totalTokens / 1000000) * 0.15;
500
+ const usagePercent = Math.round((totalTokens / 100000) * 100) || 0;
501
+ const displaySessionName = sessionName.length > 25 ? sessionName.slice(0, 22) + '...' : sessionName;
502
+
503
+ const subAgentCount = useMemo(() => {
504
+ let count = 0;
505
+ for (const msg of messages) {
506
+ if (msg.role === 'assistant' && msg.toolCalls) {
507
+ for (const tc of msg.toolCalls) {
508
+ if (tc.name === 'task') count++;
509
+ }
510
+ }
511
+ }
512
+ return count;
513
+ }, [messages]);
514
+
515
+ if (!config || !agent) return <box style={{ width, height, alignItems: 'center', justifyContent: 'center' }}><text fg="#06b6d4">Initializing J.A.R.V.I.S. systems...</text></box>;
516
+
517
+ return (
518
+ <box style={{ flexDirection: 'column', width, height, backgroundColor: '#0a0a0a' }}>
519
+ <box style={{ paddingLeft: 1, paddingRight: 1, paddingTop: 1, height: 1, flexDirection: 'row' }}>
520
+ <box style={{ flexGrow: 1, overflow: 'hidden' }}>
521
+ <text fg="#888888"># {String(displaySessionName)}</text>
522
+ </box>
523
+ <box style={{ flexDirection: 'row', flexShrink: 0 }}>
524
+ <text fg="#666666">{String(totalTokens.toLocaleString())} </text>
525
+ <text fg="#666666">{String(usagePercent)}% </text>
526
+ <text fg="#666666">(${String(costEstimate.toFixed(2))}) </text>
527
+ <text fg="#666666">v1.0.3</text>
528
+ </box>
529
+ </box>
530
+ <scrollbox ref={scrollRef} style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }} focused={false}>
531
+ <box style={{ flexDirection: 'column', minHeight: 1 }}>
532
+ {messages.map((msg, i) => (
533
+ msg.role === 'user' ? (
534
+ <UserMessage key={i} content={msg.content} />
535
+ ) : (
536
+ <AssistantMessage key={i} message={msg} expandedTools={expandedTools} onToggleTool={(idx) => setExpandedTools(prev => {const n = new Set(prev); if(n.has(idx)) n.delete(idx); else n.add(idx); return n;})} hoveredToolIndex={hoveredToolIndex} onHoverTool={setHoveredToolIndex} />
537
+ )
538
+ ))}
539
+ {streamingThinking ? (
540
+ <box style={{ marginBottom: 1 }}>
541
+ <box style={{ border: ['left'], borderStyle: 'single', borderColor: '#d97706', paddingLeft: 1, flexDirection: 'column' }}>
542
+ <text fg="#d97706" style={{ attributes: TextAttributes.ITALIC }}>Thinking:</text>
543
+ {String(streamingThinking).split('\n').map((line, i) => <text key={i} fg="#888888">{String(line)}</text>)}
544
+ </box>
545
+ </box>
546
+ ) : null}
547
+ {streamingText ? (
548
+ <box style={{ marginBottom: 1 }}>
549
+ <MarkdownText content={streamingText} />
550
+ </box>
551
+ ) : null}
552
+ </box>
553
+ </scrollbox>
554
+ {error ? <box style={{ padding: 1, backgroundColor: '#7f1d1d' }}><text fg="#fca5a5">{String(error)}</text></box> : null}
555
+ <box style={{
556
+ flexDirection: 'column',
557
+ backgroundColor: '#1a1a1a',
558
+ border: ['left'],
559
+ borderStyle: 'single',
560
+ borderColor: '#06b6d4',
561
+ marginLeft: 1,
562
+ marginRight: 1,
563
+ paddingLeft: 1,
564
+ paddingRight: 1,
565
+ paddingTop: 1,
566
+ paddingBottom: 1,
567
+ height: 5
568
+ }} onMouseDown={(e: any) => { if (e.button === 2) handlePaste(); }}>
569
+ <box style={{ height: 1 }}>
570
+ <input
571
+ focused={mode === 'chat' || (mode === 'auth' && authStep === 'apikey')}
572
+ value={inputValue}
573
+ onInput={(v) => { if (pastedContent) setPastedContent(null); setInputValue(v || ''); }}
574
+ onSubmit={handleSubmit}
575
+ placeholder={
576
+ mode === 'auth' && authStep === 'apikey' ? `Enter API Key for ${selectedProviderId}` :
577
+ isLoading ? 'Responding...' : ''
578
+ }
579
+ onMouseDown={(e: any) => { if (e.button === 2) handlePaste(); }}
580
+ />
581
+ </box>
582
+ <box style={{ height: 1 }} onMouseDown={(e: any) => { if (e.button === 2) handlePaste(); }} />
583
+ <box style={{ flexDirection: 'row', height: 1 }}>
584
+ <text fg="#06b6d4" style={{ attributes: TextAttributes.BOLD }}>{String(agent.name.toUpperCase())} </text>
585
+ <text fg="#ffffff">{String(getModelDisplayName(config.model || ''))} </text>
586
+ <text fg="#666666">{String(getProviderDisplayName(config.model || ''))}</text>
587
+ {accountStats.total > 0 ? <text fg="#666666"> · </text> : null}
588
+ {accountStats.total > 0 ? <text fg="#eab308">{String(accountStats.active)}</text> : null}
589
+ {accountStats.total > 0 ? <text fg="#666666"> ({String(accountStats.count)}/{String(accountStats.total)})</text> : null}
590
+ {skills.length > 0 ? <text fg="#666666"> · Skills: {String(skills.length)}</text> : null}
591
+ {subAgentCount > 0 ? <text fg="#666666"> · Sub-agents: {String(subAgentCount)}</text> : null}
592
+ </box>
593
+ </box>
594
+ <box style={{ flexDirection: 'row', justifyContent: 'flex-end', marginRight: 2, marginBottom: 1, height: 1 }}>
595
+ <box style={{ flexDirection: 'row' }}>
596
+ <text fg="#ffffff" style={{ attributes: TextAttributes.BOLD }}>ctrl+t</text><text fg="#666666"> variants </text>
597
+ <text fg="#ffffff" style={{ attributes: TextAttributes.BOLD }}>tab</text><text fg="#666666"> agents </text>
598
+ <text fg="#ffffff" style={{ attributes: TextAttributes.BOLD }}>ctrl+p</text><text fg="#666666"> commands</text>
599
+ </box>
600
+ </box>
601
+ {mode === 'commands' ? (
602
+ <Modal title="Commands" width={width} height={height} onClose={() => { setMode('chat'); setSearchValue(''); }}>
603
+ <scrollbox style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1 }} focused={mode === 'commands'}>
604
+ {filteredCommands.map((cmd, i) => <CommandItem key={cmd.id} name={cmd.name} shortcut={cmd.shortcut} isSelected={i === selectionIndex} onHover={() => setSelectionIndex(i)} onClick={cmd.action} />)}
605
+ </scrollbox>
606
+ </Modal>
607
+ ) : null}
608
+ {mode === 'models' ? (
609
+ <Modal title="Models" width={width} height={height} onClose={() => setMode('chat')}>
610
+ <scrollbox style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1 }} focused>
611
+ {availableModels.map((m, i) => <CommandItem key={m.id} name={m.name} isSelected={i === selectionIndex} onHover={() => setSelectionIndex(i)} onClick={() => { if(config){ config.model = m.id; setConfig({...config}); setMode('chat'); } }} />)}
612
+ </scrollbox>
613
+ </Modal>
614
+ ) : null}
615
+ {mode === 'variants' ? (
616
+ <Modal title="Variants" width={width} height={height} onClose={() => setMode('chat')}>
617
+ <scrollbox style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1 }} focused>
618
+ {currentVariants.map((v, i) => <CommandItem key={v} name={v} isSelected={i === selectionIndex} onHover={() => setSelectionIndex(i)} onClick={() => { if(v) { setVariant(v); setMode('chat'); } }} />)}
619
+ </scrollbox>
620
+ </Modal>
621
+ ) : null}
622
+ {mode === 'providers' ? (
623
+ <Modal title="Providers" width={width} height={height} onClose={() => setMode('chat')}>
624
+ <scrollbox style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1 }} focused>
625
+ {providersList.map((p, i) => (
626
+ <CommandItem
627
+ key={p.id}
628
+ name={p.name}
629
+ description={p.isAvailable ? 'Connected' : `Missing ${p.envVar}`}
630
+ isSelected={i === selectionIndex}
631
+ onHover={() => setSelectionIndex(i)}
632
+ onClick={() => setMode('chat')}
633
+ />
634
+ ))}
635
+ </scrollbox>
636
+ </Modal>
637
+ ) : null}
638
+ {mode === 'settings' ? (
639
+ <Modal title="Privacy & Security" width={width} height={height} onClose={() => setMode('chat')}>
640
+ <scrollbox style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1 }} focused>
641
+ {settingsList.map((s, i) => (
642
+ <CommandItem
643
+ key={s.id}
644
+ name={s.name}
645
+ description={String((config?.permission as any)?.[s.id] || 'ask')}
646
+ isSelected={i === selectionIndex}
647
+ onHover={() => setSelectionIndex(i)}
648
+ onClick={() => togglePermission(s.id)}
649
+ />
650
+ ))}
651
+ </scrollbox>
652
+ </Modal>
653
+ ) : null}
654
+ {mode === 'auth' ? (
655
+ <Modal title="Connect Account" width={width} height={height} onClose={() => { setMode('chat'); setAuthStep('list'); }}>
656
+ {authStep === 'list' ? (
657
+ <scrollbox style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1 }} focused>
658
+ {authProviders.map((p, i) => (
659
+ <CommandItem
660
+ key={p.id}
661
+ name={p.name + (p.id === 'google' ? ' (Recommended)' : '')}
662
+ description={p.description}
663
+ isSelected={i === selectionIndex}
664
+ onHover={() => setSelectionIndex(i)}
665
+ onClick={() => {
666
+ setSelectedProviderId(p.id);
667
+ if (p.id === 'google') {
668
+ setAuthStep('method');
669
+ setSelectionIndex(0);
670
+ } else {
671
+ setAuthStep('apikey');
672
+ setInputValue('');
673
+ }
674
+ }}
675
+ />
676
+ ))}
677
+ </scrollbox>
678
+ ) : authStep === 'method' ? (
679
+ <scrollbox style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1 }} focused>
680
+ <CommandItem
681
+ name="OAuth with Google (Recommended)"
682
+ description="Uses Google account, no API key needed"
683
+ isSelected={selectionIndex === 0}
684
+ onHover={() => setSelectionIndex(0)}
685
+ onClick={() => { setMode('chat'); startAntigravityLogin().catch(e => setError(e.message)); }}
686
+ />
687
+ <CommandItem
688
+ name="Enter API Key"
689
+ description="Use your own Google AI Studio API key"
690
+ isSelected={selectionIndex === 1}
691
+ onHover={() => setSelectionIndex(1)}
692
+ onClick={() => { setAuthStep('apikey'); setInputValue(''); }}
693
+ />
694
+ </scrollbox>
695
+ ) : (
696
+ <box style={{ padding: 1, flexDirection: 'column' }}>
697
+ <text fg="#ffffff">Enter your API key below for {String(selectedProviderId)}:</text>
698
+ <text fg="#666666">Press Enter to save.</text>
699
+ </box>
700
+ )}
701
+ </Modal>
702
+ ) : null}
703
+ {mode === 'skills' ? (
704
+ <Modal title="Skills" width={width} height={height} onClose={() => setMode('chat')}>
705
+ {skillStep === 'list' ? (
706
+ <scrollbox style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1 }} focused>
707
+ <CommandItem
708
+ name="+ Create New Skill"
709
+ description="Add a new capability"
710
+ isSelected={selectionIndex === 0}
711
+ onHover={() => setSelectionIndex(0)}
712
+ onClick={() => {
713
+ setSkillStep('create');
714
+ setNewSkillName('');
715
+ setNewSkillDesc('');
716
+ setInputValue('');
717
+ }}
718
+ />
719
+ {skills.map((s, i) => (
720
+ <CommandItem
721
+ key={s.name}
722
+ name={s.name}
723
+ description={s.description.slice(0, 50)}
724
+ isSelected={i + 1 === selectionIndex}
725
+ onHover={() => setSelectionIndex(i + 1)}
726
+ onClick={() => {
727
+ setInputValue((prev) => prev + (prev ? '\n' : '') + `Use skill: ${s.name}`);
728
+ setMode('chat');
729
+ }}
730
+ />
731
+ ))}
732
+ </scrollbox>
733
+ ) : (
734
+ <box style={{ padding: 1, flexDirection: 'column' }}>
735
+ <text fg="#ffffff">{!newSkillName ? 'Enter Skill Name:' : 'Enter Skill Description:'}</text>
736
+ <text fg="#666666">Type in the main input box below and press Enter.</text>
737
+ {newSkillName ? <text fg="#06b6d4" style={{ marginTop: 1 }}>Name: {String(newSkillName)}</text> : null}
738
+ </box>
739
+ )}
740
+ </Modal>
741
+ ) : null}
742
+ {shutdownState !== 'none' ? (
743
+ <box style={{ position: 'absolute', top: 0, left: 0, width, height, alignItems: 'center', justifyContent: 'center', backgroundColor: '#000000', zIndex: 999, flexDirection: 'column' }}>
744
+ {shutdownState === 'summary' ? (
745
+ <box style={{ flexDirection: 'column', alignItems: 'center', borderStyle: 'double', borderColor: '#06b6d4', padding: 2 }}>
746
+ <text fg="#06b6d4" style={{ attributes: TextAttributes.BOLD }}>SESSION COMPLETE</text>
747
+ <box style={{ height: 1 }} />
748
+ <text fg="#ffffff">Duration: {String(Math.floor((Date.now() - appStartTime.current) / 1000))}s</text>
749
+ <text fg="#ffffff">Tokens: {String(totalTokens.toLocaleString())}</text>
750
+ <text fg="#ffffff">Cost: ${String(costEstimate.toFixed(4))}</text>
751
+ <box style={{ height: 1 }} />
752
+ <text fg="#666666">Have a good evening.</text>
753
+ </box>
754
+ ) : (
755
+ <text fg="#06b6d4">Powering down...</text>
756
+ )}
757
+ </box>
758
+ ) : null}
759
+ </box>
760
+ );
761
+ }
762
+
763
+ export async function startTui(props: AppProps) {
764
+ const renderer = await createCliRenderer({
765
+ useMouse: true,
766
+ enableMouseMovement: true,
767
+ onDestroy: () => {
768
+ process.stdout.write('\u001b[?25h'); // Show cursor
769
+ process.stdout.write('\u001b[2J\u001b[3J\u001b[H'); // Clear screen
770
+ }
771
+ });
772
+
773
+ const runInteractive = async (task: () => Promise<void>) => {
774
+ renderer.suspend();
775
+ process.stdout.write('\u001b[2J\u001b[3J\u001b[H');
776
+ try {
777
+ await task();
778
+ } finally {
779
+ renderer.resume();
780
+ }
781
+ };
782
+
783
+ createRoot(renderer).render(<App {...props} runInteractive={runInteractive} />);
784
+ }