@gxp-dev/tools 2.0.15 → 2.0.17

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.
@@ -5,7 +5,7 @@ import Header from './components/Header.js';
5
5
  import TabBar from './components/TabBar.js';
6
6
  import LogPanel from './components/LogPanel.js';
7
7
  import CommandInput from './components/CommandInput.js';
8
- import GeminiPanel from './components/GeminiPanel.js';
8
+ import AIPanel from './components/AIPanel.js';
9
9
  import {
10
10
  serviceManager,
11
11
  startVite,
@@ -18,9 +18,10 @@ import {
18
18
  sendSocketEvent,
19
19
  ServiceStatus,
20
20
  BrowserType,
21
- geminiService,
22
- isAuthenticated,
23
- clearAuthTokens,
21
+ aiService,
22
+ getAvailableProviders,
23
+ getProviderStatus,
24
+ AIProvider,
24
25
  } from './services/index.js';
25
26
 
26
27
  export interface Service {
@@ -46,7 +47,7 @@ export interface AppProps {
46
47
  export default function App({ autoStart, args }: AppProps) {
47
48
  const { exit } = useApp();
48
49
  const { stdout } = useStdout();
49
- const [showGemini, setShowGemini] = useState(false);
50
+ const [showAIPanel, setShowAIPanel] = useState(false);
50
51
  const [services, setServices] = useState<Service[]>([]);
51
52
  const [activeTab, setActiveTab] = useState(0);
52
53
  const [suggestionRows, setSuggestionRows] = useState(0);
@@ -226,9 +227,8 @@ export default function App({ autoStart, args }: AppProps) {
226
227
  exit();
227
228
  break;
228
229
 
229
- case 'gemini':
230
230
  case 'ai':
231
- handleGeminiCommand(cmdArgs);
231
+ handleAICommand(cmdArgs);
232
232
  break;
233
233
 
234
234
  case 'extract-config':
@@ -385,68 +385,89 @@ export default function App({ autoStart, args }: AppProps) {
385
385
  syncServices();
386
386
  };
387
387
 
388
- const handleGeminiCommand = async (cmdArgs: string[]) => {
388
+ const handleAICommand = async (cmdArgs: string[]) => {
389
389
  const subCommand = cmdArgs[0];
390
390
 
391
391
  switch (subCommand) {
392
- case 'enable':
393
- addSystemLog('Starting Google OAuth flow...');
394
- addSystemLog('A browser window will open for authentication.');
395
- const result = await geminiService.startOAuthFlow();
396
- if (result.success) {
397
- addSystemLog(`✅ ${result.message}`);
392
+ case 'model':
393
+ // Set or show current AI provider
394
+ const providerArg = cmdArgs[1] as AIProvider | undefined;
395
+ if (providerArg) {
396
+ const result = aiService.setProvider(providerArg);
397
+ if (result.success) {
398
+ addSystemLog(`✅ ${result.message}`);
399
+ } else {
400
+ addSystemLog(`❌ ${result.message}`);
401
+ }
398
402
  } else {
399
- addSystemLog(`❌ ${result.message}`);
403
+ // Show current provider and available providers
404
+ const current = aiService.getProviderInfo();
405
+ const providers = getAvailableProviders();
406
+ let message = `Current AI provider: ${current ? getProviderStatus(current) : 'None'}\n\nAvailable providers:`;
407
+ for (const p of providers) {
408
+ const status = p.available ? getProviderStatus(p) : `${p.name} (not available)`;
409
+ const marker = p.id === current?.id ? ' ← current' : '';
410
+ message += `\n ${p.id}: ${status}${marker}`;
411
+ if (!p.available && p.reason) {
412
+ message += `\n ${p.reason}`;
413
+ }
414
+ }
415
+ message += '\n\nUsage: /ai model <claude|codex|gemini>';
416
+ addSystemLog(message);
400
417
  }
401
418
  break;
402
419
 
403
- case 'logout':
404
- case 'disable':
405
- clearAuthTokens();
406
- addSystemLog('Logged out from Gemini AI.');
407
- break;
408
-
409
420
  case 'status':
410
- if (isAuthenticated()) {
411
- addSystemLog('✅ Gemini AI is authenticated and ready.');
412
- } else {
413
- addSystemLog('❌ Not authenticated. Run /gemini enable to set up.');
421
+ // Show detailed status of all providers
422
+ const providers = getAvailableProviders();
423
+ const currentProvider = aiService.getProvider();
424
+ let statusMsg = 'AI Provider Status:\n';
425
+ for (const p of providers) {
426
+ const icon = p.available ? '✅' : '❌';
427
+ const current = p.id === currentProvider ? ' (current)' : '';
428
+ statusMsg += `\n ${icon} ${getProviderStatus(p)}${current}`;
429
+ if (!p.available && p.reason) {
430
+ statusMsg += `\n ${p.reason}`;
431
+ }
414
432
  }
433
+ addSystemLog(statusMsg);
415
434
  break;
416
435
 
417
436
  case 'ask':
418
437
  // Quick question without opening panel
419
438
  const question = cmdArgs.slice(1).join(' ');
420
439
  if (!question) {
421
- addSystemLog('Usage: /gemini ask <your question>');
440
+ addSystemLog('Usage: /ai ask <your question>');
422
441
  return;
423
442
  }
424
- if (!isAuthenticated()) {
425
- addSystemLog('Not authenticated. Run /gemini enable first.');
443
+ if (!aiService.isAvailable()) {
444
+ addSystemLog(`Current provider (${aiService.getProvider()}) is not available. Run /ai model to select a different provider.`);
426
445
  return;
427
446
  }
428
- addSystemLog(`Asking Gemini: ${question}`);
447
+ const providerName = aiService.getProviderInfo()?.name || 'AI';
448
+ addSystemLog(`Asking ${providerName}: ${question}`);
429
449
  try {
430
- geminiService.loadProjectContext(process.cwd());
431
- const response = await geminiService.sendMessage(question);
432
- addSystemLog(`Gemini: ${response}`);
450
+ aiService.loadProjectContext(process.cwd());
451
+ const response = await aiService.sendMessage(question);
452
+ addSystemLog(`${providerName}: ${response}`);
433
453
  } catch (err) {
434
454
  addSystemLog(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
435
455
  }
436
456
  break;
437
457
 
438
458
  case 'clear':
439
- geminiService.clearConversation();
459
+ aiService.clearConversation();
440
460
  addSystemLog('Conversation history cleared.');
441
461
  break;
442
462
 
463
+ case 'chat':
443
464
  default:
444
- // No subcommand = open chat panel
445
- if (!isAuthenticated()) {
446
- addSystemLog('Not authenticated. Run /gemini enable to set up Google authentication.');
465
+ // Open AI chat panel
466
+ if (!aiService.isAvailable()) {
467
+ addSystemLog(`Current provider (${aiService.getProvider()}) is not available. Run /ai model to select a different provider.`);
447
468
  return;
448
469
  }
449
- setShowGemini(true);
470
+ setShowAIPanel(true);
450
471
  }
451
472
  };
452
473
 
@@ -650,13 +671,12 @@ Available commands:
650
671
  /extract-config -o Overwrite existing values
651
672
 
652
673
  AI Assistant:
653
- /gemini Open Gemini AI chat panel
654
- /gemini enable Set up Google authentication
655
- /gemini ask <query> Quick question to AI
656
- /gemini status Check auth status
657
- /gemini logout Log out from Gemini
658
- /gemini clear Clear conversation history
659
- /ai Alias for /gemini
674
+ /ai Open AI chat with current provider
675
+ /ai model Show available AI providers
676
+ /ai model <name> Switch to claude, codex, or gemini
677
+ /ai ask <query> Quick question to AI
678
+ /ai status Check provider availability
679
+ /ai clear Clear conversation history
660
680
 
661
681
  Service Management:
662
682
  /stop [service] Stop current or specified service
@@ -678,11 +698,11 @@ Keyboard shortcuts:
678
698
  Esc Clear input
679
699
  `;
680
700
 
681
- // Show Gemini panel
682
- if (showGemini) {
701
+ // Show AI panel
702
+ if (showAIPanel) {
683
703
  return (
684
- <GeminiPanel
685
- onClose={() => setShowGemini(false)}
704
+ <AIPanel
705
+ onClose={() => setShowAIPanel(false)}
686
706
  onLog={addSystemLog}
687
707
  />
688
708
  );
@@ -0,0 +1,180 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text, useInput, useStdout } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import {
5
+ aiService,
6
+ getProviderStatus,
7
+ } from '../services/index.js';
8
+
9
+ interface AIPanelProps {
10
+ onClose: () => void;
11
+ onLog: (message: string) => void;
12
+ }
13
+
14
+ interface Message {
15
+ role: 'user' | 'assistant' | 'system';
16
+ content: string;
17
+ }
18
+
19
+ export default function AIPanel({ onClose, onLog }: AIPanelProps) {
20
+ const { stdout } = useStdout();
21
+ const [input, setInput] = useState('');
22
+ const [messages, setMessages] = useState<Message[]>([]);
23
+ const [isLoading, setIsLoading] = useState(false);
24
+ const [scrollOffset, setScrollOffset] = useState(0);
25
+
26
+ // Calculate available height for messages
27
+ const maxMessageLines = stdout ? Math.max(5, stdout.rows - 8) : 10;
28
+
29
+ // Get provider info for display
30
+ const providerInfo = aiService.getProviderInfo();
31
+ const providerName = providerInfo?.name || 'AI';
32
+
33
+ // Check provider availability on mount
34
+ useEffect(() => {
35
+ if (!aiService.isAvailable()) {
36
+ setMessages([{
37
+ role: 'system',
38
+ content: `${providerName} is not available. Run /ai model to select a different provider, or install the required CLI.`,
39
+ }]);
40
+ } else {
41
+ const status = getProviderStatus(providerInfo!);
42
+ setMessages([{
43
+ role: 'system',
44
+ content: `${status} ready.\nType your message and press Enter. Press Escape to close.`,
45
+ }]);
46
+ // Load project context
47
+ aiService.loadProjectContext(process.cwd());
48
+ }
49
+ }, []);
50
+
51
+ // Handle keyboard input
52
+ useInput((char, key) => {
53
+ if (key.escape) {
54
+ onClose();
55
+ return;
56
+ }
57
+
58
+ // Scroll with Shift+Up/Down
59
+ if (key.shift && key.upArrow) {
60
+ setScrollOffset(prev => Math.min(prev + 1, Math.max(0, messages.length - maxMessageLines)));
61
+ return;
62
+ }
63
+ if (key.shift && key.downArrow) {
64
+ setScrollOffset(prev => Math.max(0, prev - 1));
65
+ return;
66
+ }
67
+ });
68
+
69
+ const handleSubmit = async (value: string) => {
70
+ if (!value.trim() || isLoading) return;
71
+
72
+ const userMessage = value.trim();
73
+ setInput('');
74
+
75
+ // Add user message
76
+ setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
77
+ setIsLoading(true);
78
+
79
+ try {
80
+ const response = await aiService.sendMessage(userMessage);
81
+ setMessages(prev => [...prev, { role: 'assistant', content: response }]);
82
+ setScrollOffset(0); // Auto-scroll to bottom
83
+ } catch (err) {
84
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
85
+ setMessages(prev => [...prev, {
86
+ role: 'system',
87
+ content: `Error: ${errorMessage}`,
88
+ }]);
89
+ onLog(`${providerName} error: ${errorMessage}`);
90
+ } finally {
91
+ setIsLoading(false);
92
+ }
93
+ };
94
+
95
+ // Render messages with scrolling
96
+ const renderMessages = () => {
97
+ const start = Math.max(0, messages.length - maxMessageLines - scrollOffset);
98
+ const end = messages.length - scrollOffset;
99
+ const visibleMessages = messages.slice(start, end);
100
+
101
+ return visibleMessages.map((msg, idx) => {
102
+ let color: string;
103
+ let prefix: string;
104
+
105
+ switch (msg.role) {
106
+ case 'user':
107
+ color = 'cyan';
108
+ prefix = 'You: ';
109
+ break;
110
+ case 'assistant':
111
+ color = 'green';
112
+ prefix = `${providerName}: `;
113
+ break;
114
+ default:
115
+ color = 'yellow';
116
+ prefix = '';
117
+ }
118
+
119
+ return (
120
+ <Box key={start + idx} flexDirection="column" marginBottom={1}>
121
+ <Text color={color} bold>{prefix}</Text>
122
+ <Text wrap="wrap">{msg.content}</Text>
123
+ </Box>
124
+ );
125
+ });
126
+ };
127
+
128
+ // Get border color based on provider
129
+ const getBorderColor = (): string => {
130
+ switch (aiService.getProvider()) {
131
+ case 'claude':
132
+ return 'magenta';
133
+ case 'codex':
134
+ return 'green';
135
+ case 'gemini':
136
+ return 'blue';
137
+ default:
138
+ return 'gray';
139
+ }
140
+ };
141
+
142
+ const borderColor = getBorderColor();
143
+
144
+ return (
145
+ <Box flexDirection="column" height="100%" borderStyle="double" borderColor={borderColor}>
146
+ <Box paddingX={1} justifyContent="space-between">
147
+ <Text bold color={borderColor}>{providerName} AI Assistant</Text>
148
+ <Text color="gray" dimColor>Esc to close · /ai model to switch</Text>
149
+ </Box>
150
+
151
+ <Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
152
+ {messages.length > maxMessageLines + scrollOffset && (
153
+ <Text color="gray" dimColor>
154
+ ... {messages.length - maxMessageLines - scrollOffset} earlier messages
155
+ </Text>
156
+ )}
157
+ {renderMessages()}
158
+ {scrollOffset > 0 && (
159
+ <Text color="gray" dimColor>... {scrollOffset} newer messages below</Text>
160
+ )}
161
+ </Box>
162
+
163
+ <Box borderStyle="single" borderColor="gray" paddingX={1}>
164
+ {isLoading ? (
165
+ <Text color="yellow">Thinking...</Text>
166
+ ) : (
167
+ <Box>
168
+ <Text color={borderColor}>&gt; </Text>
169
+ <TextInput
170
+ value={input}
171
+ onChange={setInput}
172
+ onSubmit={handleSubmit}
173
+ placeholder={`Ask ${providerName} something...`}
174
+ />
175
+ </Box>
176
+ )}
177
+ </Box>
178
+ </Box>
179
+ );
180
+ }
@@ -35,13 +35,14 @@ const COMMANDS = [
35
35
  { cmd: '/extract-config', args: '--overwrite', desc: 'Overwrite existing config values' },
36
36
 
37
37
  // AI commands
38
- { cmd: '/gemini', args: '', desc: 'Open Gemini AI chat panel' },
39
- { cmd: '/gemini', args: 'enable', desc: 'Set up Google authentication' },
40
- { cmd: '/gemini', args: 'ask <query>', desc: 'Quick AI question' },
41
- { cmd: '/gemini', args: 'status', desc: 'Check AI auth status' },
42
- { cmd: '/gemini', args: 'logout', desc: 'Log out from Gemini' },
43
- { cmd: '/gemini', args: 'clear', desc: 'Clear conversation history' },
44
- { cmd: '/ai', args: '', desc: 'Open Gemini AI chat (alias)' },
38
+ { cmd: '/ai', args: '', desc: 'Open AI chat with current provider' },
39
+ { cmd: '/ai', args: 'model', desc: 'Show available AI providers' },
40
+ { cmd: '/ai', args: 'model claude', desc: 'Switch to Claude AI' },
41
+ { cmd: '/ai', args: 'model codex', desc: 'Switch to Codex AI' },
42
+ { cmd: '/ai', args: 'model gemini', desc: 'Switch to Gemini AI' },
43
+ { cmd: '/ai', args: 'ask <query>', desc: 'Quick AI question' },
44
+ { cmd: '/ai', args: 'status', desc: 'Check provider availability' },
45
+ { cmd: '/ai', args: 'clear', desc: 'Clear conversation history' },
45
46
 
46
47
  // General
47
48
  { cmd: '/help', args: '', desc: 'Show all commands' },
@@ -72,7 +73,10 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
72
73
  // Track previous suggestion count to avoid unnecessary parent updates
73
74
  const prevSuggestionCount = useRef(0);
74
75
 
75
- // Filter commands based on typed value
76
+ // Maximum visible suggestions in the dropdown
77
+ const MAX_VISIBLE = 8;
78
+
79
+ // Filter commands based on typed value (no limit - we'll handle display separately)
76
80
  const suggestions = useMemo(() => {
77
81
  if (!value.startsWith('/')) return [];
78
82
 
@@ -81,9 +85,31 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
81
85
  const fullCmd = c.args ? `${c.cmd} ${c.args}` : c.cmd;
82
86
  return fullCmd.toLowerCase().includes(search) ||
83
87
  c.cmd.toLowerCase().startsWith(search);
84
- }).slice(0, 8); // Limit to 8 suggestions for cleaner UI
88
+ });
85
89
  }, [value]);
86
90
 
91
+ // Calculate visible window of suggestions (scrolls to keep selection visible)
92
+ const { visibleSuggestions, startIndex } = useMemo(() => {
93
+ if (suggestions.length <= MAX_VISIBLE) {
94
+ return { visibleSuggestions: suggestions, startIndex: 0 };
95
+ }
96
+
97
+ // Calculate window to keep selected item visible
98
+ let start = 0;
99
+ if (selectedSuggestion >= MAX_VISIBLE) {
100
+ // Selected item is beyond initial window, scroll down
101
+ start = selectedSuggestion - MAX_VISIBLE + 1;
102
+ }
103
+ // Ensure we don't go past the end
104
+ start = Math.min(start, suggestions.length - MAX_VISIBLE);
105
+ start = Math.max(0, start);
106
+
107
+ return {
108
+ visibleSuggestions: suggestions.slice(start, start + MAX_VISIBLE),
109
+ startIndex: start,
110
+ };
111
+ }, [suggestions, selectedSuggestion]);
112
+
87
113
  const showSuggestions = value.startsWith('/') && value.length >= 1 && suggestions.length > 0;
88
114
 
89
115
  // Helper to build full command string from suggestion
@@ -97,9 +123,10 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
97
123
  return suggestion.cmd;
98
124
  }, []);
99
125
 
100
- // Notify parent when suggestions change (debounced to reduce flicker)
126
+ // Notify parent when suggestions change (use visible count, not total)
101
127
  useEffect(() => {
102
- const count = showSuggestions ? suggestions.length + 2 : 0;
128
+ const visibleCount = Math.min(suggestions.length, MAX_VISIBLE);
129
+ const count = showSuggestions ? visibleCount + 2 : 0; // +2 for border and hint line
103
130
  if (count !== prevSuggestionCount.current) {
104
131
  prevSuggestionCount.current = count;
105
132
  onSuggestionsChange?.(count);
@@ -124,14 +151,22 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
124
151
  return;
125
152
  }
126
153
 
127
- // Up/Down to navigate suggestions when showing
154
+ // Up/Down to navigate suggestions when showing (circular navigation)
128
155
  if (showSuggestions) {
129
156
  if (key.upArrow) {
130
- setSelectedSuggestion(prev => Math.max(0, prev - 1));
157
+ setSelectedSuggestion(prev => {
158
+ // Wrap to bottom when at top
159
+ if (prev <= 0) return suggestions.length - 1;
160
+ return prev - 1;
161
+ });
131
162
  return;
132
163
  }
133
164
  if (key.downArrow) {
134
- setSelectedSuggestion(prev => Math.min(suggestions.length - 1, prev + 1));
165
+ setSelectedSuggestion(prev => {
166
+ // Wrap to top when at bottom
167
+ if (prev >= suggestions.length - 1) return 0;
168
+ return prev + 1;
169
+ });
135
170
  return;
136
171
  }
137
172
  } else {
@@ -219,32 +254,48 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
219
254
  borderColor="gray"
220
255
  marginBottom={0}
221
256
  >
222
- {suggestions.map((suggestion, index) => (
223
- <Box key={`${suggestion.cmd}-${suggestion.args}-${index}`} paddingX={1}>
224
- <Text
225
- backgroundColor={index === selectedSuggestion ? 'blue' : undefined}
226
- color={index === selectedSuggestion ? 'white' : 'cyan'}
227
- bold={index === selectedSuggestion}
228
- >
229
- {suggestion.cmd}
230
- </Text>
231
- {suggestion.args && (
257
+ {/* Scroll up indicator */}
258
+ {startIndex > 0 && (
259
+ <Box paddingX={1}>
260
+ <Text color="gray">↑ {startIndex} more above</Text>
261
+ </Box>
262
+ )}
263
+ {visibleSuggestions.map((suggestion, visibleIndex) => {
264
+ const actualIndex = startIndex + visibleIndex;
265
+ const isSelected = actualIndex === selectedSuggestion;
266
+ return (
267
+ <Box key={`${suggestion.cmd}-${suggestion.args}-${actualIndex}`} paddingX={1}>
232
268
  <Text
233
- color={index === selectedSuggestion ? 'white' : 'gray'}
234
- backgroundColor={index === selectedSuggestion ? 'blue' : undefined}
269
+ backgroundColor={isSelected ? 'blue' : undefined}
270
+ color={isSelected ? 'white' : 'cyan'}
271
+ bold={isSelected}
235
272
  >
236
- {' '}{suggestion.args}
273
+ {suggestion.cmd}
237
274
  </Text>
238
- )}
239
- <Text color="gray"> - </Text>
240
- <Text
241
- color={index === selectedSuggestion ? 'white' : 'gray'}
242
- dimColor={index !== selectedSuggestion}
243
- >
244
- {suggestion.desc}
245
- </Text>
275
+ {suggestion.args && (
276
+ <Text
277
+ color={isSelected ? 'white' : 'gray'}
278
+ backgroundColor={isSelected ? 'blue' : undefined}
279
+ >
280
+ {' '}{suggestion.args}
281
+ </Text>
282
+ )}
283
+ <Text color="gray"> - </Text>
284
+ <Text
285
+ color={isSelected ? 'white' : 'gray'}
286
+ dimColor={!isSelected}
287
+ >
288
+ {suggestion.desc}
289
+ </Text>
290
+ </Box>
291
+ );
292
+ })}
293
+ {/* Scroll down indicator */}
294
+ {startIndex + MAX_VISIBLE < suggestions.length && (
295
+ <Box paddingX={1}>
296
+ <Text color="gray">↓ {suggestions.length - startIndex - MAX_VISIBLE} more below</Text>
246
297
  </Box>
247
- ))}
298
+ )}
248
299
  <Box paddingX={1}>
249
300
  <Text dimColor>Tab complete · ↑↓ select · Esc cancel</Text>
250
301
  </Box>