snow-ai 0.2.22 → 0.2.23
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/dist/api/models.js +23 -7
- package/dist/app.js +5 -2
- package/dist/cli.js +43 -11
- package/dist/hooks/useCommandHandler.js +2 -3
- package/dist/hooks/useKeyboardInput.js +11 -0
- package/dist/hooks/useTerminalFocus.d.ts +23 -0
- package/dist/hooks/useTerminalFocus.js +57 -0
- package/dist/hooks/useTerminalSize.d.ts +4 -0
- package/dist/hooks/useTerminalSize.js +20 -0
- package/dist/ui/components/ChatInput.js +56 -22
- package/dist/ui/components/CommandPanel.js +1 -1
- package/dist/ui/components/FileList.js +2 -2
- package/dist/ui/components/FileRollbackConfirmation.js +1 -1
- package/dist/ui/components/Menu.js +3 -4
- package/dist/ui/pages/ApiConfigScreen.d.ts +2 -1
- package/dist/ui/pages/ApiConfigScreen.js +7 -7
- package/dist/ui/pages/ChatScreen.js +28 -10
- package/dist/ui/pages/ModelConfigScreen.d.ts +2 -1
- package/dist/ui/pages/ModelConfigScreen.js +32 -33
- package/dist/ui/pages/WelcomeScreen.js +67 -17
- package/dist/utils/terminal.d.ts +5 -0
- package/dist/utils/terminal.js +12 -0
- package/dist/utils/textBuffer.js +10 -2
- package/package.json +1 -1
package/dist/api/models.js
CHANGED
|
@@ -47,6 +47,7 @@ async function fetchGeminiModels(baseUrl, apiKey) {
|
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
49
|
* Fetch models from Anthropic API
|
|
50
|
+
* Supports both Anthropic native format and OpenAI-compatible format for backward compatibility
|
|
50
51
|
*/
|
|
51
52
|
async function fetchAnthropicModels(baseUrl, apiKey, customHeaders) {
|
|
52
53
|
const url = `${baseUrl.replace(/\/$/, '')}/models`;
|
|
@@ -57,6 +58,7 @@ async function fetchAnthropicModels(baseUrl, apiKey, customHeaders) {
|
|
|
57
58
|
};
|
|
58
59
|
if (apiKey) {
|
|
59
60
|
headers['x-api-key'] = apiKey;
|
|
61
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
60
62
|
}
|
|
61
63
|
const response = await fetch(url, {
|
|
62
64
|
method: 'GET',
|
|
@@ -66,13 +68,27 @@ async function fetchAnthropicModels(baseUrl, apiKey, customHeaders) {
|
|
|
66
68
|
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
|
|
67
69
|
}
|
|
68
70
|
const data = await response.json();
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
// Try to parse as Anthropic format first
|
|
72
|
+
if (data.data && Array.isArray(data.data) && data.data.length > 0) {
|
|
73
|
+
const firstItem = data.data[0];
|
|
74
|
+
// Check if it's Anthropic format (has created_at field)
|
|
75
|
+
if ('created_at' in firstItem && typeof firstItem.created_at === 'string') {
|
|
76
|
+
// Anthropic native format
|
|
77
|
+
return data.data.map(model => ({
|
|
78
|
+
id: model.id,
|
|
79
|
+
object: 'model',
|
|
80
|
+
created: new Date(model.created_at).getTime() / 1000,
|
|
81
|
+
owned_by: 'anthropic',
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
// Fallback to OpenAI format (has created field as number)
|
|
85
|
+
if ('id' in firstItem && 'object' in firstItem) {
|
|
86
|
+
// OpenAI-compatible format
|
|
87
|
+
return data.data;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// If no data array or empty, return empty array
|
|
91
|
+
return [];
|
|
76
92
|
}
|
|
77
93
|
/**
|
|
78
94
|
* Fetch available models based on configured request method
|
package/dist/app.js
CHANGED
|
@@ -10,12 +10,15 @@ import CustomHeadersScreen from './ui/pages/CustomHeadersScreen.js';
|
|
|
10
10
|
import ChatScreen from './ui/pages/ChatScreen.js';
|
|
11
11
|
import { useGlobalExit } from './hooks/useGlobalExit.js';
|
|
12
12
|
import { onNavigate } from './hooks/useGlobalNavigation.js';
|
|
13
|
+
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
|
13
14
|
export default function App({ version }) {
|
|
14
15
|
const [currentView, setCurrentView] = useState('welcome');
|
|
15
16
|
const [exitNotification, setExitNotification] = useState({
|
|
16
17
|
show: false,
|
|
17
18
|
message: ''
|
|
18
19
|
});
|
|
20
|
+
// Get terminal size for proper width calculation
|
|
21
|
+
const { columns: terminalWidth } = useTerminalSize();
|
|
19
22
|
// Global exit handler
|
|
20
23
|
useGlobalExit(setExitNotification);
|
|
21
24
|
// Global navigation handler
|
|
@@ -57,8 +60,8 @@ export default function App({ version }) {
|
|
|
57
60
|
return (React.createElement(WelcomeScreen, { version: version, onMenuSelect: handleMenuSelect }));
|
|
58
61
|
}
|
|
59
62
|
};
|
|
60
|
-
return (React.createElement(Box, { flexDirection: "column",
|
|
61
|
-
|
|
63
|
+
return (React.createElement(Box, { flexDirection: "column", width: terminalWidth },
|
|
64
|
+
renderView(),
|
|
62
65
|
exitNotification.show && (React.createElement(Box, { paddingX: 1, flexShrink: 0 },
|
|
63
66
|
React.createElement(Alert, { variant: "warning" }, exitNotification.message)))));
|
|
64
67
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { render } from 'ink';
|
|
3
|
+
import { render, Text, Box } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
4
5
|
import meow from 'meow';
|
|
5
|
-
import { execSync } from 'child_process';
|
|
6
|
+
import { exec, execSync } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
6
8
|
import App from './app.js';
|
|
7
9
|
import { vscodeConnection } from './utils/vscodeConnection.js';
|
|
8
|
-
|
|
10
|
+
const execAsync = promisify(exec);
|
|
11
|
+
// Check for updates asynchronously
|
|
9
12
|
async function checkForUpdates(currentVersion) {
|
|
10
13
|
try {
|
|
11
|
-
const
|
|
14
|
+
const { stdout } = await execAsync('npm view snow-ai version', {
|
|
12
15
|
encoding: 'utf8',
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
});
|
|
17
|
+
const latestVersion = stdout.trim();
|
|
15
18
|
if (latestVersion && latestVersion !== currentVersion) {
|
|
16
19
|
console.log('\n🔔 Update available!');
|
|
17
20
|
console.log(` Current version: ${currentVersion}`);
|
|
@@ -53,12 +56,41 @@ if (cli.flags.update) {
|
|
|
53
56
|
process.exit(1);
|
|
54
57
|
}
|
|
55
58
|
}
|
|
59
|
+
// Startup component that shows loading spinner during update check
|
|
60
|
+
const Startup = ({ version }) => {
|
|
61
|
+
const [appReady, setAppReady] = React.useState(false);
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
let mounted = true;
|
|
64
|
+
const init = async () => {
|
|
65
|
+
// Check for updates with timeout
|
|
66
|
+
const updateCheckPromise = version
|
|
67
|
+
? checkForUpdates(version)
|
|
68
|
+
: Promise.resolve();
|
|
69
|
+
// Race between update check and 3-second timeout
|
|
70
|
+
await Promise.race([
|
|
71
|
+
updateCheckPromise,
|
|
72
|
+
new Promise(resolve => setTimeout(resolve, 3000)),
|
|
73
|
+
]);
|
|
74
|
+
if (mounted) {
|
|
75
|
+
setAppReady(true);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
init();
|
|
79
|
+
return () => {
|
|
80
|
+
mounted = false;
|
|
81
|
+
};
|
|
82
|
+
}, [version]);
|
|
83
|
+
if (!appReady) {
|
|
84
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
85
|
+
React.createElement(Box, null,
|
|
86
|
+
React.createElement(Text, { color: "cyan" },
|
|
87
|
+
React.createElement(Spinner, { type: "dots" })),
|
|
88
|
+
React.createElement(Text, null, " Checking for updates..."))));
|
|
89
|
+
}
|
|
90
|
+
return React.createElement(App, { version: version });
|
|
91
|
+
};
|
|
56
92
|
// Disable bracketed paste mode on startup
|
|
57
93
|
process.stdout.write('\x1b[?2004l');
|
|
58
|
-
// Check for updates in the background (non-blocking)
|
|
59
|
-
if (cli.pkg.version) {
|
|
60
|
-
checkForUpdates(cli.pkg.version);
|
|
61
|
-
}
|
|
62
94
|
// Re-enable on exit to avoid polluting parent shell
|
|
63
95
|
const cleanup = () => {
|
|
64
96
|
process.stdout.write('\x1b[?2004l');
|
|
@@ -74,7 +106,7 @@ process.on('SIGTERM', () => {
|
|
|
74
106
|
cleanup();
|
|
75
107
|
process.exit(0);
|
|
76
108
|
});
|
|
77
|
-
render(React.createElement(
|
|
109
|
+
render(React.createElement(Startup, { version: cli.pkg.version }), {
|
|
78
110
|
exitOnCtrlC: false,
|
|
79
111
|
patchConsole: true,
|
|
80
112
|
});
|
|
@@ -3,6 +3,7 @@ import { useCallback } from 'react';
|
|
|
3
3
|
import { sessionManager } from '../utils/sessionManager.js';
|
|
4
4
|
import { compressContext } from '../utils/contextCompressor.js';
|
|
5
5
|
import { navigateTo } from './useGlobalNavigation.js';
|
|
6
|
+
import { resetTerminal } from '../utils/terminal.js';
|
|
6
7
|
export function useCommandHandler(options) {
|
|
7
8
|
const { stdout } = useStdout();
|
|
8
9
|
const handleCommandExecution = useCallback(async (commandName, result) => {
|
|
@@ -90,9 +91,7 @@ export function useCommandHandler(options) {
|
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
if (result.success && result.action === 'clear') {
|
|
93
|
-
|
|
94
|
-
stdout.write('\x1B[3J\x1B[2J\x1B[H');
|
|
95
|
-
}
|
|
94
|
+
resetTerminal(stdout);
|
|
96
95
|
// Clear current session and start new one
|
|
97
96
|
sessionManager.clearCurrentSession();
|
|
98
97
|
options.clearSavedMessages();
|
|
@@ -26,6 +26,17 @@ export function useKeyboardInput(options) {
|
|
|
26
26
|
useInput((input, key) => {
|
|
27
27
|
if (disabled)
|
|
28
28
|
return;
|
|
29
|
+
// Filter out focus events - ONLY if the input is exactly a focus event
|
|
30
|
+
// Focus events from terminals: ESC[I (focus in) or ESC[O (focus out)
|
|
31
|
+
// DO NOT filter if input contains other content (like drag-and-drop paths)
|
|
32
|
+
// The key insight: focus events are standalone, user input is never JUST "[I" or "[O"
|
|
33
|
+
if (input === '\x1b[I' ||
|
|
34
|
+
input === '\x1b[O' ||
|
|
35
|
+
// Some terminals may send without ESC, but only if it's the entire input
|
|
36
|
+
(input === '[I' && input.length === 2) ||
|
|
37
|
+
(input === '[O' && input.length === 2)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
29
40
|
// Shift+Tab - Toggle YOLO mode
|
|
30
41
|
if (key.shift && key.tab) {
|
|
31
42
|
executeCommand('yolo').then(result => {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook to detect terminal window focus state.
|
|
3
|
+
* Returns true when terminal has focus, false otherwise.
|
|
4
|
+
*
|
|
5
|
+
* Uses ANSI escape sequences to detect focus events:
|
|
6
|
+
* - ESC[I (\x1b[I) - Focus gained
|
|
7
|
+
* - ESC[O (\x1b[O) - Focus lost
|
|
8
|
+
*
|
|
9
|
+
* Cross-platform support:
|
|
10
|
+
* - ✅ Windows Terminal
|
|
11
|
+
* - ✅ macOS Terminal.app, iTerm2
|
|
12
|
+
* - ✅ Linux: GNOME Terminal, Konsole, Alacritty, kitty, etc.
|
|
13
|
+
*
|
|
14
|
+
* Note: Older or minimal terminals that don't support focus reporting
|
|
15
|
+
* will simply ignore the escape sequences and cursor will remain visible.
|
|
16
|
+
*
|
|
17
|
+
* Also provides a function to check if input contains focus events
|
|
18
|
+
* so they can be filtered from normal input processing.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useTerminalFocus(): {
|
|
21
|
+
hasFocus: boolean;
|
|
22
|
+
isFocusEvent: (input: string) => boolean;
|
|
23
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Hook to detect terminal window focus state.
|
|
4
|
+
* Returns true when terminal has focus, false otherwise.
|
|
5
|
+
*
|
|
6
|
+
* Uses ANSI escape sequences to detect focus events:
|
|
7
|
+
* - ESC[I (\x1b[I) - Focus gained
|
|
8
|
+
* - ESC[O (\x1b[O) - Focus lost
|
|
9
|
+
*
|
|
10
|
+
* Cross-platform support:
|
|
11
|
+
* - ✅ Windows Terminal
|
|
12
|
+
* - ✅ macOS Terminal.app, iTerm2
|
|
13
|
+
* - ✅ Linux: GNOME Terminal, Konsole, Alacritty, kitty, etc.
|
|
14
|
+
*
|
|
15
|
+
* Note: Older or minimal terminals that don't support focus reporting
|
|
16
|
+
* will simply ignore the escape sequences and cursor will remain visible.
|
|
17
|
+
*
|
|
18
|
+
* Also provides a function to check if input contains focus events
|
|
19
|
+
* so they can be filtered from normal input processing.
|
|
20
|
+
*/
|
|
21
|
+
export function useTerminalFocus() {
|
|
22
|
+
const [hasFocus, setHasFocus] = useState(true); // Default to focused
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
// Set up listener first
|
|
25
|
+
const handleData = (data) => {
|
|
26
|
+
const str = data.toString();
|
|
27
|
+
// Focus gained: ESC[I
|
|
28
|
+
if (str === '\x1b[I') {
|
|
29
|
+
setHasFocus(true);
|
|
30
|
+
}
|
|
31
|
+
// Focus lost: ESC[O
|
|
32
|
+
if (str === '\x1b[O') {
|
|
33
|
+
setHasFocus(false);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
// Listen to stdin data
|
|
37
|
+
process.stdin.on('data', handleData);
|
|
38
|
+
// Enable focus reporting AFTER listener is set up
|
|
39
|
+
// Add a small delay to ensure listener is fully registered
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
// ESC[?1004h - Enable focus events
|
|
42
|
+
process.stdout.write('\x1b[?1004h');
|
|
43
|
+
}, 50);
|
|
44
|
+
return () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
// Disable focus reporting on cleanup
|
|
47
|
+
// ESC[?1004l - Disable focus events
|
|
48
|
+
process.stdout.write('\x1b[?1004l');
|
|
49
|
+
process.stdin.off('data', handleData);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
// Helper function to check if input is a focus event
|
|
53
|
+
const isFocusEvent = (input) => {
|
|
54
|
+
return input === '\x1b[I' || input === '\x1b[O';
|
|
55
|
+
};
|
|
56
|
+
return { hasFocus, isFocusEvent };
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
export function useTerminalSize() {
|
|
3
|
+
const [size, setSize] = useState({
|
|
4
|
+
columns: process.stdout.columns || 80,
|
|
5
|
+
rows: process.stdout.rows || 20,
|
|
6
|
+
});
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
function updateSize() {
|
|
9
|
+
setSize({
|
|
10
|
+
columns: process.stdout.columns || 80,
|
|
11
|
+
rows: process.stdout.rows || 20,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
process.stdout.on('resize', updateSize);
|
|
15
|
+
return () => {
|
|
16
|
+
process.stdout.off('resize', updateSize);
|
|
17
|
+
};
|
|
18
|
+
}, []);
|
|
19
|
+
return size;
|
|
20
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import React, { useCallback, useEffect } from 'react';
|
|
2
|
-
import { Box, Text
|
|
1
|
+
import React, { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
3
|
import { cpSlice, cpLen } from '../../utils/textUtils.js';
|
|
4
4
|
import CommandPanel from './CommandPanel.js';
|
|
5
5
|
import FileList from './FileList.js';
|
|
@@ -9,12 +9,19 @@ import { useFilePicker } from '../../hooks/useFilePicker.js';
|
|
|
9
9
|
import { useHistoryNavigation } from '../../hooks/useHistoryNavigation.js';
|
|
10
10
|
import { useClipboard } from '../../hooks/useClipboard.js';
|
|
11
11
|
import { useKeyboardInput } from '../../hooks/useKeyboardInput.js';
|
|
12
|
+
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
|
13
|
+
import { useTerminalFocus } from '../../hooks/useTerminalFocus.js';
|
|
12
14
|
export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type your message...', disabled = false, chatHistory = [], onHistorySelect, yoloMode = false, contextUsage, snapshotFileCount, }) {
|
|
13
|
-
|
|
14
|
-
const terminalWidth =
|
|
15
|
+
// Use terminal size hook to listen for resize events
|
|
16
|
+
const { columns: terminalWidth } = useTerminalSize();
|
|
17
|
+
const prevTerminalWidthRef = useRef(terminalWidth);
|
|
18
|
+
// Use terminal focus hook to detect focus state
|
|
19
|
+
const { hasFocus } = useTerminalFocus();
|
|
20
|
+
// Recalculate viewport dimensions to ensure proper resizing
|
|
15
21
|
const uiOverhead = 8;
|
|
22
|
+
const viewportWidth = Math.max(40, terminalWidth - uiOverhead);
|
|
16
23
|
const viewport = {
|
|
17
|
-
width:
|
|
24
|
+
width: viewportWidth,
|
|
18
25
|
height: 1,
|
|
19
26
|
};
|
|
20
27
|
// Use input buffer hook
|
|
@@ -72,6 +79,31 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
72
79
|
}, 10);
|
|
73
80
|
return () => clearTimeout(timer);
|
|
74
81
|
}, [showFilePicker, forceUpdate]);
|
|
82
|
+
// Handle terminal width changes with debounce (like gemini-cli)
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
// Skip on initial mount
|
|
85
|
+
if (prevTerminalWidthRef.current === terminalWidth) {
|
|
86
|
+
prevTerminalWidthRef.current = terminalWidth;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
prevTerminalWidthRef.current = terminalWidth;
|
|
90
|
+
// Debounce the re-render to avoid flickering during resize
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
forceUpdate({});
|
|
93
|
+
}, 100);
|
|
94
|
+
return () => clearTimeout(timer);
|
|
95
|
+
}, [terminalWidth, forceUpdate]);
|
|
96
|
+
// Render cursor based on focus state
|
|
97
|
+
const renderCursor = useCallback((char) => {
|
|
98
|
+
if (hasFocus) {
|
|
99
|
+
// Focused: solid block cursor
|
|
100
|
+
return (React.createElement(Text, { backgroundColor: "white", color: "black" }, char));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// Unfocused: no cursor, just render the character normally
|
|
104
|
+
return React.createElement(Text, null, char);
|
|
105
|
+
}
|
|
106
|
+
}, [hasFocus]);
|
|
75
107
|
// Render content with cursor and paste placeholders
|
|
76
108
|
const renderContent = useCallback(() => {
|
|
77
109
|
if (buffer.text.length > 0) {
|
|
@@ -105,10 +137,10 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
105
137
|
const afterCursorInPart = cpSlice(part, cursorPos - partStart + 1);
|
|
106
138
|
return (React.createElement(React.Fragment, { key: partIndex }, isPlaceholder ? (React.createElement(Text, { color: isImagePlaceholder ? 'magenta' : 'cyan', dimColor: true },
|
|
107
139
|
beforeCursorInPart,
|
|
108
|
-
|
|
140
|
+
renderCursor(atCursor),
|
|
109
141
|
afterCursorInPart)) : (React.createElement(React.Fragment, null,
|
|
110
142
|
beforeCursorInPart,
|
|
111
|
-
|
|
143
|
+
renderCursor(atCursor),
|
|
112
144
|
afterCursorInPart))));
|
|
113
145
|
}
|
|
114
146
|
else {
|
|
@@ -117,27 +149,26 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
117
149
|
});
|
|
118
150
|
return (React.createElement(Text, null,
|
|
119
151
|
elements,
|
|
120
|
-
!cursorRendered && (
|
|
152
|
+
!cursorRendered && renderCursor(' ')));
|
|
121
153
|
}
|
|
122
154
|
else {
|
|
123
155
|
// 普通文本渲染
|
|
156
|
+
const charInfo = buffer.getCharAtCursor();
|
|
157
|
+
const atCursor = charInfo.char === '\n' ? ' ' : charInfo.char;
|
|
124
158
|
return (React.createElement(Text, null,
|
|
125
159
|
cpSlice(displayText, 0, cursorPos),
|
|
126
|
-
|
|
127
|
-
const charInfo = buffer.getCharAtCursor();
|
|
128
|
-
return charInfo.char === '\n' ? ' ' : charInfo.char;
|
|
129
|
-
})()),
|
|
160
|
+
renderCursor(atCursor),
|
|
130
161
|
cpSlice(displayText, cursorPos + 1)));
|
|
131
162
|
}
|
|
132
163
|
}
|
|
133
164
|
else {
|
|
134
165
|
return (React.createElement(React.Fragment, null,
|
|
135
|
-
|
|
166
|
+
renderCursor(' '),
|
|
136
167
|
React.createElement(Text, { color: disabled ? 'darkGray' : 'gray', dimColor: true }, disabled ? 'Waiting for response...' : placeholder)));
|
|
137
168
|
}
|
|
138
|
-
}, [buffer, disabled, placeholder]);
|
|
139
|
-
return (React.createElement(Box, { flexDirection: "column", paddingX: 1, width:
|
|
140
|
-
showHistoryMenu && (React.createElement(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "#A9C13E", padding: 1, width:
|
|
169
|
+
}, [buffer, disabled, placeholder, renderCursor]);
|
|
170
|
+
return (React.createElement(Box, { flexDirection: "column", paddingX: 1, width: terminalWidth },
|
|
171
|
+
showHistoryMenu && (React.createElement(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "#A9C13E", padding: 1, width: terminalWidth - 2 },
|
|
141
172
|
React.createElement(Box, { marginBottom: 1 },
|
|
142
173
|
React.createElement(Text, { color: "cyan" }, "Use \u2191\u2193 keys to navigate, press Enter to select:")),
|
|
143
174
|
React.createElement(Box, { flexDirection: "column" },
|
|
@@ -160,7 +191,7 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
160
191
|
}
|
|
161
192
|
return (React.createElement(Box, { key: message.value },
|
|
162
193
|
React.createElement(Text, { color: index === historySelectedIndex ? 'green' : 'white', bold: true },
|
|
163
|
-
index === historySelectedIndex ? '
|
|
194
|
+
index === historySelectedIndex ? '❯ ' : ' ',
|
|
164
195
|
message.label),
|
|
165
196
|
fileCount > 0 && (React.createElement(Text, { color: "yellow", dimColor: true },
|
|
166
197
|
' ',
|
|
@@ -177,11 +208,14 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
177
208
|
getUserMessages().length - 8,
|
|
178
209
|
" more items")))))),
|
|
179
210
|
!showHistoryMenu && (React.createElement(React.Fragment, null,
|
|
180
|
-
React.createElement(Box, { flexDirection: "
|
|
181
|
-
React.createElement(Text, { color: "
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
211
|
+
React.createElement(Box, { flexDirection: "column", width: terminalWidth - 2 },
|
|
212
|
+
React.createElement(Text, { color: "gray" }, '─'.repeat(terminalWidth - 2)),
|
|
213
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
214
|
+
React.createElement(Text, { color: "cyan", bold: true },
|
|
215
|
+
"\u276F",
|
|
216
|
+
' '),
|
|
217
|
+
React.createElement(Box, { flexGrow: 1 }, renderContent())),
|
|
218
|
+
React.createElement(Text, { color: "gray" }, '─'.repeat(terminalWidth - 2))),
|
|
185
219
|
React.createElement(CommandPanel, { commands: getFilteredCommands(), selectedIndex: commandSelectedIndex, query: buffer.getFullText().slice(1), visible: showCommands }),
|
|
186
220
|
React.createElement(Box, null,
|
|
187
221
|
React.createElement(FileList, { ref: fileListRef, query: fileQuery, selectedIndex: fileSelectedIndex, visible: showFilePicker, maxItems: 10, rootPath: process.cwd(), onFilteredCountChange: handleFilteredCountChange })),
|
|
@@ -39,7 +39,7 @@ const CommandPanel = memo(({ commands, selectedIndex, visible, maxHeight }) => {
|
|
|
39
39
|
commands.length > effectiveMaxItems && `(${selectedIndex + 1}/${commands.length})`)),
|
|
40
40
|
displayedCommands.map((command, index) => (React.createElement(Box, { key: command.name, flexDirection: "column", width: "100%" },
|
|
41
41
|
React.createElement(Text, { color: index === displayedSelectedIndex ? "green" : "gray", bold: true },
|
|
42
|
-
index === displayedSelectedIndex ? "
|
|
42
|
+
index === displayedSelectedIndex ? "❯ " : " ",
|
|
43
43
|
"/",
|
|
44
44
|
command.name),
|
|
45
45
|
React.createElement(Box, { marginLeft: 3 },
|
|
@@ -185,8 +185,8 @@ const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10
|
|
|
185
185
|
React.createElement(Text, { backgroundColor: index === displaySelectedIndex ? '#1E3A8A' : undefined, color: index === displaySelectedIndex
|
|
186
186
|
? '#FFFFFF'
|
|
187
187
|
: file.isDirectory
|
|
188
|
-
? '
|
|
189
|
-
: 'white' }, file.path)))),
|
|
188
|
+
? 'yellow'
|
|
189
|
+
: 'white' }, file.isDirectory ? '◇ ' + file.path : '◆ ' + file.path)))),
|
|
190
190
|
allFilteredFiles.length > effectiveMaxItems && (React.createElement(Box, { marginTop: 1 },
|
|
191
191
|
React.createElement(Text, { color: "gray", dimColor: true },
|
|
192
192
|
"\u2191\u2193 to scroll \u00B7 ",
|
|
@@ -42,7 +42,7 @@ export default function FileRollbackConfirmation({ fileCount, onConfirm }) {
|
|
|
42
42
|
React.createElement(Text, { color: "gray", dimColor: true }, "Do you want to rollback the files as well?")),
|
|
43
43
|
React.createElement(Box, { flexDirection: "column" }, options.map((option, index) => (React.createElement(Box, { key: index },
|
|
44
44
|
React.createElement(Text, { color: index === selectedIndex ? 'green' : 'white', bold: index === selectedIndex },
|
|
45
|
-
index === selectedIndex ? '
|
|
45
|
+
index === selectedIndex ? '❯ ' : ' ',
|
|
46
46
|
option.label))))),
|
|
47
47
|
React.createElement(Box, { marginTop: 1 },
|
|
48
48
|
React.createElement(Text, { color: "gray", dimColor: true }, "Use \u2191\u2193 to select, Enter to confirm, ESC to cancel"))));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useCallback } from 'react';
|
|
2
2
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
3
|
+
import { resetTerminal } from '../../utils/terminal.js';
|
|
3
4
|
function Menu({ options, onSelect, onSelectionChange, maxHeight }) {
|
|
4
5
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
5
6
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
@@ -25,9 +26,7 @@ function Menu({ options, onSelect, onSelectionChange, maxHeight }) {
|
|
|
25
26
|
}
|
|
26
27
|
}, [selectedIndex, scrollOffset, visibleItemCount]);
|
|
27
28
|
const clearTerminal = useCallback(() => {
|
|
28
|
-
|
|
29
|
-
stdout.write('\x1B[3J\x1B[2J\x1B[H');
|
|
30
|
-
}
|
|
29
|
+
resetTerminal(stdout);
|
|
31
30
|
}, [stdout]);
|
|
32
31
|
const handleInput = useCallback((_input, key) => {
|
|
33
32
|
if (key.upArrow) {
|
|
@@ -65,7 +64,7 @@ function Menu({ options, onSelect, onSelectionChange, maxHeight }) {
|
|
|
65
64
|
const actualIndex = scrollOffset + index;
|
|
66
65
|
return (React.createElement(Box, { key: option.value },
|
|
67
66
|
React.createElement(Text, { color: actualIndex === selectedIndex ? 'green' : option.color || 'white', bold: true },
|
|
68
|
-
actualIndex === selectedIndex ? '
|
|
67
|
+
actualIndex === selectedIndex ? '❯ ' : ' ',
|
|
69
68
|
option.label)));
|
|
70
69
|
}),
|
|
71
70
|
hasMoreBelow && (React.createElement(Box, null,
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
type Props = {
|
|
3
3
|
onBack: () => void;
|
|
4
4
|
onSave: () => void;
|
|
5
|
+
inlineMode?: boolean;
|
|
5
6
|
};
|
|
6
|
-
export default function ApiConfigScreen({ onBack, onSave }: Props): React.JSX.Element;
|
|
7
|
+
export default function ApiConfigScreen({ onBack, onSave, inlineMode }: Props): React.JSX.Element;
|
|
7
8
|
export {};
|
|
@@ -4,7 +4,7 @@ import Gradient from 'ink-gradient';
|
|
|
4
4
|
import { Select, Alert } from '@inkjs/ui';
|
|
5
5
|
import TextInput from 'ink-text-input';
|
|
6
6
|
import { getOpenAiConfig, updateOpenAiConfig, validateApiConfig, } from '../../utils/apiConfig.js';
|
|
7
|
-
export default function ApiConfigScreen({ onBack, onSave }) {
|
|
7
|
+
export default function ApiConfigScreen({ onBack, onSave, inlineMode = false }) {
|
|
8
8
|
const [baseUrl, setBaseUrl] = useState('');
|
|
9
9
|
const [apiKey, setApiKey] = useState('');
|
|
10
10
|
const [requestMethod, setRequestMethod] = useState('chat');
|
|
@@ -106,15 +106,15 @@ export default function ApiConfigScreen({ onBack, onSave }) {
|
|
|
106
106
|
}
|
|
107
107
|
});
|
|
108
108
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
109
|
-
React.createElement(Box, { marginBottom: 2, borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1 },
|
|
109
|
+
!inlineMode && (React.createElement(Box, { marginBottom: 2, borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1 },
|
|
110
110
|
React.createElement(Box, { flexDirection: "column" },
|
|
111
111
|
React.createElement(Gradient, { name: "rainbow" }, "OpenAI API Configuration"),
|
|
112
|
-
React.createElement(Text, { color: "gray", dimColor: true }, "Configure your OpenAI API settings"))),
|
|
112
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Configure your OpenAI API settings")))),
|
|
113
113
|
React.createElement(Box, { flexDirection: "column", marginBottom: 2 },
|
|
114
114
|
React.createElement(Box, { marginBottom: 1 },
|
|
115
115
|
React.createElement(Box, { flexDirection: "column" },
|
|
116
116
|
React.createElement(Text, { color: currentField === 'baseUrl' ? 'green' : 'white' },
|
|
117
|
-
currentField === 'baseUrl' ? '
|
|
117
|
+
currentField === 'baseUrl' ? '❯ ' : ' ',
|
|
118
118
|
"Base URL:"),
|
|
119
119
|
currentField === 'baseUrl' && isEditing && (React.createElement(Box, { marginLeft: 3 },
|
|
120
120
|
React.createElement(TextInput, { value: baseUrl, onChange: setBaseUrl, placeholder: "https://api.openai.com/v1" }))),
|
|
@@ -123,7 +123,7 @@ export default function ApiConfigScreen({ onBack, onSave }) {
|
|
|
123
123
|
React.createElement(Box, { marginBottom: 1 },
|
|
124
124
|
React.createElement(Box, { flexDirection: "column" },
|
|
125
125
|
React.createElement(Text, { color: currentField === 'apiKey' ? 'green' : 'white' },
|
|
126
|
-
currentField === 'apiKey' ? '
|
|
126
|
+
currentField === 'apiKey' ? '❯ ' : ' ',
|
|
127
127
|
"API Key:"),
|
|
128
128
|
currentField === 'apiKey' && isEditing && (React.createElement(Box, { marginLeft: 3 },
|
|
129
129
|
React.createElement(TextInput, { value: apiKey, onChange: setApiKey, placeholder: "sk-...", mask: "*" }))),
|
|
@@ -132,7 +132,7 @@ export default function ApiConfigScreen({ onBack, onSave }) {
|
|
|
132
132
|
React.createElement(Box, { marginBottom: 1 },
|
|
133
133
|
React.createElement(Box, { flexDirection: "column" },
|
|
134
134
|
React.createElement(Text, { color: currentField === 'requestMethod' ? 'green' : 'white' },
|
|
135
|
-
currentField === 'requestMethod' ? '
|
|
135
|
+
currentField === 'requestMethod' ? '❯ ' : ' ',
|
|
136
136
|
"Request Method:"),
|
|
137
137
|
currentField === 'requestMethod' && isEditing && (React.createElement(Box, { marginLeft: 3 },
|
|
138
138
|
React.createElement(Select, { options: requestMethodOptions, defaultValue: requestMethod, onChange: (value) => {
|
|
@@ -144,7 +144,7 @@ export default function ApiConfigScreen({ onBack, onSave }) {
|
|
|
144
144
|
React.createElement(Box, { marginBottom: 1 },
|
|
145
145
|
React.createElement(Box, { flexDirection: "column" },
|
|
146
146
|
React.createElement(Text, { color: currentField === 'anthropicBeta' ? 'green' : 'white' },
|
|
147
|
-
currentField === 'anthropicBeta' ? '
|
|
147
|
+
currentField === 'anthropicBeta' ? '❯ ' : ' ',
|
|
148
148
|
"Anthropic Beta (for Claude API):"),
|
|
149
149
|
React.createElement(Box, { marginLeft: 3 },
|
|
150
150
|
React.createElement(Text, { color: "gray" },
|
|
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
|
|
2
2
|
import { Box, Text, useInput, Static, useStdout } from 'ink';
|
|
3
3
|
import Spinner from 'ink-spinner';
|
|
4
4
|
import Gradient from 'ink-gradient';
|
|
5
|
+
import ansiEscapes from 'ansi-escapes';
|
|
5
6
|
import ChatInput from '../components/ChatInput.js';
|
|
6
7
|
import PendingMessages from '../components/PendingMessages.js';
|
|
7
8
|
import MCPInfoScreen from '../components/MCPInfoScreen.js';
|
|
@@ -23,6 +24,7 @@ import { useVSCodeState } from '../../hooks/useVSCodeState.js';
|
|
|
23
24
|
import { useSnapshotState } from '../../hooks/useSnapshotState.js';
|
|
24
25
|
import { useStreamingState } from '../../hooks/useStreamingState.js';
|
|
25
26
|
import { useCommandHandler } from '../../hooks/useCommandHandler.js';
|
|
27
|
+
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
|
26
28
|
import { parseAndValidateFileReferences, createMessageWithFileInstructions, getSystemInfo, } from '../../utils/fileUtils.js';
|
|
27
29
|
import { executeCommand } from '../../utils/commandExecutor.js';
|
|
28
30
|
import { convertSessionMessagesToUI } from '../../utils/sessionConverter.js';
|
|
@@ -61,9 +63,10 @@ export default function ChatScreen({}) {
|
|
|
61
63
|
const [showSessionPanel, setShowSessionPanel] = useState(false);
|
|
62
64
|
const [showMcpPanel, setShowMcpPanel] = useState(false);
|
|
63
65
|
const [shouldIncludeSystemInfo, setShouldIncludeSystemInfo] = useState(true); // Include on first message
|
|
66
|
+
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
|
|
64
67
|
const { stdout } = useStdout();
|
|
65
|
-
const terminalHeight = stdout?.rows || 24;
|
|
66
68
|
const workingDirectory = process.cwd();
|
|
69
|
+
const isInitialMount = useRef(true);
|
|
67
70
|
// Use custom hooks
|
|
68
71
|
const streamingState = useStreamingState();
|
|
69
72
|
const vscodeState = useVSCodeState();
|
|
@@ -83,6 +86,21 @@ export default function ChatScreen({}) {
|
|
|
83
86
|
// Ignore localStorage errors
|
|
84
87
|
}
|
|
85
88
|
}, [yoloMode]);
|
|
89
|
+
// Clear terminal and remount on terminal width change (like gemini-cli)
|
|
90
|
+
// Use debounce to avoid flickering during continuous resize
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (isInitialMount.current) {
|
|
93
|
+
isInitialMount.current = false;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const handler = setTimeout(() => {
|
|
97
|
+
stdout.write(ansiEscapes.clearTerminal);
|
|
98
|
+
setRemountKey(prev => prev + 1);
|
|
99
|
+
}, 200); // Wait for resize to stabilize
|
|
100
|
+
return () => {
|
|
101
|
+
clearTimeout(handler);
|
|
102
|
+
};
|
|
103
|
+
}, [terminalWidth, stdout]);
|
|
86
104
|
// Use tool confirmation hook
|
|
87
105
|
const { pendingToolConfirmation, requestToolConfirmation, isToolAutoApproved, addMultipleToAlwaysApproved, } = useToolConfirmation();
|
|
88
106
|
// Minimum terminal height required for proper rendering
|
|
@@ -445,10 +463,10 @@ export default function ChatScreen({}) {
|
|
|
445
463
|
React.createElement(Box, { marginTop: 1 },
|
|
446
464
|
React.createElement(Text, { color: "gray", dimColor: true }, "Please resize your terminal window to continue."))));
|
|
447
465
|
}
|
|
448
|
-
return (React.createElement(Box, { flexDirection: "column", height: "100%", width:
|
|
466
|
+
return (React.createElement(Box, { flexDirection: "column", height: "100%", width: terminalWidth },
|
|
449
467
|
React.createElement(Static, { key: remountKey, items: [
|
|
450
|
-
React.createElement(Box, { key: "header", paddingX: 1, width:
|
|
451
|
-
React.createElement(Box, { borderColor: 'cyan', borderStyle: "round", paddingX: 2, paddingY: 1, width:
|
|
468
|
+
React.createElement(Box, { key: "header", paddingX: 1, width: terminalWidth },
|
|
469
|
+
React.createElement(Box, { borderColor: 'cyan', borderStyle: "round", paddingX: 2, paddingY: 1, width: terminalWidth - 2 },
|
|
452
470
|
React.createElement(Box, { flexDirection: "column" },
|
|
453
471
|
React.createElement(Text, { color: "white", bold: true },
|
|
454
472
|
React.createElement(Text, { color: "cyan" }, "\u2746 "),
|
|
@@ -483,7 +501,7 @@ export default function ChatScreen({}) {
|
|
|
483
501
|
toolStatusColor = 'blue';
|
|
484
502
|
}
|
|
485
503
|
}
|
|
486
|
-
return (React.createElement(Box, { key: `msg-${index}`, marginBottom: isToolMessage ? 0 : 1, paddingX: 1, flexDirection: "column", width:
|
|
504
|
+
return (React.createElement(Box, { key: `msg-${index}`, marginBottom: isToolMessage ? 0 : 1, paddingX: 1, flexDirection: "column", width: terminalWidth },
|
|
487
505
|
React.createElement(Box, null,
|
|
488
506
|
React.createElement(Text, { color: message.role === 'user'
|
|
489
507
|
? 'green'
|
|
@@ -606,14 +624,14 @@ export default function ChatScreen({}) {
|
|
|
606
624
|
] }, item => item),
|
|
607
625
|
messages
|
|
608
626
|
.filter(m => m.toolPending)
|
|
609
|
-
.map((message, index) => (React.createElement(Box, { key: `pending-tool-${index}`, marginBottom: 1, paddingX: 1, width:
|
|
627
|
+
.map((message, index) => (React.createElement(Box, { key: `pending-tool-${index}`, marginBottom: 1, paddingX: 1, width: terminalWidth },
|
|
610
628
|
React.createElement(Text, { color: "yellowBright", bold: true }, "\u2746"),
|
|
611
629
|
React.createElement(Box, { marginLeft: 1, marginBottom: 1, flexDirection: "row" },
|
|
612
630
|
React.createElement(MarkdownRenderer, { content: message.content || ' ', color: "yellow" }),
|
|
613
631
|
React.createElement(Box, { marginLeft: 1 },
|
|
614
632
|
React.createElement(Text, { color: "yellow" },
|
|
615
633
|
React.createElement(Spinner, { type: "dots" }))))))),
|
|
616
|
-
(streamingState.isStreaming || isSaving) && !pendingToolConfirmation && (React.createElement(Box, { marginBottom: 1, paddingX: 1, width:
|
|
634
|
+
(streamingState.isStreaming || isSaving) && !pendingToolConfirmation && (React.createElement(Box, { marginBottom: 1, paddingX: 1, width: terminalWidth },
|
|
617
635
|
React.createElement(Text, { color: ['#FF6EBF', 'green', 'blue', 'cyan', '#B588F8'][streamingState.animationFrame], bold: true }, "\u2746"),
|
|
618
636
|
React.createElement(Box, { marginLeft: 1, marginBottom: 1, flexDirection: "column" }, streamingState.isStreaming ? (React.createElement(React.Fragment, null, streamingState.retryStatus &&
|
|
619
637
|
streamingState.retryStatus.isRetrying ? (
|
|
@@ -653,15 +671,15 @@ export default function ChatScreen({}) {
|
|
|
653
671
|
' ',
|
|
654
672
|
"tokens"),
|
|
655
673
|
")")))) : (React.createElement(Text, { color: "gray", dimColor: true }, "Create the first dialogue record file..."))))),
|
|
656
|
-
React.createElement(Box, { paddingX: 1, width:
|
|
674
|
+
React.createElement(Box, { paddingX: 1, width: terminalWidth },
|
|
657
675
|
React.createElement(PendingMessages, { pendingMessages: pendingMessages })),
|
|
658
676
|
pendingToolConfirmation && (React.createElement(ToolConfirmation, { toolName: pendingToolConfirmation.batchToolNames ||
|
|
659
677
|
pendingToolConfirmation.tool.function.name, toolArguments: !pendingToolConfirmation.allTools
|
|
660
678
|
? pendingToolConfirmation.tool.function.arguments
|
|
661
679
|
: undefined, allTools: pendingToolConfirmation.allTools, onConfirm: pendingToolConfirmation.resolve })),
|
|
662
|
-
showSessionPanel && (React.createElement(Box, { paddingX: 1, width:
|
|
680
|
+
showSessionPanel && (React.createElement(Box, { paddingX: 1, width: terminalWidth },
|
|
663
681
|
React.createElement(SessionListPanel, { onSelectSession: handleSessionPanelSelect, onClose: () => setShowSessionPanel(false) }))),
|
|
664
|
-
showMcpPanel && (React.createElement(Box, { paddingX: 1, flexDirection: "column", width:
|
|
682
|
+
showMcpPanel && (React.createElement(Box, { paddingX: 1, flexDirection: "column", width: terminalWidth },
|
|
665
683
|
React.createElement(MCPInfoPanel, null),
|
|
666
684
|
React.createElement(Box, { marginTop: 1 },
|
|
667
685
|
React.createElement(Text, { color: "gray", dimColor: true }, "Press ESC to close")))),
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
type Props = {
|
|
3
3
|
onBack: () => void;
|
|
4
4
|
onSave: () => void;
|
|
5
|
+
inlineMode?: boolean;
|
|
5
6
|
};
|
|
6
|
-
export default function ModelConfigScreen({ onBack, onSave }: Props): React.JSX.Element;
|
|
7
|
+
export default function ModelConfigScreen({ onBack, onSave, inlineMode }: Props): React.JSX.Element;
|
|
7
8
|
export {};
|
|
@@ -4,7 +4,7 @@ import Gradient from 'ink-gradient';
|
|
|
4
4
|
import { Select, Alert } from '@inkjs/ui';
|
|
5
5
|
import { fetchAvailableModels, filterModels } from '../../api/models.js';
|
|
6
6
|
import { getOpenAiConfig, updateOpenAiConfig, } from '../../utils/apiConfig.js';
|
|
7
|
-
export default function ModelConfigScreen({ onBack, onSave }) {
|
|
7
|
+
export default function ModelConfigScreen({ onBack, onSave, inlineMode = false }) {
|
|
8
8
|
const [advancedModel, setAdvancedModel] = useState('');
|
|
9
9
|
const [basicModel, setBasicModel] = useState('');
|
|
10
10
|
const [maxContextTokens, setMaxContextTokens] = useState(4000);
|
|
@@ -340,29 +340,28 @@ export default function ModelConfigScreen({ onBack, onSave }) {
|
|
|
340
340
|
});
|
|
341
341
|
if (baseUrlMissing) {
|
|
342
342
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
343
|
-
React.createElement(Box, { marginBottom: 1, borderStyle: "double", borderColor: "cyan", paddingX: 1, paddingY: 0 },
|
|
343
|
+
!inlineMode && (React.createElement(Box, { marginBottom: 1, borderStyle: "double", borderColor: "cyan", paddingX: 1, paddingY: 0 },
|
|
344
344
|
React.createElement(Box, { flexDirection: "column" },
|
|
345
345
|
React.createElement(Gradient, { name: 'rainbow' }, "Model Configuration"),
|
|
346
|
-
React.createElement(Text, { color: "gray", dimColor: true }, "Configure AI models for different tasks"))),
|
|
346
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Configure AI models for different tasks")))),
|
|
347
347
|
React.createElement(Box, { marginBottom: 1 },
|
|
348
348
|
React.createElement(Alert, { variant: "error" }, "Base URL not configured. Please configure API settings first before setting up models.")),
|
|
349
349
|
React.createElement(Box, { flexDirection: "column" },
|
|
350
350
|
React.createElement(Alert, { variant: "info" }, "Press Esc to return to main menu"))));
|
|
351
351
|
}
|
|
352
352
|
if (loading) {
|
|
353
|
-
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
354
|
-
React.createElement(Box, {
|
|
355
|
-
React.createElement(
|
|
356
|
-
|
|
357
|
-
React.createElement(Text, { color: "gray", dimColor: true }, "Loading available models...")))));
|
|
353
|
+
return (React.createElement(Box, { flexDirection: "column", padding: 1 }, !inlineMode && (React.createElement(Box, { marginBottom: 1, borderStyle: "double", paddingX: 1, paddingY: 0 },
|
|
354
|
+
React.createElement(Box, { flexDirection: "column" },
|
|
355
|
+
React.createElement(Gradient, { name: "rainbow" }, "Model Configuration"),
|
|
356
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Loading available models..."))))));
|
|
358
357
|
}
|
|
359
358
|
// 手动输入模式的界面
|
|
360
359
|
if (manualInputMode) {
|
|
361
360
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
362
|
-
React.createElement(Box, { marginBottom: 1, borderStyle: "double", borderColor: "cyan", paddingX: 1, paddingY: 0 },
|
|
361
|
+
!inlineMode && (React.createElement(Box, { marginBottom: 1, borderStyle: "double", borderColor: "cyan", paddingX: 1, paddingY: 0 },
|
|
363
362
|
React.createElement(Box, { flexDirection: "column" },
|
|
364
363
|
React.createElement(Gradient, { name: 'rainbow' }, "Manual Input Model"),
|
|
365
|
-
React.createElement(Text, { color: "gray", dimColor: true }, "Enter model name manually"))),
|
|
364
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Enter model name manually")))),
|
|
366
365
|
React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
|
|
367
366
|
React.createElement(Text, { color: "cyan" },
|
|
368
367
|
currentField === 'advancedModel' ? 'Advanced Model' : 'Basic Model',
|
|
@@ -376,91 +375,91 @@ export default function ModelConfigScreen({ onBack, onSave }) {
|
|
|
376
375
|
React.createElement(Alert, { variant: "info" }, "Press Enter to confirm, Esc to cancel"))));
|
|
377
376
|
}
|
|
378
377
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
379
|
-
React.createElement(Box, { marginBottom: 1, borderStyle: "double", borderColor: "cyan", paddingX: 1, paddingY: 0 },
|
|
378
|
+
!inlineMode && (React.createElement(Box, { marginBottom: 1, borderStyle: "double", borderColor: "cyan", paddingX: 1, paddingY: 0 },
|
|
380
379
|
React.createElement(Box, { flexDirection: "column" },
|
|
381
380
|
React.createElement(Gradient, { name: 'rainbow' }, "Model Configuration"),
|
|
382
|
-
React.createElement(Text, { color: "gray", dimColor: true }, "Configure AI models for different tasks"))),
|
|
381
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Configure AI models for different tasks")))),
|
|
383
382
|
React.createElement(Box, { flexDirection: "column" },
|
|
384
383
|
React.createElement(Box, null,
|
|
385
384
|
React.createElement(Box, { flexDirection: "column" },
|
|
386
385
|
React.createElement(Text, { color: currentField === 'advancedModel' ? 'green' : 'white' },
|
|
387
|
-
currentField === 'advancedModel' ? '
|
|
386
|
+
currentField === 'advancedModel' ? '❯ ' : ' ',
|
|
388
387
|
"Advanced Model (Main Work):"),
|
|
389
|
-
currentField === 'advancedModel' && isEditing && (React.createElement(Box, { marginLeft:
|
|
388
|
+
currentField === 'advancedModel' && isEditing && (React.createElement(Box, { marginLeft: 3 }, loading ? (React.createElement(Text, { color: "yellow" }, "Loading models...")) : (React.createElement(Box, { flexDirection: "column" },
|
|
390
389
|
searchTerm && (React.createElement(Text, { color: "cyan" },
|
|
391
390
|
"Filter: ",
|
|
392
391
|
searchTerm)),
|
|
393
392
|
React.createElement(Select, { options: getCurrentOptions(), defaultValue: getCurrentValue(), onChange: handleModelChange }))))),
|
|
394
|
-
(!isEditing || currentField !== 'advancedModel') && (React.createElement(Box, { marginLeft:
|
|
393
|
+
(!isEditing || currentField !== 'advancedModel') && (React.createElement(Box, { marginLeft: 3 },
|
|
395
394
|
React.createElement(Text, { color: "gray" }, advancedModel || 'Not set'))))),
|
|
396
395
|
React.createElement(Box, null,
|
|
397
396
|
React.createElement(Box, { flexDirection: "column" },
|
|
398
397
|
React.createElement(Text, { color: currentField === 'basicModel' ? 'green' : 'white' },
|
|
399
|
-
currentField === 'basicModel' ? '
|
|
398
|
+
currentField === 'basicModel' ? '❯ ' : ' ',
|
|
400
399
|
"Basic Model (Summary & Analysis):"),
|
|
401
|
-
currentField === 'basicModel' && isEditing && (React.createElement(Box, { marginLeft:
|
|
400
|
+
currentField === 'basicModel' && isEditing && (React.createElement(Box, { marginLeft: 3 }, loading ? (React.createElement(Text, { color: "yellow" }, "Loading models...")) : (React.createElement(Box, { flexDirection: "column" },
|
|
402
401
|
searchTerm && (React.createElement(Text, { color: "cyan" },
|
|
403
402
|
"Filter: ",
|
|
404
403
|
searchTerm)),
|
|
405
404
|
React.createElement(Select, { options: getCurrentOptions(), defaultValue: getCurrentValue(), onChange: handleModelChange }))))),
|
|
406
|
-
(!isEditing || currentField !== 'basicModel') && (React.createElement(Box, { marginLeft:
|
|
405
|
+
(!isEditing || currentField !== 'basicModel') && (React.createElement(Box, { marginLeft: 3 },
|
|
407
406
|
React.createElement(Text, { color: "gray" }, basicModel || 'Not set'))))),
|
|
408
407
|
React.createElement(Box, null,
|
|
409
408
|
React.createElement(Box, { flexDirection: "column" },
|
|
410
409
|
React.createElement(Text, { color: currentField === 'maxContextTokens' ? 'green' : 'white' },
|
|
411
|
-
currentField === 'maxContextTokens' ? '
|
|
410
|
+
currentField === 'maxContextTokens' ? '❯ ' : ' ',
|
|
412
411
|
"Max Context Tokens (Auto-compress when reached):"),
|
|
413
|
-
currentField === 'maxContextTokens' && isEditing && (React.createElement(Box, { marginLeft:
|
|
412
|
+
currentField === 'maxContextTokens' && isEditing && (React.createElement(Box, { marginLeft: 3 },
|
|
414
413
|
React.createElement(Text, { color: "cyan" },
|
|
415
414
|
"Enter value: ",
|
|
416
415
|
maxContextTokens))),
|
|
417
|
-
(!isEditing || currentField !== 'maxContextTokens') && (React.createElement(Box, { marginLeft:
|
|
416
|
+
(!isEditing || currentField !== 'maxContextTokens') && (React.createElement(Box, { marginLeft: 3 },
|
|
418
417
|
React.createElement(Text, { color: "gray" }, maxContextTokens))))),
|
|
419
418
|
React.createElement(Box, null,
|
|
420
419
|
React.createElement(Box, { flexDirection: "column" },
|
|
421
420
|
React.createElement(Text, { color: currentField === 'maxTokens' ? 'green' : 'white' },
|
|
422
|
-
currentField === 'maxTokens' ? '
|
|
421
|
+
currentField === 'maxTokens' ? '❯ ' : ' ',
|
|
423
422
|
"Max Tokens (Max tokens for single response):"),
|
|
424
|
-
currentField === 'maxTokens' && isEditing && (React.createElement(Box, { marginLeft:
|
|
423
|
+
currentField === 'maxTokens' && isEditing && (React.createElement(Box, { marginLeft: 3 },
|
|
425
424
|
React.createElement(Text, { color: "cyan" },
|
|
426
425
|
"Enter value: ",
|
|
427
426
|
maxTokens))),
|
|
428
|
-
(!isEditing || currentField !== 'maxTokens') && (React.createElement(Box, { marginLeft:
|
|
427
|
+
(!isEditing || currentField !== 'maxTokens') && (React.createElement(Box, { marginLeft: 3 },
|
|
429
428
|
React.createElement(Text, { color: "gray" }, maxTokens))))),
|
|
430
429
|
React.createElement(Box, { marginTop: 1 },
|
|
431
430
|
React.createElement(Text, { color: "cyan", bold: true }, "Compact Model (Context Compression):")),
|
|
432
431
|
React.createElement(Box, null,
|
|
433
432
|
React.createElement(Box, { flexDirection: "column" },
|
|
434
433
|
React.createElement(Text, { color: currentField === 'compactBaseUrl' ? 'green' : 'white' },
|
|
435
|
-
currentField === 'compactBaseUrl' ? '
|
|
434
|
+
currentField === 'compactBaseUrl' ? '❯ ' : ' ',
|
|
436
435
|
"Base URL:"),
|
|
437
|
-
currentField === 'compactBaseUrl' && isEditing && (React.createElement(Box, { marginLeft:
|
|
436
|
+
currentField === 'compactBaseUrl' && isEditing && (React.createElement(Box, { marginLeft: 3 },
|
|
438
437
|
React.createElement(Text, { color: "cyan" },
|
|
439
438
|
compactBaseUrl,
|
|
440
439
|
React.createElement(Text, { color: "white" }, "_")))),
|
|
441
|
-
(!isEditing || currentField !== 'compactBaseUrl') && (React.createElement(Box, { marginLeft:
|
|
440
|
+
(!isEditing || currentField !== 'compactBaseUrl') && (React.createElement(Box, { marginLeft: 3 },
|
|
442
441
|
React.createElement(Text, { color: "gray" }, compactBaseUrl || 'Not set'))))),
|
|
443
442
|
React.createElement(Box, null,
|
|
444
443
|
React.createElement(Box, { flexDirection: "column" },
|
|
445
444
|
React.createElement(Text, { color: currentField === 'compactApiKey' ? 'green' : 'white' },
|
|
446
|
-
currentField === 'compactApiKey' ? '
|
|
445
|
+
currentField === 'compactApiKey' ? '❯ ' : ' ',
|
|
447
446
|
"API Key:"),
|
|
448
|
-
currentField === 'compactApiKey' && isEditing && (React.createElement(Box, { marginLeft:
|
|
447
|
+
currentField === 'compactApiKey' && isEditing && (React.createElement(Box, { marginLeft: 3 },
|
|
449
448
|
React.createElement(Text, { color: "cyan" },
|
|
450
449
|
compactApiKey.replace(/./g, '*'),
|
|
451
450
|
React.createElement(Text, { color: "white" }, "_")))),
|
|
452
|
-
(!isEditing || currentField !== 'compactApiKey') && (React.createElement(Box, { marginLeft:
|
|
451
|
+
(!isEditing || currentField !== 'compactApiKey') && (React.createElement(Box, { marginLeft: 3 },
|
|
453
452
|
React.createElement(Text, { color: "gray" }, compactApiKey ? compactApiKey.replace(/./g, '*') : 'Not set'))))),
|
|
454
453
|
React.createElement(Box, null,
|
|
455
454
|
React.createElement(Box, { flexDirection: "column" },
|
|
456
455
|
React.createElement(Text, { color: currentField === 'compactModelName' ? 'green' : 'white' },
|
|
457
|
-
currentField === 'compactModelName' ? '
|
|
456
|
+
currentField === 'compactModelName' ? '❯ ' : ' ',
|
|
458
457
|
"Model Name:"),
|
|
459
|
-
currentField === 'compactModelName' && isEditing && (React.createElement(Box, { marginLeft:
|
|
458
|
+
currentField === 'compactModelName' && isEditing && (React.createElement(Box, { marginLeft: 3 },
|
|
460
459
|
React.createElement(Text, { color: "cyan" },
|
|
461
460
|
compactModelName,
|
|
462
461
|
React.createElement(Text, { color: "white" }, "_")))),
|
|
463
|
-
(!isEditing || currentField !== 'compactModelName') && (React.createElement(Box, { marginLeft:
|
|
462
|
+
(!isEditing || currentField !== 'compactModelName') && (React.createElement(Box, { marginLeft: 3 },
|
|
464
463
|
React.createElement(Text, { color: "gray" }, compactModelName || 'Not set')))))),
|
|
465
464
|
React.createElement(Box, { flexDirection: "column", marginTop: 1 }, isEditing ? (React.createElement(React.Fragment, null,
|
|
466
465
|
React.createElement(Alert, { variant: "info" }, "Editing mode: Type to filter models, \u2191\u2193 to select, Enter to confirm"))) : (React.createElement(React.Fragment, null,
|
|
@@ -1,16 +1,25 @@
|
|
|
1
|
-
import React, { useState, useMemo, useCallback } from 'react';
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
1
|
+
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { Box, Text, useStdout, Static } from 'ink';
|
|
3
3
|
import { Alert } from '@inkjs/ui';
|
|
4
4
|
import Gradient from 'ink-gradient';
|
|
5
|
+
import ansiEscapes from 'ansi-escapes';
|
|
5
6
|
import Menu from '../components/Menu.js';
|
|
7
|
+
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
|
8
|
+
import ApiConfigScreen from './ApiConfigScreen.js';
|
|
9
|
+
import ModelConfigScreen from './ModelConfigScreen.js';
|
|
6
10
|
export default function WelcomeScreen({ version = '1.0.0', onMenuSelect, }) {
|
|
7
11
|
const [infoText, setInfoText] = useState('Start a new chat conversation');
|
|
12
|
+
const [inlineView, setInlineView] = useState('menu');
|
|
13
|
+
const { columns: terminalWidth } = useTerminalSize();
|
|
14
|
+
const { stdout } = useStdout();
|
|
15
|
+
const isInitialMount = useRef(true);
|
|
16
|
+
const [remountKey, setRemountKey] = useState(0);
|
|
8
17
|
const menuOptions = useMemo(() => [
|
|
9
18
|
{
|
|
10
19
|
label: 'Start',
|
|
11
20
|
value: 'chat',
|
|
12
21
|
infoText: 'Start a new chat conversation',
|
|
13
|
-
clearTerminal: true
|
|
22
|
+
clearTerminal: true,
|
|
14
23
|
},
|
|
15
24
|
{
|
|
16
25
|
label: 'API Settings',
|
|
@@ -42,22 +51,63 @@ export default function WelcomeScreen({ version = '1.0.0', onMenuSelect, }) {
|
|
|
42
51
|
value: 'exit',
|
|
43
52
|
color: 'rgb(232, 131, 136)',
|
|
44
53
|
infoText: 'Exit the application',
|
|
45
|
-
}
|
|
54
|
+
},
|
|
46
55
|
], []);
|
|
47
56
|
const handleSelectionChange = useCallback((newInfoText) => {
|
|
48
57
|
setInfoText(newInfoText);
|
|
49
58
|
}, []);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
const handleInlineMenuSelect = useCallback((value) => {
|
|
60
|
+
// Handle inline views (config, models) or pass through to parent
|
|
61
|
+
if (value === 'config') {
|
|
62
|
+
setInlineView('api-config');
|
|
63
|
+
}
|
|
64
|
+
else if (value === 'models') {
|
|
65
|
+
setInlineView('model-config');
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Pass through to parent for other actions (chat, exit, etc.)
|
|
69
|
+
onMenuSelect?.(value);
|
|
70
|
+
}
|
|
71
|
+
}, [onMenuSelect]);
|
|
72
|
+
const handleBackToMenu = useCallback(() => {
|
|
73
|
+
setInlineView('menu');
|
|
74
|
+
}, []);
|
|
75
|
+
const handleConfigSave = useCallback(() => {
|
|
76
|
+
setInlineView('menu');
|
|
77
|
+
}, []);
|
|
78
|
+
// Clear terminal and re-render on terminal width change
|
|
79
|
+
// Use debounce to avoid flickering during continuous resize
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (isInitialMount.current) {
|
|
82
|
+
isInitialMount.current = false;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const handler = setTimeout(() => {
|
|
86
|
+
stdout.write(ansiEscapes.clearTerminal);
|
|
87
|
+
setRemountKey(prev => prev + 1); // Force re-render
|
|
88
|
+
}, 0); // Wait for resize to stabilize
|
|
89
|
+
return () => {
|
|
90
|
+
clearTimeout(handler);
|
|
91
|
+
};
|
|
92
|
+
}, [terminalWidth, stdout]);
|
|
93
|
+
return (React.createElement(Box, { flexDirection: "column", width: terminalWidth },
|
|
94
|
+
React.createElement(Static, { key: remountKey, items: [
|
|
95
|
+
React.createElement(Box, { key: "welcome-header", flexDirection: "row", paddingLeft: 2, paddingTop: 1, paddingBottom: 1, width: terminalWidth },
|
|
96
|
+
React.createElement(Box, { flexDirection: "column", justifyContent: "center" },
|
|
97
|
+
React.createElement(Text, { bold: true },
|
|
98
|
+
React.createElement(Gradient, { name: "rainbow" }, "\u2746 SNOW AI CLI")),
|
|
99
|
+
React.createElement(Text, { color: "gray", dimColor: true },
|
|
100
|
+
"v",
|
|
101
|
+
version,
|
|
102
|
+
" \u2022 Intelligent Command Line Assistant"))),
|
|
103
|
+
] }, item => item),
|
|
104
|
+
onMenuSelect && inlineView === 'menu' && (React.createElement(Box, { paddingX: 1 },
|
|
105
|
+
React.createElement(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1 },
|
|
106
|
+
React.createElement(Menu, { options: menuOptions, onSelect: handleInlineMenuSelect, onSelectionChange: handleSelectionChange })))),
|
|
107
|
+
inlineView === 'menu' && (React.createElement(Box, { paddingX: 1 },
|
|
108
|
+
React.createElement(Alert, { variant: "info" }, infoText))),
|
|
109
|
+
inlineView === 'api-config' && (React.createElement(Box, { paddingX: 1 },
|
|
110
|
+
React.createElement(ApiConfigScreen, { onBack: handleBackToMenu, onSave: handleConfigSave, inlineMode: true }))),
|
|
111
|
+
inlineView === 'model-config' && (React.createElement(Box, { paddingX: 1 },
|
|
112
|
+
React.createElement(ModelConfigScreen, { onBack: handleBackToMenu, onSave: handleConfigSave, inlineMode: true })))));
|
|
63
113
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function resetTerminal(stream) {
|
|
2
|
+
const target = stream ?? process.stdout;
|
|
3
|
+
if (!target || typeof target.write !== 'function') {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
// RIS (Reset to Initial State) clears scrollback and resets terminal modes
|
|
7
|
+
target.write('\x1bc');
|
|
8
|
+
target.write('\x1B[3J\x1B[2J\x1B[H');
|
|
9
|
+
// DO NOT re-enable focus reporting here
|
|
10
|
+
// Let useTerminalFocus handle it when ChatScreen mounts
|
|
11
|
+
// This avoids the race condition where focus event arrives before listener is ready
|
|
12
|
+
}
|
package/dist/utils/textBuffer.js
CHANGED
|
@@ -8,6 +8,10 @@ function sanitizeInput(str) {
|
|
|
8
8
|
.replace(/\r\n/g, '\n') // Normalize line endings
|
|
9
9
|
.replace(/\r/g, '\n') // Convert remaining \r to \n
|
|
10
10
|
.replace(/\t/g, ' ') // Convert tabs to spaces
|
|
11
|
+
// Remove focus events - only the complete escape sequences
|
|
12
|
+
// ESC[I and ESC[O are focus events, don't confuse with user input like "[I"
|
|
13
|
+
.replace(/\x1b\[I/g, '')
|
|
14
|
+
.replace(/\x1b\[O/g, '')
|
|
11
15
|
// Remove control characters except newlines
|
|
12
16
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
|
13
17
|
}
|
|
@@ -344,9 +348,13 @@ export class TextBuffer {
|
|
|
344
348
|
* Update the viewport dimensions, useful for terminal resize handling.
|
|
345
349
|
*/
|
|
346
350
|
updateViewport(viewport) {
|
|
351
|
+
const needsRecalculation = this.viewport.width !== viewport.width ||
|
|
352
|
+
this.viewport.height !== viewport.height;
|
|
347
353
|
this.viewport = viewport;
|
|
348
|
-
|
|
349
|
-
|
|
354
|
+
if (needsRecalculation) {
|
|
355
|
+
this.recalculateVisualState();
|
|
356
|
+
this.scheduleUpdate();
|
|
357
|
+
}
|
|
350
358
|
}
|
|
351
359
|
/**
|
|
352
360
|
* Get the character and its visual info at cursor position for proper rendering.
|