@kishlay42/moth-ai 1.0.2 β 1.0.3
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/README.md +260 -14
- package/dist/agent/orchestrator.js +48 -4
- package/dist/index.js +24 -13
- package/dist/ui/App.js +214 -9
- package/dist/ui/components/CommandPalette.js +29 -0
- package/dist/ui/components/CustomTextInput.js +75 -0
- package/dist/ui/components/FileAutocomplete.js +16 -0
- package/dist/ui/components/FileChip.js +8 -0
- package/dist/utils/fileUtils.js +67 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,17 +1,85 @@
|
|
|
1
1
|
# π¦ Moth AI
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@kishlay42/moth-ai)
|
|
4
|
+
[](https://opensource.org/licenses/ISC)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
3
7
|
**The Intelligent, Local-First CLI Coding Assistant**
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
Moth AI is a powerful **terminal-native coding assistant** built for developers who value **privacy, speed, and control**. It lives inside your terminal, understands your project context, and helps you **write, debug, refactor, and reason about code** using both **local and cloud LLMs**.
|
|
10
|
+
|
|
11
|
+
<img width="1095" height="504" alt="Moth AI Screenshot" src="https://github.com/user-attachments/assets/23b83a6b-2b63-45af-b9ec-a6dcb0a89b2f" />
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## π¦ Installation
|
|
16
|
+
|
|
17
|
+
### Global Installation (Recommended)
|
|
18
|
+
|
|
19
|
+
Install Moth AI globally to use it anywhere on your system:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @kishlay42/moth-ai
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
After installation, simply run:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
moth
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Local Installation
|
|
6
32
|
|
|
7
|
-
|
|
8
|
-
|
|
33
|
+
Install in a specific project:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install @kishlay42/moth-ai
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Run using npx:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx moth
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Requirements
|
|
46
|
+
|
|
47
|
+
- **Node.js**: >= 18.0.0
|
|
48
|
+
- **npm**: >= 8.0.0
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## π Quick Start
|
|
53
|
+
|
|
54
|
+
1. **Install Moth AI globally:**
|
|
55
|
+
```bash
|
|
56
|
+
npm install -g @kishlay42/moth-ai
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
2. **Add your first LLM profile:**
|
|
60
|
+
```bash
|
|
61
|
+
moth llm add
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Choose from:
|
|
65
|
+
- **Local models** (via Ollama) - Free, private, offline
|
|
66
|
+
- **Cloud providers** (OpenAI, Anthropic, Google) - Requires API key
|
|
67
|
+
|
|
68
|
+
3. **Start chatting:**
|
|
69
|
+
```bash
|
|
70
|
+
moth
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
4. **Use the command palette:**
|
|
74
|
+
- Press `Ctrl+U` to access all commands
|
|
75
|
+
- Switch profiles, toggle autopilot, and more
|
|
9
76
|
|
|
10
77
|
---
|
|
11
78
|
|
|
12
79
|
## β¨ Key Features
|
|
13
80
|
|
|
14
81
|
### π§ LLM-Agnostic & Local-First
|
|
82
|
+
|
|
15
83
|
Use **any LLM**, local or cloud β switch instantly without changing workflows.
|
|
16
84
|
|
|
17
85
|
- **Local (via Ollama)**
|
|
@@ -24,32 +92,34 @@ Use **any LLM**, local or cloud β switch instantly without changing workflows.
|
|
|
24
92
|
- Anthropic (Claude 3.5 Sonnet)
|
|
25
93
|
- Google (Gemini)
|
|
26
94
|
|
|
27
|
-
<img width="1093" height="241" alt="
|
|
28
|
-
|
|
29
|
-
_Easily switch between local and cloud models_
|
|
95
|
+
<img width="1093" height="241" alt="LLM Switching" src="https://github.com/user-attachments/assets/2de67c9d-f562-4ce3-8bc6-51e2066b69ae" />
|
|
30
96
|
|
|
31
97
|
---
|
|
32
98
|
|
|
33
99
|
### π€ Agentic Capabilities
|
|
34
|
-
|
|
100
|
+
|
|
101
|
+
Moth is not just a chatbot β it's an **AI agent**.
|
|
35
102
|
|
|
36
103
|
- **Task Planning** β Break complex goals into executable steps
|
|
37
104
|
- **File Editing** β Precise diffs, patches, and refactors
|
|
38
105
|
- **Terminal Control** β Run builds, tests, and Git commands from chat
|
|
106
|
+
- **Context-Aware** β Understands your project structure and codebase
|
|
39
107
|
|
|
40
108
|
---
|
|
41
109
|
|
|
42
110
|
### π‘οΈ Permission-First by Design
|
|
111
|
+
|
|
43
112
|
You stay in control β always.
|
|
44
113
|
|
|
45
114
|
- Explicit approval before file edits or command execution
|
|
46
115
|
- Granular permissions per action
|
|
47
|
-
- Autopilot mode for trusted workflows
|
|
116
|
+
- **Autopilot mode** for trusted workflows
|
|
48
117
|
- Feedback loop to guide the agent instead of blind execution
|
|
49
118
|
|
|
50
119
|
---
|
|
51
120
|
|
|
52
121
|
### π Moth Profiles
|
|
122
|
+
|
|
53
123
|
Save and switch between different AI personalities.
|
|
54
124
|
|
|
55
125
|
- **Coding Profile** β Optimized for TypeScript / Python
|
|
@@ -57,17 +127,193 @@ Save and switch between different AI personalities.
|
|
|
57
127
|
- **Fast Profile** β Lightweight local model for quick answers
|
|
58
128
|
|
|
59
129
|
---
|
|
60
|
-
## π Installation
|
|
61
130
|
|
|
62
|
-
|
|
131
|
+
## οΏ½ CLI Commands
|
|
132
|
+
|
|
133
|
+
### Main Commands
|
|
134
|
+
|
|
63
135
|
```bash
|
|
64
|
-
|
|
136
|
+
# Start interactive chat
|
|
137
|
+
moth
|
|
138
|
+
|
|
139
|
+
# Show help
|
|
140
|
+
moth --help
|
|
141
|
+
|
|
142
|
+
# Display version
|
|
143
|
+
moth --version
|
|
65
144
|
```
|
|
66
145
|
|
|
67
|
-
###
|
|
146
|
+
### LLM Profile Management
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# Add a new LLM profile
|
|
150
|
+
moth llm add
|
|
151
|
+
|
|
152
|
+
# List all configured profiles
|
|
153
|
+
moth llm list
|
|
154
|
+
|
|
155
|
+
# Switch to a different profile
|
|
156
|
+
moth llm use
|
|
157
|
+
|
|
158
|
+
# Remove a profile
|
|
159
|
+
moth llm remove
|
|
68
160
|
```
|
|
69
|
-
|
|
70
|
-
|
|
161
|
+
|
|
162
|
+
### Keyboard Shortcuts
|
|
163
|
+
|
|
164
|
+
- **Ctrl+U** - Open command palette
|
|
165
|
+
- **Ctrl+C** - Exit chat
|
|
166
|
+
- **Arrow Keys** - Navigate command palette
|
|
167
|
+
- **Enter** - Execute selected command
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## βοΈ Configuration
|
|
172
|
+
|
|
173
|
+
Moth AI stores configuration in `~/.moth/config.yaml`
|
|
174
|
+
|
|
175
|
+
### Example Configuration
|
|
176
|
+
|
|
177
|
+
```yaml
|
|
178
|
+
profiles:
|
|
179
|
+
- name: "gpt-4"
|
|
180
|
+
provider: "openai"
|
|
181
|
+
model: "gpt-4"
|
|
182
|
+
apiKey: "sk-..."
|
|
183
|
+
|
|
184
|
+
- name: "local-llama"
|
|
185
|
+
provider: "ollama"
|
|
186
|
+
model: "llama3"
|
|
187
|
+
baseUrl: "http://localhost:11434"
|
|
188
|
+
|
|
189
|
+
activeProfile: "gpt-4"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Setting Up Ollama (Local Models)
|
|
193
|
+
|
|
194
|
+
1. Install Ollama: https://ollama.ai
|
|
195
|
+
2. Pull a model:
|
|
196
|
+
```bash
|
|
197
|
+
ollama pull llama3
|
|
198
|
+
```
|
|
199
|
+
3. Add to Moth:
|
|
200
|
+
```bash
|
|
201
|
+
moth llm add
|
|
202
|
+
# Select "Ollama" and choose your model
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Setting Up Cloud Providers
|
|
206
|
+
|
|
207
|
+
#### OpenAI
|
|
208
|
+
```bash
|
|
209
|
+
moth llm add
|
|
210
|
+
# Select "OpenAI"
|
|
211
|
+
# Enter your API key from https://platform.openai.com/api-keys
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
#### Anthropic (Claude)
|
|
215
|
+
```bash
|
|
216
|
+
moth llm add
|
|
217
|
+
# Select "Anthropic"
|
|
218
|
+
# Enter your API key from https://console.anthropic.com/
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
#### Google (Gemini)
|
|
222
|
+
```bash
|
|
223
|
+
moth llm add
|
|
224
|
+
# Select "Google"
|
|
225
|
+
# Enter your API key from https://makersuite.google.com/app/apikey
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## π‘ Usage Examples
|
|
231
|
+
|
|
232
|
+
### Basic Chat
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
moth
|
|
236
|
+
> How do I implement a binary search in TypeScript?
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Code Refactoring
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
moth
|
|
243
|
+
> Refactor src/utils.ts to use async/await instead of promises
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Debugging
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
moth
|
|
250
|
+
> Why is my React component re-rendering infinitely?
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Project Analysis
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
moth
|
|
257
|
+
> Analyze the architecture of this project and suggest improvements
|
|
71
258
|
```
|
|
72
259
|
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## π§ Troubleshooting
|
|
263
|
+
|
|
264
|
+
### Command not found: moth
|
|
265
|
+
|
|
266
|
+
Make sure the global npm bin directory is in your PATH:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
npm config get prefix
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Add the bin directory to your PATH in `~/.bashrc` or `~/.zshrc`:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
export PATH="$PATH:$(npm config get prefix)/bin"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Ollama connection error
|
|
279
|
+
|
|
280
|
+
Ensure Ollama is running:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
ollama serve
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### API key errors
|
|
287
|
+
|
|
288
|
+
Verify your API key is correctly configured:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
moth llm list
|
|
292
|
+
# Check if your profile shows the correct provider
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## π License
|
|
298
|
+
|
|
299
|
+
ISC License - see [LICENSE](LICENSE) file for details
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## π€ Contributing
|
|
304
|
+
|
|
305
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
306
|
+
|
|
307
|
+
Repository: https://github.com/kishlay42/Moth-ai
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## π Links
|
|
312
|
+
|
|
313
|
+
- **npm Package**: https://www.npmjs.com/package/@kishlay42/moth-ai
|
|
314
|
+
- **GitHub**: https://github.com/kishlay42/Moth-ai
|
|
315
|
+
- **Issues**: https://github.com/kishlay42/Moth-ai/issues
|
|
316
|
+
|
|
317
|
+
---
|
|
73
318
|
|
|
319
|
+
**Made with β€οΈ for developers who code in the terminal**
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
import { formatFileForContext, truncateFileContent } from '../utils/fileUtils.js';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
1
4
|
export class AgentOrchestrator {
|
|
2
5
|
config;
|
|
3
6
|
state;
|
|
4
7
|
tools;
|
|
5
|
-
|
|
8
|
+
root;
|
|
9
|
+
constructor(config, registry, root = process.cwd()) {
|
|
6
10
|
this.config = config;
|
|
7
11
|
this.tools = registry;
|
|
12
|
+
this.root = root;
|
|
8
13
|
this.state = {
|
|
9
14
|
history: [],
|
|
10
15
|
maxSteps: config.maxSteps || 10
|
|
@@ -13,8 +18,10 @@ export class AgentOrchestrator {
|
|
|
13
18
|
async *run(prompt, history = []) {
|
|
14
19
|
let currentPrompt = prompt;
|
|
15
20
|
for (let i = 0; i < this.state.maxSteps; i++) {
|
|
16
|
-
//
|
|
17
|
-
const
|
|
21
|
+
// Collect all attached files from history
|
|
22
|
+
const allAttachedFiles = this.collectAttachedFiles(history);
|
|
23
|
+
// Construct system prompt with tools and file context
|
|
24
|
+
const systemPrompt = await this.buildSystemPrompt(allAttachedFiles);
|
|
18
25
|
// Full context: System -> History -> Current State
|
|
19
26
|
const messages = [
|
|
20
27
|
{ role: 'user', content: systemPrompt }, // In many APIs system prompt is special, here we use user role as generic fallback or modify Client to handle system
|
|
@@ -50,17 +57,24 @@ export class AgentOrchestrator {
|
|
|
50
57
|
}
|
|
51
58
|
return "Max steps reached.";
|
|
52
59
|
}
|
|
53
|
-
buildSystemPrompt() {
|
|
60
|
+
async buildSystemPrompt(attachedFiles = []) {
|
|
54
61
|
const toolDefs = this.tools.getDefinitions().map(t => `${t.name}: ${t.description} Params: ${JSON.stringify(t.parameters)}`).join('\n');
|
|
62
|
+
let fileContext = '';
|
|
63
|
+
if (attachedFiles.length > 0) {
|
|
64
|
+
fileContext = await this.buildFileContext(attachedFiles);
|
|
65
|
+
}
|
|
55
66
|
return `You are Moth, an intelligent CLI coding assistant.
|
|
56
67
|
You have access to the following tools:
|
|
57
68
|
${toolDefs}
|
|
58
69
|
|
|
70
|
+
${fileContext}
|
|
71
|
+
|
|
59
72
|
IMPORTANT GUIDELINES:
|
|
60
73
|
1. For general questions, explanations, or code snippets that don't need to be saved, use "finalAnswer".
|
|
61
74
|
2. Do NOT use "write_to_file" unless the user explicitly asks to save a file or implies a persistent change.
|
|
62
75
|
3. If the user asks for "Hello World code", just show it in the explanation (finalAnswer). Do NOT create a file for it.
|
|
63
76
|
4. Be concise and helpful.
|
|
77
|
+
5. If files are referenced above, use that context to answer questions about them.
|
|
64
78
|
|
|
65
79
|
Format your response exactly as a JSON object:
|
|
66
80
|
{
|
|
@@ -74,6 +88,36 @@ OR if you are done/replying:
|
|
|
74
88
|
}
|
|
75
89
|
`;
|
|
76
90
|
}
|
|
91
|
+
collectAttachedFiles(history) {
|
|
92
|
+
const files = new Set();
|
|
93
|
+
for (const msg of history) {
|
|
94
|
+
if (msg.attachedFiles) {
|
|
95
|
+
msg.attachedFiles.forEach(f => files.add(f));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return Array.from(files);
|
|
99
|
+
}
|
|
100
|
+
async buildFileContext(filePaths) {
|
|
101
|
+
const fileContents = ['=== Referenced Files ===\n'];
|
|
102
|
+
for (const filePath of filePaths) {
|
|
103
|
+
try {
|
|
104
|
+
const fullPath = path.join(this.root, filePath);
|
|
105
|
+
// Security: Prevent breaking out of root
|
|
106
|
+
if (!fullPath.startsWith(this.root)) {
|
|
107
|
+
fileContents.push(`File: ${filePath}\nError: Access denied (outside project root)\n`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
111
|
+
const truncated = truncateFileContent(content, 500);
|
|
112
|
+
const formatted = formatFileForContext(filePath, truncated);
|
|
113
|
+
fileContents.push(formatted);
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
fileContents.push(`File: ${filePath}\nError: ${e.message}\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return fileContents.join('\n');
|
|
120
|
+
}
|
|
77
121
|
async callLLM(messages) {
|
|
78
122
|
// Direct integration with LLM Client
|
|
79
123
|
// This assumes Client has a simple chat interface returning string
|
package/dist/index.js
CHANGED
|
@@ -114,11 +114,12 @@ program.command('use')
|
|
|
114
114
|
let config = loadConfig();
|
|
115
115
|
if (!name) {
|
|
116
116
|
console.log("No active profile selected. Please select one:");
|
|
117
|
-
render(React.createElement(ProfileManager, {
|
|
117
|
+
const { unmount } = render(React.createElement(ProfileManager, {
|
|
118
118
|
config,
|
|
119
119
|
onSelect: (selected) => {
|
|
120
120
|
// onSelect saves internally
|
|
121
|
-
|
|
121
|
+
unmount();
|
|
122
|
+
setTimeout(() => startChatSession(), 500);
|
|
122
123
|
}
|
|
123
124
|
}));
|
|
124
125
|
return;
|
|
@@ -139,9 +140,12 @@ program.command('remove')
|
|
|
139
140
|
.action(async (name) => {
|
|
140
141
|
let config = loadConfig();
|
|
141
142
|
if (!name) {
|
|
142
|
-
render(React.createElement(LLMRemover, {
|
|
143
|
+
const { unmount } = render(React.createElement(LLMRemover, {
|
|
143
144
|
config,
|
|
144
|
-
onExit: () =>
|
|
145
|
+
onExit: () => {
|
|
146
|
+
unmount();
|
|
147
|
+
setTimeout(() => startChatSession(), 500);
|
|
148
|
+
}
|
|
145
149
|
}));
|
|
146
150
|
return;
|
|
147
151
|
}
|
|
@@ -160,7 +164,7 @@ program.command('add')
|
|
|
160
164
|
.action(async (options) => {
|
|
161
165
|
// Re-use exactly the same logic as llm add
|
|
162
166
|
if (!options.name && !options.provider && !options.model) {
|
|
163
|
-
render(React.createElement(LLMWizard, {
|
|
167
|
+
const { unmount } = render(React.createElement(LLMWizard, {
|
|
164
168
|
onComplete: async (resultConfig) => {
|
|
165
169
|
let config = loadConfig();
|
|
166
170
|
config = addProfile(config, resultConfig);
|
|
@@ -170,12 +174,14 @@ program.command('add')
|
|
|
170
174
|
config = setActiveProfile(config, resultConfig.name);
|
|
171
175
|
saveConfig(config);
|
|
172
176
|
console.log(`Profile '${resultConfig.name}' created.`);
|
|
173
|
-
//
|
|
174
|
-
|
|
177
|
+
// Redirect to chat
|
|
178
|
+
unmount();
|
|
179
|
+
setTimeout(() => startChatSession(), 500);
|
|
175
180
|
},
|
|
176
181
|
onCancel: () => {
|
|
177
182
|
console.log('Setup cancelled.');
|
|
178
|
-
|
|
183
|
+
unmount();
|
|
184
|
+
setTimeout(() => startChatSession(), 500);
|
|
179
185
|
}
|
|
180
186
|
}));
|
|
181
187
|
return;
|
|
@@ -228,7 +234,7 @@ llm.command('add')
|
|
|
228
234
|
.action(async (options) => {
|
|
229
235
|
// If no options provided, launch wizard
|
|
230
236
|
if (!options.name && !options.provider && !options.model) {
|
|
231
|
-
render(React.createElement(LLMWizard, {
|
|
237
|
+
const { unmount } = render(React.createElement(LLMWizard, {
|
|
232
238
|
onComplete: async (resultConfig) => {
|
|
233
239
|
let config = loadConfig();
|
|
234
240
|
config = addProfile(config, resultConfig);
|
|
@@ -240,11 +246,13 @@ llm.command('add')
|
|
|
240
246
|
}
|
|
241
247
|
saveConfig(config);
|
|
242
248
|
console.log(`Profile '${resultConfig.name}' added successfully!`);
|
|
243
|
-
|
|
249
|
+
unmount();
|
|
250
|
+
setTimeout(() => startChatSession(), 500);
|
|
244
251
|
},
|
|
245
252
|
onCancel: () => {
|
|
246
253
|
console.log('Setup cancelled.');
|
|
247
|
-
|
|
254
|
+
unmount();
|
|
255
|
+
setTimeout(() => startChatSession(), 500);
|
|
248
256
|
}
|
|
249
257
|
}));
|
|
250
258
|
return;
|
|
@@ -298,9 +306,12 @@ llm.command('remove')
|
|
|
298
306
|
.action(async (name) => {
|
|
299
307
|
let config = loadConfig();
|
|
300
308
|
if (!name) {
|
|
301
|
-
render(React.createElement(LLMRemover, {
|
|
309
|
+
const { unmount } = render(React.createElement(LLMRemover, {
|
|
302
310
|
config,
|
|
303
|
-
onExit: () =>
|
|
311
|
+
onExit: () => {
|
|
312
|
+
unmount();
|
|
313
|
+
setTimeout(() => startChatSession(), 500);
|
|
314
|
+
}
|
|
304
315
|
}));
|
|
305
316
|
return;
|
|
306
317
|
}
|
package/dist/ui/App.js
CHANGED
|
@@ -2,12 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput, Newline } from 'ink';
|
|
4
4
|
import * as os from 'os';
|
|
5
|
+
import { loadConfig, saveConfig, addProfile } from '../config/configManager.js';
|
|
5
6
|
import { TodoManager } from '../planning/todoManager.js';
|
|
6
7
|
import { AgentOrchestrator } from '../agent/orchestrator.js';
|
|
7
8
|
import { createToolRegistry } from '../tools/factory.js';
|
|
8
9
|
import { findProjectRoot } from '../utils/paths.js';
|
|
9
|
-
import
|
|
10
|
+
import { ProjectScanner } from '../context/scanner.js';
|
|
11
|
+
import { CustomTextInput } from './components/CustomTextInput.js';
|
|
10
12
|
import { WordMoth } from './components/WordMoth.js';
|
|
13
|
+
import { FileChip } from './components/FileChip.js';
|
|
14
|
+
import { FileAutocomplete } from './components/FileAutocomplete.js';
|
|
15
|
+
import { CommandPalette } from './components/CommandPalette.js';
|
|
16
|
+
import { ProfileManager } from './ProfileManager.js';
|
|
17
|
+
import { LLMWizard } from './wizards/LLMWizard.js';
|
|
18
|
+
import { LLMRemover } from './wizards/LLMRemover.js';
|
|
11
19
|
export const App = ({ command, args, todoManager: propTodoManager, username }) => {
|
|
12
20
|
const [messages, setMessages] = useState([]);
|
|
13
21
|
const [inputVal, setInputVal] = useState('');
|
|
@@ -18,6 +26,17 @@ export const App = ({ command, args, todoManager: propTodoManager, username }) =
|
|
|
18
26
|
const [autopilot, setAutopilot] = useState(false);
|
|
19
27
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
20
28
|
const [thinkingText, setThinkingText] = useState('Sauting...');
|
|
29
|
+
// File Reference State
|
|
30
|
+
const [selectedFiles, setSelectedFiles] = useState([]);
|
|
31
|
+
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
32
|
+
const [autocompleteQuery, setAutocompleteQuery] = useState('');
|
|
33
|
+
const [autocompleteIndex, setAutocompleteIndex] = useState(0);
|
|
34
|
+
const [availableFiles, setAvailableFiles] = useState([]);
|
|
35
|
+
const [showFileChips, setShowFileChips] = useState(false); // Collapsed by default
|
|
36
|
+
// Command Palette State
|
|
37
|
+
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
|
38
|
+
// Active Wizard State (for command palette execution)
|
|
39
|
+
const [activeWizard, setActiveWizard] = useState(null);
|
|
21
40
|
// Effect for cycling thinking text
|
|
22
41
|
useEffect(() => {
|
|
23
42
|
if (isProcessing) {
|
|
@@ -28,6 +47,21 @@ export const App = ({ command, args, todoManager: propTodoManager, username }) =
|
|
|
28
47
|
return () => clearInterval(interval);
|
|
29
48
|
}
|
|
30
49
|
}, [isProcessing]);
|
|
50
|
+
// Load available files on mount
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const loadFiles = async () => {
|
|
53
|
+
try {
|
|
54
|
+
const root = findProjectRoot() || process.cwd();
|
|
55
|
+
const scanner = new ProjectScanner(root);
|
|
56
|
+
const files = await scanner.scan();
|
|
57
|
+
setAvailableFiles(files);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
console.error('Failed to load files:', e);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
loadFiles();
|
|
64
|
+
}, []);
|
|
31
65
|
// Permission State
|
|
32
66
|
const [pendingPermission, setPendingPermission] = useState(null);
|
|
33
67
|
const [feedbackMode, setFeedbackMode] = useState(false);
|
|
@@ -45,12 +79,16 @@ export const App = ({ command, args, todoManager: propTodoManager, username }) =
|
|
|
45
79
|
runAgent(initialPrompt);
|
|
46
80
|
}
|
|
47
81
|
}, [initialPrompt]);
|
|
48
|
-
const runAgent = async (userPrompt) => {
|
|
82
|
+
const runAgent = async (userPrompt, attachedFiles) => {
|
|
49
83
|
if (showWelcome)
|
|
50
84
|
setShowWelcome(false);
|
|
51
85
|
setIsProcessing(true);
|
|
52
|
-
// Create new user message
|
|
53
|
-
const userMsg = {
|
|
86
|
+
// Create new user message with attached files
|
|
87
|
+
const userMsg = {
|
|
88
|
+
role: 'user',
|
|
89
|
+
content: userPrompt,
|
|
90
|
+
attachedFiles: attachedFiles && attachedFiles.length > 0 ? attachedFiles : undefined
|
|
91
|
+
};
|
|
54
92
|
setMessages(prev => [...prev, userMsg]);
|
|
55
93
|
try {
|
|
56
94
|
const root = findProjectRoot() || process.cwd();
|
|
@@ -76,7 +114,7 @@ export const App = ({ command, args, todoManager: propTodoManager, username }) =
|
|
|
76
114
|
model: client,
|
|
77
115
|
tools: registry.getDefinitions(),
|
|
78
116
|
maxSteps: 10
|
|
79
|
-
}, registry);
|
|
117
|
+
}, registry, root);
|
|
80
118
|
let finalAnswer = "";
|
|
81
119
|
// Pass accumulated history to the agent
|
|
82
120
|
// 'messages' state contains history prior to this turn.
|
|
@@ -111,9 +149,129 @@ export const App = ({ command, args, todoManager: propTodoManager, username }) =
|
|
|
111
149
|
setIsProcessing(false);
|
|
112
150
|
}
|
|
113
151
|
};
|
|
152
|
+
// Handle input changes to detect @ for autocomplete
|
|
153
|
+
const handleInputChange = (value) => {
|
|
154
|
+
setInputVal(value);
|
|
155
|
+
// Extract and track file references from @ mentions
|
|
156
|
+
const referencedFiles = extractFileReferences(value);
|
|
157
|
+
setSelectedFiles(referencedFiles);
|
|
158
|
+
// Detect @ symbol for file autocomplete
|
|
159
|
+
const atIndex = value.lastIndexOf('@');
|
|
160
|
+
if (atIndex !== -1) {
|
|
161
|
+
const afterAt = value.slice(atIndex + 1);
|
|
162
|
+
// Close autocomplete if there's a space after @ (end of filename)
|
|
163
|
+
if (afterAt.includes(' ')) {
|
|
164
|
+
setShowAutocomplete(false);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const query = afterAt;
|
|
168
|
+
// Only show autocomplete if @ is at start or after a space
|
|
169
|
+
if (atIndex === 0 || value[atIndex - 1] === ' ') {
|
|
170
|
+
setAutocompleteQuery(query);
|
|
171
|
+
setShowAutocomplete(true);
|
|
172
|
+
setAutocompleteIndex(0);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
setShowAutocomplete(false);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
// Get filtered files for autocomplete
|
|
180
|
+
const getFilteredFiles = () => {
|
|
181
|
+
return availableFiles
|
|
182
|
+
.filter(file => {
|
|
183
|
+
const lowerFile = file.toLowerCase();
|
|
184
|
+
const lowerQuery = autocompleteQuery.toLowerCase();
|
|
185
|
+
return lowerFile.includes(lowerQuery);
|
|
186
|
+
})
|
|
187
|
+
.filter(file => !selectedFiles.includes(file)) // Exclude already selected
|
|
188
|
+
.slice(0, 10);
|
|
189
|
+
};
|
|
190
|
+
const handleFileSelect = (file) => {
|
|
191
|
+
// Replace the entire input value to ensure cursor ends up at the end
|
|
192
|
+
const atIndex = inputVal.lastIndexOf('@');
|
|
193
|
+
if (atIndex !== -1) {
|
|
194
|
+
const beforeAt = inputVal.slice(0, atIndex);
|
|
195
|
+
// Set the complete new value - this places cursor at the end automatically
|
|
196
|
+
const newInput = beforeAt + '@' + file + ' ';
|
|
197
|
+
setInputVal(newInput);
|
|
198
|
+
}
|
|
199
|
+
setShowAutocomplete(false);
|
|
200
|
+
setAutocompleteQuery('');
|
|
201
|
+
};
|
|
202
|
+
const handleFileRemove = (file) => {
|
|
203
|
+
setSelectedFiles(prev => prev.filter(f => f !== file));
|
|
204
|
+
};
|
|
205
|
+
// Extract file references from input text
|
|
206
|
+
const extractFileReferences = (text) => {
|
|
207
|
+
const atMatches = text.match(/@[\w\/.\-]+/g) || [];
|
|
208
|
+
return atMatches.map(match => match.slice(1)); // Remove @ prefix
|
|
209
|
+
};
|
|
210
|
+
// Execute command from command palette
|
|
211
|
+
const executeCommand = (action) => {
|
|
212
|
+
setShowCommandPalette(false);
|
|
213
|
+
switch (action) {
|
|
214
|
+
case 'autopilot':
|
|
215
|
+
setAutopilot(prev => !prev);
|
|
216
|
+
setMessages(prev => [...prev, {
|
|
217
|
+
role: 'assistant',
|
|
218
|
+
content: `Autopilot ${!autopilot ? 'enabled' : 'disabled'}.`
|
|
219
|
+
}]);
|
|
220
|
+
break;
|
|
221
|
+
case 'llm-list':
|
|
222
|
+
setActiveWizard('llm-list');
|
|
223
|
+
break;
|
|
224
|
+
case 'llm-add':
|
|
225
|
+
setActiveWizard('llm-add');
|
|
226
|
+
break;
|
|
227
|
+
case 'llm-use':
|
|
228
|
+
setActiveWizard('llm-use');
|
|
229
|
+
break;
|
|
230
|
+
case 'llm-remove':
|
|
231
|
+
setActiveWizard('llm-remove');
|
|
232
|
+
break;
|
|
233
|
+
case 'help':
|
|
234
|
+
setMessages(prev => [...prev, {
|
|
235
|
+
role: 'assistant',
|
|
236
|
+
content: 'Moth AI - Your terminal coding assistant.\n\nKeyboard Shortcuts:\n- Ctrl+U: Open command palette\n- Ctrl+O: Toggle file references\n- Esc: Pause/Resume\n\nFor full help, run: moth --help'
|
|
237
|
+
}]);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
114
241
|
useInput((input, key) => {
|
|
242
|
+
// Autocomplete Navigation
|
|
243
|
+
if (showAutocomplete && !pendingPermission) {
|
|
244
|
+
const filteredFiles = getFilteredFiles();
|
|
245
|
+
if (key.escape) {
|
|
246
|
+
setShowAutocomplete(false);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (key.upArrow) {
|
|
250
|
+
setAutocompleteIndex(prev => (prev - 1 + filteredFiles.length) % filteredFiles.length);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (key.downArrow) {
|
|
254
|
+
setAutocompleteIndex(prev => (prev + 1) % filteredFiles.length);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Use Enter to select file from autocomplete
|
|
258
|
+
if (key.return && filteredFiles.length > 0) {
|
|
259
|
+
handleFileSelect(filteredFiles[autocompleteIndex]);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Ctrl+O to toggle file chips visibility
|
|
264
|
+
if (input === 'o' && key.ctrl) {
|
|
265
|
+
setShowFileChips(prev => !prev);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Ctrl+U to toggle command palette
|
|
269
|
+
if (input === 'u' && key.ctrl) {
|
|
270
|
+
setShowCommandPalette(prev => !prev);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
115
273
|
// ESC Pause
|
|
116
|
-
if (key.escape) {
|
|
274
|
+
if (key.escape && !showAutocomplete) {
|
|
117
275
|
setIsPaused(prev => !prev);
|
|
118
276
|
setStatus(prev => prev === 'Paused' ? 'Resumed' : 'Paused');
|
|
119
277
|
return;
|
|
@@ -173,10 +331,57 @@ export const App = ({ command, args, todoManager: propTodoManager, username }) =
|
|
|
173
331
|
}
|
|
174
332
|
});
|
|
175
333
|
// --- RENDER ---
|
|
176
|
-
|
|
334
|
+
// Render wizards if active
|
|
335
|
+
if (activeWizard === 'llm-list' || activeWizard === 'llm-use') {
|
|
336
|
+
const config = loadConfig();
|
|
337
|
+
return (_jsx(ProfileManager, { config: config, onSelect: (profile) => {
|
|
338
|
+
setActiveWizard(null);
|
|
339
|
+
setMessages(prev => [...prev, {
|
|
340
|
+
role: 'assistant',
|
|
341
|
+
content: `Switched to profile '${profile.name}'.`
|
|
342
|
+
}]);
|
|
343
|
+
} }));
|
|
344
|
+
}
|
|
345
|
+
if (activeWizard === 'llm-add') {
|
|
346
|
+
return (_jsx(LLMWizard, { onComplete: (newProfile) => {
|
|
347
|
+
const config = loadConfig();
|
|
348
|
+
const updatedConfig = addProfile(config, newProfile);
|
|
349
|
+
saveConfig(updatedConfig);
|
|
350
|
+
setActiveWizard(null);
|
|
351
|
+
setMessages(prev => [...prev, {
|
|
352
|
+
role: 'assistant',
|
|
353
|
+
content: `Profile '${newProfile.name}' added successfully!`
|
|
354
|
+
}]);
|
|
355
|
+
}, onCancel: () => {
|
|
356
|
+
setActiveWizard(null);
|
|
357
|
+
setMessages(prev => [...prev, {
|
|
358
|
+
role: 'assistant',
|
|
359
|
+
content: 'Cancelled.'
|
|
360
|
+
}]);
|
|
361
|
+
} }));
|
|
362
|
+
}
|
|
363
|
+
if (activeWizard === 'llm-remove') {
|
|
364
|
+
const config = loadConfig();
|
|
365
|
+
return (_jsx(LLMRemover, { config: config, onExit: () => {
|
|
366
|
+
setActiveWizard(null);
|
|
367
|
+
setMessages(prev => [...prev, {
|
|
368
|
+
role: 'assistant',
|
|
369
|
+
content: 'Returned to chat.'
|
|
370
|
+
}]);
|
|
371
|
+
} }));
|
|
372
|
+
}
|
|
373
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [command === 'run' && (_jsxs(Box, { flexDirection: "row", paddingX: 1, paddingY: 0, borderStyle: "round", borderColor: "#0192e5", children: [_jsxs(Box, { flexDirection: "column", marginTop: -1, paddingRight: 1, children: [_jsx(WordMoth, { text: "MOTH", big: true }), _jsx(Box, { marginTop: -1, children: _jsx(Text, { dimColor: true, children: "v1.0.0" }) }), _jsxs(Text, { color: "#3EA0C3", children: ["Welcome, ", username || os.userInfo().username] })] }), _jsxs(Box, { flexDirection: "column", alignItems: "flex-start", marginLeft: 2, children: [_jsxs(Text, { color: "green", children: ["Active_AI: ", activeProfile?.name || 'None'] }), _jsxs(Text, { dimColor: true, children: ["Path: ", process.cwd()] }), _jsx(Text, { dimColor: true, children: "Use Ctrl+U to view commands" })] })] })), messages.length > 0 && (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: messages.map((m, i) => (_jsxs(Box, { flexDirection: "row", marginBottom: 1, children: [_jsxs(Text, { color: m.role === 'user' ? 'blue' : 'green', bold: true, children: [m.role === 'user' ? 'You' : 'Moth', ":"] }), _jsxs(Text, { children: [" ", m.content] })] }, i))) })), pendingPermission && (_jsxs(Box, { borderStyle: "double", borderColor: "red", flexDirection: "column", padding: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "red", children: "PERMISSION REQUIRED" }), _jsxs(Text, { children: ["Tool: ", pendingPermission.toolName] }), _jsxs(Text, { children: ["Args: ", JSON.stringify(pendingPermission.args)] }), _jsx(Newline, {}), !feedbackMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: _jsxs(Text, { color: permissionSelection === 0 ? "green" : undefined, bold: permissionSelection === 0, children: [permissionSelection === 0 ? "> " : " ", " [a] Yes - execute this action"] }) }), _jsx(Text, { children: _jsxs(Text, { color: permissionSelection === 1 ? "green" : undefined, bold: permissionSelection === 1, children: [permissionSelection === 1 ? "> " : " ", " [b] Yes - enable autopilot (approve all)"] }) }), _jsx(Text, { children: _jsxs(Text, { color: permissionSelection === 2 ? "green" : undefined, bold: permissionSelection === 2, children: [permissionSelection === 2 ? "> " : " ", " [c] Tell Moth what to do instead"] }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Feedback: " }), _jsx(Text, { children: inputVal })] }))] })), !pendingPermission && (_jsxs(Box, { flexDirection: "column", children: [isProcessing && (_jsxs(Box, { flexDirection: "column", marginBottom: 0, children: [_jsx(Text, { color: "yellow", italic: true, children: thinkingText }), status !== 'Ready' && _jsxs(Text, { color: "gray", dimColor: true, children: [" ", status] })] })), autopilot && (_jsx(Text, { color: "magenta", children: "AUTOPILOT MODE" })), selectedFiles.length > 0 && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { dimColor: true, children: ["Referenced Files (", selectedFiles.length, ") "] }), _jsxs(Text, { color: "gray", dimColor: true, children: ["[Ctrl+O to ", showFileChips ? 'hide' : 'show', "]"] })] }), showFileChips && (_jsx(Box, { flexDirection: "row", flexWrap: "wrap", marginTop: 1, children: selectedFiles.map((file) => (_jsx(FileChip, { filePath: file, onRemove: () => handleFileRemove(file) }, file))) }))] })), _jsxs(Box, { borderStyle: "round", borderColor: isProcessing ? "gray" : "blue", paddingX: 1, children: [_jsx(Text, { color: isProcessing ? "gray" : "cyan", children: '> ' }), _jsx(CustomTextInput, { value: inputVal, onChange: handleInputChange, onSubmit: (val) => {
|
|
374
|
+
// Don't submit if autocomplete is open
|
|
375
|
+
if (showAutocomplete) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
177
378
|
if (val.trim() && !isProcessing) {
|
|
178
|
-
|
|
379
|
+
// Extract file references from @ mentions in text
|
|
380
|
+
const referencedFiles = extractFileReferences(val);
|
|
381
|
+
const allFiles = [...new Set([...selectedFiles, ...referencedFiles])];
|
|
382
|
+
runAgent(val, allFiles.length > 0 ? allFiles : undefined);
|
|
179
383
|
setInputVal('');
|
|
384
|
+
setSelectedFiles([]);
|
|
180
385
|
}
|
|
181
|
-
}, focus: !isProcessing && !pendingPermission })] }), _jsx(Box, { flexDirection: "row", justifyContent: "flex-end", children: _jsx(Text, { color: "gray", dimColor: true, children: activeProfile?.name }) })] }))] }));
|
|
386
|
+
}, focus: !isProcessing && !pendingPermission })] }), showAutocomplete && (_jsx(FileAutocomplete, { files: availableFiles, query: autocompleteQuery, selectedIndex: autocompleteIndex, onSelect: handleFileSelect })), showCommandPalette && (_jsx(CommandPalette, { onExecute: executeCommand, onClose: () => setShowCommandPalette(false) })), _jsx(Box, { flexDirection: "row", justifyContent: "flex-end", children: _jsx(Text, { color: "gray", dimColor: true, children: activeProfile?.name }) })] }))] }));
|
|
182
387
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
const commands = [
|
|
5
|
+
{ name: 'LLM: List Profiles', description: 'Show all configured LLM profiles', action: 'llm-list' },
|
|
6
|
+
{ name: 'LLM: Add Profile', description: 'Add a new LLM profile', action: 'llm-add' },
|
|
7
|
+
{ name: 'LLM: Switch Profile', description: 'Switch to a different LLM profile', action: 'llm-use' },
|
|
8
|
+
{ name: 'LLM: Remove Profile', description: 'Remove an LLM profile', action: 'llm-remove' },
|
|
9
|
+
{ name: 'Toggle Autopilot', description: 'Enable/disable autopilot mode', action: 'autopilot' },
|
|
10
|
+
{ name: 'Show Help', description: 'Display help information', action: 'help' },
|
|
11
|
+
];
|
|
12
|
+
export const CommandPalette = ({ onExecute, onClose }) => {
|
|
13
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
14
|
+
useInput((input, key) => {
|
|
15
|
+
if (key.upArrow) {
|
|
16
|
+
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
17
|
+
}
|
|
18
|
+
else if (key.downArrow) {
|
|
19
|
+
setSelectedIndex(prev => Math.min(commands.length - 1, prev + 1));
|
|
20
|
+
}
|
|
21
|
+
else if (key.return) {
|
|
22
|
+
onExecute(commands[selectedIndex].action);
|
|
23
|
+
}
|
|
24
|
+
else if (key.escape || (input === 'u' && key.ctrl)) {
|
|
25
|
+
onClose();
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Command Palette (Ctrl+U to close)" }), _jsx(Text, { dimColor: true, children: "Select a command and press Enter" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: commands.map((cmd, index) => (_jsxs(Box, { marginY: 0, children: [_jsxs(Text, { color: index === selectedIndex ? 'green' : undefined, bold: index === selectedIndex, children: [index === selectedIndex ? '> ' : ' ', cmd.name] }), _jsxs(Text, { dimColor: true, children: [" - ", cmd.description] })] }, cmd.action))) })] }) }));
|
|
29
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
export const CustomTextInput = ({ value: externalValue, onChange, onSubmit, focus = true, placeholder = '' }) => {
|
|
5
|
+
const [internalValue, setInternalValue] = useState(externalValue);
|
|
6
|
+
const [cursorPos, setCursorPos] = useState(externalValue.length);
|
|
7
|
+
// Sync with external value changes (e.g., from file path insertion)
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
setInternalValue(externalValue);
|
|
10
|
+
// Place cursor at end when value changes externally
|
|
11
|
+
setCursorPos(externalValue.length);
|
|
12
|
+
}, [externalValue]);
|
|
13
|
+
useInput((input, key) => {
|
|
14
|
+
if (!focus)
|
|
15
|
+
return;
|
|
16
|
+
// Submit on Enter
|
|
17
|
+
if (key.return) {
|
|
18
|
+
onSubmit(internalValue);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Backspace - delete character before cursor
|
|
22
|
+
if (key.backspace) {
|
|
23
|
+
if (cursorPos > 0) {
|
|
24
|
+
const newValue = internalValue.slice(0, cursorPos - 1) + internalValue.slice(cursorPos);
|
|
25
|
+
setInternalValue(newValue);
|
|
26
|
+
setCursorPos(cursorPos - 1);
|
|
27
|
+
onChange(newValue);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Delete - delete character after cursor
|
|
32
|
+
if (key.delete) {
|
|
33
|
+
if (cursorPos < internalValue.length) {
|
|
34
|
+
const newValue = internalValue.slice(0, cursorPos) + internalValue.slice(cursorPos + 1);
|
|
35
|
+
setInternalValue(newValue);
|
|
36
|
+
onChange(newValue);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Left arrow - move cursor left
|
|
41
|
+
if (key.leftArrow) {
|
|
42
|
+
setCursorPos(Math.max(0, cursorPos - 1));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Right arrow - move cursor right
|
|
46
|
+
if (key.rightArrow) {
|
|
47
|
+
setCursorPos(Math.min(internalValue.length, cursorPos + 1));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Home - move to start
|
|
51
|
+
if (key.home) {
|
|
52
|
+
setCursorPos(0);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// End - move to end
|
|
56
|
+
if (key.end) {
|
|
57
|
+
setCursorPos(internalValue.length);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Regular character input (ignore ctrl/meta combinations)
|
|
61
|
+
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
|
62
|
+
const newValue = internalValue.slice(0, cursorPos) + input + internalValue.slice(cursorPos);
|
|
63
|
+
setInternalValue(newValue);
|
|
64
|
+
setCursorPos(cursorPos + 1);
|
|
65
|
+
onChange(newValue);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}, { isActive: focus });
|
|
69
|
+
// Render the input with cursor
|
|
70
|
+
const displayValue = internalValue;
|
|
71
|
+
const beforeCursor = displayValue.slice(0, cursorPos);
|
|
72
|
+
const cursorChar = displayValue[cursorPos] || ' '; // Always show space if nothing there
|
|
73
|
+
const afterCursor = displayValue.slice(cursorPos + 1);
|
|
74
|
+
return (_jsxs(Box, { children: [_jsx(Text, { children: beforeCursor }), _jsx(Text, { inverse: true, children: cursorChar }), _jsx(Text, { children: afterCursor })] }));
|
|
75
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export const FileAutocomplete = ({ files, query, selectedIndex, onSelect }) => {
|
|
4
|
+
// Filter files based on query
|
|
5
|
+
const filteredFiles = files
|
|
6
|
+
.filter(file => {
|
|
7
|
+
const lowerFile = file.toLowerCase();
|
|
8
|
+
const lowerQuery = query.toLowerCase();
|
|
9
|
+
return lowerFile.includes(lowerQuery);
|
|
10
|
+
})
|
|
11
|
+
.slice(0, 10); // Limit to 10 results
|
|
12
|
+
if (filteredFiles.length === 0) {
|
|
13
|
+
return (_jsx(Box, { borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: _jsxs(Text, { dimColor: true, children: ["No files found matching \"", query, "\""] }) }));
|
|
14
|
+
}
|
|
15
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "\u2191\u2193 navigate, Enter inserts path, Esc cancels" }), filteredFiles.map((file, index) => (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: index === selectedIndex ? 'green' : undefined, bold: index === selectedIndex, children: [index === selectedIndex ? '> ' : ' ', file] }) }, file)))] }));
|
|
16
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
export const FileChip = ({ filePath, onRemove }) => {
|
|
5
|
+
const basename = path.basename(filePath);
|
|
6
|
+
const dirname = path.dirname(filePath);
|
|
7
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, marginRight: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: basename }), _jsxs(Text, { dimColor: true, children: [" ", dirname !== '.' ? `(${dirname})` : ''] }), _jsx(Text, { color: "red", bold: true, children: " [x]" })] }));
|
|
8
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
/**
|
|
3
|
+
* Filter files by query using simple substring matching
|
|
4
|
+
*/
|
|
5
|
+
export function fuzzyMatchFiles(query, files) {
|
|
6
|
+
const lowerQuery = query.toLowerCase();
|
|
7
|
+
return files.filter(file => file.toLowerCase().includes(lowerQuery));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Truncate file content to a maximum number of lines
|
|
11
|
+
*/
|
|
12
|
+
export function truncateFileContent(content, maxLines = 500) {
|
|
13
|
+
const lines = content.split('\n');
|
|
14
|
+
if (lines.length <= maxLines) {
|
|
15
|
+
return content;
|
|
16
|
+
}
|
|
17
|
+
const truncated = lines.slice(0, maxLines).join('\n');
|
|
18
|
+
return `${truncated}\n\n... (truncated ${lines.length - maxLines} lines)`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Format file for LLM context with proper delimiters
|
|
22
|
+
*/
|
|
23
|
+
export function formatFileForContext(filePath, content) {
|
|
24
|
+
const ext = getFileExtension(filePath);
|
|
25
|
+
const language = getLanguageFromExtension(ext);
|
|
26
|
+
return `File: ${filePath}\n\`\`\`${language}\n${content}\n\`\`\`\n`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Get file extension from path
|
|
30
|
+
*/
|
|
31
|
+
export function getFileExtension(filePath) {
|
|
32
|
+
return path.extname(filePath).slice(1);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Map file extension to language identifier for syntax highlighting
|
|
36
|
+
*/
|
|
37
|
+
function getLanguageFromExtension(ext) {
|
|
38
|
+
const languageMap = {
|
|
39
|
+
'ts': 'typescript',
|
|
40
|
+
'tsx': 'typescript',
|
|
41
|
+
'js': 'javascript',
|
|
42
|
+
'jsx': 'javascript',
|
|
43
|
+
'py': 'python',
|
|
44
|
+
'java': 'java',
|
|
45
|
+
'cpp': 'cpp',
|
|
46
|
+
'c': 'c',
|
|
47
|
+
'cs': 'csharp',
|
|
48
|
+
'go': 'go',
|
|
49
|
+
'rs': 'rust',
|
|
50
|
+
'rb': 'ruby',
|
|
51
|
+
'php': 'php',
|
|
52
|
+
'swift': 'swift',
|
|
53
|
+
'kt': 'kotlin',
|
|
54
|
+
'md': 'markdown',
|
|
55
|
+
'json': 'json',
|
|
56
|
+
'yaml': 'yaml',
|
|
57
|
+
'yml': 'yaml',
|
|
58
|
+
'xml': 'xml',
|
|
59
|
+
'html': 'html',
|
|
60
|
+
'css': 'css',
|
|
61
|
+
'scss': 'scss',
|
|
62
|
+
'sql': 'sql',
|
|
63
|
+
'sh': 'bash',
|
|
64
|
+
'bash': 'bash',
|
|
65
|
+
};
|
|
66
|
+
return languageMap[ext] || ext;
|
|
67
|
+
}
|