@demirarch/recode 0.1.0
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/COMMERCIAL.md +53 -0
- package/LICENSE +184 -0
- package/README.md +63 -0
- package/dist/App.js +31 -0
- package/dist/components/ChatScreen.js +130 -0
- package/dist/components/SetupScreen.js +56 -0
- package/dist/components/StatusBar.js +6 -0
- package/dist/components/TopBar.js +15 -0
- package/dist/hooks/useAgent.js +106 -0
- package/dist/lib/commands.js +79 -0
- package/dist/lib/config.js +22 -0
- package/dist/lib/models.js +56 -0
- package/dist/lib/openrouter.js +94 -0
- package/dist/lib/tools.js +211 -0
- package/dist/main.js +6 -0
- package/package.json +33 -0
- package/src/App.tsx +54 -0
- package/src/components/ChatScreen.tsx +231 -0
- package/src/components/SetupScreen.tsx +113 -0
- package/src/components/StatusBar.tsx +23 -0
- package/src/components/TopBar.tsx +33 -0
- package/src/hooks/useAgent.ts +132 -0
- package/src/lib/commands.ts +101 -0
- package/src/lib/config.ts +29 -0
- package/src/lib/models.ts +64 -0
- package/src/lib/openrouter.ts +131 -0
- package/src/lib/tools.ts +235 -0
- package/src/main.tsx +8 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useApp } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import { useAgent, type DisplayMessage } from '../hooks/useAgent.js';
|
|
6
|
+
import { TopBar } from './TopBar.js';
|
|
7
|
+
import { StatusBar } from './StatusBar.js';
|
|
8
|
+
import { parseCommand } from '../lib/commands.js';
|
|
9
|
+
import { saveConfig } from '../lib/config.js';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
|
|
12
|
+
const MODEL_EXAMPLES = [
|
|
13
|
+
'anthropic/claude-sonnet-4-5',
|
|
14
|
+
'openai/gpt-4o',
|
|
15
|
+
'google/gemini-2.5-pro',
|
|
16
|
+
'deepseek/deepseek-r1',
|
|
17
|
+
'meta-llama/llama-4-maverick',
|
|
18
|
+
'mistralai/codestral-latest',
|
|
19
|
+
'qwen/qwen3-235b-a22b',
|
|
20
|
+
'x-ai/grok-3',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
interface ChatScreenProps {
|
|
24
|
+
model: string;
|
|
25
|
+
apiKey: string;
|
|
26
|
+
initialCwd: string;
|
|
27
|
+
onModelChange: (model: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Message Renderer ─────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function RenderMessage({ msg }: { msg: DisplayMessage }) {
|
|
33
|
+
switch (msg.type) {
|
|
34
|
+
case 'user':
|
|
35
|
+
return (
|
|
36
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
37
|
+
<Text bold color="#F26207">You</Text>
|
|
38
|
+
<Text>{msg.content}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
case 'assistant':
|
|
43
|
+
return (
|
|
44
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
45
|
+
<Text bold color="cyan">Recode</Text>
|
|
46
|
+
<Text wrap="wrap">{msg.content}</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
case 'tool_call': {
|
|
51
|
+
let preview = '';
|
|
52
|
+
try {
|
|
53
|
+
const args = JSON.parse(msg.args) as Record<string, unknown>;
|
|
54
|
+
const firstVal = Object.values(args)[0];
|
|
55
|
+
preview = typeof firstVal === 'string' ? firstVal.slice(0, 50) : '';
|
|
56
|
+
} catch { preview = ''; }
|
|
57
|
+
|
|
58
|
+
const icon = msg.status === 'running' ? '⟳' : msg.status === 'done' ? '✓' : '✗';
|
|
59
|
+
const color = msg.status === 'running' ? 'yellow' : msg.status === 'done' ? 'green' : 'red';
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Box marginBottom={0}>
|
|
63
|
+
<Text color={color}>{icon} </Text>
|
|
64
|
+
<Text color="gray">{msg.name}</Text>
|
|
65
|
+
{preview ? <Text color="gray"> {preview}</Text> : null}
|
|
66
|
+
</Box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case 'system':
|
|
71
|
+
return (
|
|
72
|
+
<Box flexDirection="column" marginBottom={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
73
|
+
<Text color="gray">{msg.content}</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
case 'error':
|
|
78
|
+
return (
|
|
79
|
+
<Box marginBottom={1}>
|
|
80
|
+
<Text color="red">Error: {msg.content}</Text>
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
default:
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Model Input Overlay ───────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function ModelOverlay({
|
|
92
|
+
currentModel,
|
|
93
|
+
onConfirm,
|
|
94
|
+
onCancel,
|
|
95
|
+
}: {
|
|
96
|
+
currentModel: string;
|
|
97
|
+
onConfirm: (model: string) => void;
|
|
98
|
+
onCancel: () => void;
|
|
99
|
+
}) {
|
|
100
|
+
const [value, setValue] = useState(currentModel);
|
|
101
|
+
const [error, setError] = useState<string | null>(null);
|
|
102
|
+
|
|
103
|
+
useInput((_, key) => {
|
|
104
|
+
if (key.escape) { onCancel(); return; }
|
|
105
|
+
if (key.return) {
|
|
106
|
+
const m = value.trim();
|
|
107
|
+
if (!m.includes('/')) {
|
|
108
|
+
setError('Must include provider, e.g. anthropic/claude-sonnet-4-5');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
onConfirm(m);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Box flexDirection="column" borderStyle="double" borderColor="#F26207" padding={1} marginX={2}>
|
|
117
|
+
<Text bold color="#F26207">Change Model <Text color="gray">(Esc to cancel)</Text></Text>
|
|
118
|
+
<Box marginTop={1}>
|
|
119
|
+
<Text color="#F26207">{'> '}</Text>
|
|
120
|
+
<TextInput value={value} onChange={setValue} placeholder="provider/model-name" />
|
|
121
|
+
</Box>
|
|
122
|
+
<Text color="gray" dimColor>Press Enter to confirm</Text>
|
|
123
|
+
|
|
124
|
+
<Box flexDirection="column" marginTop={1}>
|
|
125
|
+
<Text color="gray" dimColor>Examples:</Text>
|
|
126
|
+
{MODEL_EXAMPLES.map((e) => (
|
|
127
|
+
<Text key={e} color="gray" dimColor> {e}</Text>
|
|
128
|
+
))}
|
|
129
|
+
</Box>
|
|
130
|
+
|
|
131
|
+
{error && <Text color="red">{error}</Text>}
|
|
132
|
+
</Box>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Main Chat Screen ─────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export function ChatScreen({ model, apiKey, initialCwd, onModelChange }: ChatScreenProps) {
|
|
139
|
+
const { exit } = useApp();
|
|
140
|
+
const [cwd, setCwd] = useState(initialCwd);
|
|
141
|
+
const [input, setInput] = useState('');
|
|
142
|
+
const [showModelInput, setShowModelInput] = useState(false);
|
|
143
|
+
|
|
144
|
+
const { display, thinking, sendMessage, clearHistory, addSystemMessage } =
|
|
145
|
+
useAgent(model, apiKey, cwd);
|
|
146
|
+
|
|
147
|
+
const handleSubmit = async (value: string) => {
|
|
148
|
+
const trimmed = value.trim();
|
|
149
|
+
if (!trimmed || thinking) return;
|
|
150
|
+
setInput('');
|
|
151
|
+
|
|
152
|
+
const result = parseCommand(trimmed, cwd);
|
|
153
|
+
|
|
154
|
+
switch (result.type) {
|
|
155
|
+
case 'system': addSystemMessage(result.content); return;
|
|
156
|
+
case 'open_model_select': setShowModelInput(true); return;
|
|
157
|
+
case 'clear': clearHistory(); return;
|
|
158
|
+
case 'exit': exit(); return;
|
|
159
|
+
case 'cd': {
|
|
160
|
+
const newCwd = path.isAbsolute(result.path)
|
|
161
|
+
? result.path
|
|
162
|
+
: path.resolve(cwd, result.path);
|
|
163
|
+
setCwd(newCwd);
|
|
164
|
+
addSystemMessage(`cwd: ${newCwd}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
case 'send_to_ai': await sendMessage(result.content); return;
|
|
168
|
+
case 'none': await sendMessage(trimmed); return;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const handleModelConfirm = async (newModel: string) => {
|
|
173
|
+
setShowModelInput(false);
|
|
174
|
+
onModelChange(newModel);
|
|
175
|
+
try { await saveConfig({ apiKey, model: newModel }); } catch { /* ignore */ }
|
|
176
|
+
addSystemMessage(`Model: ${newModel}`);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
useInput((inp, key) => {
|
|
180
|
+
if (key.ctrl && inp === 'l') clearHistory();
|
|
181
|
+
if (key.ctrl && inp === 'c') exit();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const visibleMessages = display.slice(-40);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<Box flexDirection="column" height="100%">
|
|
188
|
+
<TopBar model={model} cwd={cwd} />
|
|
189
|
+
|
|
190
|
+
{showModelInput ? (
|
|
191
|
+
<ModelOverlay
|
|
192
|
+
currentModel={model}
|
|
193
|
+
onConfirm={(m) => { void handleModelConfirm(m); }}
|
|
194
|
+
onCancel={() => setShowModelInput(false)}
|
|
195
|
+
/>
|
|
196
|
+
) : (
|
|
197
|
+
<Box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
|
198
|
+
{visibleMessages.length === 0 && (
|
|
199
|
+
<Box marginTop={1}>
|
|
200
|
+
<Text color="gray">
|
|
201
|
+
Type a message or <Text color="#F26207">/help</Text> for commands.
|
|
202
|
+
</Text>
|
|
203
|
+
</Box>
|
|
204
|
+
)}
|
|
205
|
+
{visibleMessages.map((msg, i) => (
|
|
206
|
+
<RenderMessage key={i} msg={msg} />
|
|
207
|
+
))}
|
|
208
|
+
{thinking && (
|
|
209
|
+
<Box>
|
|
210
|
+
<Spinner type="dots" />
|
|
211
|
+
<Text color="#F26207"> Thinking...</Text>
|
|
212
|
+
</Box>
|
|
213
|
+
)}
|
|
214
|
+
</Box>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
<Box borderStyle="single" borderColor="#F26207" paddingX={1}>
|
|
218
|
+
<Text bold color="#F26207">{'> '}</Text>
|
|
219
|
+
<TextInput
|
|
220
|
+
value={input}
|
|
221
|
+
onChange={setInput}
|
|
222
|
+
onSubmit={(v) => { void handleSubmit(v); }}
|
|
223
|
+
placeholder={thinking ? 'Waiting...' : 'Message Recode...'}
|
|
224
|
+
focus={!showModelInput}
|
|
225
|
+
/>
|
|
226
|
+
</Box>
|
|
227
|
+
|
|
228
|
+
<StatusBar />
|
|
229
|
+
</Box>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { saveConfig, type RecodeConfig } from '../lib/config.js';
|
|
5
|
+
|
|
6
|
+
const EXAMPLES = [
|
|
7
|
+
'anthropic/claude-sonnet-4-5',
|
|
8
|
+
'openai/gpt-4o',
|
|
9
|
+
'google/gemini-2.5-pro',
|
|
10
|
+
'deepseek/deepseek-r1',
|
|
11
|
+
'meta-llama/llama-4-maverick',
|
|
12
|
+
'mistralai/codestral-latest',
|
|
13
|
+
'qwen/qwen3-235b-a22b',
|
|
14
|
+
'x-ai/grok-3',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
type Step = 'apiKey' | 'model' | 'saving';
|
|
18
|
+
|
|
19
|
+
interface SetupScreenProps {
|
|
20
|
+
onComplete: (config: RecodeConfig) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SetupScreen({ onComplete }: SetupScreenProps) {
|
|
24
|
+
const [step, setStep] = useState<Step>('apiKey');
|
|
25
|
+
const [apiKey, setApiKey] = useState('');
|
|
26
|
+
const [model, setModel] = useState('');
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useInput((_, key) => {
|
|
30
|
+
if (!key.return) return;
|
|
31
|
+
|
|
32
|
+
if (step === 'apiKey') {
|
|
33
|
+
if (apiKey.trim().length < 10) {
|
|
34
|
+
setError('API key too short — paste your OpenRouter key');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setError(null);
|
|
38
|
+
setStep('model');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (step === 'model') {
|
|
43
|
+
const m = model.trim();
|
|
44
|
+
if (!m.includes('/')) {
|
|
45
|
+
setError('Model must include provider, e.g. anthropic/claude-sonnet-4-5');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
setError(null);
|
|
49
|
+
void save(m);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const save = async (m: string) => {
|
|
54
|
+
setStep('saving');
|
|
55
|
+
const config: RecodeConfig = { apiKey: apiKey.trim(), model: m };
|
|
56
|
+
try {
|
|
57
|
+
await saveConfig(config);
|
|
58
|
+
onComplete(config);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
61
|
+
setStep('model');
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<Box flexDirection="column" padding={2} gap={1}>
|
|
67
|
+
<Box flexDirection="column">
|
|
68
|
+
<Text bold color="#F26207">Recode — First Launch Setup</Text>
|
|
69
|
+
<Text color="gray">Autonomous AI coding agent by DemirArch</Text>
|
|
70
|
+
<Text color="gray">Config: ~/recode-data/config.json</Text>
|
|
71
|
+
</Box>
|
|
72
|
+
|
|
73
|
+
{step === 'apiKey' && (
|
|
74
|
+
<Box flexDirection="column" gap={1} marginTop={1}>
|
|
75
|
+
<Text>OpenRouter API key:</Text>
|
|
76
|
+
<Text color="gray" dimColor>Get one at openrouter.ai/keys</Text>
|
|
77
|
+
<Box>
|
|
78
|
+
<Text color="#F26207">{'> '}</Text>
|
|
79
|
+
<TextInput value={apiKey} onChange={setApiKey} mask="*" placeholder="sk-or-..." />
|
|
80
|
+
</Box>
|
|
81
|
+
<Text color="gray" dimColor>Press Enter to continue</Text>
|
|
82
|
+
{error && <Text color="red">{error}</Text>}
|
|
83
|
+
</Box>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{step === 'model' && (
|
|
87
|
+
<Box flexDirection="column" gap={1} marginTop={1}>
|
|
88
|
+
<Text>Model ID <Text color="gray">(provider/model-name)</Text></Text>
|
|
89
|
+
<Box>
|
|
90
|
+
<Text color="#F26207">{'> '}</Text>
|
|
91
|
+
<TextInput
|
|
92
|
+
value={model}
|
|
93
|
+
onChange={setModel}
|
|
94
|
+
placeholder="anthropic/claude-sonnet-4-5"
|
|
95
|
+
/>
|
|
96
|
+
</Box>
|
|
97
|
+
<Text color="gray" dimColor>Press Enter to confirm • /model to change later</Text>
|
|
98
|
+
|
|
99
|
+
<Box flexDirection="column" marginTop={1}>
|
|
100
|
+
<Text color="gray" dimColor>Examples:</Text>
|
|
101
|
+
{EXAMPLES.map((e) => (
|
|
102
|
+
<Text key={e} color="gray" dimColor> {e}</Text>
|
|
103
|
+
))}
|
|
104
|
+
</Box>
|
|
105
|
+
|
|
106
|
+
{error && <Text color="red">{error}</Text>}
|
|
107
|
+
</Box>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{step === 'saving' && <Text color="#F26207">Saving config...</Text>}
|
|
111
|
+
</Box>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
const CMD = ({ c, label }: { c: string; label: string }) => (
|
|
5
|
+
<Box gap={0} marginRight={2}>
|
|
6
|
+
<Text bold color="#F26207">{c}</Text>
|
|
7
|
+
<Text color="gray"> {label}</Text>
|
|
8
|
+
</Box>
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export function StatusBar() {
|
|
12
|
+
return (
|
|
13
|
+
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
|
14
|
+
<CMD c="/help" label="help" />
|
|
15
|
+
<CMD c="/model" label="model" />
|
|
16
|
+
<CMD c="/clear" label="clear" />
|
|
17
|
+
<CMD c="/cd" label="cd" />
|
|
18
|
+
<CMD c="/ls" label="ls" />
|
|
19
|
+
<CMD c="/read" label="read" />
|
|
20
|
+
<CMD c="/exit" label="exit" />
|
|
21
|
+
</Box>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getModelLabel } from '../lib/models.js';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
interface TopBarProps {
|
|
8
|
+
model: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function shortenPath(p: string): string {
|
|
13
|
+
const home = os.homedir();
|
|
14
|
+
if (p.startsWith(home)) return '~' + p.slice(home.length);
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TopBar({ model, cwd }: TopBarProps) {
|
|
19
|
+
const shortModel = getModelLabel(model);
|
|
20
|
+
const shortCwd = shortenPath(cwd);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Box borderStyle="single" borderColor="#F26207" paddingX={1} justifyContent="space-between">
|
|
24
|
+
<Box gap={2}>
|
|
25
|
+
<Text bold color="#F26207">Recode</Text>
|
|
26
|
+
<Text color="gray">{shortCwd}</Text>
|
|
27
|
+
</Box>
|
|
28
|
+
<Text color="gray">
|
|
29
|
+
{'model: '}<Text color="#F26207">{shortModel}</Text>
|
|
30
|
+
</Text>
|
|
31
|
+
</Box>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useState, useRef, useCallback } from 'react';
|
|
2
|
+
import { callOpenRouter, type Message, type ToolCall } from '../lib/openrouter.js';
|
|
3
|
+
import { executeTool } from '../lib/tools.js';
|
|
4
|
+
|
|
5
|
+
export type DisplayMessage =
|
|
6
|
+
| { type: 'user'; content: string }
|
|
7
|
+
| { type: 'assistant'; content: string }
|
|
8
|
+
| { type: 'tool_call'; name: string; args: string; status: 'running' | 'done' | 'error'; result?: string }
|
|
9
|
+
| { type: 'system'; content: string }
|
|
10
|
+
| { type: 'error'; content: string };
|
|
11
|
+
|
|
12
|
+
export function useAgent(model: string, apiKey: string, cwd: string) {
|
|
13
|
+
const displayRef = useRef<DisplayMessage[]>([]);
|
|
14
|
+
const [display, setDisplay] = useState<DisplayMessage[]>([]);
|
|
15
|
+
const [apiMessages, setApiMessages] = useState<Message[]>([]);
|
|
16
|
+
const [thinking, setThinking] = useState(false);
|
|
17
|
+
|
|
18
|
+
// Sync ref → state
|
|
19
|
+
const flush = () => setDisplay([...displayRef.current]);
|
|
20
|
+
|
|
21
|
+
const addDisplay = (msg: DisplayMessage): number => {
|
|
22
|
+
const idx = displayRef.current.length;
|
|
23
|
+
displayRef.current = [...displayRef.current, msg];
|
|
24
|
+
flush();
|
|
25
|
+
return idx;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const updateDisplay = (idx: number, updates: Partial<DisplayMessage>) => {
|
|
29
|
+
displayRef.current = displayRef.current.map((m, i) =>
|
|
30
|
+
i === idx ? { ...m, ...updates } as DisplayMessage : m
|
|
31
|
+
);
|
|
32
|
+
flush();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const addSystemMessage = useCallback((content: string) => {
|
|
36
|
+
addDisplay({ type: 'system', content });
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const clearHistory = useCallback(() => {
|
|
40
|
+
displayRef.current = [];
|
|
41
|
+
setDisplay([]);
|
|
42
|
+
setApiMessages([]);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const sendMessage = useCallback(
|
|
46
|
+
async (userInput: string) => {
|
|
47
|
+
if (thinking) return;
|
|
48
|
+
|
|
49
|
+
// Add user message to display
|
|
50
|
+
addDisplay({ type: 'user', content: userInput });
|
|
51
|
+
setThinking(true);
|
|
52
|
+
|
|
53
|
+
// Build current API messages
|
|
54
|
+
const newUserMsg: Message = { role: 'user', content: userInput };
|
|
55
|
+
let currentMessages: Message[] = [...apiMessages, newUserMsg];
|
|
56
|
+
setApiMessages(currentMessages);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Agentic loop — run until no more tool calls
|
|
60
|
+
while (true) {
|
|
61
|
+
const response = await callOpenRouter(currentMessages, model, apiKey);
|
|
62
|
+
|
|
63
|
+
if (response.tool_calls && response.tool_calls.length > 0) {
|
|
64
|
+
// Record assistant message with tool calls
|
|
65
|
+
currentMessages = [
|
|
66
|
+
...currentMessages,
|
|
67
|
+
{
|
|
68
|
+
role: 'assistant',
|
|
69
|
+
content: response.content,
|
|
70
|
+
tool_calls: response.tool_calls,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// Execute each tool
|
|
75
|
+
const toolResultMessages: Message[] = [];
|
|
76
|
+
|
|
77
|
+
for (const tc of response.tool_calls) {
|
|
78
|
+
const displayIdx = addDisplay({
|
|
79
|
+
type: 'tool_call',
|
|
80
|
+
name: tc.function.name,
|
|
81
|
+
args: tc.function.arguments,
|
|
82
|
+
status: 'running',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let result: string;
|
|
86
|
+
let status: 'done' | 'error' = 'done';
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const parsedArgs = JSON.parse(tc.function.arguments) as Record<string, unknown>;
|
|
90
|
+
result = await executeTool(tc.function.name, parsedArgs, cwd);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
93
|
+
status = 'error';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
updateDisplay(displayIdx, { status, result });
|
|
97
|
+
|
|
98
|
+
toolResultMessages.push({
|
|
99
|
+
role: 'tool',
|
|
100
|
+
content: result,
|
|
101
|
+
tool_call_id: tc.id,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
currentMessages = [...currentMessages, ...toolResultMessages];
|
|
106
|
+
} else {
|
|
107
|
+
// Final text response
|
|
108
|
+
const content = response.content ?? '(no response)';
|
|
109
|
+
addDisplay({ type: 'assistant', content });
|
|
110
|
+
setApiMessages(currentMessages);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
addDisplay({ type: 'error', content: msg });
|
|
117
|
+
} finally {
|
|
118
|
+
setThinking(false);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[apiMessages, model, apiKey, cwd, thinking]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
display,
|
|
126
|
+
thinking,
|
|
127
|
+
sendMessage,
|
|
128
|
+
clearHistory,
|
|
129
|
+
addSystemMessage,
|
|
130
|
+
apiMessages,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { MODELS } from './models.js';
|
|
2
|
+
|
|
3
|
+
export type CommandResult =
|
|
4
|
+
| { type: 'system'; content: string }
|
|
5
|
+
| { type: 'open_model_select' }
|
|
6
|
+
| { type: 'clear' }
|
|
7
|
+
| { type: 'exit' }
|
|
8
|
+
| { type: 'cd'; path: string }
|
|
9
|
+
| { type: 'none' }
|
|
10
|
+
| { type: 'send_to_ai'; content: string };
|
|
11
|
+
|
|
12
|
+
export const HELP_TEXT = `
|
|
13
|
+
Recode — AI Coding Agent (github.com/demirgitbuh/recode)
|
|
14
|
+
|
|
15
|
+
SLASH COMMANDS
|
|
16
|
+
/help Show this help
|
|
17
|
+
/model Change AI model
|
|
18
|
+
/models List all available models
|
|
19
|
+
/clear Clear conversation history
|
|
20
|
+
/exit /quit Exit Recode
|
|
21
|
+
/cd <path> Change working directory
|
|
22
|
+
/ls [path] List directory contents
|
|
23
|
+
/read <path> Read file and show contents
|
|
24
|
+
/tools List available AI tools
|
|
25
|
+
/cwd Show current working directory
|
|
26
|
+
/version Show Recode version
|
|
27
|
+
|
|
28
|
+
KEYBINDINGS
|
|
29
|
+
Enter Send message
|
|
30
|
+
Ctrl+C Exit
|
|
31
|
+
Ctrl+L Clear screen
|
|
32
|
+
`.trim();
|
|
33
|
+
|
|
34
|
+
const TOOLS_TEXT = `
|
|
35
|
+
AI TOOLS (called automatically by the AI)
|
|
36
|
+
read_file <path> Read file contents
|
|
37
|
+
write_file <path> <content> Create or overwrite a file
|
|
38
|
+
list_directory [path] List directory entries
|
|
39
|
+
create_directory <path> Create directory (recursive)
|
|
40
|
+
delete_file <path> Delete a file
|
|
41
|
+
execute_command <cmd> Run shell command in cwd
|
|
42
|
+
search_files <pattern> Search files for pattern
|
|
43
|
+
`.trim();
|
|
44
|
+
|
|
45
|
+
export function parseCommand(input: string, cwd: string): CommandResult {
|
|
46
|
+
const trimmed = input.trim();
|
|
47
|
+
if (!trimmed.startsWith('/')) return { type: 'none' };
|
|
48
|
+
|
|
49
|
+
const [cmd, ...rest] = trimmed.slice(1).split(' ');
|
|
50
|
+
const arg = rest.join(' ').trim();
|
|
51
|
+
|
|
52
|
+
switch (cmd.toLowerCase()) {
|
|
53
|
+
case 'help':
|
|
54
|
+
return { type: 'system', content: HELP_TEXT };
|
|
55
|
+
|
|
56
|
+
case 'model':
|
|
57
|
+
return { type: 'open_model_select' };
|
|
58
|
+
|
|
59
|
+
case 'models': {
|
|
60
|
+
const list = MODELS.map((m) => ` ${m.provider.padEnd(11)} ${m.label.padEnd(22)} ${m.id}`).join('\n');
|
|
61
|
+
return { type: 'system', content: `Available models:\n\n${list}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
case 'clear':
|
|
65
|
+
return { type: 'clear' };
|
|
66
|
+
|
|
67
|
+
case 'exit':
|
|
68
|
+
case 'quit':
|
|
69
|
+
case 'q':
|
|
70
|
+
return { type: 'exit' };
|
|
71
|
+
|
|
72
|
+
case 'cd':
|
|
73
|
+
if (!arg) return { type: 'system', content: `Usage: /cd <path>` };
|
|
74
|
+
return { type: 'cd', path: arg };
|
|
75
|
+
|
|
76
|
+
case 'ls': {
|
|
77
|
+
const p = arg || cwd;
|
|
78
|
+
return { type: 'send_to_ai', content: `List the directory: ${p}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case 'read': {
|
|
82
|
+
if (!arg) return { type: 'system', content: `Usage: /read <path>` };
|
|
83
|
+
return { type: 'send_to_ai', content: `Read and show the file: ${arg}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'tools':
|
|
87
|
+
return { type: 'system', content: TOOLS_TEXT };
|
|
88
|
+
|
|
89
|
+
case 'cwd':
|
|
90
|
+
return { type: 'system', content: `Working directory: ${cwd}` };
|
|
91
|
+
|
|
92
|
+
case 'version':
|
|
93
|
+
return { type: 'system', content: 'Recode v0.1.0 — by DemirArch' };
|
|
94
|
+
|
|
95
|
+
default:
|
|
96
|
+
return {
|
|
97
|
+
type: 'system',
|
|
98
|
+
content: `Unknown command: /${cmd}\nType /help for available commands.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export interface RecodeConfig {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
model: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), 'recode-data');
|
|
11
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
12
|
+
|
|
13
|
+
export async function loadConfig(): Promise<RecodeConfig | null> {
|
|
14
|
+
try {
|
|
15
|
+
const raw = await fs.readFile(CONFIG_PATH, 'utf-8');
|
|
16
|
+
const parsed = JSON.parse(raw) as Partial<RecodeConfig>;
|
|
17
|
+
if (parsed.apiKey && parsed.model) {
|
|
18
|
+
return { apiKey: parsed.apiKey, model: parsed.model };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function saveConfig(config: RecodeConfig): Promise<void> {
|
|
27
|
+
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
28
|
+
await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
|
|
29
|
+
}
|