snow-ai 0.2.3 → 0.2.5

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.
@@ -476,7 +476,7 @@ export async function createResponseWithTools(options, maxToolRounds = 5) {
476
476
  reasoning: options.reasoning || { summary: 'auto', effort: 'high' },
477
477
  store: options.store ?? false, // 默认不存储对话历史,提高缓存命中
478
478
  include: options.include || ['reasoning.encrypted_content'], // 包含加密推理内容
479
- prompt_cache_key: options.prompt_cache_key, // 缓存键(可选)
479
+ prompt_cache_key: options.prompt_cache_key, // 缓存键
480
480
  });
481
481
  const output = response.output;
482
482
  if (!output || output.length === 0) {
@@ -1,3 +1,4 @@
1
+ import { type Diagnostic } from '../utils/vscodeConnection.js';
1
2
  interface SearchMatch {
2
3
  filePath: string;
3
4
  lineNumber: number;
@@ -90,6 +91,7 @@ export declare class FilesystemMCPService {
90
91
  contextStartLine: number;
91
92
  contextEndLine: number;
92
93
  totalLines: number;
94
+ diagnostics?: Diagnostic[];
93
95
  }>;
94
96
  /**
95
97
  * Search for code keywords in files within a directory
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from 'fs';
2
2
  import * as path from 'path';
3
+ import { vscodeConnection } from '../utils/vscodeConnection.js';
3
4
  const { resolve, dirname, isAbsolute } = path;
4
5
  /**
5
6
  * Filesystem MCP Service
@@ -251,7 +252,17 @@ export class FilesystemMCPService {
251
252
  const newContextContent = newContextLines.join('\n');
252
253
  // Write the modified content back to file
253
254
  await fs.writeFile(fullPath, modifiedLines.join('\n'), 'utf-8');
254
- return {
255
+ // Try to get diagnostics from VS Code after editing
256
+ let diagnostics = [];
257
+ try {
258
+ // Wait a bit for VS Code to process the file change
259
+ await new Promise(resolve => setTimeout(resolve, 500));
260
+ diagnostics = await vscodeConnection.requestDiagnostics(fullPath);
261
+ }
262
+ catch (error) {
263
+ // Ignore diagnostics errors, they are optional
264
+ }
265
+ const result = {
255
266
  message: `File edited successfully: ${filePath} (lines ${startLine}-${adjustedEndLine} replaced)`,
256
267
  oldContent,
257
268
  newContent: newContextContent,
@@ -259,6 +270,16 @@ export class FilesystemMCPService {
259
270
  contextEndLine: newContextEnd,
260
271
  totalLines: newTotalLines
261
272
  };
273
+ // Add diagnostics if any were found
274
+ if (diagnostics.length > 0) {
275
+ result.diagnostics = diagnostics;
276
+ const errorCount = diagnostics.filter(d => d.severity === 'error').length;
277
+ const warningCount = diagnostics.filter(d => d.severity === 'warning').length;
278
+ if (errorCount > 0 || warningCount > 0) {
279
+ result.message += `\n\n⚠️ Diagnostics detected: ${errorCount} error(s), ${warningCount} warning(s)`;
280
+ }
281
+ }
282
+ return result;
262
283
  }
263
284
  catch (error) {
264
285
  throw new Error(`Failed to edit file ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`);
@@ -12,7 +12,9 @@ const commands = [
12
12
  { name: 'resume', description: 'Resume a conversation' },
13
13
  { name: 'mcp', description: 'Show Model Context Protocol services and tools' },
14
14
  { name: 'yolo', description: 'Toggle unattended mode (auto-approve all tools)' },
15
- { name: 'init', description: 'Analyze project and generate/update SNOW.md documentation' }
15
+ { name: 'init', description: 'Analyze project and generate/update SNOW.md documentation' },
16
+ { name: 'ide', description: 'Connect to VSCode editor and sync context' },
17
+ { name: 'compact', description: 'Compress conversation history using compact model' }
16
18
  ];
17
19
  export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type your message...', disabled = false, chatHistory = [], onHistorySelect, yoloMode = false, contextUsage }) {
18
20
  const { stdout } = useStdout();
@@ -251,6 +253,24 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
251
253
  // For any other key in history menu, just return to prevent interference
252
254
  return;
253
255
  }
256
+ // Ctrl+L - Delete from cursor to beginning
257
+ if (key.ctrl && input === 'l') {
258
+ const fullText = buffer.getFullText();
259
+ const cursorPos = buffer.getCursorPosition();
260
+ const afterCursor = fullText.slice(cursorPos);
261
+ buffer.setText(afterCursor);
262
+ forceStateUpdate();
263
+ return;
264
+ }
265
+ // Ctrl+R - Delete from cursor to end
266
+ if (key.ctrl && input === 'r') {
267
+ const fullText = buffer.getFullText();
268
+ const cursorPos = buffer.getCursorPosition();
269
+ const beforeCursor = fullText.slice(0, cursorPos);
270
+ buffer.setText(beforeCursor);
271
+ forceStateUpdate();
272
+ return;
273
+ }
254
274
  // Alt+V / Option+V - Paste from clipboard (including images)
255
275
  if (key.meta && input === 'v') {
256
276
  try {
@@ -588,5 +608,5 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
588
608
  ? "Type to filter commands"
589
609
  : showFilePicker
590
610
  ? "Type to filter files • Tab/Enter to select • ESC to cancel"
591
- : "Press Ctrl+C twice to exit • Alt+V to paste images • Type '@' for files • Type '/' for commands"))))));
611
+ : "Ctrl+L: delete to start • Ctrl+R: delete to end • Alt+V: paste images • '@': files • '/': commands"))))));
592
612
  }
@@ -8,6 +8,7 @@ interface Props {
8
8
  selectedIndex: number;
9
9
  query: string;
10
10
  visible: boolean;
11
+ maxHeight?: number;
11
12
  }
12
- declare const CommandPanel: React.MemoExoticComponent<({ commands, selectedIndex, query, visible }: Props) => React.JSX.Element | null>;
13
+ declare const CommandPanel: React.MemoExoticComponent<({ commands, selectedIndex, visible, maxHeight }: Props) => React.JSX.Element | null>;
13
14
  export default CommandPanel;
@@ -1,6 +1,31 @@
1
- import React, { memo } from 'react';
1
+ import React, { memo, useMemo } from 'react';
2
2
  import { Box, Text } from 'ink';
3
- const CommandPanel = memo(({ commands, selectedIndex, query, visible }) => {
3
+ const CommandPanel = memo(({ commands, selectedIndex, visible, maxHeight }) => {
4
+ // Fixed maximum display items to prevent rendering issues
5
+ const MAX_DISPLAY_ITEMS = 5;
6
+ const effectiveMaxItems = maxHeight ? Math.min(maxHeight, MAX_DISPLAY_ITEMS) : MAX_DISPLAY_ITEMS;
7
+ // Limit displayed commands
8
+ const displayedCommands = useMemo(() => {
9
+ if (commands.length <= effectiveMaxItems) {
10
+ return commands;
11
+ }
12
+ // Show commands around the selected index
13
+ const halfWindow = Math.floor(effectiveMaxItems / 2);
14
+ let startIndex = Math.max(0, selectedIndex - halfWindow);
15
+ let endIndex = Math.min(commands.length, startIndex + effectiveMaxItems);
16
+ // Adjust if we're near the end
17
+ if (endIndex - startIndex < effectiveMaxItems) {
18
+ startIndex = Math.max(0, endIndex - effectiveMaxItems);
19
+ }
20
+ return commands.slice(startIndex, endIndex);
21
+ }, [commands, selectedIndex, effectiveMaxItems]);
22
+ // Calculate actual selected index in the displayed subset
23
+ const displayedSelectedIndex = useMemo(() => {
24
+ return displayedCommands.findIndex((cmd) => {
25
+ const originalIndex = commands.indexOf(cmd);
26
+ return originalIndex === selectedIndex;
27
+ });
28
+ }, [displayedCommands, commands, selectedIndex]);
4
29
  // Don't show panel if not visible or no commands found
5
30
  if (!visible || commands.length === 0) {
6
31
  return null;
@@ -11,16 +36,21 @@ const CommandPanel = memo(({ commands, selectedIndex, query, visible }) => {
11
36
  React.createElement(Box, null,
12
37
  React.createElement(Text, { color: "yellow", bold: true },
13
38
  "Available Commands ",
14
- query && `(${commands.length} matches)`)),
15
- commands.map((command, index) => (React.createElement(Box, { key: command.name, flexDirection: "column", width: "100%" },
16
- React.createElement(Text, { color: index === selectedIndex ? "green" : "gray", bold: true },
17
- index === selectedIndex ? "➣ " : " ",
39
+ commands.length > effectiveMaxItems && `(${selectedIndex + 1}/${commands.length})`)),
40
+ displayedCommands.map((command, index) => (React.createElement(Box, { key: command.name, flexDirection: "column", width: "100%" },
41
+ React.createElement(Text, { color: index === displayedSelectedIndex ? "green" : "gray", bold: true },
42
+ index === displayedSelectedIndex ? "➣ " : " ",
18
43
  "/",
19
44
  command.name),
20
45
  React.createElement(Box, { marginLeft: 3 },
21
- React.createElement(Text, { color: index === selectedIndex ? "green" : "gray", dimColor: true },
46
+ React.createElement(Text, { color: index === displayedSelectedIndex ? "green" : "gray", dimColor: true },
22
47
  "\u2514\u2500 ",
23
- command.description)))))))));
48
+ command.description))))),
49
+ commands.length > effectiveMaxItems && (React.createElement(Box, { marginTop: 1 },
50
+ React.createElement(Text, { color: "gray", dimColor: true },
51
+ "\u2191\u2193 to scroll \u00B7 ",
52
+ commands.length - effectiveMaxItems,
53
+ " more hidden")))))));
24
54
  });
25
55
  CommandPanel.displayName = 'CommandPanel';
26
56
  export default CommandPanel;
@@ -5,6 +5,11 @@ import path from 'path';
5
5
  const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10, rootPath = process.cwd(), onFilteredCountChange }, ref) => {
6
6
  const [files, setFiles] = useState([]);
7
7
  const [isLoading, setIsLoading] = useState(false);
8
+ // Fixed maximum display items to prevent rendering issues
9
+ const MAX_DISPLAY_ITEMS = 5;
10
+ const effectiveMaxItems = useMemo(() => {
11
+ return maxItems ? Math.min(maxItems, MAX_DISPLAY_ITEMS) : MAX_DISPLAY_ITEMS;
12
+ }, [maxItems]);
8
13
  // Get files from directory - optimized to batch updates
9
14
  const loadFiles = useCallback(async () => {
10
15
  const getFilesRecursively = async (dir, depth = 0, maxDepth = 3) => {
@@ -60,48 +65,59 @@ const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10
60
65
  loadFiles();
61
66
  }
62
67
  }, [visible, loadFiles]);
63
- // Filter files based on query
64
- const filteredFiles = useMemo(() => {
65
- let filtered;
68
+ // Filter files based on query (no limit here, we'll slice for display)
69
+ const allFilteredFiles = useMemo(() => {
66
70
  if (!query.trim()) {
67
- filtered = files.slice(0, maxItems);
68
- }
69
- else {
70
- const queryLower = query.toLowerCase();
71
- filtered = files.filter(file => {
72
- const fileName = file.name.toLowerCase();
73
- const filePath = file.path.toLowerCase();
74
- return fileName.includes(queryLower) || filePath.includes(queryLower);
75
- });
76
- // Sort by relevance (exact name matches first, then path matches)
77
- filtered.sort((a, b) => {
78
- const aNameMatch = a.name.toLowerCase().startsWith(queryLower);
79
- const bNameMatch = b.name.toLowerCase().startsWith(queryLower);
80
- if (aNameMatch && !bNameMatch)
81
- return -1;
82
- if (!aNameMatch && bNameMatch)
83
- return 1;
84
- return a.name.localeCompare(b.name);
85
- });
86
- filtered = filtered.slice(0, maxItems);
71
+ return files;
87
72
  }
73
+ const queryLower = query.toLowerCase();
74
+ const filtered = files.filter(file => {
75
+ const fileName = file.name.toLowerCase();
76
+ const filePath = file.path.toLowerCase();
77
+ return fileName.includes(queryLower) || filePath.includes(queryLower);
78
+ });
79
+ // Sort by relevance (exact name matches first, then path matches)
80
+ filtered.sort((a, b) => {
81
+ const aNameMatch = a.name.toLowerCase().startsWith(queryLower);
82
+ const bNameMatch = b.name.toLowerCase().startsWith(queryLower);
83
+ if (aNameMatch && !bNameMatch)
84
+ return -1;
85
+ if (!aNameMatch && bNameMatch)
86
+ return 1;
87
+ return a.name.localeCompare(b.name);
88
+ });
88
89
  return filtered;
89
- }, [files, query, maxItems]);
90
+ }, [files, query]);
91
+ // Display with scrolling window
92
+ const filteredFiles = useMemo(() => {
93
+ if (allFilteredFiles.length <= effectiveMaxItems) {
94
+ return allFilteredFiles;
95
+ }
96
+ // Show files around the selected index
97
+ const halfWindow = Math.floor(effectiveMaxItems / 2);
98
+ let startIndex = Math.max(0, selectedIndex - halfWindow);
99
+ let endIndex = Math.min(allFilteredFiles.length, startIndex + effectiveMaxItems);
100
+ // Adjust if we're near the end
101
+ if (endIndex - startIndex < effectiveMaxItems) {
102
+ startIndex = Math.max(0, endIndex - effectiveMaxItems);
103
+ }
104
+ return allFilteredFiles.slice(startIndex, endIndex);
105
+ }, [allFilteredFiles, selectedIndex, effectiveMaxItems]);
90
106
  // Notify parent of filtered count changes
91
107
  useEffect(() => {
92
108
  if (onFilteredCountChange) {
93
- onFilteredCountChange(filteredFiles.length);
109
+ onFilteredCountChange(allFilteredFiles.length);
94
110
  }
95
- }, [filteredFiles.length, onFilteredCountChange]);
111
+ }, [allFilteredFiles.length, onFilteredCountChange]);
96
112
  // Expose methods to parent
97
113
  useImperativeHandle(ref, () => ({
98
114
  getSelectedFile: () => {
99
- if (filteredFiles.length > 0 && selectedIndex < filteredFiles.length && filteredFiles[selectedIndex]) {
100
- return filteredFiles[selectedIndex].path;
115
+ if (allFilteredFiles.length > 0 && selectedIndex < allFilteredFiles.length && allFilteredFiles[selectedIndex]) {
116
+ return allFilteredFiles[selectedIndex].path;
101
117
  }
102
118
  return null;
103
119
  }
104
- }), [filteredFiles, selectedIndex]);
120
+ }), [allFilteredFiles, selectedIndex]);
105
121
  if (!visible) {
106
122
  return null;
107
123
  }
@@ -113,19 +129,25 @@ const FileList = memo(forwardRef(({ query, selectedIndex, visible, maxItems = 10
113
129
  return (React.createElement(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1 },
114
130
  React.createElement(Text, { color: "gray" }, "No files found")));
115
131
  }
132
+ // Calculate display index for the scrolling window
133
+ const displaySelectedIndex = useMemo(() => {
134
+ return filteredFiles.findIndex((file) => {
135
+ const originalIndex = allFilteredFiles.indexOf(file);
136
+ return originalIndex === selectedIndex;
137
+ });
138
+ }, [filteredFiles, allFilteredFiles, selectedIndex]);
116
139
  return (React.createElement(Box, { paddingX: 1, marginTop: 1, flexDirection: "column" },
117
140
  React.createElement(Box, { marginBottom: 1 },
118
141
  React.createElement(Text, { color: "blue", bold: true },
119
- "\uD83D\uDDD0 Files (",
120
- filteredFiles.length,
121
- ")")),
142
+ "\uD83D\uDDD0 Files ",
143
+ allFilteredFiles.length > effectiveMaxItems && `(${selectedIndex + 1}/${allFilteredFiles.length})`)),
122
144
  filteredFiles.map((file, index) => (React.createElement(Box, { key: file.path },
123
- React.createElement(Text, { backgroundColor: index === selectedIndex ? "blue" : undefined, color: index === selectedIndex ? "white" : file.isDirectory ? "cyan" : "white" }, file.path)))),
124
- filteredFiles.length === maxItems && files.length > maxItems && (React.createElement(Box, { marginTop: 1 },
145
+ React.createElement(Text, { backgroundColor: index === displaySelectedIndex ? "blue" : undefined, color: index === displaySelectedIndex ? "white" : file.isDirectory ? "cyan" : "white" }, file.path)))),
146
+ allFilteredFiles.length > effectiveMaxItems && (React.createElement(Box, { marginTop: 1 },
125
147
  React.createElement(Text, { color: "gray", dimColor: true },
126
- "... and ",
127
- files.length - maxItems,
128
- " more files")))));
148
+ "\u2191\u2193 to scroll \u00B7 ",
149
+ allFilteredFiles.length - effectiveMaxItems,
150
+ " more hidden")))));
129
151
  }));
130
152
  FileList.displayName = 'FileList';
131
153
  export default FileList;
@@ -53,7 +53,7 @@ function Menu({ options, onSelect, onSelectionChange, maxHeight }) {
53
53
  const hasMoreBelow = scrollOffset + visibleItemCount < options.length;
54
54
  const moreAboveCount = scrollOffset;
55
55
  const moreBelowCount = options.length - (scrollOffset + visibleItemCount);
56
- return (React.createElement(Box, { flexDirection: "column", width: '100%', borderStyle: 'round', borderColor: "#A9C13E", padding: 1 },
56
+ return (React.createElement(Box, { flexDirection: "column", width: '100%', padding: 1 },
57
57
  React.createElement(Box, { marginBottom: 1 },
58
58
  React.createElement(Text, { color: "cyan" }, "Use \u2191\u2193 keys to navigate, press Enter to select:")),
59
59
  hasMoreAbove && (React.createElement(Box, null,
@@ -4,6 +4,8 @@ import '../../utils/commands/resume.js';
4
4
  import '../../utils/commands/mcp.js';
5
5
  import '../../utils/commands/yolo.js';
6
6
  import '../../utils/commands/init.js';
7
+ import '../../utils/commands/ide.js';
8
+ import '../../utils/commands/compact.js';
7
9
  type Props = {};
8
10
  export default function ChatScreen({}: Props): React.JSX.Element;
9
11
  export {};
@@ -18,13 +18,17 @@ import { useSessionManagement } from '../../hooks/useSessionManagement.js';
18
18
  import { useToolConfirmation } from '../../hooks/useToolConfirmation.js';
19
19
  import { handleConversationWithTools } from '../../hooks/useConversation.js';
20
20
  import { parseAndValidateFileReferences, createMessageWithFileInstructions, getSystemInfo } from '../../utils/fileUtils.js';
21
+ import { compressContext } from '../../utils/contextCompressor.js';
21
22
  // Import commands to register them
22
23
  import '../../utils/commands/clear.js';
23
24
  import '../../utils/commands/resume.js';
24
25
  import '../../utils/commands/mcp.js';
25
26
  import '../../utils/commands/yolo.js';
26
27
  import '../../utils/commands/init.js';
28
+ import '../../utils/commands/ide.js';
29
+ import '../../utils/commands/compact.js';
27
30
  import { navigateTo } from '../../hooks/useGlobalNavigation.js';
31
+ import { vscodeConnection } from '../../utils/vscodeConnection.js';
28
32
  // Format elapsed time to human readable format
29
33
  function formatElapsedTime(seconds) {
30
34
  if (seconds < 60) {
@@ -59,8 +63,16 @@ export default function ChatScreen({}) {
59
63
  const [contextUsage, setContextUsage] = useState(null);
60
64
  const [elapsedSeconds, setElapsedSeconds] = useState(0);
61
65
  const [timerStartTime, setTimerStartTime] = useState(null);
66
+ const [vscodeConnected, setVscodeConnected] = useState(false);
67
+ const [vscodeConnectionStatus, setVscodeConnectionStatus] = useState('disconnected');
68
+ const [editorContext, setEditorContext] = useState({});
69
+ const [isCompressing, setIsCompressing] = useState(false);
70
+ const [compressionError, setCompressionError] = useState(null);
62
71
  const { stdout } = useStdout();
72
+ const terminalHeight = stdout?.rows || 24;
63
73
  const workingDirectory = process.cwd();
74
+ // Minimum terminal height required for proper rendering
75
+ const MIN_TERMINAL_HEIGHT = 10;
64
76
  // Use session save hook
65
77
  const { saveMessage, clearSavedMessages, initializeFromSession } = useSessionSave();
66
78
  // Sync pendingMessages to ref for real-time access in callbacks
@@ -105,6 +117,60 @@ export default function ChatScreen({}) {
105
117
  }, 1000);
106
118
  return () => clearInterval(interval);
107
119
  }, [timerStartTime]);
120
+ // Monitor VSCode connection status and editor context
121
+ useEffect(() => {
122
+ let connectingTimeout = null;
123
+ const checkConnection = setInterval(() => {
124
+ const isConnected = vscodeConnection.isConnected();
125
+ const isServerRunning = vscodeConnection.isServerRunning();
126
+ setVscodeConnected(isConnected);
127
+ // Update connection status based on actual connection state
128
+ if (isConnected && vscodeConnectionStatus !== 'connected') {
129
+ setVscodeConnectionStatus('connected');
130
+ if (connectingTimeout) {
131
+ clearTimeout(connectingTimeout);
132
+ connectingTimeout = null;
133
+ }
134
+ }
135
+ else if (!isConnected && vscodeConnectionStatus === 'connected') {
136
+ setVscodeConnectionStatus('disconnected');
137
+ }
138
+ else if (vscodeConnectionStatus === 'connecting' && !isServerRunning) {
139
+ // Server failed to start
140
+ setVscodeConnectionStatus('error');
141
+ if (connectingTimeout) {
142
+ clearTimeout(connectingTimeout);
143
+ connectingTimeout = null;
144
+ }
145
+ }
146
+ }, 1000);
147
+ // Set timeout for connecting state (15 seconds)
148
+ if (vscodeConnectionStatus === 'connecting') {
149
+ connectingTimeout = setTimeout(() => {
150
+ if (vscodeConnectionStatus === 'connecting') {
151
+ setVscodeConnectionStatus('error');
152
+ }
153
+ }, 15000);
154
+ }
155
+ const unsubscribe = vscodeConnection.onContextUpdate((context) => {
156
+ setEditorContext(context);
157
+ // When we receive context, it means connection is successful
158
+ if (vscodeConnectionStatus !== 'connected') {
159
+ setVscodeConnectionStatus('connected');
160
+ if (connectingTimeout) {
161
+ clearTimeout(connectingTimeout);
162
+ connectingTimeout = null;
163
+ }
164
+ }
165
+ });
166
+ return () => {
167
+ clearInterval(checkConnection);
168
+ if (connectingTimeout) {
169
+ clearTimeout(connectingTimeout);
170
+ }
171
+ unsubscribe();
172
+ };
173
+ }, [vscodeConnectionStatus]);
108
174
  // Pending messages are now handled inline during tool execution in useConversation
109
175
  // Auto-send pending messages when streaming completely stops (as fallback)
110
176
  useEffect(() => {
@@ -140,7 +206,74 @@ export default function ChatScreen({}) {
140
206
  setStreamTokenCount(0);
141
207
  }
142
208
  });
143
- const handleCommandExecution = (commandName, result) => {
209
+ const handleCommandExecution = async (commandName, result) => {
210
+ // Handle /compact command
211
+ if (commandName === 'compact' && result.success && result.action === 'compact') {
212
+ // Set compressing state (不添加命令面板消息)
213
+ setIsCompressing(true);
214
+ setCompressionError(null);
215
+ try {
216
+ // Convert messages to ChatMessage format for compression
217
+ const chatMessages = messages
218
+ .filter(msg => msg.role !== 'command')
219
+ .map(msg => ({
220
+ role: msg.role,
221
+ content: msg.content,
222
+ tool_call_id: msg.toolCallId
223
+ }));
224
+ // Compress the context
225
+ const result = await compressContext(chatMessages);
226
+ // Replace all messages with a summary message (不包含 "Context Compressed" 标题)
227
+ const summaryMessage = {
228
+ role: 'assistant',
229
+ content: result.summary,
230
+ streaming: false
231
+ };
232
+ // Clear session and set new compressed state
233
+ sessionManager.clearCurrentSession();
234
+ clearSavedMessages();
235
+ setMessages([summaryMessage]);
236
+ setRemountKey(prev => prev + 1);
237
+ // Update token usage with compression result
238
+ setContextUsage({
239
+ prompt_tokens: result.usage.prompt_tokens,
240
+ completion_tokens: result.usage.completion_tokens,
241
+ total_tokens: result.usage.total_tokens
242
+ });
243
+ }
244
+ catch (error) {
245
+ // Show error message
246
+ const errorMsg = error instanceof Error ? error.message : 'Unknown compression error';
247
+ setCompressionError(errorMsg);
248
+ const errorMessage = {
249
+ role: 'assistant',
250
+ content: `**Compression Failed**\n\n${errorMsg}`,
251
+ streaming: false
252
+ };
253
+ setMessages(prev => [...prev, errorMessage]);
254
+ }
255
+ finally {
256
+ setIsCompressing(false);
257
+ }
258
+ return;
259
+ }
260
+ // Handle /ide command
261
+ if (commandName === 'ide') {
262
+ if (result.success) {
263
+ setVscodeConnectionStatus('connecting');
264
+ // Add command execution feedback
265
+ const commandMessage = {
266
+ role: 'command',
267
+ content: '',
268
+ commandName: commandName
269
+ };
270
+ setMessages(prev => [...prev, commandMessage]);
271
+ }
272
+ else {
273
+ setVscodeConnectionStatus('error');
274
+ }
275
+ return;
276
+ }
144
277
  if (result.success && result.action === 'clear') {
145
278
  if (stdout && typeof stdout.write === 'function') {
146
279
  stdout.write('\x1B[3J\x1B[2J\x1B[H');
@@ -241,8 +374,8 @@ export default function ChatScreen({}) {
241
374
  const controller = new AbortController();
242
375
  setAbortController(controller);
243
376
  try {
244
- // Create message for AI with file read instructions and system info
245
- const messageForAI = createMessageWithFileInstructions(cleanContent, regularFiles, systemInfo);
377
+ // Create message for AI with file read instructions, system info, and editor context
378
+ const messageForAI = createMessageWithFileInstructions(cleanContent, regularFiles, systemInfo, vscodeConnected ? editorContext : undefined);
246
379
  // Start conversation with tool support
247
380
  await handleConversationWithTools({
248
381
  userContent: messageForAI,
@@ -351,6 +484,21 @@ export default function ChatScreen({}) {
351
484
  if (showMcpInfo) {
352
485
  return (React.createElement(MCPInfoScreen, { onClose: () => setShowMcpInfo(false), panelKey: mcpPanelKey }));
353
486
  }
487
+ // Show warning if terminal is too small
488
+ if (terminalHeight < MIN_TERMINAL_HEIGHT) {
489
+ return (React.createElement(Box, { flexDirection: "column", padding: 2 },
490
+ React.createElement(Box, { borderStyle: "round", borderColor: "red", padding: 1 },
491
+ React.createElement(Text, { color: "red", bold: true }, "\u26A0 Terminal Too Small")),
492
+ React.createElement(Box, { marginTop: 1 },
493
+ React.createElement(Text, { color: "yellow" },
494
+ "Your terminal height is ",
495
+ terminalHeight,
496
+ " lines, but at least ",
497
+ MIN_TERMINAL_HEIGHT,
498
+ " lines are required.")),
499
+ React.createElement(Box, { marginTop: 1 },
500
+ React.createElement(Text, { color: "gray", dimColor: true }, "Please resize your terminal window to continue."))));
501
+ }
354
502
  return (React.createElement(Box, { flexDirection: "column" },
355
503
  React.createElement(Static, { key: remountKey, items: [
356
504
  React.createElement(Box, { key: "header", marginX: 1, borderColor: 'cyan', borderStyle: "round", paddingX: 2, paddingY: 1 },
@@ -469,8 +617,27 @@ export default function ChatScreen({}) {
469
617
  React.createElement(Box, { marginX: 1 },
470
618
  React.createElement(PendingMessages, { pendingMessages: pendingMessages })),
471
619
  pendingToolConfirmation && (React.createElement(ToolConfirmation, { toolName: pendingToolConfirmation.batchToolNames || pendingToolConfirmation.tool.function.name, onConfirm: pendingToolConfirmation.resolve })),
472
- !pendingToolConfirmation && (React.createElement(ChatInput, { onSubmit: handleMessageSubmit, onCommand: handleCommandExecution, placeholder: "Ask me anything about coding...", disabled: !!pendingToolConfirmation, chatHistory: messages, onHistorySelect: handleHistorySelect, yoloMode: yoloMode, contextUsage: contextUsage ? {
473
- inputTokens: contextUsage.prompt_tokens,
474
- maxContextTokens: getOpenAiConfig().maxContextTokens || 4000
475
- } : undefined }))));
620
+ !pendingToolConfirmation && !isCompressing && (React.createElement(React.Fragment, null,
621
+ React.createElement(ChatInput, { onSubmit: handleMessageSubmit, onCommand: handleCommandExecution, placeholder: "Ask me anything about coding...", disabled: !!pendingToolConfirmation, chatHistory: messages, onHistorySelect: handleHistorySelect, yoloMode: yoloMode, contextUsage: contextUsage ? {
622
+ inputTokens: contextUsage.prompt_tokens,
623
+ maxContextTokens: getOpenAiConfig().maxContextTokens || 4000
624
+ } : undefined }),
625
+ vscodeConnectionStatus !== 'disconnected' && (React.createElement(Box, { marginTop: 1 },
626
+ React.createElement(Text, { color: vscodeConnectionStatus === 'connecting' ? 'yellow' :
627
+ vscodeConnectionStatus === 'connected' ? 'green' :
628
+ vscodeConnectionStatus === 'error' ? 'red' : 'gray', dimColor: vscodeConnectionStatus !== 'error' },
629
+ "\u25CF ",
630
+ vscodeConnectionStatus === 'connecting' ? 'Connecting to VSCode...' :
631
+ vscodeConnectionStatus === 'connected' ? 'VSCode Connected' :
632
+ vscodeConnectionStatus === 'error' ? 'Connection Failed' : 'VSCode',
633
+ vscodeConnectionStatus === 'connected' && editorContext.activeFile && ` | ${editorContext.activeFile}`,
634
+ vscodeConnectionStatus === 'connected' && editorContext.selectedText && ` | ${editorContext.selectedText.length} chars selected`))))),
635
+ isCompressing && (React.createElement(Box, { marginTop: 1 },
636
+ React.createElement(Text, { color: "cyan" },
637
+ React.createElement(Spinner, { type: "dots" }),
638
+ " Compressing conversation history..."))),
639
+ compressionError && (React.createElement(Box, { marginTop: 1 },
640
+ React.createElement(Text, { color: "red" },
641
+ "\u2717 Compression failed: ",
642
+ compressionError)))));
476
643
  }
@@ -8,6 +8,9 @@ export default function ModelConfigScreen({ onBack, onSave }) {
8
8
  const [advancedModel, setAdvancedModel] = useState('');
9
9
  const [basicModel, setBasicModel] = useState('');
10
10
  const [maxContextTokens, setMaxContextTokens] = useState(4000);
11
+ const [compactBaseUrl, setCompactBaseUrl] = useState('');
12
+ const [compactApiKey, setCompactApiKey] = useState('');
13
+ const [compactModelName, setCompactModelName] = useState('');
11
14
  const [currentField, setCurrentField] = useState('advancedModel');
12
15
  const [isEditing, setIsEditing] = useState(false);
13
16
  const [models, setModels] = useState([]);
@@ -21,6 +24,9 @@ export default function ModelConfigScreen({ onBack, onSave }) {
21
24
  setAdvancedModel(config.advancedModel || '');
22
25
  setBasicModel(config.basicModel || '');
23
26
  setMaxContextTokens(config.maxContextTokens || 4000);
27
+ setCompactBaseUrl(config.compactModel?.baseUrl || '');
28
+ setCompactApiKey(config.compactModel?.apiKey || '');
29
+ setCompactModelName(config.compactModel?.modelName || '');
24
30
  if (!config.baseUrl) {
25
31
  setBaseUrlMissing(true);
26
32
  return;
@@ -57,7 +63,15 @@ export default function ModelConfigScreen({ onBack, onSave }) {
57
63
  return advancedModel;
58
64
  if (currentField === 'basicModel')
59
65
  return basicModel;
60
- return maxContextTokens.toString();
66
+ if (currentField === 'maxContextTokens')
67
+ return maxContextTokens.toString();
68
+ if (currentField === 'compactBaseUrl')
69
+ return compactBaseUrl;
70
+ if (currentField === 'compactApiKey')
71
+ return compactApiKey;
72
+ if (currentField === 'compactModelName')
73
+ return compactModelName;
74
+ return '';
61
75
  };
62
76
  const handleModelChange = (value) => {
63
77
  // 如果选择了手动输入选项
@@ -146,6 +160,34 @@ export default function ModelConfigScreen({ onBack, onSave }) {
146
160
  setIsEditing(false);
147
161
  }
148
162
  }
163
+ else if (currentField === 'compactBaseUrl' || currentField === 'compactApiKey' || currentField === 'compactModelName') {
164
+ // Handle text input for compact model fields
165
+ if (key.return) {
166
+ setIsEditing(false);
167
+ }
168
+ else if (key.backspace || key.delete) {
169
+ if (currentField === 'compactBaseUrl') {
170
+ setCompactBaseUrl(prev => prev.slice(0, -1));
171
+ }
172
+ else if (currentField === 'compactApiKey') {
173
+ setCompactApiKey(prev => prev.slice(0, -1));
174
+ }
175
+ else if (currentField === 'compactModelName') {
176
+ setCompactModelName(prev => prev.slice(0, -1));
177
+ }
178
+ }
179
+ else if (input && input.match(/[a-zA-Z0-9-_./:]/)) {
180
+ if (currentField === 'compactBaseUrl') {
181
+ setCompactBaseUrl(prev => prev + input);
182
+ }
183
+ else if (currentField === 'compactApiKey') {
184
+ setCompactApiKey(prev => prev + input);
185
+ }
186
+ else if (currentField === 'compactModelName') {
187
+ setCompactModelName(prev => prev + input);
188
+ }
189
+ }
190
+ }
149
191
  else {
150
192
  // Allow typing to filter in edit mode for model selection
151
193
  if (input && input.match(/[a-zA-Z0-9-_.]/)) {
@@ -164,6 +206,14 @@ export default function ModelConfigScreen({ onBack, onSave }) {
164
206
  basicModel,
165
207
  maxContextTokens,
166
208
  };
209
+ // 只有当所有字段都填写时才保存 compactModel
210
+ if (compactBaseUrl && compactApiKey && compactModelName) {
211
+ config.compactModel = {
212
+ baseUrl: compactBaseUrl,
213
+ apiKey: compactApiKey,
214
+ modelName: compactModelName,
215
+ };
216
+ }
167
217
  updateOpenAiConfig(config);
168
218
  onSave();
169
219
  }
@@ -173,13 +223,22 @@ export default function ModelConfigScreen({ onBack, onSave }) {
173
223
  basicModel,
174
224
  maxContextTokens,
175
225
  };
226
+ // 只有当所有字段都填写时才保存 compactModel
227
+ if (compactBaseUrl && compactApiKey && compactModelName) {
228
+ config.compactModel = {
229
+ baseUrl: compactBaseUrl,
230
+ apiKey: compactApiKey,
231
+ modelName: compactModelName,
232
+ };
233
+ }
176
234
  updateOpenAiConfig(config);
177
235
  onBack();
178
236
  }
179
237
  else if (key.return) {
180
- // Load models first for model fields, or enter edit mode directly for maxContextTokens
238
+ // Load models first for model fields, or enter edit mode directly for maxContextTokens and compact fields
181
239
  setSearchTerm(''); // Reset search when entering edit mode
182
- if (currentField === 'maxContextTokens') {
240
+ const isCompactField = currentField === 'compactBaseUrl' || currentField === 'compactApiKey' || currentField === 'compactModelName';
241
+ if (currentField === 'maxContextTokens' || isCompactField) {
183
242
  setIsEditing(true);
184
243
  }
185
244
  else {
@@ -194,7 +253,8 @@ export default function ModelConfigScreen({ onBack, onSave }) {
194
253
  }
195
254
  else if (input === 'm') {
196
255
  // 快捷键:按 'm' 直接进入手动输入模式
197
- if (currentField !== 'maxContextTokens') {
256
+ const isCompactField = currentField === 'compactBaseUrl' || currentField === 'compactApiKey' || currentField === 'compactModelName';
257
+ if (currentField !== 'maxContextTokens' && !isCompactField) {
198
258
  setManualInputMode(true);
199
259
  setManualInputValue(getCurrentValue());
200
260
  }
@@ -206,6 +266,15 @@ export default function ModelConfigScreen({ onBack, onSave }) {
206
266
  else if (currentField === 'maxContextTokens') {
207
267
  setCurrentField('basicModel');
208
268
  }
269
+ else if (currentField === 'compactBaseUrl') {
270
+ setCurrentField('maxContextTokens');
271
+ }
272
+ else if (currentField === 'compactApiKey') {
273
+ setCurrentField('compactBaseUrl');
274
+ }
275
+ else if (currentField === 'compactModelName') {
276
+ setCurrentField('compactApiKey');
277
+ }
209
278
  }
210
279
  else if (key.downArrow) {
211
280
  if (currentField === 'advancedModel') {
@@ -214,6 +283,15 @@ export default function ModelConfigScreen({ onBack, onSave }) {
214
283
  else if (currentField === 'basicModel') {
215
284
  setCurrentField('maxContextTokens');
216
285
  }
286
+ else if (currentField === 'maxContextTokens') {
287
+ setCurrentField('compactBaseUrl');
288
+ }
289
+ else if (currentField === 'compactBaseUrl') {
290
+ setCurrentField('compactApiKey');
291
+ }
292
+ else if (currentField === 'compactApiKey') {
293
+ setCurrentField('compactModelName');
294
+ }
217
295
  }
218
296
  });
219
297
  if (baseUrlMissing) {
@@ -293,7 +371,42 @@ export default function ModelConfigScreen({ onBack, onSave }) {
293
371
  "Enter value: ",
294
372
  maxContextTokens))),
295
373
  (!isEditing || currentField !== 'maxContextTokens') && (React.createElement(Box, { marginLeft: 3 },
296
- React.createElement(Text, { color: "gray" }, maxContextTokens)))))),
374
+ React.createElement(Text, { color: "gray" }, maxContextTokens))))),
375
+ React.createElement(Box, { marginBottom: 2, marginTop: 1 },
376
+ React.createElement(Text, { color: "cyan", bold: true }, "Compact Model (Context Compression):")),
377
+ React.createElement(Box, { marginBottom: 1 },
378
+ React.createElement(Box, { flexDirection: "column" },
379
+ React.createElement(Text, { color: currentField === 'compactBaseUrl' ? 'green' : 'white' },
380
+ currentField === 'compactBaseUrl' ? '➣ ' : ' ',
381
+ "Base URL:"),
382
+ currentField === 'compactBaseUrl' && isEditing && (React.createElement(Box, { marginLeft: 3 },
383
+ React.createElement(Text, { color: "cyan" },
384
+ compactBaseUrl,
385
+ React.createElement(Text, { color: "white" }, "_")))),
386
+ (!isEditing || currentField !== 'compactBaseUrl') && (React.createElement(Box, { marginLeft: 3 },
387
+ React.createElement(Text, { color: "gray" }, compactBaseUrl || 'Not set'))))),
388
+ React.createElement(Box, { marginBottom: 1 },
389
+ React.createElement(Box, { flexDirection: "column" },
390
+ React.createElement(Text, { color: currentField === 'compactApiKey' ? 'green' : 'white' },
391
+ currentField === 'compactApiKey' ? '➣ ' : ' ',
392
+ "API Key:"),
393
+ currentField === 'compactApiKey' && isEditing && (React.createElement(Box, { marginLeft: 3 },
394
+ React.createElement(Text, { color: "cyan" },
395
+ compactApiKey.replace(/./g, '*'),
396
+ React.createElement(Text, { color: "white" }, "_")))),
397
+ (!isEditing || currentField !== 'compactApiKey') && (React.createElement(Box, { marginLeft: 3 },
398
+ React.createElement(Text, { color: "gray" }, compactApiKey ? compactApiKey.replace(/./g, '*') : 'Not set'))))),
399
+ React.createElement(Box, { marginBottom: 1 },
400
+ React.createElement(Box, { flexDirection: "column" },
401
+ React.createElement(Text, { color: currentField === 'compactModelName' ? 'green' : 'white' },
402
+ currentField === 'compactModelName' ? '➣ ' : ' ',
403
+ "Model Name:"),
404
+ currentField === 'compactModelName' && isEditing && (React.createElement(Box, { marginLeft: 3 },
405
+ React.createElement(Text, { color: "cyan" },
406
+ compactModelName,
407
+ React.createElement(Text, { color: "white" }, "_")))),
408
+ (!isEditing || currentField !== 'compactModelName') && (React.createElement(Box, { marginLeft: 3 },
409
+ React.createElement(Text, { color: "gray" }, compactModelName || 'Not set')))))),
297
410
  React.createElement(Box, { flexDirection: "column" }, isEditing ? (React.createElement(React.Fragment, null,
298
411
  React.createElement(Alert, { variant: "info" }, "Editing mode: Type to filter models, \u2191\u2193 to select, Enter to confirm"))) : (React.createElement(React.Fragment, null,
299
412
  React.createElement(Alert, { variant: "info" }, "Use \u2191\u2193 to navigate, Enter to edit, M for manual input, Ctrl+S or Esc to save"))))));
@@ -46,9 +46,8 @@ export default function WelcomeScreen({ version = '1.0.0', onMenuSelect, }) {
46
46
  React.createElement(Text, { color: "gray", dimColor: true }, "Intelligent Command Line Assistant"),
47
47
  React.createElement(Text, { color: "magenta", dimColor: true },
48
48
  "Version ",
49
- version))),
50
- onMenuSelect && (React.createElement(Box, null,
51
- React.createElement(Menu, { options: menuOptions, onSelect: onMenuSelect, onSelectionChange: handleSelectionChange }))),
52
- React.createElement(Box, { justifyContent: "space-between" },
53
- React.createElement(Alert, { variant: 'info' }, infoText))));
49
+ version),
50
+ onMenuSelect && (React.createElement(Box, null,
51
+ React.createElement(Menu, { options: menuOptions, onSelect: onMenuSelect, onSelectionChange: handleSelectionChange }))),
52
+ React.createElement(Alert, { variant: 'info' }, infoText)))));
54
53
  }
@@ -1,4 +1,9 @@
1
1
  export type RequestMethod = 'chat' | 'responses';
2
+ export interface CompactModelConfig {
3
+ baseUrl: string;
4
+ apiKey: string;
5
+ modelName: string;
6
+ }
2
7
  export interface ApiConfig {
3
8
  baseUrl: string;
4
9
  apiKey: string;
@@ -6,6 +11,7 @@ export interface ApiConfig {
6
11
  advancedModel?: string;
7
12
  basicModel?: string;
8
13
  maxContextTokens?: number;
14
+ compactModel?: CompactModelConfig;
9
15
  }
10
16
  export interface MCPServer {
11
17
  url?: string;
@@ -1,7 +1,7 @@
1
1
  export interface CommandResult {
2
2
  success: boolean;
3
3
  message?: string;
4
- action?: 'clear' | 'resume' | 'info' | 'showMcpInfo' | 'goHome' | 'toggleYolo' | 'initProject';
4
+ action?: 'clear' | 'resume' | 'info' | 'showMcpInfo' | 'goHome' | 'toggleYolo' | 'initProject' | 'compact';
5
5
  prompt?: string;
6
6
  }
7
7
  export interface CommandHandler {
@@ -0,0 +1,2 @@
1
+ declare const _default: {};
2
+ export default _default;
@@ -0,0 +1,12 @@
1
+ import { registerCommand } from '../commandExecutor.js';
2
+ // Compact command handler - compress conversation history
3
+ registerCommand('compact', {
4
+ execute: () => {
5
+ return {
6
+ success: true,
7
+ action: 'compact',
8
+ message: 'Compressing conversation history...'
9
+ };
10
+ }
11
+ });
12
+ export default {};
@@ -0,0 +1,2 @@
1
+ declare const _default: {};
2
+ export default _default;
@@ -0,0 +1,29 @@
1
+ import { registerCommand } from '../commandExecutor.js';
2
+ import { vscodeConnection } from '../vscodeConnection.js';
3
+ // IDE connection command handler
4
+ registerCommand('ide', {
5
+ execute: async () => {
6
+ if (vscodeConnection.isConnected()) {
7
+ return {
8
+ success: true,
9
+ action: 'info',
10
+ message: 'Already connected to VSCode editor'
11
+ };
12
+ }
13
+ try {
14
+ await vscodeConnection.start();
15
+ return {
16
+ success: true,
17
+ action: 'info',
18
+ message: `VSCode connection server started on port ${vscodeConnection.getPort()}\nPlease connect from the Snow CLI extension in VSCode`
19
+ };
20
+ }
21
+ catch (error) {
22
+ return {
23
+ success: false,
24
+ message: error instanceof Error ? error.message : 'Failed to start IDE connection'
25
+ };
26
+ }
27
+ }
28
+ });
29
+ export default {};
@@ -0,0 +1,15 @@
1
+ import type { ChatMessage } from '../api/chat.js';
2
+ export interface CompressionResult {
3
+ summary: string;
4
+ usage: {
5
+ prompt_tokens: number;
6
+ completion_tokens: number;
7
+ total_tokens: number;
8
+ };
9
+ }
10
+ /**
11
+ * Compress conversation history using the compact model
12
+ * @param messages - Array of messages to compress
13
+ * @returns Compressed summary and token usage information
14
+ */
15
+ export declare function compressContext(messages: ChatMessage[]): Promise<CompressionResult>;
@@ -0,0 +1,69 @@
1
+ import OpenAI from 'openai';
2
+ import { getOpenAiConfig } from './apiConfig.js';
3
+ /**
4
+ * Compress conversation history using the compact model
5
+ * @param messages - Array of messages to compress
6
+ * @returns Compressed summary and token usage information
7
+ */
8
+ export async function compressContext(messages) {
9
+ const config = getOpenAiConfig();
10
+ // Check if compact model is configured
11
+ if (!config.compactModel || !config.compactModel.baseUrl || !config.compactModel.apiKey || !config.compactModel.modelName) {
12
+ throw new Error('Compact model not configured. Please configure it in Model Settings.');
13
+ }
14
+ // Create OpenAI client with compact model config
15
+ const client = new OpenAI({
16
+ apiKey: config.compactModel.apiKey,
17
+ baseURL: config.compactModel.baseUrl,
18
+ });
19
+ // Filter out system messages and create a conversation text
20
+ const conversationText = messages
21
+ .filter(msg => msg.role !== 'system')
22
+ .map(msg => {
23
+ const role = msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'Tool';
24
+ return `${role}: ${msg.content}`;
25
+ })
26
+ .join('\n\n');
27
+ // Create compression prompt
28
+ const compressionPrompt = `Please summarize the following conversation history in a concise way, preserving all important context, decisions, and key information. The summary should be detailed enough to continue the conversation seamlessly.
29
+
30
+ Conversation:
31
+ ${conversationText}
32
+
33
+ Summary:`;
34
+ try {
35
+ const response = await client.chat.completions.create({
36
+ model: config.compactModel.modelName,
37
+ messages: [
38
+ {
39
+ role: 'user',
40
+ content: compressionPrompt,
41
+ },
42
+ ]
43
+ });
44
+ const summary = response.choices[0]?.message?.content;
45
+ if (!summary) {
46
+ throw new Error('Failed to generate summary from compact model');
47
+ }
48
+ // Extract usage information
49
+ const usage = response.usage || {
50
+ prompt_tokens: 0,
51
+ completion_tokens: 0,
52
+ total_tokens: 0
53
+ };
54
+ return {
55
+ summary,
56
+ usage: {
57
+ prompt_tokens: usage.prompt_tokens,
58
+ completion_tokens: usage.completion_tokens,
59
+ total_tokens: usage.total_tokens
60
+ }
61
+ };
62
+ }
63
+ catch (error) {
64
+ if (error instanceof Error) {
65
+ throw new Error(`Context compression failed: ${error.message}`);
66
+ }
67
+ throw new Error('Unknown error occurred during context compression');
68
+ }
69
+ }
@@ -33,6 +33,14 @@ export declare function createMessageWithFileInstructions(content: string, files
33
33
  platform: string;
34
34
  shell: string;
35
35
  workingDirectory: string;
36
+ }, editorContext?: {
37
+ activeFile?: string;
38
+ selectedText?: string;
39
+ cursorPosition?: {
40
+ line: number;
41
+ character: number;
42
+ };
43
+ workspaceFolder?: string;
36
44
  }): string;
37
45
  /**
38
46
  * Get system information (OS, shell, working directory)
@@ -154,7 +154,7 @@ export async function parseAndValidateFileReferences(content) {
154
154
  /**
155
155
  * Create message with file read instructions for AI
156
156
  */
157
- export function createMessageWithFileInstructions(content, files, systemInfo) {
157
+ export function createMessageWithFileInstructions(content, files, systemInfo, editorContext) {
158
158
  const parts = [content];
159
159
  // Add system info if provided
160
160
  if (systemInfo) {
@@ -165,6 +165,25 @@ export function createMessageWithFileInstructions(content, files, systemInfo) {
165
165
  ];
166
166
  parts.push(systemInfoLines.join('\n'));
167
167
  }
168
+ // Add editor context if provided (from VSCode connection)
169
+ if (editorContext) {
170
+ const editorLines = [];
171
+ if (editorContext.workspaceFolder) {
172
+ editorLines.push(`└─ VSCode Workspace: ${editorContext.workspaceFolder}`);
173
+ }
174
+ if (editorContext.activeFile) {
175
+ editorLines.push(`└─ Active File: ${editorContext.activeFile}`);
176
+ }
177
+ if (editorContext.cursorPosition) {
178
+ editorLines.push(`└─ Cursor: Line ${editorContext.cursorPosition.line + 1}, Column ${editorContext.cursorPosition.character + 1}`);
179
+ }
180
+ if (editorContext.selectedText) {
181
+ editorLines.push(`└─ Selected Code:\n\`\`\`\n${editorContext.selectedText}\n\`\`\``);
182
+ }
183
+ if (editorLines.length > 0) {
184
+ parts.push(editorLines.join('\n'));
185
+ }
186
+ }
168
187
  // Add file instructions if provided
169
188
  if (files.length > 0) {
170
189
  const fileInstructions = files
@@ -0,0 +1,41 @@
1
+ interface EditorContext {
2
+ activeFile?: string;
3
+ selectedText?: string;
4
+ cursorPosition?: {
5
+ line: number;
6
+ character: number;
7
+ };
8
+ workspaceFolder?: string;
9
+ }
10
+ interface Diagnostic {
11
+ message: string;
12
+ severity: 'error' | 'warning' | 'info' | 'hint';
13
+ line: number;
14
+ character: number;
15
+ source?: string;
16
+ code?: string | number;
17
+ }
18
+ declare class VSCodeConnectionManager {
19
+ private server;
20
+ private client;
21
+ private port;
22
+ private editorContext;
23
+ private listeners;
24
+ start(): Promise<void>;
25
+ stop(): void;
26
+ isConnected(): boolean;
27
+ isServerRunning(): boolean;
28
+ getContext(): EditorContext;
29
+ onContextUpdate(listener: (context: EditorContext) => void): () => void;
30
+ private handleMessage;
31
+ private notifyListeners;
32
+ getPort(): number;
33
+ /**
34
+ * Request diagnostics for a specific file from VS Code
35
+ * @param filePath - The file path to get diagnostics for
36
+ * @returns Promise that resolves with diagnostics array
37
+ */
38
+ requestDiagnostics(filePath: string): Promise<Diagnostic[]>;
39
+ }
40
+ export declare const vscodeConnection: VSCodeConnectionManager;
41
+ export type { EditorContext, Diagnostic };
@@ -0,0 +1,155 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ class VSCodeConnectionManager {
3
+ constructor() {
4
+ Object.defineProperty(this, "server", {
5
+ enumerable: true,
6
+ configurable: true,
7
+ writable: true,
8
+ value: null
9
+ });
10
+ Object.defineProperty(this, "client", {
11
+ enumerable: true,
12
+ configurable: true,
13
+ writable: true,
14
+ value: null
15
+ });
16
+ Object.defineProperty(this, "port", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: 9527
21
+ });
22
+ Object.defineProperty(this, "editorContext", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: {}
27
+ });
28
+ Object.defineProperty(this, "listeners", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: []
33
+ });
34
+ }
35
+ async start() {
36
+ // If already running, just return success
37
+ if (this.server) {
38
+ return Promise.resolve();
39
+ }
40
+ return new Promise((resolve, reject) => {
41
+ try {
42
+ this.server = new WebSocketServer({ port: this.port });
43
+ this.server.on('connection', (ws) => {
44
+ this.client = ws;
45
+ ws.on('message', (message) => {
46
+ try {
47
+ const data = JSON.parse(message.toString());
48
+ this.handleMessage(data);
49
+ }
50
+ catch (error) {
51
+ // Ignore invalid JSON
52
+ }
53
+ });
54
+ ws.on('close', () => {
55
+ this.client = null;
56
+ });
57
+ });
58
+ this.server.on('listening', () => {
59
+ resolve();
60
+ });
61
+ this.server.on('error', (error) => {
62
+ reject(error);
63
+ });
64
+ }
65
+ catch (error) {
66
+ reject(error);
67
+ }
68
+ });
69
+ }
70
+ stop() {
71
+ if (this.client) {
72
+ this.client.close();
73
+ this.client = null;
74
+ }
75
+ if (this.server) {
76
+ this.server.close();
77
+ this.server = null;
78
+ }
79
+ }
80
+ isConnected() {
81
+ return this.client !== null && this.client.readyState === WebSocket.OPEN;
82
+ }
83
+ isServerRunning() {
84
+ return this.server !== null;
85
+ }
86
+ getContext() {
87
+ return { ...this.editorContext };
88
+ }
89
+ onContextUpdate(listener) {
90
+ this.listeners.push(listener);
91
+ return () => {
92
+ this.listeners = this.listeners.filter((l) => l !== listener);
93
+ };
94
+ }
95
+ handleMessage(data) {
96
+ if (data.type === 'context') {
97
+ this.editorContext = {
98
+ activeFile: data.activeFile,
99
+ selectedText: data.selectedText,
100
+ cursorPosition: data.cursorPosition,
101
+ workspaceFolder: data.workspaceFolder
102
+ };
103
+ this.notifyListeners();
104
+ }
105
+ }
106
+ notifyListeners() {
107
+ for (const listener of this.listeners) {
108
+ listener(this.editorContext);
109
+ }
110
+ }
111
+ getPort() {
112
+ return this.port;
113
+ }
114
+ /**
115
+ * Request diagnostics for a specific file from VS Code
116
+ * @param filePath - The file path to get diagnostics for
117
+ * @returns Promise that resolves with diagnostics array
118
+ */
119
+ async requestDiagnostics(filePath) {
120
+ return new Promise((resolve) => {
121
+ if (!this.client || this.client.readyState !== WebSocket.OPEN) {
122
+ resolve([]); // Return empty array if not connected
123
+ return;
124
+ }
125
+ const requestId = Math.random().toString(36).substring(7);
126
+ const timeout = setTimeout(() => {
127
+ cleanup();
128
+ resolve([]); // Timeout, return empty array
129
+ }, 5000); // 5 second timeout
130
+ const handler = (message) => {
131
+ try {
132
+ const data = JSON.parse(message.toString());
133
+ if (data.type === 'diagnostics' && data.requestId === requestId) {
134
+ cleanup();
135
+ resolve(data.diagnostics || []);
136
+ }
137
+ }
138
+ catch (error) {
139
+ // Ignore invalid JSON
140
+ }
141
+ };
142
+ const cleanup = () => {
143
+ clearTimeout(timeout);
144
+ this.client?.removeListener('message', handler);
145
+ };
146
+ this.client.on('message', handler);
147
+ this.client.send(JSON.stringify({
148
+ type: 'getDiagnostics',
149
+ requestId,
150
+ filePath
151
+ }));
152
+ });
153
+ }
154
+ }
155
+ export const vscodeConnection = new VSCodeConnectionManager();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snow-ai",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Intelligent Command Line Assistant powered by AI",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -56,13 +56,15 @@
56
56
  "openai": "^6.1.0",
57
57
  "react": "^18.2.0",
58
58
  "string-width": "^7.2.0",
59
- "tiktoken": "^1.0.22"
59
+ "tiktoken": "^1.0.22",
60
+ "ws": "^8.14.2"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@sindresorhus/tsconfig": "^3.0.1",
63
64
  "@types/diff": "^7.0.2",
64
65
  "@types/figlet": "^1.7.0",
65
66
  "@types/react": "^18.0.32",
67
+ "@types/ws": "^8.5.8",
66
68
  "@vdemedes/prettier-config": "^2.0.1",
67
69
  "ava": "^5.2.0",
68
70
  "chalk": "^5.2.0",