snow-ai 0.1.12 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/chat.d.ts +65 -2
- package/dist/api/chat.js +299 -16
- package/dist/api/responses.d.ts +52 -0
- package/dist/api/responses.js +541 -0
- package/dist/api/systemPrompt.d.ts +4 -0
- package/dist/api/systemPrompt.js +43 -0
- package/dist/app.js +15 -4
- package/dist/cli.js +17 -1
- package/dist/hooks/useConversation.d.ts +32 -0
- package/dist/hooks/useConversation.js +403 -0
- package/dist/hooks/useGlobalNavigation.d.ts +6 -0
- package/dist/hooks/useGlobalNavigation.js +15 -0
- package/dist/hooks/useSessionManagement.d.ts +10 -0
- package/dist/hooks/useSessionManagement.js +43 -0
- package/dist/hooks/useSessionSave.d.ts +8 -0
- package/dist/hooks/useSessionSave.js +52 -0
- package/dist/hooks/useToolConfirmation.d.ts +18 -0
- package/dist/hooks/useToolConfirmation.js +49 -0
- package/dist/mcp/bash.d.ts +57 -0
- package/dist/mcp/bash.js +138 -0
- package/dist/mcp/filesystem.d.ts +307 -0
- package/dist/mcp/filesystem.js +520 -0
- package/dist/mcp/todo.d.ts +55 -0
- package/dist/mcp/todo.js +329 -0
- package/dist/test/logger-test.d.ts +1 -0
- package/dist/test/logger-test.js +7 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/ui/components/ChatInput.d.ts +15 -2
- package/dist/ui/components/ChatInput.js +445 -59
- package/dist/ui/components/CommandPanel.d.ts +2 -2
- package/dist/ui/components/CommandPanel.js +11 -7
- package/dist/ui/components/DiffViewer.d.ts +9 -0
- package/dist/ui/components/DiffViewer.js +93 -0
- package/dist/ui/components/FileList.d.ts +14 -0
- package/dist/ui/components/FileList.js +131 -0
- package/dist/ui/components/MCPInfoPanel.d.ts +2 -0
- package/dist/ui/components/MCPInfoPanel.js +74 -0
- package/dist/ui/components/MCPInfoScreen.d.ts +7 -0
- package/dist/ui/components/MCPInfoScreen.js +27 -0
- package/dist/ui/components/MarkdownRenderer.d.ts +7 -0
- package/dist/ui/components/MarkdownRenderer.js +110 -0
- package/dist/ui/components/Menu.d.ts +5 -2
- package/dist/ui/components/Menu.js +60 -9
- package/dist/ui/components/MessageList.d.ts +30 -2
- package/dist/ui/components/MessageList.js +64 -12
- package/dist/ui/components/PendingMessages.js +1 -1
- package/dist/ui/components/ScrollableSelectInput.d.ts +29 -0
- package/dist/ui/components/ScrollableSelectInput.js +157 -0
- package/dist/ui/components/SessionListScreen.d.ts +7 -0
- package/dist/ui/components/SessionListScreen.js +196 -0
- package/dist/ui/components/SessionListScreenWrapper.d.ts +7 -0
- package/dist/ui/components/SessionListScreenWrapper.js +14 -0
- package/dist/ui/components/TodoTree.d.ts +15 -0
- package/dist/ui/components/TodoTree.js +60 -0
- package/dist/ui/components/ToolConfirmation.d.ts +8 -0
- package/dist/ui/components/ToolConfirmation.js +38 -0
- package/dist/ui/components/ToolResultPreview.d.ts +12 -0
- package/dist/ui/components/ToolResultPreview.js +115 -0
- package/dist/ui/pages/ChatScreen.d.ts +4 -0
- package/dist/ui/pages/ChatScreen.js +385 -196
- package/dist/ui/pages/MCPConfigScreen.d.ts +6 -0
- package/dist/ui/pages/MCPConfigScreen.js +55 -0
- package/dist/ui/pages/ModelConfigScreen.js +73 -12
- package/dist/ui/pages/WelcomeScreen.js +17 -11
- package/dist/utils/apiConfig.d.ts +12 -0
- package/dist/utils/apiConfig.js +95 -9
- package/dist/utils/commandExecutor.d.ts +2 -1
- package/dist/utils/commands/init.d.ts +2 -0
- package/dist/utils/commands/init.js +93 -0
- package/dist/utils/commands/mcp.d.ts +2 -0
- package/dist/utils/commands/mcp.js +12 -0
- package/dist/utils/commands/resume.d.ts +2 -0
- package/dist/utils/commands/resume.js +12 -0
- package/dist/utils/commands/yolo.d.ts +2 -0
- package/dist/utils/commands/yolo.js +12 -0
- package/dist/utils/fileUtils.d.ts +44 -0
- package/dist/utils/fileUtils.js +222 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/logger.d.ts +31 -0
- package/dist/utils/logger.js +97 -0
- package/dist/utils/mcpToolsManager.d.ts +47 -0
- package/dist/utils/mcpToolsManager.js +476 -0
- package/dist/utils/messageFormatter.d.ts +12 -0
- package/dist/utils/messageFormatter.js +32 -0
- package/dist/utils/sessionConverter.d.ts +6 -0
- package/dist/utils/sessionConverter.js +61 -0
- package/dist/utils/sessionManager.d.ts +39 -0
- package/dist/utils/sessionManager.js +141 -0
- package/dist/utils/textBuffer.d.ts +36 -7
- package/dist/utils/textBuffer.js +265 -179
- package/dist/utils/todoPreprocessor.d.ts +5 -0
- package/dist/utils/todoPreprocessor.js +19 -0
- package/dist/utils/toolExecutor.d.ts +21 -0
- package/dist/utils/toolExecutor.js +28 -0
- package/package.json +12 -3
- package/readme.md +2 -2
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
2
|
import { Box, Text, useStdout, useInput } from 'ink';
|
|
3
3
|
import { TextBuffer } from '../../utils/textBuffer.js';
|
|
4
|
-
import { cpSlice } from '../../utils/textUtils.js';
|
|
4
|
+
import { cpSlice, cpLen } from '../../utils/textUtils.js';
|
|
5
5
|
import CommandPanel from './CommandPanel.js';
|
|
6
6
|
import { executeCommand } from '../../utils/commandExecutor.js';
|
|
7
|
+
import FileList from './FileList.js';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
7
9
|
// Command Definition
|
|
8
10
|
const commands = [
|
|
9
11
|
{ name: 'clear', description: 'Clear chat context and conversation history' },
|
|
10
|
-
{ name: '
|
|
12
|
+
{ name: 'resume', description: 'Resume a conversation' },
|
|
13
|
+
{ name: 'mcp', description: 'Show Model Context Protocol services and tools' },
|
|
14
|
+
{ name: 'yolo', description: 'Toggle unattended mode (auto-approve all tools)' },
|
|
15
|
+
{ name: 'init', description: 'Analyze project and generate/update SNOW.md documentation' }
|
|
11
16
|
];
|
|
12
|
-
export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type your message...', disabled = false }) {
|
|
17
|
+
export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type your message...', disabled = false, chatHistory = [], onHistorySelect, yoloMode = false, contextUsage }) {
|
|
13
18
|
const { stdout } = useStdout();
|
|
14
19
|
const terminalWidth = stdout?.columns || 80;
|
|
15
20
|
const uiOverhead = 8;
|
|
@@ -23,6 +28,32 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
23
28
|
// Command panel state
|
|
24
29
|
const [showCommands, setShowCommands] = useState(false);
|
|
25
30
|
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
|
31
|
+
// File picker state
|
|
32
|
+
const [showFilePicker, setShowFilePicker] = useState(false);
|
|
33
|
+
const [fileSelectedIndex, setFileSelectedIndex] = useState(0);
|
|
34
|
+
const [fileQuery, setFileQuery] = useState('');
|
|
35
|
+
const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
|
|
36
|
+
const [filteredFileCount, setFilteredFileCount] = useState(0);
|
|
37
|
+
// Refs
|
|
38
|
+
const fileListRef = useRef(null);
|
|
39
|
+
// History navigation state
|
|
40
|
+
const [showHistoryMenu, setShowHistoryMenu] = useState(false);
|
|
41
|
+
const [historySelectedIndex, setHistorySelectedIndex] = useState(0);
|
|
42
|
+
const [escapeKeyCount, setEscapeKeyCount] = useState(0);
|
|
43
|
+
const escapeKeyTimer = useRef(null);
|
|
44
|
+
// Get user messages from chat history for navigation
|
|
45
|
+
const getUserMessages = useCallback(() => {
|
|
46
|
+
const userMessages = chatHistory
|
|
47
|
+
.map((msg, index) => ({ ...msg, originalIndex: index }))
|
|
48
|
+
.filter(msg => msg.role === 'user' && msg.content.trim());
|
|
49
|
+
// Keep original order (oldest first, newest last) and map with display numbers
|
|
50
|
+
return userMessages
|
|
51
|
+
.map((msg, index) => ({
|
|
52
|
+
label: `${index + 1}. ${msg.content.slice(0, 50)}${msg.content.length > 50 ? '...' : ''}`,
|
|
53
|
+
value: msg.originalIndex.toString(),
|
|
54
|
+
infoText: msg.content
|
|
55
|
+
}));
|
|
56
|
+
}, [chatHistory]);
|
|
26
57
|
// Get filtered commands based on current input
|
|
27
58
|
const getFilteredCommands = useCallback(() => {
|
|
28
59
|
const text = buffer.getFullText();
|
|
@@ -43,15 +74,92 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
43
74
|
setCommandSelectedIndex(0);
|
|
44
75
|
}
|
|
45
76
|
}, []);
|
|
77
|
+
// Update file picker state
|
|
78
|
+
const updateFilePickerState = useCallback((text, cursorPos) => {
|
|
79
|
+
if (!text.includes('@')) {
|
|
80
|
+
if (showFilePicker) {
|
|
81
|
+
setShowFilePicker(false);
|
|
82
|
+
setFileSelectedIndex(0);
|
|
83
|
+
setFileQuery('');
|
|
84
|
+
setAtSymbolPosition(-1);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Find the last '@' symbol before the cursor
|
|
89
|
+
const beforeCursor = text.slice(0, cursorPos);
|
|
90
|
+
const lastAtIndex = beforeCursor.lastIndexOf('@');
|
|
91
|
+
if (lastAtIndex !== -1) {
|
|
92
|
+
// Check if there's no space between '@' and cursor
|
|
93
|
+
const afterAt = beforeCursor.slice(lastAtIndex + 1);
|
|
94
|
+
if (!afterAt.includes(' ') && !afterAt.includes('\n')) {
|
|
95
|
+
if (!showFilePicker || fileQuery !== afterAt || atSymbolPosition !== lastAtIndex) {
|
|
96
|
+
setShowFilePicker(true);
|
|
97
|
+
setFileSelectedIndex(0);
|
|
98
|
+
setFileQuery(afterAt);
|
|
99
|
+
setAtSymbolPosition(lastAtIndex);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Hide file picker if no valid @ context found
|
|
105
|
+
if (showFilePicker) {
|
|
106
|
+
setShowFilePicker(false);
|
|
107
|
+
setFileSelectedIndex(0);
|
|
108
|
+
setFileQuery('');
|
|
109
|
+
setAtSymbolPosition(-1);
|
|
110
|
+
}
|
|
111
|
+
}, [showFilePicker, fileQuery, atSymbolPosition]);
|
|
112
|
+
// Force immediate state update for critical operations like backspace
|
|
113
|
+
const forceStateUpdate = useCallback(() => {
|
|
114
|
+
const text = buffer.getFullText();
|
|
115
|
+
const cursorPos = buffer.getCursorPosition();
|
|
116
|
+
updateFilePickerState(text, cursorPos);
|
|
117
|
+
updateCommandPanelState(text);
|
|
118
|
+
forceUpdate({});
|
|
119
|
+
}, [buffer, updateFilePickerState, updateCommandPanelState]);
|
|
46
120
|
// Force re-render when buffer changes
|
|
47
121
|
const triggerUpdate = useCallback(() => {
|
|
48
122
|
const now = Date.now();
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
123
|
+
lastUpdateTime.current = now;
|
|
124
|
+
forceUpdate({});
|
|
125
|
+
}, []);
|
|
126
|
+
// Handle file selection
|
|
127
|
+
const handleFileSelect = useCallback(async (filePath) => {
|
|
128
|
+
if (atSymbolPosition !== -1) {
|
|
129
|
+
const text = buffer.getFullText();
|
|
130
|
+
const cursorPos = buffer.getCursorPosition();
|
|
131
|
+
// Replace @query with @filePath + space
|
|
132
|
+
const beforeAt = text.slice(0, atSymbolPosition);
|
|
133
|
+
const afterCursor = text.slice(cursorPos);
|
|
134
|
+
const newText = beforeAt + '@' + filePath + ' ' + afterCursor;
|
|
135
|
+
// Set the new text and position cursor after the inserted file path + space
|
|
136
|
+
buffer.setText(newText);
|
|
137
|
+
// Calculate cursor position after the inserted file path + space
|
|
138
|
+
// Reset cursor to beginning, then move to correct position
|
|
139
|
+
for (let i = 0; i < atSymbolPosition + filePath.length + 2; i++) { // +2 for @ and space
|
|
140
|
+
if (i < buffer.getFullText().length) {
|
|
141
|
+
buffer.moveRight();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
setShowFilePicker(false);
|
|
145
|
+
setFileSelectedIndex(0);
|
|
146
|
+
setFileQuery('');
|
|
147
|
+
setAtSymbolPosition(-1);
|
|
148
|
+
triggerUpdate();
|
|
53
149
|
}
|
|
150
|
+
}, [atSymbolPosition, buffer, triggerUpdate]);
|
|
151
|
+
// Handle filtered file count change
|
|
152
|
+
const handleFilteredCountChange = useCallback((count) => {
|
|
153
|
+
setFilteredFileCount(count);
|
|
54
154
|
}, []);
|
|
155
|
+
// Force full re-render when file picker visibility changes to prevent artifacts
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
// Use a small delay to ensure the component tree has updated
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
forceUpdate({});
|
|
160
|
+
}, 10);
|
|
161
|
+
return () => clearTimeout(timer);
|
|
162
|
+
}, [showFilePicker]);
|
|
55
163
|
// Update buffer viewport when terminal width changes
|
|
56
164
|
useEffect(() => {
|
|
57
165
|
const newViewport = {
|
|
@@ -61,18 +169,183 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
61
169
|
buffer.updateViewport(newViewport);
|
|
62
170
|
triggerUpdate();
|
|
63
171
|
}, [terminalWidth, buffer, triggerUpdate]);
|
|
172
|
+
// Track paste detection
|
|
173
|
+
const inputBuffer = useRef('');
|
|
174
|
+
const inputTimer = useRef(null);
|
|
64
175
|
// Handle input using useInput hook instead of raw stdin
|
|
65
176
|
useInput((input, key) => {
|
|
66
177
|
if (disabled)
|
|
67
178
|
return;
|
|
179
|
+
// Debug: Log key presses
|
|
180
|
+
// console.error('Input:', JSON.stringify(input), 'Key:', JSON.stringify(key));
|
|
181
|
+
// Handle escape key for double-ESC history navigation
|
|
182
|
+
if (key.escape) {
|
|
183
|
+
// Close file picker if open
|
|
184
|
+
if (showFilePicker) {
|
|
185
|
+
setShowFilePicker(false);
|
|
186
|
+
setFileSelectedIndex(0);
|
|
187
|
+
setFileQuery('');
|
|
188
|
+
setAtSymbolPosition(-1);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Don't interfere with existing ESC behavior if in command panel
|
|
192
|
+
if (showCommands) {
|
|
193
|
+
setShowCommands(false);
|
|
194
|
+
setCommandSelectedIndex(0);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Handle history navigation
|
|
198
|
+
if (showHistoryMenu) {
|
|
199
|
+
setShowHistoryMenu(false);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Count escape key presses for double-ESC detection
|
|
203
|
+
setEscapeKeyCount(prev => prev + 1);
|
|
204
|
+
// Clear any existing timer
|
|
205
|
+
if (escapeKeyTimer.current) {
|
|
206
|
+
clearTimeout(escapeKeyTimer.current);
|
|
207
|
+
}
|
|
208
|
+
// Set timer to reset count after 500ms
|
|
209
|
+
escapeKeyTimer.current = setTimeout(() => {
|
|
210
|
+
setEscapeKeyCount(0);
|
|
211
|
+
}, 500);
|
|
212
|
+
// Check for double escape
|
|
213
|
+
if (escapeKeyCount >= 1) { // This will be 2 after increment
|
|
214
|
+
const userMessages = getUserMessages();
|
|
215
|
+
if (userMessages.length > 0) {
|
|
216
|
+
setShowHistoryMenu(true);
|
|
217
|
+
setHistorySelectedIndex(0); // Reset selection to first item
|
|
218
|
+
setEscapeKeyCount(0);
|
|
219
|
+
if (escapeKeyTimer.current) {
|
|
220
|
+
clearTimeout(escapeKeyTimer.current);
|
|
221
|
+
escapeKeyTimer.current = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Handle history menu navigation
|
|
228
|
+
if (showHistoryMenu) {
|
|
229
|
+
const userMessages = getUserMessages();
|
|
230
|
+
// Up arrow in history menu
|
|
231
|
+
if (key.upArrow) {
|
|
232
|
+
setHistorySelectedIndex(prev => Math.max(0, prev - 1));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// Down arrow in history menu
|
|
236
|
+
if (key.downArrow) {
|
|
237
|
+
const maxIndex = Math.max(0, userMessages.length - 1);
|
|
238
|
+
setHistorySelectedIndex(prev => Math.min(maxIndex, prev + 1));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Enter - select history item
|
|
242
|
+
if (key.return) {
|
|
243
|
+
if (userMessages.length > 0 && historySelectedIndex < userMessages.length) {
|
|
244
|
+
const selectedMessage = userMessages[historySelectedIndex];
|
|
245
|
+
if (selectedMessage) {
|
|
246
|
+
handleHistorySelect(selectedMessage.value);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// For any other key in history menu, just return to prevent interference
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// Alt+V / Option+V - Paste from clipboard (including images)
|
|
255
|
+
if (key.meta && input === 'v') {
|
|
256
|
+
try {
|
|
257
|
+
// Try to read image from clipboard using PowerShell (Windows)
|
|
258
|
+
if (process.platform === 'win32') {
|
|
259
|
+
try {
|
|
260
|
+
const psScript = `Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing; $clipboard = [System.Windows.Forms.Clipboard]::GetImage(); if ($clipboard -ne $null) { $ms = New-Object System.IO.MemoryStream; $clipboard.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); $bytes = $ms.ToArray(); $ms.Close(); [Convert]::ToBase64String($bytes) }`;
|
|
261
|
+
const base64 = execSync(`powershell -Command "${psScript}"`, {
|
|
262
|
+
encoding: 'utf-8',
|
|
263
|
+
timeout: 5000
|
|
264
|
+
}).trim();
|
|
265
|
+
if (base64 && base64.length > 100) {
|
|
266
|
+
const dataUrl = `data:image/png;base64,${base64}`;
|
|
267
|
+
buffer.insertImage(dataUrl, 'image/png');
|
|
268
|
+
const text = buffer.getFullText();
|
|
269
|
+
const cursorPos = buffer.getCursorPosition();
|
|
270
|
+
updateCommandPanelState(text);
|
|
271
|
+
updateFilePickerState(text, cursorPos);
|
|
272
|
+
triggerUpdate();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (imgError) {
|
|
277
|
+
// No image in clipboard or error, fall through to text
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// If no image, try to read text from clipboard
|
|
281
|
+
try {
|
|
282
|
+
let clipboardText = '';
|
|
283
|
+
if (process.platform === 'win32') {
|
|
284
|
+
clipboardText = execSync('powershell -Command "Get-Clipboard"', {
|
|
285
|
+
encoding: 'utf-8',
|
|
286
|
+
timeout: 2000
|
|
287
|
+
}).trim();
|
|
288
|
+
}
|
|
289
|
+
else if (process.platform === 'darwin') {
|
|
290
|
+
clipboardText = execSync('pbpaste', {
|
|
291
|
+
encoding: 'utf-8',
|
|
292
|
+
timeout: 2000
|
|
293
|
+
}).trim();
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
clipboardText = execSync('xclip -selection clipboard -o', {
|
|
297
|
+
encoding: 'utf-8',
|
|
298
|
+
timeout: 2000
|
|
299
|
+
}).trim();
|
|
300
|
+
}
|
|
301
|
+
if (clipboardText) {
|
|
302
|
+
buffer.insert(clipboardText);
|
|
303
|
+
const fullText = buffer.getFullText();
|
|
304
|
+
const cursorPos = buffer.getCursorPosition();
|
|
305
|
+
updateCommandPanelState(fullText);
|
|
306
|
+
updateFilePickerState(fullText, cursorPos);
|
|
307
|
+
triggerUpdate();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (textError) {
|
|
311
|
+
console.error('Failed to read text from clipboard:', textError);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
console.error('Failed to read from clipboard:', error);
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
68
319
|
// Backspace
|
|
69
320
|
if (key.backspace || key.delete) {
|
|
70
321
|
buffer.backspace();
|
|
71
|
-
|
|
72
|
-
updateCommandPanelState(text);
|
|
73
|
-
triggerUpdate();
|
|
322
|
+
forceStateUpdate();
|
|
74
323
|
return;
|
|
75
324
|
}
|
|
325
|
+
// Handle file picker navigation
|
|
326
|
+
if (showFilePicker) {
|
|
327
|
+
// Up arrow in file picker
|
|
328
|
+
if (key.upArrow) {
|
|
329
|
+
setFileSelectedIndex(prev => Math.max(0, prev - 1));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// Down arrow in file picker
|
|
333
|
+
if (key.downArrow) {
|
|
334
|
+
const maxIndex = Math.max(0, filteredFileCount - 1);
|
|
335
|
+
setFileSelectedIndex(prev => Math.min(maxIndex, prev + 1));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
// Tab or Enter - select file
|
|
339
|
+
if (key.tab || key.return) {
|
|
340
|
+
if (filteredFileCount > 0 && fileSelectedIndex < filteredFileCount) {
|
|
341
|
+
const selectedFile = fileListRef.current?.getSelectedFile();
|
|
342
|
+
if (selectedFile) {
|
|
343
|
+
handleFileSelect(selectedFile);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
76
349
|
// Handle command panel navigation
|
|
77
350
|
if (showCommands) {
|
|
78
351
|
const filteredCommands = getFilteredCommands();
|
|
@@ -112,95 +385,208 @@ export default function ChatInput({ onSubmit, onCommand, placeholder = 'Type you
|
|
|
112
385
|
if (key.return) {
|
|
113
386
|
const message = buffer.getFullText().trim();
|
|
114
387
|
if (message) {
|
|
388
|
+
// 获取图片数据
|
|
389
|
+
const images = buffer.getImages().map(img => ({
|
|
390
|
+
data: img.data,
|
|
391
|
+
mimeType: img.mimeType
|
|
392
|
+
}));
|
|
115
393
|
buffer.setText('');
|
|
116
394
|
forceUpdate({});
|
|
117
|
-
onSubmit(message);
|
|
395
|
+
onSubmit(message, images.length > 0 ? images : undefined);
|
|
118
396
|
}
|
|
119
397
|
return;
|
|
120
398
|
}
|
|
121
399
|
// Arrow keys for cursor movement
|
|
122
400
|
if (key.leftArrow) {
|
|
123
401
|
buffer.moveLeft();
|
|
402
|
+
const text = buffer.getFullText();
|
|
403
|
+
const cursorPos = buffer.getCursorPosition();
|
|
404
|
+
updateFilePickerState(text, cursorPos);
|
|
124
405
|
triggerUpdate();
|
|
125
406
|
return;
|
|
126
407
|
}
|
|
127
408
|
if (key.rightArrow) {
|
|
128
409
|
buffer.moveRight();
|
|
410
|
+
const text = buffer.getFullText();
|
|
411
|
+
const cursorPos = buffer.getCursorPosition();
|
|
412
|
+
updateFilePickerState(text, cursorPos);
|
|
129
413
|
triggerUpdate();
|
|
130
414
|
return;
|
|
131
415
|
}
|
|
132
|
-
if (key.upArrow && !showCommands) {
|
|
416
|
+
if (key.upArrow && !showCommands && !showFilePicker) {
|
|
133
417
|
buffer.moveUp();
|
|
418
|
+
const text = buffer.getFullText();
|
|
419
|
+
const cursorPos = buffer.getCursorPosition();
|
|
420
|
+
updateFilePickerState(text, cursorPos);
|
|
134
421
|
triggerUpdate();
|
|
135
422
|
return;
|
|
136
423
|
}
|
|
137
|
-
if (key.downArrow && !showCommands) {
|
|
424
|
+
if (key.downArrow && !showCommands && !showFilePicker) {
|
|
138
425
|
buffer.moveDown();
|
|
426
|
+
const text = buffer.getFullText();
|
|
427
|
+
const cursorPos = buffer.getCursorPosition();
|
|
428
|
+
updateFilePickerState(text, cursorPos);
|
|
139
429
|
triggerUpdate();
|
|
140
430
|
return;
|
|
141
431
|
}
|
|
142
432
|
// Regular character input
|
|
143
433
|
if (input && !key.ctrl && !key.meta && !key.escape) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// Let TextBuffer handle the paste processing
|
|
158
|
-
buffer.insert(pastedText);
|
|
434
|
+
// Accumulate input for paste detection
|
|
435
|
+
inputBuffer.current += input;
|
|
436
|
+
// Clear existing timer
|
|
437
|
+
if (inputTimer.current) {
|
|
438
|
+
clearTimeout(inputTimer.current);
|
|
439
|
+
}
|
|
440
|
+
// Set timer to process accumulated input
|
|
441
|
+
inputTimer.current = setTimeout(() => {
|
|
442
|
+
const accumulated = inputBuffer.current;
|
|
443
|
+
inputBuffer.current = '';
|
|
444
|
+
// If we accumulated input, it's likely a paste
|
|
445
|
+
if (accumulated) {
|
|
446
|
+
buffer.insert(accumulated);
|
|
159
447
|
const text = buffer.getFullText();
|
|
448
|
+
const cursorPos = buffer.getCursorPosition();
|
|
160
449
|
updateCommandPanelState(text);
|
|
450
|
+
updateFilePickerState(text, cursorPos);
|
|
161
451
|
triggerUpdate();
|
|
162
452
|
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
453
|
+
}, 10); // Short delay to accumulate rapid input
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
// Handle history selection
|
|
457
|
+
const handleHistorySelect = useCallback((value) => {
|
|
458
|
+
const selectedIndex = parseInt(value, 10);
|
|
459
|
+
const selectedMessage = chatHistory[selectedIndex];
|
|
460
|
+
if (selectedMessage && onHistorySelect) {
|
|
461
|
+
// Put the message content in the input buffer
|
|
462
|
+
buffer.setText(selectedMessage.content);
|
|
463
|
+
setShowHistoryMenu(false);
|
|
464
|
+
triggerUpdate();
|
|
465
|
+
onHistorySelect(selectedIndex, selectedMessage.content);
|
|
466
|
+
}
|
|
467
|
+
}, [chatHistory, onHistorySelect, buffer, triggerUpdate]);
|
|
175
468
|
// Render content with cursor and paste placeholders
|
|
176
469
|
const renderContent = useCallback(() => {
|
|
177
470
|
if (buffer.text.length > 0) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
471
|
+
// 使用buffer的内部文本而不是getFullText(),这样可以显示占位符
|
|
472
|
+
const displayText = buffer.text;
|
|
473
|
+
const cursorPos = buffer.getCursorPosition();
|
|
474
|
+
// 检查是否包含粘贴占位符或图片占位符并高亮显示
|
|
475
|
+
const hasPastePlaceholder = displayText.includes('[Paste ') && displayText.includes(' characters #');
|
|
476
|
+
const hasImagePlaceholder = displayText.includes('[image #');
|
|
477
|
+
if (hasPastePlaceholder || hasImagePlaceholder) {
|
|
478
|
+
const atCursor = (() => {
|
|
479
|
+
const charInfo = buffer.getCharAtCursor();
|
|
480
|
+
return charInfo.char === '\n' ? ' ' : charInfo.char;
|
|
481
|
+
})();
|
|
482
|
+
// 分割文本并高亮占位符(粘贴和图片)
|
|
483
|
+
const parts = displayText.split(/(\[Paste \d+ characters #\d+\]|\[image #\d+\])/);
|
|
484
|
+
let processedLength = 0;
|
|
485
|
+
let cursorRendered = false;
|
|
486
|
+
const elements = parts.map((part, partIndex) => {
|
|
487
|
+
const isPastePlaceholder = part.match(/^\[Paste \d+ characters #\d+\]$/);
|
|
488
|
+
const isImagePlaceholder = part.match(/^\[image #\d+\]$/);
|
|
489
|
+
const isPlaceholder = isPastePlaceholder || isImagePlaceholder;
|
|
490
|
+
const partStart = processedLength;
|
|
491
|
+
const partEnd = processedLength + cpLen(part);
|
|
492
|
+
processedLength = partEnd;
|
|
493
|
+
// 检查光标是否在这个部分
|
|
494
|
+
if (cursorPos >= partStart && cursorPos < partEnd) {
|
|
495
|
+
cursorRendered = true;
|
|
496
|
+
const beforeCursorInPart = cpSlice(part, 0, cursorPos - partStart);
|
|
497
|
+
const afterCursorInPart = cpSlice(part, cursorPos - partStart + 1);
|
|
498
|
+
return (React.createElement(React.Fragment, { key: partIndex }, isPlaceholder ? (React.createElement(Text, { color: isImagePlaceholder ? "magenta" : "cyan", dimColor: true },
|
|
499
|
+
beforeCursorInPart,
|
|
500
|
+
React.createElement(Text, { backgroundColor: "white", color: "black" }, atCursor),
|
|
501
|
+
afterCursorInPart)) : (React.createElement(React.Fragment, null,
|
|
502
|
+
beforeCursorInPart,
|
|
503
|
+
React.createElement(Text, { backgroundColor: "white", color: "black" }, atCursor),
|
|
504
|
+
afterCursorInPart))));
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
return isPlaceholder ? (React.createElement(Text, { key: partIndex, color: isImagePlaceholder ? "magenta" : "cyan", dimColor: true }, part)) : (React.createElement(Text, { key: partIndex }, part));
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
return (React.createElement(Text, null,
|
|
511
|
+
elements,
|
|
512
|
+
!cursorRendered && (React.createElement(Text, { backgroundColor: "white", color: "black" }, ' '))));
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
// 普通文本渲染
|
|
516
|
+
return (React.createElement(Text, null,
|
|
517
|
+
cpSlice(displayText, 0, cursorPos),
|
|
181
518
|
React.createElement(Text, { backgroundColor: "white", color: "black" }, (() => {
|
|
182
519
|
const charInfo = buffer.getCharAtCursor();
|
|
183
520
|
return charInfo.char === '\n' ? ' ' : charInfo.char;
|
|
184
521
|
})()),
|
|
185
|
-
cpSlice(
|
|
186
|
-
|
|
187
|
-
line.includes('[Paste ') && line.includes(' line #') ? (React.createElement(Text, null, line.split(/(\[Paste \d+ line #\d+\])/).map((part, partIndex) => part.match(/^\[Paste \d+ line #\d+\]$/) ? (React.createElement(Text, { key: partIndex, color: "cyan", dimColor: true }, part)) : (React.createElement(Text, { key: partIndex }, part))))) : (line || ' '))))));
|
|
522
|
+
cpSlice(displayText, cursorPos + 1)));
|
|
523
|
+
}
|
|
188
524
|
}
|
|
189
525
|
else {
|
|
190
|
-
return (React.createElement(
|
|
526
|
+
return (React.createElement(React.Fragment, null,
|
|
191
527
|
React.createElement(Text, { backgroundColor: disabled ? "gray" : "white", color: disabled ? "darkGray" : "black" }, ' '),
|
|
192
528
|
React.createElement(Text, { color: disabled ? "darkGray" : "gray", dimColor: true }, disabled ? 'Waiting for response...' : placeholder)));
|
|
193
529
|
}
|
|
194
|
-
}, [
|
|
195
|
-
return (React.createElement(Box, { flexDirection: "column",
|
|
196
|
-
React.createElement(Box, { flexDirection: "
|
|
197
|
-
React.createElement(
|
|
198
|
-
"\
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
530
|
+
}, [buffer, disabled, placeholder]);
|
|
531
|
+
return (React.createElement(Box, { flexDirection: "column", marginX: 1, key: `input-${showFilePicker ? 'picker' : 'normal'}` },
|
|
532
|
+
showHistoryMenu && (React.createElement(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "#A9C13E", padding: 1 },
|
|
533
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
534
|
+
React.createElement(Text, { color: "cyan" }, "Use \u2191\u2193 keys to navigate, press Enter to select:")),
|
|
535
|
+
React.createElement(Box, { flexDirection: "column" },
|
|
536
|
+
(() => {
|
|
537
|
+
const userMessages = getUserMessages();
|
|
538
|
+
const maxHeight = 8;
|
|
539
|
+
const visibleMessages = userMessages.slice(0, maxHeight);
|
|
540
|
+
return visibleMessages.map((message, index) => (React.createElement(Box, { key: message.value },
|
|
541
|
+
React.createElement(Text, { color: index === historySelectedIndex ? 'green' : 'white', bold: true },
|
|
542
|
+
index === historySelectedIndex ? '➣ ' : ' ',
|
|
543
|
+
message.label))));
|
|
544
|
+
})(),
|
|
545
|
+
getUserMessages().length > 8 && (React.createElement(Box, null,
|
|
546
|
+
React.createElement(Text, { color: "gray", dimColor: true },
|
|
547
|
+
"... and ",
|
|
548
|
+
getUserMessages().length - 8,
|
|
549
|
+
" more items")))))),
|
|
550
|
+
!showHistoryMenu && (React.createElement(React.Fragment, null,
|
|
551
|
+
React.createElement(Box, { flexDirection: "row", borderStyle: "round", borderColor: "gray", paddingX: 1, paddingY: 0, flexGrow: 1 },
|
|
552
|
+
React.createElement(Text, { color: "cyan", bold: true },
|
|
553
|
+
"\u27A3",
|
|
554
|
+
' '),
|
|
555
|
+
React.createElement(Box, { flexGrow: 1 }, renderContent())),
|
|
556
|
+
React.createElement(CommandPanel, { commands: getFilteredCommands(), selectedIndex: commandSelectedIndex, query: buffer.getFullText().slice(1), visible: showCommands }),
|
|
557
|
+
React.createElement(Box, null,
|
|
558
|
+
React.createElement(FileList, { ref: fileListRef, query: fileQuery, selectedIndex: fileSelectedIndex, visible: showFilePicker, maxItems: 10, rootPath: process.cwd(), onFilteredCountChange: handleFilteredCountChange })),
|
|
559
|
+
yoloMode && (React.createElement(Box, { marginTop: 1, paddingX: 1 },
|
|
560
|
+
React.createElement(Text, { color: "yellow", dimColor: true }, "\u2741 YOLO MODE ACTIVE - All tools will be auto-approved without confirmation"))),
|
|
561
|
+
contextUsage && (React.createElement(Box, { marginTop: 1, paddingX: 1 },
|
|
562
|
+
React.createElement(Text, { color: "gray", dimColor: true }, (() => {
|
|
563
|
+
const percentage = Math.min(100, (contextUsage.inputTokens / contextUsage.maxContextTokens) * 100);
|
|
564
|
+
let color;
|
|
565
|
+
if (percentage < 50)
|
|
566
|
+
color = 'green';
|
|
567
|
+
else if (percentage < 75)
|
|
568
|
+
color = 'yellow';
|
|
569
|
+
else if (percentage < 90)
|
|
570
|
+
color = 'orange';
|
|
571
|
+
else
|
|
572
|
+
color = 'red';
|
|
573
|
+
const formatNumber = (num) => {
|
|
574
|
+
if (num >= 1000)
|
|
575
|
+
return `${(num / 1000).toFixed(1)}k`;
|
|
576
|
+
return num.toString();
|
|
577
|
+
};
|
|
578
|
+
return (React.createElement(React.Fragment, null,
|
|
579
|
+
React.createElement(Text, { color: color },
|
|
580
|
+
percentage.toFixed(1),
|
|
581
|
+
"%"),
|
|
582
|
+
React.createElement(Text, null, " \u00B7 "),
|
|
583
|
+
React.createElement(Text, { color: color }, formatNumber(contextUsage.inputTokens)),
|
|
584
|
+
React.createElement(Text, null, " tokens")));
|
|
585
|
+
})()))),
|
|
586
|
+
React.createElement(Box, { marginTop: 1 },
|
|
587
|
+
React.createElement(Text, { color: "gray", dimColor: true }, showCommands && getFilteredCommands().length > 0
|
|
588
|
+
? "Type to filter commands"
|
|
589
|
+
: showFilePicker
|
|
590
|
+
? "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"))))));
|
|
206
592
|
}
|
|
@@ -9,5 +9,5 @@ interface Props {
|
|
|
9
9
|
query: string;
|
|
10
10
|
visible: boolean;
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
export
|
|
12
|
+
declare const CommandPanel: React.MemoExoticComponent<({ commands, selectedIndex, query, visible }: Props) => React.JSX.Element | null>;
|
|
13
|
+
export default CommandPanel;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import React from 'react';
|
|
1
|
+
import React, { memo } from 'react';
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
|
|
3
|
+
const CommandPanel = memo(({ commands, selectedIndex, query, visible }) => {
|
|
4
4
|
// Don't show panel if not visible or no commands found
|
|
5
5
|
if (!visible || commands.length === 0) {
|
|
6
6
|
return null;
|
|
@@ -12,11 +12,15 @@ export default function CommandPanel({ commands, selectedIndex, query, visible }
|
|
|
12
12
|
React.createElement(Text, { color: "yellow", bold: true },
|
|
13
13
|
"Available Commands ",
|
|
14
14
|
query && `(${commands.length} matches)`)),
|
|
15
|
-
commands.map((command, index) => (React.createElement(Box, { key: command.name, flexDirection: "
|
|
16
|
-
React.createElement(Text, { color: index === selectedIndex ? "green" : "gray" },
|
|
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
17
|
index === selectedIndex ? "➣ " : " ",
|
|
18
18
|
"/",
|
|
19
19
|
command.name),
|
|
20
|
-
React.createElement(Box, { marginLeft:
|
|
21
|
-
React.createElement(Text, { color: index === selectedIndex ? "green" : "gray", dimColor: true },
|
|
22
|
-
|
|
20
|
+
React.createElement(Box, { marginLeft: 3 },
|
|
21
|
+
React.createElement(Text, { color: index === selectedIndex ? "green" : "gray", dimColor: true },
|
|
22
|
+
"\u2514\u2500 ",
|
|
23
|
+
command.description)))))))));
|
|
24
|
+
});
|
|
25
|
+
CommandPanel.displayName = 'CommandPanel';
|
|
26
|
+
export default CommandPanel;
|