snow-ai 0.2.21 → 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.
@@ -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
- // Convert Anthropic format to standard Model format
70
- return (data.data || []).map(model => ({
71
- id: model.id,
72
- object: 'model',
73
- created: new Date(model.created_at).getTime() / 1000, // Convert to Unix timestamp
74
- owned_by: 'anthropic',
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", height: "100%" },
61
- React.createElement(Box, { flexGrow: 1, flexShrink: 1, minHeight: 0 }, renderView()),
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
- // Check for updates in the background
10
+ const execAsync = promisify(exec);
11
+ // Check for updates asynchronously
9
12
  async function checkForUpdates(currentVersion) {
10
13
  try {
11
- const latestVersion = execSync('npm view snow-ai version', {
14
+ const { stdout } = await execAsync('npm view snow-ai version', {
12
15
  encoding: 'utf8',
13
- stdio: ['pipe', 'pipe', 'ignore'],
14
- }).trim();
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(App, { version: cli.pkg.version }), {
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
- if (stdout && typeof stdout.write === 'function') {
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,4 @@
1
+ export declare function useTerminalSize(): {
2
+ columns: number;
3
+ rows: number;
4
+ };
@@ -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, useStdout } from 'ink';
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
- const { stdout } = useStdout();
14
- const terminalWidth = stdout?.columns || 80;
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: Math.max(40, terminalWidth - uiOverhead),
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
- React.createElement(Text, { backgroundColor: "white", color: "black" }, atCursor),
140
+ renderCursor(atCursor),
109
141
  afterCursorInPart)) : (React.createElement(React.Fragment, null,
110
142
  beforeCursorInPart,
111
- React.createElement(Text, { backgroundColor: "white", color: "black" }, atCursor),
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 && (React.createElement(Text, { backgroundColor: "white", color: "black" }, ' '))));
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
- React.createElement(Text, { backgroundColor: "white", color: "black" }, (() => {
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
- React.createElement(Text, { backgroundColor: disabled ? 'gray' : 'white', color: disabled ? 'darkGray' : 'black' }, ' '),
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: "100%", key: `input-${showFilePicker ? 'picker' : 'normal'}` },
140
- showHistoryMenu && (React.createElement(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "#A9C13E", padding: 1, width: "100%" },
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: "row", borderStyle: "round", borderColor: "gray", paddingX: 1, paddingY: 0, flexGrow: 1, width: "100%" },
181
- React.createElement(Text, { color: "cyan", bold: true },
182
- "\u27A3",
183
- ' '),
184
- React.createElement(Box, { flexGrow: 1 }, renderContent())),
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
- ? 'cyan'
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
- if (stdout && typeof stdout.write === 'function') {
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: "100%" },
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: "100%" },
451
- React.createElement(Box, { borderColor: 'cyan', borderStyle: "round", paddingX: 2, paddingY: 1, width: "100%" },
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: "100%" },
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: "100%" },
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: "100%" },
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: "100%" },
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: "100%" },
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: "100%" },
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, { marginBottom: 1, borderStyle: "double", paddingX: 1, paddingY: 0 },
355
- React.createElement(Box, { flexDirection: "column" },
356
- React.createElement(Gradient, { name: "rainbow" }, "Model Configuration"),
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: 2 }, loading ? (React.createElement(Text, { color: "yellow" }, "Loading models...")) : (React.createElement(Box, { flexDirection: "column" },
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: 2 },
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: 2 }, loading ? (React.createElement(Text, { color: "yellow" }, "Loading models...")) : (React.createElement(Box, { flexDirection: "column" },
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: 2 },
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: 2 },
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: 2 },
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: 2 },
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: 2 },
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: 2 },
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: 2 },
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: 2 },
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: 2 },
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: 2 },
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: 2 },
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
- return (React.createElement(Box, { flexDirection: "column", padding: 1 },
51
- React.createElement(Box, { borderStyle: "double", paddingX: 1, paddingY: 1, borderColor: 'cyan' },
52
- React.createElement(Box, { flexDirection: "column" },
53
- React.createElement(Text, { color: "white", bold: true },
54
- React.createElement(Text, { color: "cyan" }, "\u2746 "),
55
- React.createElement(Gradient, { name: "rainbow" }, "SNOW AI CLI")),
56
- React.createElement(Text, { color: "gray", dimColor: true }, "Intelligent Command Line Assistant"),
57
- React.createElement(Text, { color: "magenta", dimColor: true },
58
- "Version ",
59
- version),
60
- onMenuSelect && (React.createElement(Box, null,
61
- React.createElement(Menu, { options: menuOptions, onSelect: onMenuSelect, onSelectionChange: handleSelectionChange }))),
62
- React.createElement(Alert, { variant: 'info' }, infoText)))));
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,5 @@
1
+ type WritableStreamLike = Pick<NodeJS.WriteStream, 'write'> | {
2
+ write: (data: string) => unknown;
3
+ };
4
+ export declare function resetTerminal(stream?: WritableStreamLike): void;
5
+ export {};
@@ -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
+ }
@@ -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
- this.recalculateVisualState();
349
- this.scheduleUpdate();
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.
@@ -31,6 +31,12 @@ declare class VSCodeConnectionManager {
31
31
  private listeners;
32
32
  private currentWorkingDirectory;
33
33
  start(): Promise<void>;
34
+ /**
35
+ * Normalize path for cross-platform compatibility
36
+ * - Converts Windows backslashes to forward slashes
37
+ * - Converts drive letters to lowercase for consistent comparison
38
+ */
39
+ private normalizePath;
34
40
  /**
35
41
  * Find the correct port for the current workspace
36
42
  */
@@ -149,6 +149,19 @@ class VSCodeConnectionManager {
149
149
  tryConnect(targetPort);
150
150
  });
151
151
  }
152
+ /**
153
+ * Normalize path for cross-platform compatibility
154
+ * - Converts Windows backslashes to forward slashes
155
+ * - Converts drive letters to lowercase for consistent comparison
156
+ */
157
+ normalizePath(filePath) {
158
+ let normalized = filePath.replace(/\\/g, '/');
159
+ // Convert Windows drive letter to lowercase (C: -> c:)
160
+ if (/^[A-Z]:/.test(normalized)) {
161
+ normalized = normalized.charAt(0).toLowerCase() + normalized.slice(1);
162
+ }
163
+ return normalized;
164
+ }
152
165
  /**
153
166
  * Find the correct port for the current workspace
154
167
  */
@@ -157,15 +170,16 @@ class VSCodeConnectionManager {
157
170
  const portInfoPath = path.join(os.tmpdir(), 'snow-cli-ports.json');
158
171
  if (fs.existsSync(portInfoPath)) {
159
172
  const portInfo = JSON.parse(fs.readFileSync(portInfoPath, 'utf8'));
160
- // Try to match current working directory
161
- const cwd = this.currentWorkingDirectory;
173
+ // Normalize cwd for consistent comparison
174
+ const cwd = this.normalizePath(this.currentWorkingDirectory);
162
175
  // Direct match
163
176
  if (portInfo[cwd]) {
164
177
  return portInfo[cwd];
165
178
  }
166
179
  // Check if cwd is within any of the workspace folders
167
180
  for (const [workspace, port] of Object.entries(portInfo)) {
168
- if (cwd.startsWith(workspace)) {
181
+ const normalizedWorkspace = this.normalizePath(workspace);
182
+ if (cwd.startsWith(normalizedWorkspace)) {
169
183
  return port;
170
184
  }
171
185
  }
@@ -185,11 +199,12 @@ class VSCodeConnectionManager {
185
199
  if (!data.workspaceFolder) {
186
200
  return true;
187
201
  }
188
- // Check if message's workspace folder matches our current working directory
189
- const cwd = this.currentWorkingDirectory;
202
+ // Normalize paths for consistent comparison across platforms
203
+ const cwd = this.normalizePath(this.currentWorkingDirectory);
204
+ const workspaceFolder = this.normalizePath(data.workspaceFolder);
190
205
  // Bidirectional check: either cwd is within IDE workspace, or IDE workspace is within cwd
191
- const cwdInWorkspace = cwd.startsWith(data.workspaceFolder);
192
- const workspaceInCwd = data.workspaceFolder.startsWith(cwd);
206
+ const cwdInWorkspace = cwd.startsWith(workspaceFolder);
207
+ const workspaceInCwd = workspaceFolder.startsWith(cwd);
193
208
  const matches = cwdInWorkspace || workspaceInCwd;
194
209
  return matches;
195
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snow-ai",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Intelligent Command Line Assistant powered by AI",
5
5
  "license": "MIT",
6
6
  "bin": {
package/readme.md CHANGED
@@ -57,10 +57,16 @@ $ npm uninstall --global snow-ai
57
57
 
58
58
  ## Install VSCode Extension
59
59
 
60
- * download [VSIX/snow-cli-0.2.6.vsix](https://github.com/MayDay-wpf/snow-cli/blob/main/VSIX/snow-cli-0.2.6.vsix)
60
+ * download [VSIX/snow-cli-x.x.x.vsix](https://github.com/MayDay-wpf/snow-cli/blob/main/VSIX/)
61
61
 
62
62
  * open VSCode, click `Extensions` -> `Install from VSIX...` -> select `snow-cli-0.2.6.vsix`
63
63
 
64
+ ## Install JetBrains plugin
65
+
66
+ * download [JetBrains/build/distributions](https://github.com/MayDay-wpf/snow-cli/tree/main/JetBrains/build/distributions)
67
+
68
+ * File > Settings > Plugins
69
+
64
70
  ## Live View
65
71
  * **Welcome & Settings**
66
72