@gxp-dev/tools 2.0.11 → 2.0.13

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.
@@ -30,6 +30,14 @@ export interface Service {
30
30
  logs: string[];
31
31
  }
32
32
 
33
+ interface ExtractedConfig {
34
+ strings: Record<string, string>;
35
+ settings: Record<string, unknown>;
36
+ assets: Record<string, string>;
37
+ triggerState: Record<string, unknown>;
38
+ dependencies: Array<{ identifier: string; path: string; events?: Record<string, string> }>;
39
+ }
40
+
33
41
  export interface AppProps {
34
42
  autoStart?: string[];
35
43
  args?: Record<string, unknown>;
@@ -129,16 +137,7 @@ export default function App({ autoStart, args }: AppProps) {
129
137
  return;
130
138
  }
131
139
 
132
- // Tab to cycle through tabs (when not in input)
133
- if (key.tab && services.length > 0) {
134
- const nextTab = key.shift
135
- ? (activeTab - 1 + services.length) % services.length
136
- : (activeTab + 1) % services.length;
137
- setActiveTab(nextTab);
138
- return;
139
- }
140
-
141
- // Left/Right arrow to switch tabs
140
+ // Left/Right arrow to switch tabs (Tab is reserved for command autocomplete)
142
141
  if (key.leftArrow && services.length > 0) {
143
142
  setActiveTab((activeTab - 1 + services.length) % services.length);
144
143
  return;
@@ -148,7 +147,7 @@ export default function App({ autoStart, args }: AppProps) {
148
147
  return;
149
148
  }
150
149
 
151
- // Ctrl+1-9 or Cmd+1-9 to switch tabs (for compatibility)
150
+ // Ctrl+1-9 or Cmd+1-9 to switch tabs directly
152
151
  if ((key.ctrl || key.meta) && /^[1-9]$/.test(input)) {
153
152
  const tabIndex = parseInt(input) - 1;
154
153
  if (tabIndex < services.length) {
@@ -232,6 +231,11 @@ export default function App({ autoStart, args }: AppProps) {
232
231
  handleGeminiCommand(cmdArgs);
233
232
  break;
234
233
 
234
+ case 'extract-config':
235
+ case 'extract':
236
+ handleExtractConfig(cmdArgs);
237
+ break;
238
+
235
239
  default:
236
240
  addSystemLog(`Unknown command: ${command}. Type /help for available commands.`);
237
241
  }
@@ -510,42 +514,155 @@ export default function App({ autoStart, args }: AppProps) {
510
514
  addSystemLog(message);
511
515
  };
512
516
 
517
+ const handleExtractConfig = async (cmdArgs: string[]) => {
518
+ const dryRun = cmdArgs.includes('--dry-run') || cmdArgs.includes('-d');
519
+ const overwrite = cmdArgs.includes('--overwrite') || cmdArgs.includes('-o');
520
+
521
+ addSystemLog('Scanning source files for GxP configuration...');
522
+
523
+ try {
524
+ // Use dynamic imports for ES modules
525
+ const path = await import('path');
526
+ const fs = await import('fs');
527
+ const { createRequire } = await import('module');
528
+
529
+ // Create a require function to load CommonJS modules
530
+ const require = createRequire(import.meta.url);
531
+ const extractConfigUtils = require('../utils/extract-config.js') as {
532
+ extractConfigFromSource: (srcDir: string) => ExtractedConfig;
533
+ mergeConfig: (existing: Record<string, unknown>, extracted: ExtractedConfig, options: { overwrite: boolean }) => Record<string, unknown>;
534
+ generateSummary: (config: ExtractedConfig) => string;
535
+ };
536
+
537
+ const projectPath = process.cwd();
538
+ const srcDir = path.join(projectPath, 'src');
539
+ const manifestPath = path.join(projectPath, 'app-manifest.json');
540
+
541
+ // Check if src directory exists
542
+ if (!fs.existsSync(srcDir)) {
543
+ addSystemLog('Source directory not found: src/');
544
+ return;
545
+ }
546
+
547
+ // Extract configuration
548
+ const extractedConfig = extractConfigUtils.extractConfigFromSource(srcDir);
549
+ const summary = extractConfigUtils.generateSummary(extractedConfig);
550
+ addSystemLog(summary);
551
+
552
+ // Count total items
553
+ const totalItems =
554
+ Object.keys(extractedConfig.strings).length +
555
+ Object.keys(extractedConfig.settings).length +
556
+ Object.keys(extractedConfig.assets).length +
557
+ Object.keys(extractedConfig.triggerState).length +
558
+ extractedConfig.dependencies.length;
559
+
560
+ if (totalItems === 0) {
561
+ addSystemLog('No GxP configuration found in source files.');
562
+ return;
563
+ }
564
+
565
+ if (dryRun) {
566
+ addSystemLog('Dry run mode - no changes made.');
567
+ addSystemLog('Run /extract-config without --dry-run to apply changes.');
568
+ return;
569
+ }
570
+
571
+ // Load or create manifest
572
+ let existingManifest: Record<string, unknown> = {};
573
+ if (fs.existsSync(manifestPath)) {
574
+ try {
575
+ existingManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
576
+ } catch {
577
+ addSystemLog('Could not parse existing manifest, creating new one.');
578
+ existingManifest = getDefaultManifest();
579
+ }
580
+ } else {
581
+ addSystemLog('Creating new app-manifest.json');
582
+ existingManifest = getDefaultManifest();
583
+ }
584
+
585
+ // Merge and write
586
+ const mergedManifest = extractConfigUtils.mergeConfig(existingManifest, extractedConfig, { overwrite });
587
+ fs.writeFileSync(manifestPath, JSON.stringify(mergedManifest, null, '\t'));
588
+ addSystemLog('Updated app-manifest.json');
589
+ } catch (err) {
590
+ addSystemLog(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
591
+ }
592
+ };
593
+
594
+ const getDefaultManifest = () => ({
595
+ name: 'GxToolkit',
596
+ version: '1.0.0',
597
+ description: 'GxToolkit Plugin',
598
+ manifest_version: 3,
599
+ asset_dir: '/src/assets/',
600
+ configurationFile: 'configuration.json',
601
+ appInstructionsFile: 'app-instructions.md',
602
+ defaultStylingFile: 'default-styling.css',
603
+ settings: {},
604
+ strings: { default: {} },
605
+ assets: {},
606
+ triggerState: {},
607
+ dependencies: [],
608
+ permissions: [],
609
+ });
610
+
513
611
  const getHelpText = () => `
514
612
  Available commands:
515
- /dev Start Vite (+ Socket if SOCKET_IO_ENABLED=true)
516
- /dev --with-socket Start Vite + Socket.IO together
517
- /dev --with-mock Start Vite + Socket.IO + Mock API server
518
- /dev --no-socket Start Vite only (skip Socket.IO)
519
- /dev --no-https Start Vite without SSL
520
- /dev --firefox Start Vite + Firefox extension
521
- /dev --chrome Start Vite + Chrome extension
522
- /socket Start Socket.IO server
523
- /socket --with-mock Start Socket.IO with Mock API enabled
524
- /socket send <event> Send socket event
525
- /socket list List available events
526
- /mock Start Socket.IO + Mock API (shorthand)
527
- /ext chrome Launch Chrome extension
528
- /ext firefox Launch Firefox extension
529
- /stop [service] Stop a running service
530
- /restart [service] Restart a service
531
- /clear Clear current log panel
532
- /gemini Open Gemini AI chat panel
533
- /gemini enable Set up Google authentication
534
- /gemini ask <query> Quick question to Gemini
535
- /gemini status Check authentication status
536
- /gemini logout Log out from Gemini
537
- /help Show this help message
538
- /quit Exit the application
613
+
614
+ Development Server:
615
+ /dev Start Vite dev server
616
+ /dev --with-socket Start Vite + Socket.IO
617
+ /dev --with-mock Start Vite + Socket.IO + Mock API
618
+ /dev --no-https Start without SSL
619
+ /dev --no-socket Start without Socket.IO
620
+ /dev --chrome Start + launch Chrome extension
621
+ /dev --firefox Start + launch Firefox extension
622
+
623
+ Socket.IO:
624
+ /socket Start Socket.IO server
625
+ /socket --with-mock Start with Mock API enabled
626
+ /socket send <event> Send a socket event
627
+ /socket list List available events
628
+ /mock Shorthand for /socket --with-mock
629
+
630
+ Browser Extensions:
631
+ /ext chrome Launch Chrome with GxP extension
632
+ /ext firefox Launch Firefox with GxP extension
633
+
634
+ Config Extraction:
635
+ /extract-config Extract GxP config from source
636
+ /extract-config -d Dry run (preview changes)
637
+ /extract-config -o Overwrite existing values
638
+
639
+ AI Assistant:
640
+ /gemini Open Gemini AI chat panel
641
+ /gemini enable Set up Google authentication
642
+ /gemini ask <query> Quick question to AI
643
+ /gemini status Check auth status
644
+ /gemini logout Log out from Gemini
645
+ /gemini clear Clear conversation history
646
+ /ai Alias for /gemini
647
+
648
+ Service Management:
649
+ /stop [service] Stop current or specified service
650
+ /restart [service] Restart a service
651
+ /clear Clear current log panel
652
+ /help Show this help
653
+ /quit Exit application
539
654
 
540
655
  Keyboard shortcuts:
541
- Tab / Shift+Tab Cycle through tabs
542
- Left/Right Switch tabs
543
- Cmd+1/2/3... Jump to tab (Mac)
544
- Shift+Up/Down Scroll logs
545
- Cmd+Up/Down Jump to top/bottom of logs
656
+ ←/→ Switch tabs
657
+ Ctrl+1/2/3... Jump to tab directly
658
+ Shift+↑/↓ Scroll logs
659
+ Ctrl+↑/↓ Jump to top/bottom of logs
546
660
  Ctrl+L Clear current log
661
+ Ctrl+K Stop current service
547
662
  Ctrl+C Exit application
548
- Up/Down Command history (in input)
663
+ Tab Autocomplete command
664
+ ↑/↓ Navigate suggestions or command history
665
+ Esc Clear input
549
666
  `;
550
667
 
551
668
  // Show Gemini panel
@@ -1,29 +1,52 @@
1
- import React, { useState, useMemo, useEffect } from 'react';
1
+ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import TextInput from 'ink-text-input';
4
4
 
5
- // Command definitions with descriptions
5
+ // Command definitions with descriptions - comprehensive list
6
6
  const COMMANDS = [
7
- { cmd: '/dev', args: '', desc: 'Start Vite (+ Socket if SOCKET_IO_ENABLED)' },
7
+ // Dev server commands
8
+ { cmd: '/dev', args: '', desc: 'Start Vite dev server' },
8
9
  { cmd: '/dev', args: '--with-socket', desc: 'Start Vite + Socket.IO' },
9
- { cmd: '/dev', args: '--with-mock', desc: 'Start Vite + Socket.IO + Mock API server' },
10
- { cmd: '/dev', args: '--firefox', desc: 'Start Vite + Firefox extension' },
11
- { cmd: '/dev', args: '--chrome', desc: 'Start Vite + Chrome extension' },
12
- { cmd: '/dev', args: '--no-socket', desc: 'Start Vite without Socket.IO' },
10
+ { cmd: '/dev', args: '--with-mock', desc: 'Start Vite + Socket.IO + Mock API' },
13
11
  { cmd: '/dev', args: '--no-https', desc: 'Start Vite without SSL' },
12
+ { cmd: '/dev', args: '--no-socket', desc: 'Start Vite without Socket.IO' },
13
+ { cmd: '/dev', args: '--chrome', desc: 'Start Vite + Chrome extension' },
14
+ { cmd: '/dev', args: '--firefox', desc: 'Start Vite + Firefox extension' },
15
+
16
+ // Socket commands
14
17
  { cmd: '/socket', args: '', desc: 'Start Socket.IO server' },
15
- { cmd: '/socket', args: 'send <event>', desc: 'Send socket event' },
16
- { cmd: '/socket', args: 'list', desc: 'List available events' },
17
- { cmd: '/ext', args: 'chrome', desc: 'Launch Chrome extension' },
18
- { cmd: '/ext', args: 'firefox', desc: 'Launch Firefox extension' },
19
- { cmd: '/stop', args: '[service]', desc: 'Stop current/specified service' },
20
- { cmd: '/restart', args: '[service]', desc: 'Restart current/specified service' },
18
+ { cmd: '/socket', args: '--with-mock', desc: 'Start Socket.IO + Mock API' },
19
+ { cmd: '/socket', args: 'send <event>', desc: 'Send a socket event' },
20
+ { cmd: '/socket', args: 'list', desc: 'List available socket events' },
21
+ { cmd: '/mock', args: '', desc: 'Start Socket.IO + Mock API (shorthand)' },
22
+
23
+ // Browser extension commands
24
+ { cmd: '/ext', args: 'chrome', desc: 'Launch Chrome with extension' },
25
+ { cmd: '/ext', args: 'firefox', desc: 'Launch Firefox with extension' },
26
+
27
+ // Service management
28
+ { cmd: '/stop', args: '[service]', desc: 'Stop current or specified service' },
29
+ { cmd: '/restart', args: '[service]', desc: 'Restart current or specified service' },
21
30
  { cmd: '/clear', args: '', desc: 'Clear current log panel' },
22
- { cmd: '/gemini', args: '', desc: 'Open Gemini AI chat' },
23
- { cmd: '/gemini', args: 'enable', desc: 'Set up Google auth' },
31
+
32
+ // Config extraction
33
+ { cmd: '/extract-config', args: '', desc: 'Extract GxP config from source' },
34
+ { cmd: '/extract-config', args: '--dry-run', desc: 'Preview config extraction' },
35
+ { cmd: '/extract-config', args: '--overwrite', desc: 'Overwrite existing config values' },
36
+
37
+ // AI commands
38
+ { cmd: '/gemini', args: '', desc: 'Open Gemini AI chat panel' },
39
+ { cmd: '/gemini', args: 'enable', desc: 'Set up Google authentication' },
24
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)' },
45
+
46
+ // General
25
47
  { cmd: '/help', args: '', desc: 'Show all commands' },
26
48
  { cmd: '/quit', args: '', desc: 'Exit application' },
49
+ { cmd: '/exit', args: '', desc: 'Exit application (alias)' },
27
50
  ];
28
51
 
29
52
  interface ActiveService {
@@ -39,16 +62,17 @@ interface CommandInputProps {
39
62
  }
40
63
 
41
64
  export default function CommandInput({ onSubmit, activeService, onSuggestionsChange }: CommandInputProps) {
42
- const [value, setValue] = useState(''); // The actual typed value (for filtering)
43
- const [displayValue, setDisplayValue] = useState(''); // What's shown in input (may differ when navigating)
44
- const [isNavigating, setIsNavigating] = useState(false); // Track if user is arrowing through suggestions
65
+ const [value, setValue] = useState('');
45
66
  const [history, setHistory] = useState<string[]>([]);
46
67
  const [historyIndex, setHistoryIndex] = useState(-1);
47
68
  const [selectedSuggestion, setSelectedSuggestion] = useState(0);
48
- // Key to force TextInput remount when value is set programmatically (resets cursor)
49
- const [inputKey, setInputKey] = useState(0);
50
69
 
51
- // Filter commands based on the TYPED value (not display value)
70
+ // Use ref to track if we should skip the next onChange (after programmatic update)
71
+ const skipNextChange = useRef(false);
72
+ // Track previous suggestion count to avoid unnecessary parent updates
73
+ const prevSuggestionCount = useRef(0);
74
+
75
+ // Filter commands based on typed value
52
76
  const suggestions = useMemo(() => {
53
77
  if (!value.startsWith('/')) return [];
54
78
 
@@ -57,13 +81,13 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
57
81
  const fullCmd = c.args ? `${c.cmd} ${c.args}` : c.cmd;
58
82
  return fullCmd.toLowerCase().includes(search) ||
59
83
  c.cmd.toLowerCase().startsWith(search);
60
- });
84
+ }).slice(0, 8); // Limit to 8 suggestions for cleaner UI
61
85
  }, [value]);
62
86
 
63
87
  const showSuggestions = value.startsWith('/') && value.length >= 1 && suggestions.length > 0;
64
88
 
65
89
  // Helper to build full command string from suggestion
66
- const buildFullCommand = (suggestion: typeof COMMANDS[0]): string => {
90
+ const buildFullCommand = useCallback((suggestion: typeof COMMANDS[0]): string => {
67
91
  if (suggestion.args) {
68
92
  const hasPlaceholder = suggestion.args.includes('<') || suggestion.args.includes('[');
69
93
  if (!hasPlaceholder) {
@@ -71,55 +95,43 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
71
95
  }
72
96
  }
73
97
  return suggestion.cmd;
74
- };
98
+ }, []);
75
99
 
76
- // Notify parent when suggestions change (for layout adjustment)
100
+ // Notify parent when suggestions change (debounced to reduce flicker)
77
101
  useEffect(() => {
78
- if (onSuggestionsChange) {
79
- // +2 for border and help text row
80
- onSuggestionsChange(showSuggestions ? suggestions.length + 2 : 0);
102
+ const count = showSuggestions ? suggestions.length + 2 : 0;
103
+ if (count !== prevSuggestionCount.current) {
104
+ prevSuggestionCount.current = count;
105
+ onSuggestionsChange?.(count);
81
106
  }
82
107
  }, [showSuggestions, suggestions.length, onSuggestionsChange]);
83
108
 
109
+ // Reset selected suggestion when suggestions change
110
+ useEffect(() => {
111
+ if (selectedSuggestion >= suggestions.length) {
112
+ setSelectedSuggestion(Math.max(0, suggestions.length - 1));
113
+ }
114
+ }, [suggestions.length, selectedSuggestion]);
115
+
84
116
  useInput((input, key) => {
85
- // Tab to autocomplete selected suggestion (commits the selection)
117
+ // Tab to autocomplete selected suggestion
86
118
  if (key.tab && showSuggestions && suggestions[selectedSuggestion]) {
87
119
  const suggestion = suggestions[selectedSuggestion];
88
120
  const fullCmd = buildFullCommand(suggestion);
89
- // Commit to both value and displayValue
121
+ skipNextChange.current = true;
90
122
  setValue(fullCmd);
91
- setDisplayValue(fullCmd);
92
123
  setSelectedSuggestion(0);
93
- setIsNavigating(false);
94
- // Increment key to force TextInput remount (resets cursor to end)
95
- setInputKey(k => k + 1);
96
124
  return;
97
125
  }
98
126
 
99
127
  // Up/Down to navigate suggestions when showing
100
128
  if (showSuggestions) {
101
129
  if (key.upArrow) {
102
- const newIndex = Math.max(0, selectedSuggestion - 1);
103
- setSelectedSuggestion(newIndex);
104
- setIsNavigating(true);
105
- // Update display value to show highlighted command
106
- const suggestion = suggestions[newIndex];
107
- if (suggestion) {
108
- setDisplayValue(buildFullCommand(suggestion));
109
- setInputKey(k => k + 1);
110
- }
130
+ setSelectedSuggestion(prev => Math.max(0, prev - 1));
111
131
  return;
112
132
  }
113
133
  if (key.downArrow) {
114
- const newIndex = Math.min(suggestions.length - 1, selectedSuggestion + 1);
115
- setSelectedSuggestion(newIndex);
116
- setIsNavigating(true);
117
- // Update display value to show highlighted command
118
- const suggestion = suggestions[newIndex];
119
- if (suggestion) {
120
- setDisplayValue(buildFullCommand(suggestion));
121
- setInputKey(k => k + 1);
122
- }
134
+ setSelectedSuggestion(prev => Math.min(suggestions.length - 1, prev + 1));
123
135
  return;
124
136
  }
125
137
  } else {
@@ -127,81 +139,75 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
127
139
  if (key.upArrow && history.length > 0) {
128
140
  const newIndex = Math.min(historyIndex + 1, history.length - 1);
129
141
  setHistoryIndex(newIndex);
130
- const histVal = history[history.length - 1 - newIndex] || '';
131
- setValue(histVal);
132
- setDisplayValue(histVal);
133
- setInputKey(k => k + 1);
142
+ skipNextChange.current = true;
143
+ setValue(history[history.length - 1 - newIndex] || '');
134
144
  return;
135
145
  }
136
146
 
137
147
  if (key.downArrow) {
138
148
  const newIndex = Math.max(historyIndex - 1, -1);
139
149
  setHistoryIndex(newIndex);
150
+ skipNextChange.current = true;
140
151
  if (newIndex < 0) {
141
152
  setValue('');
142
- setDisplayValue('');
143
153
  } else {
144
- const histVal = history[history.length - 1 - newIndex] || '';
145
- setValue(histVal);
146
- setDisplayValue(histVal);
154
+ setValue(history[history.length - 1 - newIndex] || '');
147
155
  }
148
- setInputKey(k => k + 1);
149
156
  return;
150
157
  }
151
158
  }
159
+
160
+ // Escape to clear input or close suggestions
161
+ if (key.escape) {
162
+ setValue('');
163
+ setSelectedSuggestion(0);
164
+ setHistoryIndex(-1);
165
+ return;
166
+ }
152
167
  });
153
168
 
154
- const handleSubmit = (input: string) => {
169
+ const handleSubmit = useCallback((input: string) => {
155
170
  if (!input.trim()) return;
156
171
 
157
- // Add to history
158
- if (input.trim()) {
159
- setHistory(prev => [...prev.filter(h => h !== input.trim()), input.trim()]);
160
- }
172
+ // Add to history (avoid duplicates)
173
+ setHistory(prev => [...prev.filter(h => h !== input.trim()), input.trim()]);
161
174
 
162
175
  // Reset state
163
176
  setValue('');
164
- setDisplayValue('');
165
177
  setHistoryIndex(-1);
166
178
  setSelectedSuggestion(0);
167
- setIsNavigating(false);
168
179
 
169
180
  // Call handler
170
181
  onSubmit(input);
171
- };
182
+ }, [onSubmit]);
172
183
 
173
184
  // Handle text input changes
174
- const handleChange = (v: string) => {
175
- // User typed something, reset navigation state
185
+ const handleChange = useCallback((v: string) => {
186
+ if (skipNextChange.current) {
187
+ skipNextChange.current = false;
188
+ return;
189
+ }
176
190
  setValue(v);
177
- setDisplayValue(v);
178
191
  setSelectedSuggestion(0);
179
- setIsNavigating(false);
180
- };
192
+ }, []);
181
193
 
182
194
  // Get context-specific hints for current tab
183
- const getHints = (): string[] => {
184
- const hints: string[] = [];
195
+ const hints = useMemo((): string[] => {
196
+ const h: string[] = [];
185
197
 
186
198
  if (activeService) {
187
199
  const isRunning = activeService.status === 'running' || activeService.status === 'starting';
188
- if (isRunning) {
189
- hints.push(`Ctrl+K stop ${activeService.name}`);
190
- hints.push(`/restart to restart`);
191
- }
192
- if (activeService.id === 'vite') {
193
- hints.push('r to refresh browser');
200
+ if (isRunning && activeService.id !== 'system') {
201
+ h.push(`Ctrl+K stop`);
194
202
  }
195
203
  }
196
204
 
197
- hints.push('Ctrl+L clear logs');
198
- hints.push('Tab/Arrow switch tabs');
199
- hints.push('Ctrl+C quit');
200
-
201
- return hints;
202
- };
205
+ h.push('Ctrl+L clear');
206
+ h.push('←/→ tabs');
207
+ h.push('Esc cancel');
203
208
 
204
- const hints = getHints();
209
+ return h;
210
+ }, [activeService]);
205
211
 
206
212
  return (
207
213
  <Box flexDirection="column">
@@ -239,8 +245,8 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
239
245
  </Text>
240
246
  </Box>
241
247
  ))}
242
- <Box paddingX={1} borderStyle={undefined}>
243
- <Text dimColor>Tab to complete · Up/Down to select</Text>
248
+ <Box paddingX={1}>
249
+ <Text dimColor>Tab complete · ↑↓ select · Esc cancel</Text>
244
250
  </Box>
245
251
  </Box>
246
252
  )}
@@ -254,10 +260,9 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
254
260
  <Text color="cyan" bold>&gt;</Text>
255
261
  <Text> </Text>
256
262
  <TextInput
257
- key={inputKey}
258
- value={displayValue}
263
+ value={value}
259
264
  onChange={handleChange}
260
- onSubmit={() => handleSubmit(displayValue.startsWith('/') ? displayValue : '/' + displayValue)}
265
+ onSubmit={() => handleSubmit(value.startsWith('/') ? value : '/' + value)}
261
266
  placeholder="Type / to run a command..."
262
267
  />
263
268
  </Box>
@@ -265,7 +270,7 @@ export default function CommandInput({ onSubmit, activeService, onSuggestionsCha
265
270
  {/* Hints bar (below input) */}
266
271
  <Box paddingX={1} justifyContent="space-between">
267
272
  <Box>
268
- {hints.slice(0, 4).map((hint, index) => (
273
+ {hints.map((hint, index) => (
269
274
  <React.Fragment key={hint}>
270
275
  {index > 0 && <Text color="gray"> · </Text>}
271
276
  <Text dimColor>{hint}</Text>
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useMemo, useCallback, memo } from 'react';
2
2
  import { Box, Text, useStdout, useInput } from 'ink';
3
3
 
4
4
  interface LogPanelProps {
@@ -7,15 +7,25 @@ interface LogPanelProps {
7
7
  maxHeight?: number; // Maximum height in rows (from parent container)
8
8
  }
9
9
 
10
- export default function LogPanel({ logs, isActive = true, maxHeight }: LogPanelProps) {
10
+ // Memoized log line component to prevent unnecessary re-renders
11
+ const LogLine = memo(({ log, index }: { log: string; index: number }) => (
12
+ <Text key={index} wrap="wrap">
13
+ {formatLog(log)}
14
+ </Text>
15
+ ));
16
+ LogLine.displayName = 'LogLine';
17
+
18
+ function LogPanel({ logs, isActive = true, maxHeight }: LogPanelProps) {
11
19
  const { stdout } = useStdout();
12
20
  const [scrollOffset, setScrollOffset] = useState(0);
13
21
  const [autoScroll, setAutoScroll] = useState(true);
14
22
 
15
23
  // Calculate visible lines based on provided maxHeight or terminal height
16
24
  // Subtract 2 for padding, 2 for border
17
- const defaultMaxLines = stdout ? Math.max(5, stdout.rows - 10) : 15;
18
- const maxLines = maxHeight ? Math.max(3, maxHeight - 4) : defaultMaxLines;
25
+ const maxLines = useMemo(() => {
26
+ const defaultMaxLines = stdout ? Math.max(5, stdout.rows - 10) : 15;
27
+ return maxHeight ? Math.max(3, maxHeight - 4) : defaultMaxLines;
28
+ }, [stdout?.rows, maxHeight]);
19
29
 
20
30
  // Reset scroll when logs are cleared
21
31
  useEffect(() => {
@@ -23,7 +33,7 @@ export default function LogPanel({ logs, isActive = true, maxHeight }: LogPanelP
23
33
  setScrollOffset(0);
24
34
  setAutoScroll(true);
25
35
  }
26
- }, [logs.length === 0]);
36
+ }, [logs.length]);
27
37
 
28
38
  // Auto-scroll to bottom when new logs arrive (if autoScroll is enabled)
29
39
  useEffect(() => {
@@ -70,15 +80,18 @@ export default function LogPanel({ logs, isActive = true, maxHeight }: LogPanelP
70
80
  }
71
81
  });
72
82
 
73
- // Calculate visible logs with scroll offset
74
- const totalLogs = logs.length;
75
- const startIndex = Math.max(0, totalLogs - maxLines - scrollOffset);
76
- const endIndex = Math.max(0, totalLogs - scrollOffset);
77
- const visibleLogs = logs.slice(startIndex, endIndex);
78
-
79
- // Show scroll indicator
80
- const canScrollUp = startIndex > 0;
81
- const canScrollDown = scrollOffset > 0;
83
+ // Calculate visible logs with scroll offset - memoized
84
+ const { visibleLogs, startIndex, canScrollUp, canScrollDown } = useMemo(() => {
85
+ const totalLogs = logs.length;
86
+ const start = Math.max(0, totalLogs - maxLines - scrollOffset);
87
+ const end = Math.max(0, totalLogs - scrollOffset);
88
+ return {
89
+ visibleLogs: logs.slice(start, end),
90
+ startIndex: start,
91
+ canScrollUp: start > 0,
92
+ canScrollDown: scrollOffset > 0,
93
+ };
94
+ }, [logs, maxLines, scrollOffset]);
82
95
 
83
96
  return (
84
97
  <Box flexDirection="column" padding={1} flexGrow={1} overflow="hidden">
@@ -89,9 +102,7 @@ export default function LogPanel({ logs, isActive = true, maxHeight }: LogPanelP
89
102
  <Text color="gray" dimColor>No logs yet...</Text>
90
103
  ) : (
91
104
  visibleLogs.map((log, index) => (
92
- <Text key={startIndex + index} wrap="wrap">
93
- {formatLog(log)}
94
- </Text>
105
+ <LogLine key={startIndex + index} log={log} index={startIndex + index} />
95
106
  ))
96
107
  )}
97
108
  {canScrollDown && (
@@ -120,3 +131,5 @@ function formatLog(log: string): React.ReactNode {
120
131
  }
121
132
  return <Text>{log}</Text>;
122
133
  }
134
+
135
+ export default memo(LogPanel);
@@ -49,7 +49,7 @@ export default function TabBar({ services, activeTab, onTabChange }: TabBarProps
49
49
  })}
50
50
  </Box>
51
51
  <Box>
52
- <Text color="gray">Tab/←→ switch tabs</Text>
52
+ <Text color="gray">←/→ switch tabs</Text>
53
53
  </Box>
54
54
  </Box>
55
55
  );