@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.
- package/bin/lib/cli.js +42 -0
- package/bin/lib/commands/extract-config.js +186 -0
- package/bin/lib/commands/index.js +2 -0
- package/bin/lib/commands/init.js +446 -180
- package/bin/lib/tui/App.tsx +158 -41
- package/bin/lib/tui/components/CommandInput.tsx +100 -95
- package/bin/lib/tui/components/LogPanel.tsx +30 -17
- package/bin/lib/tui/components/TabBar.tsx +1 -1
- package/bin/lib/utils/ai-scaffold.js +877 -0
- package/bin/lib/utils/extract-config.js +468 -0
- package/bin/lib/utils/files.js +43 -2
- package/bin/lib/utils/index.js +4 -0
- package/bin/lib/utils/prompts.js +352 -0
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +134 -40
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/components/CommandInput.d.ts.map +1 -1
- package/dist/tui/components/CommandInput.js +87 -89
- package/dist/tui/components/CommandInput.js.map +1 -1
- package/dist/tui/components/LogPanel.d.ts +4 -2
- package/dist/tui/components/LogPanel.d.ts.map +1 -1
- package/dist/tui/components/LogPanel.js +25 -15
- package/dist/tui/components/LogPanel.js.map +1 -1
- package/dist/tui/components/TabBar.js +1 -1
- package/dist/tui/components/TabBar.js.map +1 -1
- package/mcp/gxp-api-server.js +524 -0
- package/package.json +4 -2
- package/runtime/stores/gxpPortalConfigStore.js +9 -0
- package/template/.claude/agents/gxp-developer.md +335 -0
- package/template/.claude/settings.json +9 -0
- package/template/AGENTS.md +125 -0
- package/template/GEMINI.md +80 -0
- package/template/app-manifest.json +1 -0
package/bin/lib/tui/App.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: '
|
|
16
|
-
{ cmd: '/socket', args: '
|
|
17
|
-
{ cmd: '/
|
|
18
|
-
{ cmd: '/
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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('');
|
|
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
|
-
//
|
|
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 (
|
|
100
|
+
// Notify parent when suggestions change (debounced to reduce flicker)
|
|
77
101
|
useEffect(() => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
setValue(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
};
|
|
192
|
+
}, []);
|
|
181
193
|
|
|
182
194
|
// Get context-specific hints for current tab
|
|
183
|
-
const
|
|
184
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return hints;
|
|
202
|
-
};
|
|
205
|
+
h.push('Ctrl+L clear');
|
|
206
|
+
h.push('←/→ tabs');
|
|
207
|
+
h.push('Esc cancel');
|
|
203
208
|
|
|
204
|
-
|
|
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}
|
|
243
|
-
<Text dimColor>Tab
|
|
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>></Text>
|
|
255
261
|
<Text> </Text>
|
|
256
262
|
<TextInput
|
|
257
|
-
|
|
258
|
-
value={displayValue}
|
|
263
|
+
value={value}
|
|
259
264
|
onChange={handleChange}
|
|
260
|
-
onSubmit={() => handleSubmit(
|
|
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.
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
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
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
<
|
|
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);
|