aryx-cli 1.0.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/bin/aryx +15 -0
- package/bin/aryx.cjs +15 -0
- package/package.json +42 -0
- package/src/components/CommandSuggestions.tsx +62 -0
- package/src/components/Header.tsx +77 -0
- package/src/components/HelpView.tsx +36 -0
- package/src/components/IAmAryx.tsx +51 -0
- package/src/components/Loader.tsx +57 -0
- package/src/components/LoginView.tsx +129 -0
- package/src/components/Message.tsx +65 -0
- package/src/components/ThemeSelector.tsx +92 -0
- package/src/components/UsageView.tsx +52 -0
- package/src/constants.ts +32 -0
- package/src/hooks/useChat.ts +306 -0
- package/src/index.tsx +18 -0
- package/src/screens/ChatScreen.tsx +277 -0
- package/src/screens/SetupScreen.tsx +85 -0
- package/src/services/ai.ts +187 -0
- package/src/services/auth.ts +100 -0
- package/src/services/config.ts +30 -0
- package/src/services/fileTools.ts +257 -0
- package/src/services/firestoreRest.ts +119 -0
- package/src/services/loginServer.ts +91 -0
- package/src/services/platform.ts +26 -0
- package/src/theme.ts +46 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { theme } from '../theme.js';
|
|
4
|
+
import type { SessionUsage } from '../hooks/useChat.js';
|
|
5
|
+
|
|
6
|
+
const fmtCost = (n: number) => n === 0 ? '$0.00' : `$${n.toFixed(6)}`;
|
|
7
|
+
const fmtMs = (ms: number) => {
|
|
8
|
+
if (ms === 0) return 'N/A';
|
|
9
|
+
if (ms < 1000) return `${ms}ms`;
|
|
10
|
+
const s = Math.floor(ms / 1000);
|
|
11
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const Row: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
|
15
|
+
<Box marginBottom={0}>
|
|
16
|
+
<Box width={20}><Text color="white">{label}</Text></Box>
|
|
17
|
+
<Text color={theme.colors.primary} bold>{value}</Text>
|
|
18
|
+
</Box>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
interface Props { sessionUsage: SessionUsage }
|
|
22
|
+
|
|
23
|
+
const UsageView: React.FC<Props> = ({ sessionUsage }) => (
|
|
24
|
+
<Box
|
|
25
|
+
flexDirection="column"
|
|
26
|
+
marginTop={1}
|
|
27
|
+
paddingLeft={2}>
|
|
28
|
+
<Box marginBottom={1}>
|
|
29
|
+
<Text bold color={theme.colors.highlight}>Usage</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
|
|
32
|
+
<Row label="Total Tokens" value={`${sessionUsage.totalTokens}`} />
|
|
33
|
+
<Row label="Input Tokens" value={`${sessionUsage.inputTokens}`} />
|
|
34
|
+
<Row label="Output Tokens" value={`${sessionUsage.outputTokens}`} />
|
|
35
|
+
<Row label="Total Cost" value={fmtCost(sessionUsage.cost)} />
|
|
36
|
+
|
|
37
|
+
<Box marginY={1}>
|
|
38
|
+
<Text color="gray">{'─'.repeat(32)}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
|
|
41
|
+
<Row label="Model Name" value={sessionUsage.model || 'N/A'} />
|
|
42
|
+
<Row label="Response Time" value={fmtMs(sessionUsage.totalResponseMs)} />
|
|
43
|
+
<Row label="Messages Count" value={`${sessionUsage.messagesCount}`} />
|
|
44
|
+
|
|
45
|
+
<Box marginTop={1}>
|
|
46
|
+
<Text color="gray" italic>Esc to cancel</Text>
|
|
47
|
+
</Box>
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
export default UsageView;
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = dirname(__filename);
|
|
7
|
+
|
|
8
|
+
const pkg = JSON.parse(
|
|
9
|
+
readFileSync(join(__dirname, '../package.json'), 'utf-8')
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/** Brand display names — change here to update everywhere. */
|
|
13
|
+
export const BRAND_NAME = 'Aryx';
|
|
14
|
+
export const BRAND_NAME_LOWER = 'aryx';
|
|
15
|
+
|
|
16
|
+
/** App version from package.json */
|
|
17
|
+
export const VERSION = pkg.version;
|
|
18
|
+
|
|
19
|
+
/** Web dashboard URL used for auth and upgrade flows. */
|
|
20
|
+
export const WEB_URL = process.env.ARYX_WEB_URL ?? 'https://aryx.vasuu.in';
|
|
21
|
+
|
|
22
|
+
/** Firebase config — project ID and API key are not sensitive (client-side values). */
|
|
23
|
+
export const FIREBASE_PROJECT_ID = process.env.FIREBASE_PROJECT_ID ?? 'aryx-lab';
|
|
24
|
+
export const FIREBASE_API_KEY = process.env.FIREBASE_API_KEY ?? 'AIzaSyDlD8_gQBTlCbje-zrAf10lo-mY4tYGSVQ';
|
|
25
|
+
|
|
26
|
+
/** OpenRouter config — bundled keys for subscription-gated usage. */
|
|
27
|
+
export const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? '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';
|
|
28
|
+
export const OPENROUTER_API_KEY_BACKUP = process.env.OPENROUTER_API_KEY_BACKUP ?? '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';
|
|
29
|
+
export const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-4o-mini';
|
|
30
|
+
|
|
31
|
+
/** Spinner animation frames shared by Loader and LoginView. */
|
|
32
|
+
export const SPINNER_FRAMES = ['*', '✶', '✻', '✻', '✶', '*', '✢', '·', '✢'];
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { chatService } from '../services/ai.js';
|
|
6
|
+
import { commands } from '../components/CommandSuggestions.js';
|
|
7
|
+
import { readAuth, getValidToken, deleteConfig, type AuthData } from '../services/auth.js';
|
|
8
|
+
import { saveMessageToFirestore, getUserPlan, incrementMessageCount } from '../services/firestoreRest.js';
|
|
9
|
+
import { openBrowser } from '../services/platform.js';
|
|
10
|
+
import { WEB_URL } from '../constants.js';
|
|
11
|
+
|
|
12
|
+
export type Message = {
|
|
13
|
+
id: string;
|
|
14
|
+
role: 'user' | 'assistant';
|
|
15
|
+
content: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type SessionUsage = {
|
|
19
|
+
inputTokens: number;
|
|
20
|
+
outputTokens: number;
|
|
21
|
+
totalTokens: number;
|
|
22
|
+
cost: number;
|
|
23
|
+
model: string;
|
|
24
|
+
totalResponseMs: number;
|
|
25
|
+
messagesCount: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type View = 'chat' | 'help' | 'theme' | 'usage' | 'login' | 'logout' | 'iamaryx';
|
|
29
|
+
|
|
30
|
+
const emptyUsage = (): SessionUsage => ({ inputTokens: 0, outputTokens: 0, totalTokens: 0, cost: 0, model: '', totalResponseMs: 0, messagesCount: 0 });
|
|
31
|
+
|
|
32
|
+
const scan = (d: string, b = ''): string[] => {
|
|
33
|
+
try {
|
|
34
|
+
return fs.readdirSync(d, { withFileTypes: true }).flatMap(e => {
|
|
35
|
+
if (/^(node_modules|\.git|\.next|dist)$/.test(e.name)) return [];
|
|
36
|
+
if (e.isDirectory()) return scan(path.join(d, e.name), b ? `${b}/${e.name}` : e.name);
|
|
37
|
+
return [b ? `${b}/${e.name}` : e.name];
|
|
38
|
+
});
|
|
39
|
+
} catch { return []; }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let _f: string[] | null = null;
|
|
43
|
+
const getFiles = () => (_f ??= scan(process.cwd()));
|
|
44
|
+
|
|
45
|
+
export const useChat = () => {
|
|
46
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
47
|
+
const [input, setInput] = useState('');
|
|
48
|
+
const [view, setView] = useState<View>('chat');
|
|
49
|
+
const [isExiting, setIsExiting] = useState(false);
|
|
50
|
+
const [logoutDone, setLogoutDone] = useState(false);
|
|
51
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
52
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
53
|
+
const [sessionUsage, setSessionUsage] = useState<SessionUsage>(emptyUsage());
|
|
54
|
+
const [user, setUser] = useState<AuthData | null>(readAuth());
|
|
55
|
+
|
|
56
|
+
const [planMessages, setPlanMessages] = useState(0);
|
|
57
|
+
const [subscriptionPlan, setSubscriptionPlan] = useState('free');
|
|
58
|
+
const [isLimitReached, setIsLimitReached] = useState(false);
|
|
59
|
+
const [isFetchingUser, setIsFetchingUser] = useState(() => !!readAuth());
|
|
60
|
+
const [userFetchError, setUserFetchError] = useState(false);
|
|
61
|
+
const [commandError, setCommandError] = useState(false);
|
|
62
|
+
const messageCount = useRef(0);
|
|
63
|
+
const upgradeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
64
|
+
const prevPlanRef = useRef('');
|
|
65
|
+
|
|
66
|
+
const [sessionKey, setSessionKey] = useState(randomUUID());
|
|
67
|
+
const sessionId = useRef<string>(sessionKey);
|
|
68
|
+
const sessionStart = useRef<number>(Date.now());
|
|
69
|
+
const sessionTitle = useRef<string>('');
|
|
70
|
+
|
|
71
|
+
const fetchPlan = async (uid: string) => {
|
|
72
|
+
try {
|
|
73
|
+
const token = await getValidToken();
|
|
74
|
+
if (!token) { setUserFetchError(true); setIsFetchingUser(false); return; }
|
|
75
|
+
const p = await getUserPlan(uid, token);
|
|
76
|
+
if (!p) { setUserFetchError(true); setIsFetchingUser(false); return; }
|
|
77
|
+
setPlanMessages(p.planMessages);
|
|
78
|
+
setSubscriptionPlan(p.subscriptionPlan);
|
|
79
|
+
messageCount.current = p.messageCount;
|
|
80
|
+
setIsLimitReached(p.planMessages > 0 && p.messageCount >= p.planMessages);
|
|
81
|
+
} catch {
|
|
82
|
+
setUserFetchError(true);
|
|
83
|
+
} finally {
|
|
84
|
+
setIsFetchingUser(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Fetch plan on login
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!user) { setPlanMessages(20); setSubscriptionPlan('free'); messageCount.current = 0; setIsLimitReached(false); setIsFetchingUser(false); return; }
|
|
91
|
+
setUserFetchError(false);
|
|
92
|
+
fetchPlan(user.uid);
|
|
93
|
+
}, [user]);
|
|
94
|
+
|
|
95
|
+
// Poll Firebase every 5s when limit is reached — unlock as soon as plan refreshes
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!isLimitReached || !user) return;
|
|
98
|
+
const id = setInterval(() => fetchPlan(user.uid), 5000);
|
|
99
|
+
return () => clearInterval(id);
|
|
100
|
+
}, [isLimitReached, user]);
|
|
101
|
+
|
|
102
|
+
const isSuggesting = input.startsWith('/');
|
|
103
|
+
const filteredCommands = isSuggesting
|
|
104
|
+
? commands.filter(c => {
|
|
105
|
+
if (!c.name.startsWith(input)) return false;
|
|
106
|
+
if (user && c.name === '/login') return false;
|
|
107
|
+
if (!user && c.name === '/logout') return false;
|
|
108
|
+
return true;
|
|
109
|
+
})
|
|
110
|
+
: [];
|
|
111
|
+
|
|
112
|
+
const atMatch = input.startsWith('@') ? input.match(/^@([^\s]*)$/) : null;
|
|
113
|
+
const isFileSuggesting = !!atMatch && !isSuggesting;
|
|
114
|
+
const fileSuggestions = isFileSuggesting ? getFiles().filter(f => f.toLowerCase().includes(atMatch![1].toLowerCase())).slice(0, 50) : [];
|
|
115
|
+
|
|
116
|
+
const resetSession = (refetchPlan = false) => {
|
|
117
|
+
process.stdout.write('\x1Bc');
|
|
118
|
+
setMessages([]);
|
|
119
|
+
setSessionUsage(emptyUsage());
|
|
120
|
+
const newKey = randomUUID();
|
|
121
|
+
setSessionKey(newKey);
|
|
122
|
+
sessionId.current = newKey;
|
|
123
|
+
sessionStart.current = Date.now();
|
|
124
|
+
sessionTitle.current = '';
|
|
125
|
+
// Reset limit state immediately; fetchPlan will re-sync via user useEffect
|
|
126
|
+
setIsLimitReached(false);
|
|
127
|
+
messageCount.current = 0;
|
|
128
|
+
const auth = readAuth();
|
|
129
|
+
if (auth && refetchPlan) setIsFetchingUser(true);
|
|
130
|
+
setUser(auth);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Auto-refresh when subscriptionPlan changes during upgrade polling
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!prevPlanRef.current) { prevPlanRef.current = subscriptionPlan; return; }
|
|
136
|
+
if (prevPlanRef.current !== subscriptionPlan) {
|
|
137
|
+
prevPlanRef.current = subscriptionPlan;
|
|
138
|
+
if (upgradeIntervalRef.current) {
|
|
139
|
+
clearInterval(upgradeIntervalRef.current);
|
|
140
|
+
upgradeIntervalRef.current = null;
|
|
141
|
+
resetSession();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}, [subscriptionPlan]);
|
|
145
|
+
|
|
146
|
+
const handleSetInput = (value: string) => {
|
|
147
|
+
if (commandError) setCommandError(false);
|
|
148
|
+
if (view === 'help' || view === 'usage' || view === 'iamaryx') {
|
|
149
|
+
setView('chat');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (isLimitReached && !value.startsWith('/') && value !== '?') return;
|
|
153
|
+
if (!input && value === '?') {
|
|
154
|
+
setView('help');
|
|
155
|
+
return setInput('');
|
|
156
|
+
}
|
|
157
|
+
setInput(value);
|
|
158
|
+
setSelectedIndex(0);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const executeCommand = (cmd: string) => {
|
|
162
|
+
setInput('');
|
|
163
|
+
if (cmd === '/clear') return resetSession();
|
|
164
|
+
if (cmd === '/help') return setView('help');
|
|
165
|
+
if (cmd === '/theme') return setView('theme');
|
|
166
|
+
if (cmd === '/usage') return setView('usage');
|
|
167
|
+
if (cmd === '/login') return setView('login');
|
|
168
|
+
if (cmd === '/logout') { deleteConfig(); setLogoutDone(true); return setTimeout(() => process.exit(0), 1500); }
|
|
169
|
+
if (cmd === '/exit') {
|
|
170
|
+
setIsExiting(true);
|
|
171
|
+
return setTimeout(() => process.exit(0), 1500);
|
|
172
|
+
}
|
|
173
|
+
if (cmd === '/aryx') return setView('iamaryx');
|
|
174
|
+
if (cmd === '/upgrade') {
|
|
175
|
+
const auth = readAuth();
|
|
176
|
+
if (!auth) { setCommandError(true); return; }
|
|
177
|
+
openBrowser(`${WEB_URL}/upgrade?uid=${auth.uid}`);
|
|
178
|
+
// Clear any existing upgrade interval before starting a new one
|
|
179
|
+
if (upgradeIntervalRef.current) clearInterval(upgradeIntervalRef.current);
|
|
180
|
+
upgradeIntervalRef.current = setInterval(() => fetchPlan(auth.uid), 10000);
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
if (upgradeIntervalRef.current) {
|
|
183
|
+
clearInterval(upgradeIntervalRef.current);
|
|
184
|
+
upgradeIntervalRef.current = null;
|
|
185
|
+
}
|
|
186
|
+
}, 5 * 60 * 1000); // stop after 5min
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handleSubmit = (value: string) => {
|
|
191
|
+
if (isLoading) return;
|
|
192
|
+
if (isSuggesting && filteredCommands.length > 0) {
|
|
193
|
+
return executeCommand(filteredCommands[selectedIndex].name);
|
|
194
|
+
}
|
|
195
|
+
if (isFileSuggesting && fileSuggestions.length > 0) {
|
|
196
|
+
setInput(input.replace(/@[^\s]*$/, `@${fileSuggestions[selectedIndex]} `));
|
|
197
|
+
setSelectedIndex(0);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!value.trim()) return;
|
|
202
|
+
if (value.startsWith('/')) return executeCommand(value);
|
|
203
|
+
|
|
204
|
+
if (planMessages > 0 && messageCount.current >= planMessages) {
|
|
205
|
+
setIsLimitReached(true);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const ctx = [...value.matchAll(/@(\S+)/g)]
|
|
210
|
+
.map(m => { try { return `### File: ${m[1]}\n\`\`\`\n${fs.readFileSync(path.resolve(process.cwd(), m[1]), 'utf-8')}\n\`\`\``; } catch { return ''; } })
|
|
211
|
+
.filter(Boolean).join('\n\n');
|
|
212
|
+
const content = ctx ? `${ctx}\n\n${value}` : value;
|
|
213
|
+
|
|
214
|
+
const messageId = randomUUID();
|
|
215
|
+
const userMessage: Message = { id: randomUUID(), role: 'user', content: value };
|
|
216
|
+
if (!sessionTitle.current) sessionTitle.current = value;
|
|
217
|
+
const updatedMessages = [...messages, userMessage];
|
|
218
|
+
setMessages(updatedMessages);
|
|
219
|
+
setInput('');
|
|
220
|
+
setIsLoading(true);
|
|
221
|
+
|
|
222
|
+
(async () => {
|
|
223
|
+
try {
|
|
224
|
+
const aiResponse = await chatService.fetchResponse({
|
|
225
|
+
messages: [...updatedMessages.slice(0, -1).map(m => ({ role: m.role, content: m.content })), { role: 'user' as const, content }],
|
|
226
|
+
});
|
|
227
|
+
setMessages((prev) => [...prev, { id: randomUUID(), role: 'assistant', content: aiResponse.content }]);
|
|
228
|
+
|
|
229
|
+
if (aiResponse.usage) {
|
|
230
|
+
const u = aiResponse.usage;
|
|
231
|
+
setSessionUsage(prev => {
|
|
232
|
+
const updated = {
|
|
233
|
+
inputTokens: prev.inputTokens + u.inputTokens,
|
|
234
|
+
outputTokens: prev.outputTokens + u.outputTokens,
|
|
235
|
+
totalTokens: prev.totalTokens + u.totalTokens,
|
|
236
|
+
cost: prev.cost + u.cost,
|
|
237
|
+
model: u.model || prev.model,
|
|
238
|
+
totalResponseMs: prev.totalResponseMs + u.responseTimeMs,
|
|
239
|
+
messagesCount: updatedMessages.filter(m => m.role === 'user').length,
|
|
240
|
+
};
|
|
241
|
+
// Save to Firestore if logged in (fire-and-forget)
|
|
242
|
+
messageCount.current += 1;
|
|
243
|
+
if (planMessages > 0 && messageCount.current >= planMessages) setIsLimitReached(true);
|
|
244
|
+
const auth = readAuth();
|
|
245
|
+
if (auth) {
|
|
246
|
+
getValidToken().then(token => {
|
|
247
|
+
if (token) {
|
|
248
|
+
incrementMessageCount(auth.uid, messageCount.current, token).catch(() => {
|
|
249
|
+
// Revert local count on sync failure so it stays consistent with Firestore
|
|
250
|
+
messageCount.current -= 1;
|
|
251
|
+
setIsLimitReached(planMessages > 0 && messageCount.current >= planMessages);
|
|
252
|
+
});
|
|
253
|
+
saveMessageToFirestore(
|
|
254
|
+
auth.uid,
|
|
255
|
+
sessionId.current,
|
|
256
|
+
new Date(sessionStart.current),
|
|
257
|
+
messageId,
|
|
258
|
+
value,
|
|
259
|
+
aiResponse.content,
|
|
260
|
+
u,
|
|
261
|
+
{ ...updated },
|
|
262
|
+
token,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return updated;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error('AI Error:', error);
|
|
273
|
+
} finally {
|
|
274
|
+
setIsLoading(false);
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
messages,
|
|
281
|
+
input,
|
|
282
|
+
setInput: handleSetInput,
|
|
283
|
+
rawSetInput: setInput,
|
|
284
|
+
view,
|
|
285
|
+
setView,
|
|
286
|
+
isSuggesting,
|
|
287
|
+
isFileSuggesting,
|
|
288
|
+
fileSuggestions,
|
|
289
|
+
isExiting,
|
|
290
|
+
logoutDone,
|
|
291
|
+
isLoading,
|
|
292
|
+
handleSubmit,
|
|
293
|
+
filteredCommands,
|
|
294
|
+
selectedIndex,
|
|
295
|
+
setSelectedIndex,
|
|
296
|
+
sessionUsage,
|
|
297
|
+
resetSession,
|
|
298
|
+
sessionKey,
|
|
299
|
+
user,
|
|
300
|
+
isLimitReached,
|
|
301
|
+
subscriptionPlan,
|
|
302
|
+
isFetchingUser,
|
|
303
|
+
userFetchError,
|
|
304
|
+
commandError,
|
|
305
|
+
};
|
|
306
|
+
};
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { render } from 'ink';
|
|
3
|
+
import ChatScreen from './screens/ChatScreen.js';
|
|
4
|
+
import SetupScreen from './screens/SetupScreen.js';
|
|
5
|
+
import { isSetupDone } from './theme.js';
|
|
6
|
+
|
|
7
|
+
const App: React.FC = () => {
|
|
8
|
+
// // Set to false when you want to re-run initial setup (e.g., during development/testing)
|
|
9
|
+
// const [ready, setReady] = useState(false);
|
|
10
|
+
const [ready, setReady] = useState(isSetupDone());
|
|
11
|
+
|
|
12
|
+
if (!ready) return <SetupScreen onComplete={() => setReady(true)} />;
|
|
13
|
+
|
|
14
|
+
return <ChatScreen />;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
render(<App />, { exitOnCtrlC: false });
|
|
18
|
+
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text, useInput, Static } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { useChat } from '../hooks/useChat.js';
|
|
5
|
+
import Header from '../components/Header.js';
|
|
6
|
+
import Message from '../components/Message.js';
|
|
7
|
+
import Loader from '../components/Loader.js';
|
|
8
|
+
import CommandSuggestions from '../components/CommandSuggestions.js';
|
|
9
|
+
import HelpView from '../components/HelpView.js';
|
|
10
|
+
import ThemeSelector from '../components/ThemeSelector.js';
|
|
11
|
+
import UsageView from '../components/UsageView.js';
|
|
12
|
+
import LoginView from '../components/LoginView.js';
|
|
13
|
+
import IAmAryxView, { GradientText } from '../components/IAmAryx.js';
|
|
14
|
+
import { theme } from '../theme.js';
|
|
15
|
+
import { BRAND_NAME, BRAND_NAME_LOWER } from '../constants.js';
|
|
16
|
+
|
|
17
|
+
const ChatScreen: React.FC = () => {
|
|
18
|
+
const [showExitPrompt, setShowExitPrompt] = React.useState(false);
|
|
19
|
+
const {
|
|
20
|
+
messages,
|
|
21
|
+
input,
|
|
22
|
+
setInput,
|
|
23
|
+
rawSetInput,
|
|
24
|
+
view,
|
|
25
|
+
setView,
|
|
26
|
+
isSuggesting,
|
|
27
|
+
isExiting,
|
|
28
|
+
isFileSuggesting,
|
|
29
|
+
fileSuggestions,
|
|
30
|
+
logoutDone,
|
|
31
|
+
isLoading,
|
|
32
|
+
handleSubmit,
|
|
33
|
+
filteredCommands,
|
|
34
|
+
selectedIndex,
|
|
35
|
+
setSelectedIndex,
|
|
36
|
+
sessionUsage,
|
|
37
|
+
resetSession,
|
|
38
|
+
sessionKey,
|
|
39
|
+
user,
|
|
40
|
+
isLimitReached,
|
|
41
|
+
subscriptionPlan,
|
|
42
|
+
isFetchingUser,
|
|
43
|
+
userFetchError,
|
|
44
|
+
commandError,
|
|
45
|
+
} = useChat();
|
|
46
|
+
|
|
47
|
+
useInput((_inputArg: string, key: any) => {
|
|
48
|
+
if (key.ctrl && _inputArg === 'c') {
|
|
49
|
+
if (showExitPrompt) {
|
|
50
|
+
handleSubmit('/exit');
|
|
51
|
+
} else {
|
|
52
|
+
rawSetInput('');
|
|
53
|
+
setShowExitPrompt(true);
|
|
54
|
+
setTimeout(() => setShowExitPrompt(false), 1000);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (view === 'theme') return;
|
|
59
|
+
if (key.backspace || key.delete) {
|
|
60
|
+
if (input === '/') {
|
|
61
|
+
rawSetInput('');
|
|
62
|
+
} else if (view === 'help' && !input) {
|
|
63
|
+
setView('chat');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (key.escape) {
|
|
67
|
+
if (view === 'help' || view === 'usage' || view === 'iamaryx') {
|
|
68
|
+
setView('chat');
|
|
69
|
+
} else {
|
|
70
|
+
rawSetInput('');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if ((isSuggesting && filteredCommands.length > 0) || (isFileSuggesting && fileSuggestions.length > 0)) {
|
|
74
|
+
const len = isSuggesting ? filteredCommands.length : fileSuggestions.length;
|
|
75
|
+
if (key.upArrow) setSelectedIndex((p: number) => (p > 0 ? p - 1 : len - 1));
|
|
76
|
+
if (key.downArrow) setSelectedIndex((p: number) => (p < len - 1 ? p + 1 : 0));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (logoutDone) {
|
|
81
|
+
return (
|
|
82
|
+
<Box paddingY={1}>
|
|
83
|
+
<Text color="yellow" bold>Logged out. Run {BRAND_NAME_LOWER} again to restart.</Text>
|
|
84
|
+
</Box>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (isExiting) {
|
|
89
|
+
return (
|
|
90
|
+
<Box paddingY={1}>
|
|
91
|
+
<Text color="green" bold>
|
|
92
|
+
Thanks for using <GradientText text={BRAND_NAME} bold />. Bye Bye! See you soon.😊
|
|
93
|
+
</Text>
|
|
94
|
+
</Box>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (isFetchingUser) {
|
|
99
|
+
return (
|
|
100
|
+
<Box paddingY={1}>
|
|
101
|
+
<Loader text="Fetching user details.." />
|
|
102
|
+
</Box>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (userFetchError) {
|
|
107
|
+
return (
|
|
108
|
+
<Box paddingY={1}>
|
|
109
|
+
<Text color="red">We couldn't fetch your account data right now. Please log in again.</Text>
|
|
110
|
+
</Box>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const staticItems = [
|
|
115
|
+
{ id: `header-${sessionKey}`, type: 'header' as const },
|
|
116
|
+
...messages.map((msg, index) => ({ id: `msg-${sessionKey}-${msg.id}`, type: 'message' as const, message: msg, isFirst: index === 0 }))
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Box
|
|
121
|
+
key={sessionKey}
|
|
122
|
+
flexDirection="column"
|
|
123
|
+
paddingBottom={1}
|
|
124
|
+
width="100%">
|
|
125
|
+
|
|
126
|
+
{/* 1 & 2. Static Content (Header + Message History) */}
|
|
127
|
+
<Static items={staticItems}>
|
|
128
|
+
{(item: any) => {
|
|
129
|
+
if (item.type === 'header') {
|
|
130
|
+
return (
|
|
131
|
+
<Box
|
|
132
|
+
key={item.id}
|
|
133
|
+
width="100%"
|
|
134
|
+
paddingTop={1}>
|
|
135
|
+
<Header
|
|
136
|
+
name={user?.displayName || "Guest"}
|
|
137
|
+
cwd={process.cwd()}
|
|
138
|
+
subscriptionPlan={subscriptionPlan} />
|
|
139
|
+
</Box>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (item.type === 'message') {
|
|
144
|
+
return (
|
|
145
|
+
<Box
|
|
146
|
+
key={item.id}
|
|
147
|
+
width="100%"
|
|
148
|
+
flexDirection="column"
|
|
149
|
+
marginTop={item.isFirst ? 1 : 0}>
|
|
150
|
+
<Message message={item.message} />
|
|
151
|
+
</Box>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}}
|
|
156
|
+
</Static>
|
|
157
|
+
|
|
158
|
+
{/* Loader when AI is thinking */}
|
|
159
|
+
<Box
|
|
160
|
+
flexDirection="column"
|
|
161
|
+
marginTop={messages.length === 0 ? 1 : 0}>
|
|
162
|
+
{isLoading && <Loader />}
|
|
163
|
+
</Box>
|
|
164
|
+
|
|
165
|
+
{/* Input Prompt with Status Footer & Suggestions */}
|
|
166
|
+
<Box
|
|
167
|
+
flexDirection="column"
|
|
168
|
+
marginTop={view === 'login' ? 0 : 1}>
|
|
169
|
+
|
|
170
|
+
{commandError && view !== 'login' && (
|
|
171
|
+
<Box paddingX={1} marginBottom={0}>
|
|
172
|
+
<Text color="red">Please
|
|
173
|
+
<Text color={theme.colors.highlight}> /login </Text>
|
|
174
|
+
first to upgrade your plan.</Text>
|
|
175
|
+
</Box>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{isLimitReached && view !== 'login' && (
|
|
179
|
+
<Box paddingX={1} marginBottom={0}>
|
|
180
|
+
<Text color="red">Your plan limit has been reached. </Text>
|
|
181
|
+
<Text color="gray">Run <Text color={theme.colors.highlight}>/upgrade</Text> to unlock more usage</Text>
|
|
182
|
+
</Box>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
<Box
|
|
186
|
+
width="100%"
|
|
187
|
+
paddingX={1}
|
|
188
|
+
borderStyle="single"
|
|
189
|
+
borderLeft={false}
|
|
190
|
+
borderRight={false}
|
|
191
|
+
borderBottom={view !== 'login'}
|
|
192
|
+
borderColor={isLimitReached ? 'red' : isLoading ? 'gray' : theme.colors.primary}>
|
|
193
|
+
|
|
194
|
+
{view !== 'login' && (
|
|
195
|
+
<>
|
|
196
|
+
<Text color={isLimitReached ? 'red' : isLoading ? 'gray' : 'white'}>{'❯ '}</Text>
|
|
197
|
+
<TextInput
|
|
198
|
+
key={view}
|
|
199
|
+
value={input}
|
|
200
|
+
onChange={setInput}
|
|
201
|
+
onSubmit={handleSubmit}
|
|
202
|
+
focus={!isExiting && view !== 'theme'} />
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
</Box>
|
|
206
|
+
|
|
207
|
+
{isSuggesting && (
|
|
208
|
+
<Box minHeight={6}>
|
|
209
|
+
<CommandSuggestions
|
|
210
|
+
filteredCommands={filteredCommands}
|
|
211
|
+
selectedIndex={selectedIndex} />
|
|
212
|
+
</Box>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
{isFileSuggesting && fileSuggestions.length > 0 && (() => {
|
|
216
|
+
const ITEMS_PER_PAGE = 5;
|
|
217
|
+
const start = Math.floor(selectedIndex / ITEMS_PER_PAGE) * ITEMS_PER_PAGE;
|
|
218
|
+
const visible = fileSuggestions.slice(start, start + ITEMS_PER_PAGE);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<Box
|
|
222
|
+
flexDirection="column"
|
|
223
|
+
marginTop={1}
|
|
224
|
+
paddingLeft={2}
|
|
225
|
+
minHeight={ITEMS_PER_PAGE + 1}>
|
|
226
|
+
{visible.map((f, i) => {
|
|
227
|
+
const idx = i + start;
|
|
228
|
+
return (
|
|
229
|
+
<Text
|
|
230
|
+
key={f}
|
|
231
|
+
wrap="truncate-end"
|
|
232
|
+
color={idx === selectedIndex ? theme.colors.highlight : 'gray'}>
|
|
233
|
+
{idx === selectedIndex ? '❯ ' : ' '}{f}
|
|
234
|
+
</Text>
|
|
235
|
+
);
|
|
236
|
+
})}
|
|
237
|
+
|
|
238
|
+
{fileSuggestions.length > ITEMS_PER_PAGE ? (
|
|
239
|
+
<Text color="gray" dimColor>
|
|
240
|
+
{' '}({fileSuggestions.length} files - Page {Math.floor(selectedIndex / ITEMS_PER_PAGE) + 1}/{Math.ceil(fileSuggestions.length / ITEMS_PER_PAGE)})
|
|
241
|
+
</Text>
|
|
242
|
+
) : <Text />}
|
|
243
|
+
</Box>
|
|
244
|
+
);
|
|
245
|
+
})()}
|
|
246
|
+
|
|
247
|
+
{view === 'help' && <HelpView />}
|
|
248
|
+
{view === 'usage' && <UsageView sessionUsage={sessionUsage} />}
|
|
249
|
+
{view === 'theme' && <ThemeSelector onClose={() => setView('chat')} />}
|
|
250
|
+
{view === 'login' && (
|
|
251
|
+
<LoginView
|
|
252
|
+
onComplete={() => { resetSession(true); setView('chat'); }}
|
|
253
|
+
onCancel={() => setView('chat')} />
|
|
254
|
+
)}
|
|
255
|
+
{view === 'iamaryx' && <IAmAryxView />}
|
|
256
|
+
|
|
257
|
+
{!isSuggesting && view === 'chat' && (
|
|
258
|
+
<Box width="100%" marginTop={0} paddingX={1}>
|
|
259
|
+
{input.trim().length === 0 && (
|
|
260
|
+
<Box flexGrow={1}>
|
|
261
|
+
<Text color="gray">
|
|
262
|
+
{showExitPrompt
|
|
263
|
+
? 'Press Ctrl+C again to exit'
|
|
264
|
+
: '? for shortcuts'}
|
|
265
|
+
</Text>
|
|
266
|
+
</Box>
|
|
267
|
+
)}
|
|
268
|
+
{input.trim().length > 0 && <Box flexGrow={1} />}
|
|
269
|
+
</Box>
|
|
270
|
+
)}
|
|
271
|
+
</Box>
|
|
272
|
+
</Box>
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export default ChatScreen;
|