git-reverse-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/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/cli.js +2202 -0
- package/package.json +62 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.tsx
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
|
|
6
|
+
// src/ui/App.tsx
|
|
7
|
+
import { useState as useState8, useEffect as useEffect2 } from "react";
|
|
8
|
+
import { Box as Box13, useInput as useInput4, useApp as useApp2 } from "ink";
|
|
9
|
+
|
|
10
|
+
// src/ui/components/Header.tsx
|
|
11
|
+
import { Box, Text } from "ink";
|
|
12
|
+
|
|
13
|
+
// src/utils/format.ts
|
|
14
|
+
function truncate(str, maxLen) {
|
|
15
|
+
if (str.length <= maxLen) return str;
|
|
16
|
+
return str.slice(0, maxLen - 3) + "...";
|
|
17
|
+
}
|
|
18
|
+
function timeAgo(isoDate) {
|
|
19
|
+
const diff = Date.now() - new Date(isoDate).getTime();
|
|
20
|
+
const mins = Math.floor(diff / 6e4);
|
|
21
|
+
const hours = Math.floor(mins / 60);
|
|
22
|
+
const days = Math.floor(hours / 24);
|
|
23
|
+
if (days > 0) return `${days}d ago`;
|
|
24
|
+
if (hours > 0) return `${hours}h ago`;
|
|
25
|
+
if (mins > 0) return `${mins}m ago`;
|
|
26
|
+
return "just now";
|
|
27
|
+
}
|
|
28
|
+
function formatModelId(id) {
|
|
29
|
+
const parts = id.split("/");
|
|
30
|
+
const name = parts[parts.length - 1] ?? id;
|
|
31
|
+
return name.replace(/:free$/, "").replace(/-it$/, "");
|
|
32
|
+
}
|
|
33
|
+
function formatModelProvider(id) {
|
|
34
|
+
return id.split("/")[0] ?? id;
|
|
35
|
+
}
|
|
36
|
+
function countWords(text) {
|
|
37
|
+
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/ui/components/Header.tsx
|
|
41
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
42
|
+
var LOGO = [
|
|
43
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557",
|
|
44
|
+
"\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2551\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D",
|
|
45
|
+
"\u2588\u2588\u2551 \u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2557 ",
|
|
46
|
+
"\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u255D \u255A\u2588\u2588\u2557 \u2588\u2588\u2554\u255D\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D ",
|
|
47
|
+
"\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u255A\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557",
|
|
48
|
+
" \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D"
|
|
49
|
+
];
|
|
50
|
+
function Header({ username, model, version = "1.0.0", newModelCount = 0 }) {
|
|
51
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
|
|
52
|
+
LOGO.map((line, i) => /* @__PURE__ */ jsx(Text, { color: "cyan", bold: i < 4, dimColor: i >= 4, children: line }, i)),
|
|
53
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "row", gap: 2, children: [
|
|
54
|
+
/* @__PURE__ */ jsxs(Text, { color: "white", dimColor: true, children: [
|
|
55
|
+
" ",
|
|
56
|
+
"v",
|
|
57
|
+
version
|
|
58
|
+
] }),
|
|
59
|
+
model && /* @__PURE__ */ jsxs(Box, { children: [
|
|
60
|
+
/* @__PURE__ */ jsx(Text, { color: "white", dimColor: true, children: "\xB7" }),
|
|
61
|
+
/* @__PURE__ */ jsxs(Text, { color: "yellow", dimColor: true, children: [
|
|
62
|
+
" ",
|
|
63
|
+
formatModelProvider(model)
|
|
64
|
+
] }),
|
|
65
|
+
/* @__PURE__ */ jsx(Text, { color: "white", dimColor: true, children: "/" }),
|
|
66
|
+
/* @__PURE__ */ jsx(Text, { color: "yellow", children: formatModelId(model) })
|
|
67
|
+
] }),
|
|
68
|
+
newModelCount > 0 && /* @__PURE__ */ jsxs(Box, { children: [
|
|
69
|
+
/* @__PURE__ */ jsx(Text, { color: "white", dimColor: true, children: "\xB7" }),
|
|
70
|
+
/* @__PURE__ */ jsxs(Text, { color: "green", children: [
|
|
71
|
+
" ",
|
|
72
|
+
newModelCount,
|
|
73
|
+
" new model",
|
|
74
|
+
newModelCount > 1 ? "s" : "",
|
|
75
|
+
" available"
|
|
76
|
+
] })
|
|
77
|
+
] })
|
|
78
|
+
] }),
|
|
79
|
+
username && /* @__PURE__ */ jsxs(Box, { marginTop: 0, paddingLeft: 2, children: [
|
|
80
|
+
/* @__PURE__ */ jsxs(Text, { color: "white", dimColor: true, children: [
|
|
81
|
+
"Hello,",
|
|
82
|
+
" "
|
|
83
|
+
] }),
|
|
84
|
+
/* @__PURE__ */ jsxs(Text, { color: "white", children: [
|
|
85
|
+
username,
|
|
86
|
+
"."
|
|
87
|
+
] })
|
|
88
|
+
] }),
|
|
89
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: "white", dimColor: true, children: "\u2500".repeat(79) }) })
|
|
90
|
+
] });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/ui/screens/SetupUsername.tsx
|
|
94
|
+
import { useState } from "react";
|
|
95
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
96
|
+
import TextInput from "ink-text-input";
|
|
97
|
+
|
|
98
|
+
// src/config/store.ts
|
|
99
|
+
import Conf from "conf";
|
|
100
|
+
var store = new Conf({
|
|
101
|
+
projectName: "git-reverse",
|
|
102
|
+
projectVersion: "1.0.0",
|
|
103
|
+
defaults: {
|
|
104
|
+
user: {
|
|
105
|
+
name: "",
|
|
106
|
+
apiKey: "",
|
|
107
|
+
selectedModel: "",
|
|
108
|
+
seenModelIds: [],
|
|
109
|
+
setupComplete: false,
|
|
110
|
+
setupStep: "username"
|
|
111
|
+
},
|
|
112
|
+
cachedModels: {
|
|
113
|
+
models: [],
|
|
114
|
+
fetchedAt: 0
|
|
115
|
+
},
|
|
116
|
+
sessions: {},
|
|
117
|
+
lastLaunchVersion: ""
|
|
118
|
+
},
|
|
119
|
+
schema: {
|
|
120
|
+
user: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
name: { type: "string" },
|
|
124
|
+
apiKey: { type: "string" },
|
|
125
|
+
selectedModel: { type: "string" },
|
|
126
|
+
seenModelIds: { type: "array", items: { type: "string" } },
|
|
127
|
+
setupComplete: { type: "boolean" },
|
|
128
|
+
setupStep: { type: "string" }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
cachedModels: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
models: { type: "array" },
|
|
135
|
+
fetchedAt: { type: "number" }
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
sessions: { type: "object" },
|
|
139
|
+
lastLaunchVersion: { type: "string" }
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
function getUser() {
|
|
143
|
+
return store.get("user");
|
|
144
|
+
}
|
|
145
|
+
function setUserName(name) {
|
|
146
|
+
const user = getUser();
|
|
147
|
+
store.set("user", { ...user, name: name.trim() });
|
|
148
|
+
}
|
|
149
|
+
function setApiKey(apiKey) {
|
|
150
|
+
const user = getUser();
|
|
151
|
+
store.set("user", { ...user, apiKey });
|
|
152
|
+
}
|
|
153
|
+
function setSelectedModel(modelId) {
|
|
154
|
+
const user = getUser();
|
|
155
|
+
store.set("user", { ...user, selectedModel: modelId });
|
|
156
|
+
}
|
|
157
|
+
function setSetupStep(step) {
|
|
158
|
+
const user = getUser();
|
|
159
|
+
store.set("user", { ...user, setupStep: step });
|
|
160
|
+
}
|
|
161
|
+
function markSetupComplete() {
|
|
162
|
+
const user = getUser();
|
|
163
|
+
store.set("user", { ...user, setupComplete: true, setupStep: "done" });
|
|
164
|
+
}
|
|
165
|
+
function markModelsSeen(modelIds) {
|
|
166
|
+
const user = getUser();
|
|
167
|
+
const combined = Array.from(/* @__PURE__ */ new Set([...user.seenModelIds, ...modelIds]));
|
|
168
|
+
store.set("user", { ...user, seenModelIds: combined });
|
|
169
|
+
}
|
|
170
|
+
function getCachedModels() {
|
|
171
|
+
return store.get("cachedModels");
|
|
172
|
+
}
|
|
173
|
+
function setCachedModels(models) {
|
|
174
|
+
store.set("cachedModels", { models, fetchedAt: Date.now() });
|
|
175
|
+
}
|
|
176
|
+
function getNewModels(fetchedModels) {
|
|
177
|
+
const { seenModelIds } = getUser();
|
|
178
|
+
return fetchedModels.filter((m) => !seenModelIds.includes(m.id));
|
|
179
|
+
}
|
|
180
|
+
function saveSession(session) {
|
|
181
|
+
const sessions = store.get("sessions");
|
|
182
|
+
store.set("sessions", { ...sessions, [session.id]: session });
|
|
183
|
+
}
|
|
184
|
+
function getSession(id) {
|
|
185
|
+
const sessions = store.get("sessions");
|
|
186
|
+
return sessions[id] ?? null;
|
|
187
|
+
}
|
|
188
|
+
function getAllSessions() {
|
|
189
|
+
const sessions = store.get("sessions");
|
|
190
|
+
return Object.values(sessions).sort(
|
|
191
|
+
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
function getStorePath() {
|
|
195
|
+
return store.path;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/ui/screens/SetupUsername.tsx
|
|
199
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
200
|
+
function SetupUsername({ onComplete }) {
|
|
201
|
+
const [value, setValue] = useState("");
|
|
202
|
+
const [error, setError] = useState("");
|
|
203
|
+
const handleSubmit = (val) => {
|
|
204
|
+
const trimmed = val.trim();
|
|
205
|
+
if (trimmed.length < 2) {
|
|
206
|
+
setError("Name must be at least 2 characters.");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (trimmed.length > 32) {
|
|
210
|
+
setError("Name must be 32 characters or fewer.");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
setUserName(trimmed);
|
|
214
|
+
setSetupStep("apikey");
|
|
215
|
+
onComplete(trimmed);
|
|
216
|
+
};
|
|
217
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingLeft: 2, children: [
|
|
218
|
+
/* @__PURE__ */ jsx2(Box2, { marginBottom: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "white", dimColor: true, children: "First time setup. This takes about 60 seconds." }) }),
|
|
219
|
+
/* @__PURE__ */ jsxs2(Box2, { marginBottom: 1, children: [
|
|
220
|
+
/* @__PURE__ */ jsxs2(Text2, { color: "white", dimColor: true, children: [
|
|
221
|
+
"Step 1 of 3 \u2014 ",
|
|
222
|
+
" "
|
|
223
|
+
] }),
|
|
224
|
+
/* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "Who are you?" })
|
|
225
|
+
] }),
|
|
226
|
+
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
227
|
+
/* @__PURE__ */ jsx2(Text2, { color: "white", dimColor: true, children: " name " }),
|
|
228
|
+
/* @__PURE__ */ jsx2(
|
|
229
|
+
TextInput,
|
|
230
|
+
{
|
|
231
|
+
value,
|
|
232
|
+
onChange: (v) => {
|
|
233
|
+
setValue(v);
|
|
234
|
+
setError("");
|
|
235
|
+
},
|
|
236
|
+
onSubmit: handleSubmit,
|
|
237
|
+
placeholder: "Enter your name"
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
] }),
|
|
241
|
+
error && /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
|
|
242
|
+
" ",
|
|
243
|
+
error
|
|
244
|
+
] }) }),
|
|
245
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { color: "white", dimColor: true, children: [
|
|
246
|
+
" ",
|
|
247
|
+
"Press Enter to continue"
|
|
248
|
+
] }) })
|
|
249
|
+
] });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/ui/screens/SetupApiKey.tsx
|
|
253
|
+
import { useState as useState2 } from "react";
|
|
254
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
255
|
+
import TextInput2 from "ink-text-input";
|
|
256
|
+
import terminalLink from "terminal-link";
|
|
257
|
+
|
|
258
|
+
// src/api/OpenRouterClient.ts
|
|
259
|
+
import axios from "axios";
|
|
260
|
+
var BASE_URL = "https://openrouter.ai/api/v1";
|
|
261
|
+
var CACHE_TTL_MS = 1e3 * 60 * 60;
|
|
262
|
+
var OpenRouterClient = class {
|
|
263
|
+
client;
|
|
264
|
+
apiKey;
|
|
265
|
+
constructor(apiKey) {
|
|
266
|
+
this.apiKey = apiKey;
|
|
267
|
+
this.client = axios.create({
|
|
268
|
+
baseURL: BASE_URL,
|
|
269
|
+
headers: {
|
|
270
|
+
Authorization: `Bearer ${apiKey}`,
|
|
271
|
+
"Content-Type": "application/json",
|
|
272
|
+
"HTTP-Referer": "https://github.com/git-reverse/git-reverse",
|
|
273
|
+
"X-Title": "git-reverse"
|
|
274
|
+
},
|
|
275
|
+
timeout: 3e4
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// ── Validation ────────────────────────────────────────────
|
|
279
|
+
async validateKey() {
|
|
280
|
+
try {
|
|
281
|
+
const res = await this.client.get("/auth/key");
|
|
282
|
+
if (res.status === 200 && res.data?.data) {
|
|
283
|
+
return { valid: true };
|
|
284
|
+
}
|
|
285
|
+
return { valid: false, error: "Invalid API key response" };
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (axios.isAxiosError(err)) {
|
|
288
|
+
if (err.response?.status === 401) {
|
|
289
|
+
return { valid: false, error: "Invalid API key \u2014 check your key and try again." };
|
|
290
|
+
}
|
|
291
|
+
if (err.response?.status === 429) {
|
|
292
|
+
return { valid: false, error: "Rate limited \u2014 wait a moment and try again." };
|
|
293
|
+
}
|
|
294
|
+
return { valid: false, error: err.message };
|
|
295
|
+
}
|
|
296
|
+
return { valid: false, error: "Network error \u2014 check your connection." };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// ── Models ────────────────────────────────────────────────
|
|
300
|
+
async fetchModels() {
|
|
301
|
+
const res = await this.client.get("/models");
|
|
302
|
+
const models = res.data?.data ?? [];
|
|
303
|
+
return models;
|
|
304
|
+
}
|
|
305
|
+
async fetchFreeModels() {
|
|
306
|
+
const all = await this.fetchModels();
|
|
307
|
+
return all.filter((m) => {
|
|
308
|
+
const promptPrice = parseFloat(m.pricing?.prompt ?? "1");
|
|
309
|
+
const completionPrice = parseFloat(m.pricing?.completion ?? "1");
|
|
310
|
+
return promptPrice === 0 && completionPrice === 0;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
// ── Streaming Completion ──────────────────────────────────
|
|
314
|
+
async *streamCompletion(model, messages, options = {}) {
|
|
315
|
+
const { maxTokens = 4096, temperature = 0.3 } = options;
|
|
316
|
+
const response = await this.client.post(
|
|
317
|
+
"/chat/completions",
|
|
318
|
+
{
|
|
319
|
+
model,
|
|
320
|
+
messages,
|
|
321
|
+
stream: true,
|
|
322
|
+
max_tokens: maxTokens,
|
|
323
|
+
temperature
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
responseType: "stream",
|
|
327
|
+
timeout: 12e4
|
|
328
|
+
}
|
|
329
|
+
);
|
|
330
|
+
const stream = response.data;
|
|
331
|
+
let buffer = "";
|
|
332
|
+
let receivedDone = false;
|
|
333
|
+
try {
|
|
334
|
+
for await (const chunk of stream) {
|
|
335
|
+
if (receivedDone) break;
|
|
336
|
+
buffer += chunk.toString("utf-8");
|
|
337
|
+
const lines = buffer.split("\n");
|
|
338
|
+
buffer = lines.pop() ?? "";
|
|
339
|
+
for (const line of lines) {
|
|
340
|
+
const trimmed = line.trim();
|
|
341
|
+
if (trimmed === "data: [DONE]") {
|
|
342
|
+
const done = { content: "", done: true };
|
|
343
|
+
options.onChunk?.(done);
|
|
344
|
+
yield done;
|
|
345
|
+
receivedDone = true;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
if (!trimmed || !trimmed.startsWith("data: ")) continue;
|
|
349
|
+
try {
|
|
350
|
+
const json = JSON.parse(trimmed.slice(6));
|
|
351
|
+
const content = json?.choices?.[0]?.delta?.content ?? "";
|
|
352
|
+
if (content) {
|
|
353
|
+
const streamChunk = { content, done: false };
|
|
354
|
+
options.onChunk?.(streamChunk);
|
|
355
|
+
yield streamChunk;
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (receivedDone) return;
|
|
363
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
364
|
+
const isStreamClose = msg === "aborted" || msg.includes("ECONNRESET") || msg.includes("socket hang up") || msg.includes("premature close") || err instanceof Error && err.name === "AbortError";
|
|
365
|
+
if (!isStreamClose) throw err;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// ── One-shot Completion (non-streaming) ───────────────────
|
|
369
|
+
async complete(model, messages, options = {}) {
|
|
370
|
+
const { maxTokens = 4096, temperature = 0.3 } = options;
|
|
371
|
+
const res = await this.client.post("/chat/completions", {
|
|
372
|
+
model,
|
|
373
|
+
messages,
|
|
374
|
+
stream: false,
|
|
375
|
+
max_tokens: maxTokens,
|
|
376
|
+
temperature
|
|
377
|
+
});
|
|
378
|
+
return res.data?.choices?.[0]?.message?.content ?? "";
|
|
379
|
+
}
|
|
380
|
+
static isCacheStale(fetchedAt) {
|
|
381
|
+
return Date.now() - fetchedAt > CACHE_TTL_MS;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// src/ui/components/Spinner.tsx
|
|
386
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
387
|
+
import InkSpinner from "ink-spinner";
|
|
388
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
389
|
+
function Spinner({ label, color = "cyan" }) {
|
|
390
|
+
return /* @__PURE__ */ jsxs3(Box3, { paddingLeft: 2, children: [
|
|
391
|
+
/* @__PURE__ */ jsx3(Text3, { color, children: /* @__PURE__ */ jsx3(InkSpinner, { type: "dots" }) }),
|
|
392
|
+
/* @__PURE__ */ jsxs3(Text3, { color: "white", dimColor: true, children: [
|
|
393
|
+
" ",
|
|
394
|
+
label
|
|
395
|
+
] })
|
|
396
|
+
] });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/ui/screens/SetupApiKey.tsx
|
|
400
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
401
|
+
var OPENROUTER_KEYS_URL = "https://openrouter.ai/keys";
|
|
402
|
+
var OPENROUTER_LINK = terminalLink("openrouter.ai/keys", OPENROUTER_KEYS_URL, {
|
|
403
|
+
fallback: (_, url) => url
|
|
404
|
+
});
|
|
405
|
+
function SetupApiKey({ onComplete }) {
|
|
406
|
+
const [value, setValue] = useState2("");
|
|
407
|
+
const [phase, setPhase] = useState2("input");
|
|
408
|
+
const [error, setError] = useState2("");
|
|
409
|
+
const [models, setModels] = useState2([]);
|
|
410
|
+
const handleSubmit = async (val) => {
|
|
411
|
+
const trimmed = val.trim();
|
|
412
|
+
if (!trimmed.startsWith("sk-or-")) {
|
|
413
|
+
setError('OpenRouter keys start with "sk-or-". Check your key.');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
setPhase("validating");
|
|
417
|
+
setError("");
|
|
418
|
+
try {
|
|
419
|
+
const client = new OpenRouterClient(trimmed);
|
|
420
|
+
const { valid, error: validError } = await client.validateKey();
|
|
421
|
+
if (!valid) {
|
|
422
|
+
setPhase("error");
|
|
423
|
+
setError(validError ?? "Validation failed.");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const freeModels = await client.fetchFreeModels();
|
|
427
|
+
setApiKey(trimmed);
|
|
428
|
+
setCachedModels(freeModels);
|
|
429
|
+
markModelsSeen(freeModels.map((m) => m.id));
|
|
430
|
+
setSetupStep("model");
|
|
431
|
+
setModels(freeModels);
|
|
432
|
+
setPhase("done");
|
|
433
|
+
setTimeout(() => onComplete(freeModels), 600);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
setPhase("error");
|
|
436
|
+
setError(err instanceof Error ? err.message : "Unknown error during validation.");
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingLeft: 2, children: [
|
|
440
|
+
/* @__PURE__ */ jsxs4(Box4, { marginBottom: 1, children: [
|
|
441
|
+
/* @__PURE__ */ jsxs4(Text4, { color: "white", dimColor: true, children: [
|
|
442
|
+
"Step 2 of 3 \u2014 ",
|
|
443
|
+
" "
|
|
444
|
+
] }),
|
|
445
|
+
/* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "OpenRouter API Key" })
|
|
446
|
+
] }),
|
|
447
|
+
/* @__PURE__ */ jsxs4(Box4, { marginBottom: 1, flexDirection: "column", children: [
|
|
448
|
+
/* @__PURE__ */ jsxs4(Text4, { color: "white", dimColor: true, children: [
|
|
449
|
+
" ",
|
|
450
|
+
"Get a free key at",
|
|
451
|
+
" ",
|
|
452
|
+
/* @__PURE__ */ jsx4(Text4, { color: "cyan", children: OPENROUTER_LINK })
|
|
453
|
+
] }),
|
|
454
|
+
/* @__PURE__ */ jsxs4(Text4, { color: "white", dimColor: true, children: [
|
|
455
|
+
" ",
|
|
456
|
+
"Create an account \u2192 API Keys \u2192 Create Key \u2192 copy it below."
|
|
457
|
+
] })
|
|
458
|
+
] }),
|
|
459
|
+
phase === "input" || phase === "error" ? /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
|
|
460
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
461
|
+
/* @__PURE__ */ jsx4(Text4, { color: "white", dimColor: true, children: " key " }),
|
|
462
|
+
/* @__PURE__ */ jsx4(
|
|
463
|
+
TextInput2,
|
|
464
|
+
{
|
|
465
|
+
value,
|
|
466
|
+
onChange: (v) => {
|
|
467
|
+
setValue(v);
|
|
468
|
+
setError("");
|
|
469
|
+
if (phase === "error") setPhase("input");
|
|
470
|
+
},
|
|
471
|
+
onSubmit: handleSubmit,
|
|
472
|
+
placeholder: "sk-or-v1-...",
|
|
473
|
+
mask: "*"
|
|
474
|
+
}
|
|
475
|
+
)
|
|
476
|
+
] }),
|
|
477
|
+
error && /* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
|
|
478
|
+
" ",
|
|
479
|
+
error
|
|
480
|
+
] }) }),
|
|
481
|
+
/* @__PURE__ */ jsx4(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: "white", dimColor: true, children: [
|
|
482
|
+
" ",
|
|
483
|
+
"Press Enter to validate"
|
|
484
|
+
] }) })
|
|
485
|
+
] }) : phase === "validating" ? /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
|
|
486
|
+
/* @__PURE__ */ jsx4(Spinner, { label: "Validating key..." }),
|
|
487
|
+
/* @__PURE__ */ jsx4(Spinner, { label: "Fetching free models...", color: "white" })
|
|
488
|
+
] }) : /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
|
|
489
|
+
/* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsxs4(Text4, { color: "green", children: [
|
|
490
|
+
" ",
|
|
491
|
+
"\u2713 Key valid"
|
|
492
|
+
] }) }),
|
|
493
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
494
|
+
/* @__PURE__ */ jsxs4(Text4, { color: "white", dimColor: true, children: [
|
|
495
|
+
" ",
|
|
496
|
+
"Found",
|
|
497
|
+
" "
|
|
498
|
+
] }),
|
|
499
|
+
/* @__PURE__ */ jsx4(Text4, { color: "cyan", children: models.length }),
|
|
500
|
+
/* @__PURE__ */ jsxs4(Text4, { color: "white", dimColor: true, children: [
|
|
501
|
+
" ",
|
|
502
|
+
"free model",
|
|
503
|
+
models.length !== 1 ? "s" : ""
|
|
504
|
+
] })
|
|
505
|
+
] })
|
|
506
|
+
] })
|
|
507
|
+
] });
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/ui/screens/SetupModel.tsx
|
|
511
|
+
import { useState as useState3, useMemo } from "react";
|
|
512
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
513
|
+
import SelectInput from "ink-select-input";
|
|
514
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
515
|
+
function SetupModel({ models, isSettings = false, onComplete }) {
|
|
516
|
+
const [selected, setSelected] = useState3(null);
|
|
517
|
+
const sortedModels = useMemo(
|
|
518
|
+
() => [...models].sort((a, b) => (b.context_length ?? 0) - (a.context_length ?? 0)),
|
|
519
|
+
[models]
|
|
520
|
+
);
|
|
521
|
+
const modelMap = useMemo(
|
|
522
|
+
() => new Map(sortedModels.map((m) => [m.id, m])),
|
|
523
|
+
[sortedModels]
|
|
524
|
+
);
|
|
525
|
+
const items = sortedModels.map((m) => ({
|
|
526
|
+
label: formatItem(m),
|
|
527
|
+
value: m.id
|
|
528
|
+
}));
|
|
529
|
+
const handleSelect = (item) => {
|
|
530
|
+
const model = modelMap.get(item.value);
|
|
531
|
+
if (!model) return;
|
|
532
|
+
setSelectedModel(item.value);
|
|
533
|
+
if (!isSettings) markSetupComplete();
|
|
534
|
+
setSelected(model);
|
|
535
|
+
setTimeout(() => onComplete(model), 300);
|
|
536
|
+
};
|
|
537
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", paddingLeft: 2, children: [
|
|
538
|
+
/* @__PURE__ */ jsxs5(Box5, { marginBottom: 1, children: [
|
|
539
|
+
/* @__PURE__ */ jsx5(Text5, { color: "white", dimColor: true, children: isSettings ? "Change active model \u2014 " : "Step 3 of 3 \u2014 " }),
|
|
540
|
+
/* @__PURE__ */ jsx5(Text5, { color: "cyan", children: "Select a model" })
|
|
541
|
+
] }),
|
|
542
|
+
/* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: "white", dimColor: true, children: [
|
|
543
|
+
" ",
|
|
544
|
+
models.length,
|
|
545
|
+
" free model",
|
|
546
|
+
models.length !== 1 ? "s" : "",
|
|
547
|
+
" available \xB7 \u2191\u2193 navigate \xB7 Enter select"
|
|
548
|
+
] }) }),
|
|
549
|
+
selected ? /* @__PURE__ */ jsxs5(Box5, { paddingLeft: 2, children: [
|
|
550
|
+
/* @__PURE__ */ jsx5(Text5, { color: "green", children: "\u2713 " }),
|
|
551
|
+
/* @__PURE__ */ jsx5(Text5, { color: "white", children: formatModelId(selected.id) }),
|
|
552
|
+
/* @__PURE__ */ jsxs5(Text5, { color: "white", dimColor: true, children: [
|
|
553
|
+
" ",
|
|
554
|
+
"set as default"
|
|
555
|
+
] })
|
|
556
|
+
] }) : /* @__PURE__ */ jsxs5(Box5, { paddingLeft: 2, flexDirection: "column", children: [
|
|
557
|
+
/* @__PURE__ */ jsx5(Box5, { marginBottom: 0, children: /* @__PURE__ */ jsxs5(Text5, { color: "white", dimColor: true, children: [
|
|
558
|
+
" PROVIDER/MODEL".padEnd(36),
|
|
559
|
+
"CTX"
|
|
560
|
+
] }) }),
|
|
561
|
+
/* @__PURE__ */ jsx5(
|
|
562
|
+
SelectInput,
|
|
563
|
+
{
|
|
564
|
+
items,
|
|
565
|
+
onSelect: handleSelect,
|
|
566
|
+
itemComponent: ModelItem
|
|
567
|
+
}
|
|
568
|
+
)
|
|
569
|
+
] })
|
|
570
|
+
] });
|
|
571
|
+
}
|
|
572
|
+
function formatItem(m) {
|
|
573
|
+
const ctx = m.context_length ? `${(m.context_length / 1e3).toFixed(0)}k` : "\u2014";
|
|
574
|
+
const name = `${formatModelProvider(m.id)}/${formatModelId(m.id)}`;
|
|
575
|
+
return `${truncate(name, 34).padEnd(36)}${ctx}`;
|
|
576
|
+
}
|
|
577
|
+
function ModelItem({
|
|
578
|
+
isSelected,
|
|
579
|
+
label
|
|
580
|
+
}) {
|
|
581
|
+
return /* @__PURE__ */ jsx5(Box5, { children: /* @__PURE__ */ jsxs5(Text5, { color: isSelected ? "cyan" : "white", dimColor: !isSelected, children: [
|
|
582
|
+
isSelected ? "\u203A " : " ",
|
|
583
|
+
label
|
|
584
|
+
] }) });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/ui/screens/Dashboard.tsx
|
|
588
|
+
import { useState as useState4 } from "react";
|
|
589
|
+
import { Box as Box8, Text as Text8, useInput as useInput2 } from "ink";
|
|
590
|
+
import TextInput3 from "ink-text-input";
|
|
591
|
+
|
|
592
|
+
// src/ui/components/CommandPalette.tsx
|
|
593
|
+
import { Box as Box6, Text as Text6 } from "ink";
|
|
594
|
+
import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
595
|
+
var COMMANDS = [
|
|
596
|
+
{
|
|
597
|
+
name: "settings",
|
|
598
|
+
aliases: ["\\settings"],
|
|
599
|
+
description: "to open settings page for any modification",
|
|
600
|
+
hint: "settings"
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
name: "compact",
|
|
604
|
+
aliases: ["\\compact", "\\summarize"],
|
|
605
|
+
description: "to summarize the session",
|
|
606
|
+
hint: "compact"
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
name: "deepdive",
|
|
610
|
+
aliases: ["\\deepdive", "\\learn"],
|
|
611
|
+
description: "for deeply analyzing and giving a very detailed prompt in such a way that user can understand and also the following prompt can be used the same project from scratch according to the user",
|
|
612
|
+
hint: "deepdive"
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
name: "resume",
|
|
616
|
+
aliases: ["\\resume <id>"],
|
|
617
|
+
description: "Resume a previous session by its ID",
|
|
618
|
+
hint: "resume"
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: "sessions",
|
|
622
|
+
aliases: ["\\sessions"],
|
|
623
|
+
description: "List all saved sessions",
|
|
624
|
+
hint: "sessions"
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: "clear",
|
|
628
|
+
aliases: ["\\clear"],
|
|
629
|
+
description: "Clear the current output buffer",
|
|
630
|
+
hint: "clear"
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
name: "quit",
|
|
634
|
+
aliases: ["\\quit", "\\exit"],
|
|
635
|
+
description: "Save session and exit",
|
|
636
|
+
hint: "quit"
|
|
637
|
+
}
|
|
638
|
+
];
|
|
639
|
+
function CommandPalette({ visible }) {
|
|
640
|
+
if (!visible) return null;
|
|
641
|
+
const COL1 = 24;
|
|
642
|
+
return /* @__PURE__ */ jsxs6(
|
|
643
|
+
Box6,
|
|
644
|
+
{
|
|
645
|
+
flexDirection: "column",
|
|
646
|
+
borderStyle: "single",
|
|
647
|
+
borderColor: "cyan",
|
|
648
|
+
paddingX: 2,
|
|
649
|
+
paddingY: 0,
|
|
650
|
+
marginBottom: 1,
|
|
651
|
+
marginLeft: 2,
|
|
652
|
+
children: [
|
|
653
|
+
/* @__PURE__ */ jsx6(Text6, { color: "cyan", dimColor: true, children: "Commands" }),
|
|
654
|
+
/* @__PURE__ */ jsx6(Box6, { marginTop: 0, children: /* @__PURE__ */ jsx6(Text6, { color: "white", dimColor: true, children: "\u2500".repeat(40) }) }),
|
|
655
|
+
COMMANDS.map((cmd) => /* @__PURE__ */ jsxs6(Box6, { flexDirection: "row", children: [
|
|
656
|
+
/* @__PURE__ */ jsx6(Box6, { width: COL1, children: /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: cmd.aliases.join(", ") }) }),
|
|
657
|
+
/* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsx6(Text6, { color: "white", dimColor: true, children: cmd.description }) })
|
|
658
|
+
] }, cmd.name)),
|
|
659
|
+
/* @__PURE__ */ jsx6(Box6, { marginTop: 0, children: /* @__PURE__ */ jsx6(Text6, { color: "white", dimColor: true, children: "\u2500".repeat(40) }) }),
|
|
660
|
+
/* @__PURE__ */ jsx6(Text6, { color: "white", dimColor: true, children: "esc close palette" })
|
|
661
|
+
]
|
|
662
|
+
}
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/ui/components/Notification.tsx
|
|
667
|
+
import { Box as Box7, Text as Text7 } from "ink";
|
|
668
|
+
import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
669
|
+
function Notification({ newModels }) {
|
|
670
|
+
if (newModels.length === 0) return null;
|
|
671
|
+
return /* @__PURE__ */ jsxs7(
|
|
672
|
+
Box7,
|
|
673
|
+
{
|
|
674
|
+
flexDirection: "column",
|
|
675
|
+
borderStyle: "single",
|
|
676
|
+
borderColor: "green",
|
|
677
|
+
paddingX: 2,
|
|
678
|
+
paddingY: 0,
|
|
679
|
+
marginBottom: 1,
|
|
680
|
+
marginLeft: 2,
|
|
681
|
+
children: [
|
|
682
|
+
/* @__PURE__ */ jsxs7(Text7, { color: "green", children: [
|
|
683
|
+
"\u2191 ",
|
|
684
|
+
newModels.length,
|
|
685
|
+
" new free model",
|
|
686
|
+
newModels.length > 1 ? "s" : "",
|
|
687
|
+
" on OpenRouter"
|
|
688
|
+
] }),
|
|
689
|
+
newModels.slice(0, 3).map((m) => /* @__PURE__ */ jsxs7(Text7, { color: "white", dimColor: true, children: [
|
|
690
|
+
" ",
|
|
691
|
+
formatModelProvider(m.id),
|
|
692
|
+
"/",
|
|
693
|
+
formatModelId(m.id),
|
|
694
|
+
" ",
|
|
695
|
+
/* @__PURE__ */ jsx7(Text7, { color: "white", dimColor: true, children: m.context_length ? `${(m.context_length / 1e3).toFixed(0)}k ctx` : "" })
|
|
696
|
+
] }, m.id)),
|
|
697
|
+
newModels.length > 3 && /* @__PURE__ */ jsxs7(Text7, { color: "white", dimColor: true, children: [
|
|
698
|
+
" ",
|
|
699
|
+
"+",
|
|
700
|
+
newModels.length - 3,
|
|
701
|
+
" more \xB7 run \\settings to view all"
|
|
702
|
+
] })
|
|
703
|
+
]
|
|
704
|
+
}
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// src/utils/github.ts
|
|
709
|
+
function parseGitHubInput(input) {
|
|
710
|
+
const trimmed = input.trim();
|
|
711
|
+
const urlMatch = trimmed.match(/(https?:\/\/github\.com\/[^\s]+)/);
|
|
712
|
+
if (!urlMatch) {
|
|
713
|
+
return { parsed: null, query: trimmed };
|
|
714
|
+
}
|
|
715
|
+
const urlStr = urlMatch[1];
|
|
716
|
+
const urlIndex = trimmed.indexOf(urlStr);
|
|
717
|
+
const before = trimmed.slice(0, urlIndex).trim();
|
|
718
|
+
const after = trimmed.slice(urlIndex + urlStr.length).trim();
|
|
719
|
+
const query = [before, after].filter(Boolean).join(" ");
|
|
720
|
+
const urlPattern = /github\.com\/([^/\s]+)\/([^/\s?#]+)(?:\/tree\/([^/\s?#]+))?/;
|
|
721
|
+
const match = urlStr.match(urlPattern);
|
|
722
|
+
if (!match) return { parsed: null, query };
|
|
723
|
+
return {
|
|
724
|
+
parsed: {
|
|
725
|
+
owner: match[1],
|
|
726
|
+
repo: match[2].replace(/\.git$/, ""),
|
|
727
|
+
branch: match[3],
|
|
728
|
+
raw: urlStr
|
|
729
|
+
},
|
|
730
|
+
query
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// src/core/SessionManager.ts
|
|
735
|
+
import { nanoid } from "nanoid";
|
|
736
|
+
var SessionManager = class {
|
|
737
|
+
currentSession = null;
|
|
738
|
+
sigintHandler = null;
|
|
739
|
+
// ── Create new session ────────────────────────────────────
|
|
740
|
+
createSession(params) {
|
|
741
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
742
|
+
const session = {
|
|
743
|
+
id: nanoid(8),
|
|
744
|
+
createdAt: now,
|
|
745
|
+
updatedAt: now,
|
|
746
|
+
repoUrl: params.repoUrl,
|
|
747
|
+
query: params.query,
|
|
748
|
+
model: params.model,
|
|
749
|
+
messages: [],
|
|
750
|
+
analysisOutput: "",
|
|
751
|
+
repoMeta: params.repoMeta,
|
|
752
|
+
mode: params.mode
|
|
753
|
+
};
|
|
754
|
+
this.currentSession = session;
|
|
755
|
+
return session;
|
|
756
|
+
}
|
|
757
|
+
// ── Load existing session ─────────────────────────────────
|
|
758
|
+
loadSession(id) {
|
|
759
|
+
const session = getSession(id);
|
|
760
|
+
if (session) {
|
|
761
|
+
this.currentSession = session;
|
|
762
|
+
}
|
|
763
|
+
return session;
|
|
764
|
+
}
|
|
765
|
+
// ── Update session ────────────────────────────────────────
|
|
766
|
+
appendMessage(message) {
|
|
767
|
+
if (!this.currentSession) return;
|
|
768
|
+
this.currentSession.messages.push(message);
|
|
769
|
+
this.currentSession.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
770
|
+
}
|
|
771
|
+
appendOutput(chunk) {
|
|
772
|
+
if (!this.currentSession) return;
|
|
773
|
+
this.currentSession.analysisOutput += chunk;
|
|
774
|
+
this.currentSession.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
775
|
+
}
|
|
776
|
+
setAnalysisOutput(output) {
|
|
777
|
+
if (!this.currentSession) return;
|
|
778
|
+
this.currentSession.analysisOutput = output;
|
|
779
|
+
this.currentSession.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
780
|
+
}
|
|
781
|
+
updateRepoMeta(meta) {
|
|
782
|
+
if (!this.currentSession) return;
|
|
783
|
+
this.currentSession.repoMeta = meta;
|
|
784
|
+
}
|
|
785
|
+
// ── Persist session ───────────────────────────────────────
|
|
786
|
+
save() {
|
|
787
|
+
if (!this.currentSession) return null;
|
|
788
|
+
saveSession(this.currentSession);
|
|
789
|
+
return this.currentSession;
|
|
790
|
+
}
|
|
791
|
+
getCurrent() {
|
|
792
|
+
return this.currentSession;
|
|
793
|
+
}
|
|
794
|
+
// ── Static helpers ────────────────────────────────────────
|
|
795
|
+
static listSessions() {
|
|
796
|
+
return getAllSessions();
|
|
797
|
+
}
|
|
798
|
+
static getSession(id) {
|
|
799
|
+
return getSession(id);
|
|
800
|
+
}
|
|
801
|
+
static formatSessionId(id) {
|
|
802
|
+
return `#${id}`;
|
|
803
|
+
}
|
|
804
|
+
// ── SIGINT handler ────────────────────────────────────────
|
|
805
|
+
// Registers a handler that saves the session on Ctrl+C
|
|
806
|
+
// and calls the provided onExit callback with the session ID
|
|
807
|
+
registerSigintHandler(onExit) {
|
|
808
|
+
this.sigintHandler = () => {
|
|
809
|
+
const saved = this.save();
|
|
810
|
+
onExit(saved?.id ?? null);
|
|
811
|
+
process.exit(0);
|
|
812
|
+
};
|
|
813
|
+
process.on("SIGINT", this.sigintHandler);
|
|
814
|
+
}
|
|
815
|
+
removeSigintHandler() {
|
|
816
|
+
if (this.sigintHandler) {
|
|
817
|
+
process.removeListener("SIGINT", this.sigintHandler);
|
|
818
|
+
this.sigintHandler = null;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
// src/ui/screens/Dashboard.tsx
|
|
824
|
+
import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
825
|
+
function Dashboard({ username, model, newModels, onAnalyze, onCommand }) {
|
|
826
|
+
const [inputValue, setInputValue] = useState4("");
|
|
827
|
+
const [error, setError] = useState4("");
|
|
828
|
+
const [showPalette, setShowPalette] = useState4(false);
|
|
829
|
+
const [mode, setMode] = useState4("standard");
|
|
830
|
+
const recentSessions = SessionManager.listSessions().slice(0, 3);
|
|
831
|
+
useInput2((input, key) => {
|
|
832
|
+
if (key.escape && showPalette) {
|
|
833
|
+
setShowPalette(false);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (input === "\\" && !showPalette) {
|
|
837
|
+
setShowPalette(true);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
const handleSubmit = (val) => {
|
|
842
|
+
const trimmed = val.trim();
|
|
843
|
+
if (!trimmed) return;
|
|
844
|
+
if (trimmed.startsWith("\\")) {
|
|
845
|
+
handleCommand(trimmed);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const { parsed, query } = parseGitHubInput(trimmed);
|
|
849
|
+
if (!parsed) {
|
|
850
|
+
setError("No GitHub URL detected. Paste a URL like: https://github.com/owner/repo");
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
setError("");
|
|
854
|
+
const detectedMode = detectMode(query);
|
|
855
|
+
onAnalyze(parsed.raw, query, detectedMode);
|
|
856
|
+
};
|
|
857
|
+
const handleCommand = (cmd) => {
|
|
858
|
+
const lower = cmd.toLowerCase().trim();
|
|
859
|
+
setShowPalette(false);
|
|
860
|
+
setInputValue("");
|
|
861
|
+
if (lower === "\\settings") return onCommand("settings");
|
|
862
|
+
if (lower === "\\compact" || lower === "\\summarize") return onCommand("compact");
|
|
863
|
+
if (lower === "\\deepdive" || lower === "\\learn") {
|
|
864
|
+
setMode("deep");
|
|
865
|
+
setInputValue("");
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (lower === "\\sessions") return onCommand("sessions");
|
|
869
|
+
if (lower === "\\clear") return onCommand("clear");
|
|
870
|
+
if (lower.startsWith("\\resume ")) {
|
|
871
|
+
const id = lower.slice(8).trim();
|
|
872
|
+
return onCommand(`resume:${id}`);
|
|
873
|
+
}
|
|
874
|
+
if (lower === "\\quit" || lower === "\\exit") return onCommand("quit");
|
|
875
|
+
setError(`Unknown command: ${cmd}. Press \\ to see all commands.`);
|
|
876
|
+
};
|
|
877
|
+
const detectMode = (query) => {
|
|
878
|
+
if (mode === "deep") return "deep";
|
|
879
|
+
const lower = query.toLowerCase();
|
|
880
|
+
if (/deep.?dive|in.?depth|detailed|understand|learn|explain/.test(lower)) return "deep";
|
|
881
|
+
return "standard";
|
|
882
|
+
};
|
|
883
|
+
return /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", paddingLeft: 2, children: [
|
|
884
|
+
newModels.length > 0 && /* @__PURE__ */ jsx8(Notification, { newModels }),
|
|
885
|
+
/* @__PURE__ */ jsx8(CommandPalette, { visible: showPalette }),
|
|
886
|
+
mode === "deep" && /* @__PURE__ */ jsxs8(Box8, { marginBottom: 1, children: [
|
|
887
|
+
/* @__PURE__ */ jsx8(Text8, { color: "yellow", children: " \u25C8 Deep-dive mode active" }),
|
|
888
|
+
/* @__PURE__ */ jsx8(Text8, { color: "white", dimColor: true, children: " \u2014 detailed educational output" })
|
|
889
|
+
] }),
|
|
890
|
+
/* @__PURE__ */ jsx8(Box8, { marginBottom: 1, children: /* @__PURE__ */ jsxs8(Text8, { color: "white", dimColor: true, children: [
|
|
891
|
+
" ",
|
|
892
|
+
"Paste a GitHub URL to reverse-engineer it."
|
|
893
|
+
] }) }),
|
|
894
|
+
/* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", children: [
|
|
895
|
+
/* @__PURE__ */ jsxs8(Box8, { children: [
|
|
896
|
+
/* @__PURE__ */ jsx8(Text8, { color: "cyan", dimColor: true, children: " \u203A " }),
|
|
897
|
+
/* @__PURE__ */ jsx8(
|
|
898
|
+
TextInput3,
|
|
899
|
+
{
|
|
900
|
+
value: inputValue,
|
|
901
|
+
onChange: (v) => {
|
|
902
|
+
setInputValue(v);
|
|
903
|
+
setError("");
|
|
904
|
+
},
|
|
905
|
+
onSubmit: handleSubmit,
|
|
906
|
+
placeholder: "https://github.com/owner/repo [optional: your question]"
|
|
907
|
+
}
|
|
908
|
+
)
|
|
909
|
+
] }),
|
|
910
|
+
error && /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text8, { color: "red", children: [
|
|
911
|
+
" ",
|
|
912
|
+
error
|
|
913
|
+
] }) }),
|
|
914
|
+
/* @__PURE__ */ jsxs8(Box8, { marginTop: 1, flexDirection: "column", children: [
|
|
915
|
+
/* @__PURE__ */ jsxs8(Text8, { color: "white", dimColor: true, children: [
|
|
916
|
+
" ",
|
|
917
|
+
"Enter to analyze \xB7 \\\\ for commands \xB7 \\deepdive for detailed mode"
|
|
918
|
+
] }),
|
|
919
|
+
mode === "deep" && /* @__PURE__ */ jsxs8(Text8, { color: "white", dimColor: true, children: [
|
|
920
|
+
" ",
|
|
921
|
+
"\\\\ + Enter to exit deep-dive mode"
|
|
922
|
+
] })
|
|
923
|
+
] })
|
|
924
|
+
] }),
|
|
925
|
+
recentSessions.length > 0 && /* @__PURE__ */ jsxs8(Box8, { flexDirection: "column", marginTop: 2, children: [
|
|
926
|
+
/* @__PURE__ */ jsx8(Box8, { marginBottom: 0, children: /* @__PURE__ */ jsxs8(Text8, { color: "white", dimColor: true, children: [
|
|
927
|
+
" ",
|
|
928
|
+
"\u2500".repeat(44)
|
|
929
|
+
] }) }),
|
|
930
|
+
/* @__PURE__ */ jsxs8(Text8, { color: "white", dimColor: true, children: [
|
|
931
|
+
" ",
|
|
932
|
+
"Recent sessions"
|
|
933
|
+
] }),
|
|
934
|
+
recentSessions.map((s) => /* @__PURE__ */ jsxs8(Box8, { flexDirection: "row", paddingLeft: 2, children: [
|
|
935
|
+
/* @__PURE__ */ jsxs8(Text8, { color: "cyan", dimColor: true, children: [
|
|
936
|
+
"#",
|
|
937
|
+
s.id,
|
|
938
|
+
" "
|
|
939
|
+
] }),
|
|
940
|
+
/* @__PURE__ */ jsx8(Text8, { color: "white", dimColor: true, children: s.repoUrl.replace("https://github.com/", "") }),
|
|
941
|
+
/* @__PURE__ */ jsxs8(Text8, { color: "white", dimColor: true, children: [
|
|
942
|
+
" ",
|
|
943
|
+
timeAgo(s.updatedAt)
|
|
944
|
+
] })
|
|
945
|
+
] }, s.id)),
|
|
946
|
+
/* @__PURE__ */ jsxs8(Text8, { color: "white", dimColor: true, children: [
|
|
947
|
+
" ",
|
|
948
|
+
"\\\\resume ",
|
|
949
|
+
"<id>",
|
|
950
|
+
" to continue"
|
|
951
|
+
] })
|
|
952
|
+
] })
|
|
953
|
+
] });
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// src/ui/screens/Analysis.tsx
|
|
957
|
+
import { useEffect, useState as useState5, useRef } from "react";
|
|
958
|
+
import { Box as Box10, Text as Text10, useInput as useInput3 } from "ink";
|
|
959
|
+
|
|
960
|
+
// src/api/GitHubClient.ts
|
|
961
|
+
import { Octokit } from "@octokit/rest";
|
|
962
|
+
var KEY_FILE_PATTERNS = [
|
|
963
|
+
{ pattern: /^README(\.(md|txt|rst))?$/i, role: "readme" },
|
|
964
|
+
{ pattern: /^package\.json$/, role: "package" },
|
|
965
|
+
{ pattern: /^pyproject\.toml$/, role: "package" },
|
|
966
|
+
{ pattern: /^Cargo\.toml$/, role: "package" },
|
|
967
|
+
{ pattern: /^go\.mod$/, role: "package" },
|
|
968
|
+
{ pattern: /^pom\.xml$/, role: "package" },
|
|
969
|
+
{ pattern: /^build\.gradle(\.kts)?$/, role: "package" },
|
|
970
|
+
{ pattern: /^Gemfile$/, role: "package" },
|
|
971
|
+
{ pattern: /^requirements\.txt$/, role: "package" },
|
|
972
|
+
{ pattern: /^tsconfig\.json$/, role: "config" },
|
|
973
|
+
{ pattern: /^\.eslintrc(\.(js|json|yml|yaml))?$/, role: "config" },
|
|
974
|
+
{ pattern: /^eslint\.config\.(js|ts|mjs)$/, role: "config" },
|
|
975
|
+
{ pattern: /^vite\.config\.(js|ts)$/, role: "config" },
|
|
976
|
+
{ pattern: /^webpack\.config\.(js|ts)$/, role: "config" },
|
|
977
|
+
{ pattern: /^next\.config\.(js|ts|mjs)$/, role: "config" },
|
|
978
|
+
{ pattern: /^docker-compose\.(yml|yaml)$/, role: "config" },
|
|
979
|
+
{ pattern: /^Dockerfile$/, role: "config" },
|
|
980
|
+
{ pattern: /^\.env\.example$/, role: "config" },
|
|
981
|
+
{ pattern: /^main\.(ts|js|py|go|rs|rb)$/, role: "entrypoint" },
|
|
982
|
+
{ pattern: /^index\.(ts|js|tsx|jsx)$/, role: "entrypoint" },
|
|
983
|
+
{ pattern: /^app\.(ts|js|tsx|jsx|py)$/, role: "entrypoint" },
|
|
984
|
+
{ pattern: /^server\.(ts|js|py)$/, role: "entrypoint" },
|
|
985
|
+
{ pattern: /^schema\.(prisma|graphql|sql)$/, role: "schema" },
|
|
986
|
+
{ pattern: /^models?\.(ts|js|py)$/, role: "schema" }
|
|
987
|
+
];
|
|
988
|
+
var MAX_FILE_SIZE_BYTES = 8e4;
|
|
989
|
+
var MAX_KEY_FILES = 12;
|
|
990
|
+
var MAX_TREE_DEPTH = 4;
|
|
991
|
+
var GitHubClient = class _GitHubClient {
|
|
992
|
+
octokit;
|
|
993
|
+
constructor(githubToken) {
|
|
994
|
+
this.octokit = new Octokit({
|
|
995
|
+
auth: githubToken,
|
|
996
|
+
userAgent: "git-reverse/1.0.0"
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
// ── URL Parsing ───────────────────────────────────────────
|
|
1000
|
+
static parseUrl(url) {
|
|
1001
|
+
const parts = url.trim().split(/\s+/);
|
|
1002
|
+
const rawUrl = parts[0] ?? "";
|
|
1003
|
+
const patterns = [
|
|
1004
|
+
/github\.com\/([^/]+)\/([^/?\s]+)(?:\/tree\/([^/?\s]+))?(?:\/(.+))?/
|
|
1005
|
+
];
|
|
1006
|
+
for (const pattern of patterns) {
|
|
1007
|
+
const match = rawUrl.match(pattern);
|
|
1008
|
+
if (match) {
|
|
1009
|
+
return {
|
|
1010
|
+
owner: match[1],
|
|
1011
|
+
repo: match[2].replace(/\.git$/, ""),
|
|
1012
|
+
branch: match[3],
|
|
1013
|
+
subPath: match[4],
|
|
1014
|
+
raw: rawUrl
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
// ── Repo Metadata ─────────────────────────────────────────
|
|
1021
|
+
async fetchRepoMeta(owner, repo) {
|
|
1022
|
+
const { data } = await this.octokit.repos.get({ owner, repo });
|
|
1023
|
+
return {
|
|
1024
|
+
owner,
|
|
1025
|
+
repo,
|
|
1026
|
+
description: data.description ?? "",
|
|
1027
|
+
stars: data.stargazers_count ?? 0,
|
|
1028
|
+
forks: data.forks_count ?? 0,
|
|
1029
|
+
language: data.language ?? "Unknown",
|
|
1030
|
+
topics: data.topics ?? [],
|
|
1031
|
+
defaultBranch: data.default_branch ?? "main",
|
|
1032
|
+
size: data.size ?? 0,
|
|
1033
|
+
createdAt: data.created_at ?? "",
|
|
1034
|
+
updatedAt: data.updated_at ?? ""
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
// ── File Tree ─────────────────────────────────────────────
|
|
1038
|
+
async fetchRepoTree(owner, repo, branch) {
|
|
1039
|
+
try {
|
|
1040
|
+
const { data } = await this.octokit.git.getTree({
|
|
1041
|
+
owner,
|
|
1042
|
+
repo,
|
|
1043
|
+
tree_sha: branch,
|
|
1044
|
+
recursive: "1"
|
|
1045
|
+
});
|
|
1046
|
+
return (data.tree ?? []).filter((item) => item.path && item.type).filter((item) => {
|
|
1047
|
+
const depth = (item.path ?? "").split("/").length;
|
|
1048
|
+
const isArtifact = /^(node_modules|\.git|dist|build|\.next|__pycache__|\.venv|vendor|target)\//.test(
|
|
1049
|
+
item.path ?? ""
|
|
1050
|
+
);
|
|
1051
|
+
return depth <= MAX_TREE_DEPTH && !isArtifact;
|
|
1052
|
+
}).map((item) => ({
|
|
1053
|
+
path: item.path,
|
|
1054
|
+
type: item.type === "tree" ? "dir" : "file",
|
|
1055
|
+
size: item.size,
|
|
1056
|
+
sha: item.sha
|
|
1057
|
+
}));
|
|
1058
|
+
} catch {
|
|
1059
|
+
const { data } = await this.octokit.repos.getContent({ owner, repo, path: "" });
|
|
1060
|
+
if (!Array.isArray(data)) return [];
|
|
1061
|
+
return data.map((item) => ({
|
|
1062
|
+
path: item.path,
|
|
1063
|
+
type: item.type === "dir" ? "dir" : "file",
|
|
1064
|
+
size: "size" in item ? item.size : void 0,
|
|
1065
|
+
sha: item.sha
|
|
1066
|
+
}));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
// ── Key File Detection ────────────────────────────────────
|
|
1070
|
+
static identifyKeyFiles(tree) {
|
|
1071
|
+
const identified = [];
|
|
1072
|
+
const ARTIFACT_RE = /^(node_modules|\.git|dist|build|\.next|__pycache__|\.venv|vendor|target)\//;
|
|
1073
|
+
for (const file of tree) {
|
|
1074
|
+
if (file.type !== "file") continue;
|
|
1075
|
+
if (ARTIFACT_RE.test(file.path)) continue;
|
|
1076
|
+
const filename = file.path.split("/").pop() ?? "";
|
|
1077
|
+
const depth = file.path.split("/").length;
|
|
1078
|
+
for (let i = 0; i < KEY_FILE_PATTERNS.length; i++) {
|
|
1079
|
+
const { pattern, role } = KEY_FILE_PATTERNS[i];
|
|
1080
|
+
if (pattern.test(filename)) {
|
|
1081
|
+
identified.push({ path: file.path, role, priority: i + depth * 10 });
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return identified.sort((a, b) => a.priority - b.priority).slice(0, MAX_KEY_FILES).map(({ path, role }) => ({ path, role }));
|
|
1087
|
+
}
|
|
1088
|
+
// ── File Content Fetch ────────────────────────────────────
|
|
1089
|
+
async fetchFileContent(owner, repo, path) {
|
|
1090
|
+
try {
|
|
1091
|
+
const { data } = await this.octokit.repos.getContent({ owner, repo, path });
|
|
1092
|
+
if ("content" in data && typeof data.content === "string") {
|
|
1093
|
+
if ((data.size ?? 0) > MAX_FILE_SIZE_BYTES) {
|
|
1094
|
+
return `[File too large to display \u2014 ${data.size} bytes]`;
|
|
1095
|
+
}
|
|
1096
|
+
return Buffer.from(data.content, "base64").toString("utf-8");
|
|
1097
|
+
}
|
|
1098
|
+
return null;
|
|
1099
|
+
} catch {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// ── Full Analysis Fetch ───────────────────────────────────
|
|
1104
|
+
async analyzeRepo(owner, repo) {
|
|
1105
|
+
const meta = await this.fetchRepoMeta(owner, repo);
|
|
1106
|
+
const tree = await this.fetchRepoTree(owner, repo, meta.defaultBranch);
|
|
1107
|
+
const keyFilePaths = _GitHubClient.identifyKeyFiles(tree);
|
|
1108
|
+
const keyFiles = [];
|
|
1109
|
+
const chunks = chunkArray(keyFilePaths, 5);
|
|
1110
|
+
for (const chunk of chunks) {
|
|
1111
|
+
const results = await Promise.allSettled(
|
|
1112
|
+
chunk.map(async ({ path, role }) => {
|
|
1113
|
+
const content = await this.fetchFileContent(owner, repo, path);
|
|
1114
|
+
return { path, content: content ?? "", role };
|
|
1115
|
+
})
|
|
1116
|
+
);
|
|
1117
|
+
for (const result of results) {
|
|
1118
|
+
if (result.status === "fulfilled" && result.value.content) {
|
|
1119
|
+
keyFiles.push(result.value);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const dependencyMap = parseDependencies(keyFiles);
|
|
1124
|
+
const techStack = detectTechStack(meta, tree, keyFiles, dependencyMap);
|
|
1125
|
+
const entryPoints = tree.filter((f) => keyFilePaths.some((k) => k.path === f.path && k.role === "entrypoint")).map((f) => f.path);
|
|
1126
|
+
const configFiles = tree.filter((f) => keyFilePaths.some((k) => k.path === f.path && k.role === "config")).map((f) => f.path);
|
|
1127
|
+
return { meta, tree, keyFiles, dependencyMap, techStack, entryPoints, configFiles };
|
|
1128
|
+
}
|
|
1129
|
+
};
|
|
1130
|
+
function chunkArray(arr, size) {
|
|
1131
|
+
const chunks = [];
|
|
1132
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
1133
|
+
chunks.push(arr.slice(i, i + size));
|
|
1134
|
+
}
|
|
1135
|
+
return chunks;
|
|
1136
|
+
}
|
|
1137
|
+
function parseDependencies(keyFiles) {
|
|
1138
|
+
const pkgFile = keyFiles.find((f) => f.path === "package.json" || f.path.endsWith("/package.json"));
|
|
1139
|
+
if (pkgFile) {
|
|
1140
|
+
try {
|
|
1141
|
+
const pkg = JSON.parse(pkgFile.content);
|
|
1142
|
+
return {
|
|
1143
|
+
runtime: pkg.dependencies ?? {},
|
|
1144
|
+
dev: pkg.devDependencies ?? {},
|
|
1145
|
+
peers: pkg.peerDependencies,
|
|
1146
|
+
packageManager: pkg.packageManager,
|
|
1147
|
+
lockFile: "package-lock.json / yarn.lock / pnpm-lock.yaml"
|
|
1148
|
+
};
|
|
1149
|
+
} catch {
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return { runtime: {}, dev: {} };
|
|
1153
|
+
}
|
|
1154
|
+
function detectTechStack(meta, tree, keyFiles, deps) {
|
|
1155
|
+
const stack = /* @__PURE__ */ new Set();
|
|
1156
|
+
if (meta.language) stack.add(meta.language);
|
|
1157
|
+
const allDeps = { ...deps.runtime, ...deps.dev };
|
|
1158
|
+
const depKeys = Object.keys(allDeps);
|
|
1159
|
+
const techMap = {
|
|
1160
|
+
React: ["react", "react-dom"],
|
|
1161
|
+
"Next.js": ["next"],
|
|
1162
|
+
"Vue.js": ["vue"],
|
|
1163
|
+
"Svelte": ["svelte"],
|
|
1164
|
+
"Angular": ["@angular/core"],
|
|
1165
|
+
"Vite": ["vite"],
|
|
1166
|
+
"Express": ["express"],
|
|
1167
|
+
"Fastify": ["fastify"],
|
|
1168
|
+
"NestJS": ["@nestjs/core"],
|
|
1169
|
+
"Prisma": ["prisma", "@prisma/client"],
|
|
1170
|
+
"TypeORM": ["typeorm"],
|
|
1171
|
+
"GraphQL": ["graphql", "apollo-server"],
|
|
1172
|
+
"tRPC": ["@trpc/server"],
|
|
1173
|
+
"Tailwind CSS": ["tailwindcss"],
|
|
1174
|
+
"Framer Motion": ["framer-motion"],
|
|
1175
|
+
"Ink (CLI)": ["ink"],
|
|
1176
|
+
"Electron": ["electron"],
|
|
1177
|
+
"Tauri": ["@tauri-apps/api"],
|
|
1178
|
+
"Vitest": ["vitest"],
|
|
1179
|
+
"Jest": ["jest"],
|
|
1180
|
+
"ESLint": ["eslint"],
|
|
1181
|
+
"TypeScript": ["typescript"],
|
|
1182
|
+
"Zod": ["zod"],
|
|
1183
|
+
"Redux": ["redux", "@reduxjs/toolkit"],
|
|
1184
|
+
"Zustand": ["zustand"],
|
|
1185
|
+
"Axios": ["axios"],
|
|
1186
|
+
"Socket.io": ["socket.io"],
|
|
1187
|
+
"Docker": [],
|
|
1188
|
+
"Kubernetes": []
|
|
1189
|
+
};
|
|
1190
|
+
for (const [tech, pkgs] of Object.entries(techMap)) {
|
|
1191
|
+
if (pkgs.some((p) => depKeys.includes(p))) {
|
|
1192
|
+
stack.add(tech);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const paths = tree.map((f) => f.path);
|
|
1196
|
+
if (paths.some((p) => p.endsWith("Dockerfile") || p.includes("docker-compose"))) {
|
|
1197
|
+
stack.add("Docker");
|
|
1198
|
+
}
|
|
1199
|
+
if (paths.some((p) => p.includes("k8s/") || p.endsWith(".yaml") && p.includes("kubernetes"))) {
|
|
1200
|
+
stack.add("Kubernetes");
|
|
1201
|
+
}
|
|
1202
|
+
if (paths.some((p) => p.endsWith(".prisma"))) {
|
|
1203
|
+
stack.add("Prisma");
|
|
1204
|
+
}
|
|
1205
|
+
return Array.from(stack);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// src/core/PromptBuilder.ts
|
|
1209
|
+
var PromptBuilder = class _PromptBuilder {
|
|
1210
|
+
// ── System Prompt ─────────────────────────────────────────
|
|
1211
|
+
static buildSystemPrompt() {
|
|
1212
|
+
return {
|
|
1213
|
+
role: "system",
|
|
1214
|
+
content: `You are git-reverse, an expert software architect and reverse-engineering analyst.
|
|
1215
|
+
|
|
1216
|
+
Your task is to analyze GitHub repository data and produce a comprehensive, accurate, structured recreation prompt that:
|
|
1217
|
+
1. Explains exactly how the project was originally conceived and built
|
|
1218
|
+
2. Documents the architecture, design decisions, and rationale
|
|
1219
|
+
3. Provides enough detail that a developer could recreate it from scratch
|
|
1220
|
+
4. Is honest about inferred vs. confirmed information
|
|
1221
|
+
|
|
1222
|
+
## Output Format Rules
|
|
1223
|
+
- Use markdown with clear section headers
|
|
1224
|
+
- Be specific: name actual files, packages, patterns
|
|
1225
|
+
- Do NOT use filler phrases like "leverages", "seamlessly", "robust", "cutting-edge"
|
|
1226
|
+
- Do NOT invent features not evidenced in the repository data
|
|
1227
|
+
- Prefix any inferred information with [Inferred]
|
|
1228
|
+
- Keep technical accuracy above completeness
|
|
1229
|
+
|
|
1230
|
+
## Sections to Include (always)
|
|
1231
|
+
1. Project Overview (what it is, core purpose, target user)
|
|
1232
|
+
2. Tech Stack (every confirmed dependency with purpose)
|
|
1233
|
+
3. Architecture (how components relate, data flow)
|
|
1234
|
+
4. Directory Structure (annotated)
|
|
1235
|
+
5. Key Design Decisions (why these choices were made)
|
|
1236
|
+
6. Build & Dev Process (scripts, tooling, CI)
|
|
1237
|
+
7. Recreation Steps (numbered, from git init to working state)
|
|
1238
|
+
8. Notable Patterns (code patterns, conventions used)
|
|
1239
|
+
|
|
1240
|
+
## Additional Sections (if query provided)
|
|
1241
|
+
- Direct Answer to query, grounded in repo evidence`
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
// ── Standard Analysis Prompt ──────────────────────────────
|
|
1245
|
+
static buildAnalysisPrompt(analysis, userQuery) {
|
|
1246
|
+
const { meta, tree, keyFiles, dependencyMap, techStack, entryPoints, configFiles } = analysis;
|
|
1247
|
+
const treeStr = buildTreeString(tree, 3);
|
|
1248
|
+
const depsStr = formatDeps(dependencyMap);
|
|
1249
|
+
const keyFilesStr = keyFiles.map((f) => `### ${f.path} [${f.role}]
|
|
1250
|
+
\`\`\`
|
|
1251
|
+
${f.content.slice(0, 3e3)}
|
|
1252
|
+
\`\`\``).join("\n\n");
|
|
1253
|
+
const querySection = userQuery?.trim() ? `
|
|
1254
|
+
|
|
1255
|
+
## User Query
|
|
1256
|
+
${userQuery.trim()}
|
|
1257
|
+
|
|
1258
|
+
After your full analysis, provide a direct, evidence-based answer to this query in a section titled "## Direct Answer".` : "";
|
|
1259
|
+
return {
|
|
1260
|
+
role: "user",
|
|
1261
|
+
content: `# Repository: ${meta.owner}/${meta.repo}
|
|
1262
|
+
|
|
1263
|
+
## Metadata
|
|
1264
|
+
- Description: ${meta.description || "No description"}
|
|
1265
|
+
- Primary Language: ${meta.language}
|
|
1266
|
+
- Stars: ${meta.stars.toLocaleString()} | Forks: ${meta.forks.toLocaleString()}
|
|
1267
|
+
- Topics: ${meta.topics.join(", ") || "none"}
|
|
1268
|
+
- Created: ${meta.createdAt.split("T")[0]} | Last Updated: ${meta.updatedAt.split("T")[0]}
|
|
1269
|
+
- Repo Size: ${meta.size} KB
|
|
1270
|
+
- Default Branch: ${meta.defaultBranch}
|
|
1271
|
+
|
|
1272
|
+
## Detected Tech Stack
|
|
1273
|
+
${techStack.map((t) => `- ${t}`).join("\n")}
|
|
1274
|
+
|
|
1275
|
+
## Entry Points
|
|
1276
|
+
${entryPoints.length ? entryPoints.map((e) => `- \`${e}\``).join("\n") : "- Not detected"}
|
|
1277
|
+
|
|
1278
|
+
## Config Files
|
|
1279
|
+
${configFiles.length ? configFiles.map((c) => `- \`${c}\``).join("\n") : "- None found"}
|
|
1280
|
+
|
|
1281
|
+
## Dependencies
|
|
1282
|
+
${depsStr}
|
|
1283
|
+
|
|
1284
|
+
## Directory Structure (filtered)
|
|
1285
|
+
\`\`\`
|
|
1286
|
+
${treeStr}
|
|
1287
|
+
\`\`\`
|
|
1288
|
+
|
|
1289
|
+
## Key Files Content
|
|
1290
|
+
${keyFilesStr}
|
|
1291
|
+
${querySection}
|
|
1292
|
+
|
|
1293
|
+
---
|
|
1294
|
+
|
|
1295
|
+
Produce the full structured recreation prompt now. Be precise, developer-grade, and actionable.`
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
// ── Deep Dive Prompt ──────────────────────────────────────
|
|
1299
|
+
static buildDeepDivePrompt(analysis, userQuery) {
|
|
1300
|
+
const base = _PromptBuilder.buildAnalysisPrompt(analysis, userQuery);
|
|
1301
|
+
return {
|
|
1302
|
+
...base,
|
|
1303
|
+
content: base.content + `
|
|
1304
|
+
|
|
1305
|
+
## Deep Dive Mode
|
|
1306
|
+
You are in DEEP DIVE mode. In addition to the standard analysis:
|
|
1307
|
+
- Explain every architectural decision in depth with rationale
|
|
1308
|
+
- Document all design patterns identified (Factory, Observer, Repository, etc.)
|
|
1309
|
+
- Provide a learning path: what concepts must be understood to build this
|
|
1310
|
+
- Add a "Pitfalls & Non-obvious Issues" section
|
|
1311
|
+
- Add a "Scaling Considerations" section
|
|
1312
|
+
- Add estimated time to recreate broken down by phase
|
|
1313
|
+
- Make this suitable as a comprehensive technical document, not just a reference`
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
// ── Compact / Summarize Prompt ────────────────────────────
|
|
1317
|
+
static buildSummarizePrompt(messages) {
|
|
1318
|
+
const sessionText = messages.filter((m) => m.role !== "system").map((m) => `[${m.role.toUpperCase()}]: ${m.content}`).join("\n\n---\n\n");
|
|
1319
|
+
return {
|
|
1320
|
+
role: "user",
|
|
1321
|
+
content: `Summarize this git-reverse session concisely. Include:
|
|
1322
|
+
1. Repository analyzed (owner/repo, stack, purpose)
|
|
1323
|
+
2. Key findings from the analysis
|
|
1324
|
+
3. Questions or queries the user had and how they were answered
|
|
1325
|
+
4. Any next steps or action items mentioned
|
|
1326
|
+
|
|
1327
|
+
Session transcript:
|
|
1328
|
+
---
|
|
1329
|
+
${sessionText}
|
|
1330
|
+
---
|
|
1331
|
+
Produce a clean, scannable summary.`
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
// ── Build Full Message Chain ──────────────────────────────
|
|
1335
|
+
static buildMessages(analysis, mode, userQuery) {
|
|
1336
|
+
const system = _PromptBuilder.buildSystemPrompt();
|
|
1337
|
+
switch (mode) {
|
|
1338
|
+
case "deep":
|
|
1339
|
+
return [system, _PromptBuilder.buildDeepDivePrompt(analysis, userQuery)];
|
|
1340
|
+
default:
|
|
1341
|
+
return [system, _PromptBuilder.buildAnalysisPrompt(analysis, userQuery)];
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
function buildTreeString(files, maxDepth) {
|
|
1346
|
+
const lines = [];
|
|
1347
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
1348
|
+
for (const f of files) {
|
|
1349
|
+
const parts = f.path.split("/");
|
|
1350
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1351
|
+
dirs.add(parts.slice(0, i).join("/"));
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
const sorted = [...files].sort((a, b) => {
|
|
1355
|
+
const aDepth = a.path.split("/").length;
|
|
1356
|
+
const bDepth = b.path.split("/").length;
|
|
1357
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
1358
|
+
return aDepth - bDepth || a.path.localeCompare(b.path);
|
|
1359
|
+
});
|
|
1360
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1361
|
+
for (const f of sorted) {
|
|
1362
|
+
const depth = f.path.split("/").length - 1;
|
|
1363
|
+
if (depth > maxDepth) continue;
|
|
1364
|
+
if (seen.has(f.path)) continue;
|
|
1365
|
+
seen.add(f.path);
|
|
1366
|
+
const indent = " ".repeat(depth);
|
|
1367
|
+
const name = f.path.split("/").pop() ?? f.path;
|
|
1368
|
+
const suffix = f.type === "dir" ? "/" : "";
|
|
1369
|
+
lines.push(`${indent}${name}${suffix}`);
|
|
1370
|
+
}
|
|
1371
|
+
return lines.slice(0, 120).join("\n");
|
|
1372
|
+
}
|
|
1373
|
+
function formatDeps(deps) {
|
|
1374
|
+
const lines = [];
|
|
1375
|
+
const rt = Object.entries(deps.runtime);
|
|
1376
|
+
const dev = Object.entries(deps.dev);
|
|
1377
|
+
if (rt.length) {
|
|
1378
|
+
lines.push("**Runtime:**");
|
|
1379
|
+
for (const [name, version] of rt.slice(0, 30)) {
|
|
1380
|
+
lines.push(` ${name}: ${version}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
if (dev.length) {
|
|
1384
|
+
lines.push("**Dev:**");
|
|
1385
|
+
for (const [name, version] of dev.slice(0, 20)) {
|
|
1386
|
+
lines.push(` ${name}: ${version}`);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (!rt.length && !dev.length) lines.push(" No package manifest found");
|
|
1390
|
+
return lines.join("\n");
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/core/AnalysisService.ts
|
|
1394
|
+
var AnalysisService = class {
|
|
1395
|
+
githubClient;
|
|
1396
|
+
openrouterClient;
|
|
1397
|
+
constructor(apiKey, githubToken) {
|
|
1398
|
+
this.githubClient = new GitHubClient(githubToken);
|
|
1399
|
+
this.openrouterClient = new OpenRouterClient(apiKey);
|
|
1400
|
+
}
|
|
1401
|
+
async *analyze(input, onProgress) {
|
|
1402
|
+
const { repoUrl, query, mode, model } = input;
|
|
1403
|
+
onProgress({ phase: "parsing-url", message: "Parsing repository URL..." });
|
|
1404
|
+
const parsed = GitHubClient.parseUrl(repoUrl);
|
|
1405
|
+
if (!parsed) {
|
|
1406
|
+
throw new Error(
|
|
1407
|
+
`Could not parse GitHub URL: "${repoUrl}"
|
|
1408
|
+
Expected format: https://github.com/owner/repo`
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
onProgress({
|
|
1412
|
+
phase: "fetching-meta",
|
|
1413
|
+
message: `Fetching ${parsed.owner}/${parsed.repo} metadata...`
|
|
1414
|
+
});
|
|
1415
|
+
let analysis;
|
|
1416
|
+
try {
|
|
1417
|
+
const meta = await this.githubClient.fetchRepoMeta(parsed.owner, parsed.repo);
|
|
1418
|
+
onProgress({
|
|
1419
|
+
phase: "fetching-tree",
|
|
1420
|
+
message: `Mapping file tree (${meta.defaultBranch})...`,
|
|
1421
|
+
repoMeta: meta
|
|
1422
|
+
});
|
|
1423
|
+
const tree = await this.githubClient.fetchRepoTree(
|
|
1424
|
+
parsed.owner,
|
|
1425
|
+
parsed.repo,
|
|
1426
|
+
meta.defaultBranch
|
|
1427
|
+
);
|
|
1428
|
+
onProgress({
|
|
1429
|
+
phase: "fetching-files",
|
|
1430
|
+
message: `Reading ${Math.min(tree.filter((f) => f.type === "file").length, 12)} key files...`,
|
|
1431
|
+
repoMeta: meta
|
|
1432
|
+
});
|
|
1433
|
+
analysis = await this.githubClient.analyzeRepo(parsed.owner, parsed.repo);
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1436
|
+
if (msg.includes("404")) {
|
|
1437
|
+
throw new Error(
|
|
1438
|
+
`Repository not found: ${parsed.owner}/${parsed.repo}
|
|
1439
|
+
Check the URL and ensure the repository is public.`
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
if (msg.includes("403") || msg.includes("rate limit")) {
|
|
1443
|
+
throw new Error(
|
|
1444
|
+
"GitHub API rate limit reached.\nWait a few minutes, or set a GITHUB_TOKEN environment variable for higher limits."
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
throw err;
|
|
1448
|
+
}
|
|
1449
|
+
onProgress({
|
|
1450
|
+
phase: "building-prompt",
|
|
1451
|
+
message: "Building analysis prompt...",
|
|
1452
|
+
repoMeta: analysis.meta
|
|
1453
|
+
});
|
|
1454
|
+
let messages;
|
|
1455
|
+
if (mode === "compact") {
|
|
1456
|
+
messages = PromptBuilder.buildMessages(analysis, "standard", query);
|
|
1457
|
+
} else {
|
|
1458
|
+
messages = PromptBuilder.buildMessages(analysis, mode, query);
|
|
1459
|
+
}
|
|
1460
|
+
onProgress({
|
|
1461
|
+
phase: "streaming",
|
|
1462
|
+
message: `Analyzing with ${model.split("/").pop()}...`,
|
|
1463
|
+
repoMeta: analysis.meta
|
|
1464
|
+
});
|
|
1465
|
+
yield* this.openrouterClient.streamCompletion(model, messages, {
|
|
1466
|
+
maxTokens: mode === "deep" ? 8192 : 4096,
|
|
1467
|
+
temperature: mode === "deep" ? 0.4 : 0.3
|
|
1468
|
+
});
|
|
1469
|
+
onProgress({ phase: "done", message: "Analysis complete.", repoMeta: analysis.meta });
|
|
1470
|
+
}
|
|
1471
|
+
// ── Summarize existing session ────────────────────────────
|
|
1472
|
+
async *summarizeSession(messages, model) {
|
|
1473
|
+
const summarizeMsg = PromptBuilder.buildSummarizePrompt(messages);
|
|
1474
|
+
yield* this.openrouterClient.streamCompletion(model, [summarizeMsg], {
|
|
1475
|
+
maxTokens: 1024,
|
|
1476
|
+
temperature: 0.2
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
// ── Static helper for repo meta display ──────────────────
|
|
1480
|
+
static formatRepoStats(meta) {
|
|
1481
|
+
return `${meta.owner}/${meta.repo} \xB7 ${meta.language} \xB7 \u2605 ${meta.stars.toLocaleString()}`;
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
// src/ui/components/OutputRenderer.tsx
|
|
1486
|
+
import { Box as Box9, Text as Text9 } from "ink";
|
|
1487
|
+
import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
1488
|
+
function OutputRenderer({ content, isStreaming = false, wordCount }) {
|
|
1489
|
+
const lines = content.split("\n");
|
|
1490
|
+
return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", paddingLeft: 2, children: [
|
|
1491
|
+
lines.map((line, i) => /* @__PURE__ */ jsx9(RenderedLine, { line }, i)),
|
|
1492
|
+
isStreaming && /* @__PURE__ */ jsx9(Text9, { color: "cyan", dimColor: true, children: "\u258B" }),
|
|
1493
|
+
!isStreaming && content.length > 0 && /* @__PURE__ */ jsxs9(Box9, { marginTop: 1, children: [
|
|
1494
|
+
/* @__PURE__ */ jsxs9(Text9, { color: "white", dimColor: true, children: [
|
|
1495
|
+
"\u2500".repeat(44),
|
|
1496
|
+
" "
|
|
1497
|
+
] }),
|
|
1498
|
+
/* @__PURE__ */ jsxs9(Text9, { color: "white", dimColor: true, children: [
|
|
1499
|
+
wordCount ?? content.trim().split(/\s+/).filter(Boolean).length,
|
|
1500
|
+
" words"
|
|
1501
|
+
] })
|
|
1502
|
+
] })
|
|
1503
|
+
] });
|
|
1504
|
+
}
|
|
1505
|
+
function RenderedLine({ line }) {
|
|
1506
|
+
if (/^# /.test(line)) {
|
|
1507
|
+
return /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { color: "cyan", bold: true, children: line.slice(2) }) });
|
|
1508
|
+
}
|
|
1509
|
+
if (/^## /.test(line)) {
|
|
1510
|
+
return /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { color: "white", bold: true, children: line.slice(3) }) });
|
|
1511
|
+
}
|
|
1512
|
+
if (/^### /.test(line)) {
|
|
1513
|
+
return /* @__PURE__ */ jsxs9(Text9, { color: "yellow", children: [
|
|
1514
|
+
" ",
|
|
1515
|
+
line.slice(4)
|
|
1516
|
+
] });
|
|
1517
|
+
}
|
|
1518
|
+
if (/^```/.test(line)) {
|
|
1519
|
+
const lang = line.slice(3).trim();
|
|
1520
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "white", dimColor: true, children: lang ? ` \u250C\u2500 ${lang}` : " \u250C\u2500" });
|
|
1521
|
+
}
|
|
1522
|
+
if (/^---+$/.test(line.trim())) {
|
|
1523
|
+
return /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsxs9(Text9, { color: "white", dimColor: true, children: [
|
|
1524
|
+
" ",
|
|
1525
|
+
"\u2500".repeat(40)
|
|
1526
|
+
] }) });
|
|
1527
|
+
}
|
|
1528
|
+
if (/^[-*] /.test(line)) {
|
|
1529
|
+
return /* @__PURE__ */ jsxs9(Text9, { color: "white", children: [
|
|
1530
|
+
" \xB7 ",
|
|
1531
|
+
/* @__PURE__ */ jsx9(InlineText, { text: line.slice(2) })
|
|
1532
|
+
] });
|
|
1533
|
+
}
|
|
1534
|
+
if (/^\d+\. /.test(line)) {
|
|
1535
|
+
const match = line.match(/^(\d+)\. (.+)$/);
|
|
1536
|
+
if (match) {
|
|
1537
|
+
return /* @__PURE__ */ jsxs9(Text9, { color: "white", children: [
|
|
1538
|
+
" ",
|
|
1539
|
+
/* @__PURE__ */ jsxs9(Text9, { color: "cyan", dimColor: true, children: [
|
|
1540
|
+
match[1],
|
|
1541
|
+
"."
|
|
1542
|
+
] }),
|
|
1543
|
+
" ",
|
|
1544
|
+
/* @__PURE__ */ jsx9(InlineText, { text: match[2] })
|
|
1545
|
+
] });
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if (line.trim() === "") {
|
|
1549
|
+
return /* @__PURE__ */ jsx9(Text9, { children: "" });
|
|
1550
|
+
}
|
|
1551
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "white", dimColor: true, children: /* @__PURE__ */ jsx9(InlineText, { text: line }) });
|
|
1552
|
+
}
|
|
1553
|
+
function InlineText({ text }) {
|
|
1554
|
+
return /* @__PURE__ */ jsx9(Text9, { color: "white", children: text });
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// src/ui/screens/Analysis.tsx
|
|
1558
|
+
import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
|
|
1559
|
+
function AnalysisScreen({ input, onComplete, onBack, onError }) {
|
|
1560
|
+
const [phase, setPhase] = useState5("running");
|
|
1561
|
+
const [progressMsg, setProgressMsg] = useState5("Starting...");
|
|
1562
|
+
const [output, setOutput] = useState5("");
|
|
1563
|
+
const [repoMeta, setRepoMeta] = useState5(null);
|
|
1564
|
+
const [errorMsg, setErrorMsg] = useState5("");
|
|
1565
|
+
const [sessionId, setSessionId] = useState5("");
|
|
1566
|
+
const repoMetaRef = useRef(null);
|
|
1567
|
+
const sessionRef = useRef(new SessionManager());
|
|
1568
|
+
const abortRef = useRef(false);
|
|
1569
|
+
useInput3((_, key) => {
|
|
1570
|
+
if (key.escape && phase === "done") {
|
|
1571
|
+
onBack();
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
useEffect(() => {
|
|
1575
|
+
let accum = "";
|
|
1576
|
+
abortRef.current = false;
|
|
1577
|
+
const run = async () => {
|
|
1578
|
+
const service = new AnalysisService(input.apiKey);
|
|
1579
|
+
try {
|
|
1580
|
+
const gen = service.analyze(input, (progress) => {
|
|
1581
|
+
if (abortRef.current) return;
|
|
1582
|
+
setProgressMsg(progress.message);
|
|
1583
|
+
if (progress.repoMeta) {
|
|
1584
|
+
setRepoMeta(progress.repoMeta);
|
|
1585
|
+
repoMetaRef.current = progress.repoMeta;
|
|
1586
|
+
}
|
|
1587
|
+
if (progress.phase === "streaming") setPhase("streaming");
|
|
1588
|
+
});
|
|
1589
|
+
for await (const chunk of gen) {
|
|
1590
|
+
if (abortRef.current) break;
|
|
1591
|
+
if (chunk.done) break;
|
|
1592
|
+
accum += chunk.content;
|
|
1593
|
+
setOutput(accum);
|
|
1594
|
+
}
|
|
1595
|
+
if (abortRef.current) return;
|
|
1596
|
+
const session = sessionRef.current.createSession({
|
|
1597
|
+
repoUrl: input.repoUrl,
|
|
1598
|
+
query: input.query ?? "",
|
|
1599
|
+
model: input.model,
|
|
1600
|
+
mode: input.mode,
|
|
1601
|
+
repoMeta: repoMetaRef.current ?? void 0
|
|
1602
|
+
});
|
|
1603
|
+
sessionRef.current.setAnalysisOutput(accum);
|
|
1604
|
+
sessionRef.current.appendMessage({ role: "assistant", content: accum });
|
|
1605
|
+
sessionRef.current.save();
|
|
1606
|
+
setSessionId(session.id);
|
|
1607
|
+
setPhase("done");
|
|
1608
|
+
onComplete(accum, session.id);
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
if (abortRef.current) return;
|
|
1611
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1612
|
+
setErrorMsg(msg);
|
|
1613
|
+
setPhase("error");
|
|
1614
|
+
onError(msg);
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
run();
|
|
1618
|
+
return () => {
|
|
1619
|
+
abortRef.current = true;
|
|
1620
|
+
};
|
|
1621
|
+
}, []);
|
|
1622
|
+
const spinnerLabel = {
|
|
1623
|
+
"parsing-url": "Parsing URL...",
|
|
1624
|
+
"fetching-meta": progressMsg,
|
|
1625
|
+
"fetching-tree": progressMsg,
|
|
1626
|
+
"fetching-files": progressMsg,
|
|
1627
|
+
"building-prompt": progressMsg,
|
|
1628
|
+
streaming: progressMsg,
|
|
1629
|
+
done: "Complete",
|
|
1630
|
+
idle: "Initializing...",
|
|
1631
|
+
error: "Error"
|
|
1632
|
+
};
|
|
1633
|
+
return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", paddingLeft: 2, children: [
|
|
1634
|
+
repoMeta && /* @__PURE__ */ jsxs10(Box10, { marginBottom: 1, flexDirection: "row", gap: 2, children: [
|
|
1635
|
+
/* @__PURE__ */ jsxs10(Text10, { color: "cyan", children: [
|
|
1636
|
+
repoMeta.owner,
|
|
1637
|
+
"/",
|
|
1638
|
+
repoMeta.repo
|
|
1639
|
+
] }),
|
|
1640
|
+
/* @__PURE__ */ jsx10(Text10, { color: "white", dimColor: true, children: "\xB7" }),
|
|
1641
|
+
/* @__PURE__ */ jsx10(Text10, { color: "white", dimColor: true, children: repoMeta.language }),
|
|
1642
|
+
/* @__PURE__ */ jsx10(Text10, { color: "white", dimColor: true, children: "\xB7" }),
|
|
1643
|
+
/* @__PURE__ */ jsxs10(Text10, { color: "white", dimColor: true, children: [
|
|
1644
|
+
"\u2605 ",
|
|
1645
|
+
repoMeta.stars.toLocaleString()
|
|
1646
|
+
] })
|
|
1647
|
+
] }),
|
|
1648
|
+
input.mode !== "standard" && /* @__PURE__ */ jsx10(Box10, { marginBottom: 1, children: /* @__PURE__ */ jsxs10(Text10, { color: "yellow", dimColor: true, children: [
|
|
1649
|
+
"\u25C8 ",
|
|
1650
|
+
input.mode === "deep" ? "deep-dive" : input.mode,
|
|
1651
|
+
" mode"
|
|
1652
|
+
] }) }),
|
|
1653
|
+
input.query && /* @__PURE__ */ jsxs10(Box10, { marginBottom: 1, children: [
|
|
1654
|
+
/* @__PURE__ */ jsx10(Text10, { color: "white", dimColor: true, children: "Query: " }),
|
|
1655
|
+
/* @__PURE__ */ jsx10(Text10, { color: "white", children: input.query })
|
|
1656
|
+
] }),
|
|
1657
|
+
(phase === "running" || phase === "streaming") && /* @__PURE__ */ jsx10(Spinner, { label: progressMsg, color: phase === "streaming" ? "white" : "cyan" }),
|
|
1658
|
+
output.length > 0 && /* @__PURE__ */ jsx10(Box10, { marginTop: 1, flexDirection: "column", children: /* @__PURE__ */ jsx10(
|
|
1659
|
+
OutputRenderer,
|
|
1660
|
+
{
|
|
1661
|
+
content: output,
|
|
1662
|
+
isStreaming: phase === "streaming",
|
|
1663
|
+
wordCount: countWords(output)
|
|
1664
|
+
}
|
|
1665
|
+
) }),
|
|
1666
|
+
phase === "done" && /* @__PURE__ */ jsxs10(Box10, { marginTop: 1, flexDirection: "column", children: [
|
|
1667
|
+
/* @__PURE__ */ jsxs10(Box10, { children: [
|
|
1668
|
+
/* @__PURE__ */ jsx10(Text10, { color: "green", children: "\u2713 Analysis complete" }),
|
|
1669
|
+
/* @__PURE__ */ jsxs10(Text10, { color: "white", dimColor: true, children: [
|
|
1670
|
+
" ",
|
|
1671
|
+
"Session",
|
|
1672
|
+
" "
|
|
1673
|
+
] }),
|
|
1674
|
+
/* @__PURE__ */ jsxs10(Text10, { color: "cyan", children: [
|
|
1675
|
+
"#",
|
|
1676
|
+
sessionId
|
|
1677
|
+
] }),
|
|
1678
|
+
/* @__PURE__ */ jsxs10(Text10, { color: "white", dimColor: true, children: [
|
|
1679
|
+
" ",
|
|
1680
|
+
"saved"
|
|
1681
|
+
] })
|
|
1682
|
+
] }),
|
|
1683
|
+
/* @__PURE__ */ jsx10(Box10, { marginTop: 0, children: /* @__PURE__ */ jsxs10(Text10, { color: "white", dimColor: true, children: [
|
|
1684
|
+
" ",
|
|
1685
|
+
"Esc to return to dashboard"
|
|
1686
|
+
] }) })
|
|
1687
|
+
] }),
|
|
1688
|
+
phase === "error" && /* @__PURE__ */ jsxs10(Box10, { marginTop: 1, flexDirection: "column", children: [
|
|
1689
|
+
/* @__PURE__ */ jsx10(Text10, { color: "red", children: "\u2717 Error" }),
|
|
1690
|
+
/* @__PURE__ */ jsx10(Box10, { paddingLeft: 2, children: /* @__PURE__ */ jsx10(Text10, { color: "white", dimColor: true, children: errorMsg }) }),
|
|
1691
|
+
/* @__PURE__ */ jsx10(Box10, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text10, { color: "white", dimColor: true, children: [
|
|
1692
|
+
" ",
|
|
1693
|
+
"Esc to return to dashboard"
|
|
1694
|
+
] }) })
|
|
1695
|
+
] })
|
|
1696
|
+
] });
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// src/ui/screens/Settings.tsx
|
|
1700
|
+
import { useState as useState6 } from "react";
|
|
1701
|
+
import { Box as Box11, Text as Text11 } from "ink";
|
|
1702
|
+
import SelectInput2 from "ink-select-input";
|
|
1703
|
+
import TextInput4 from "ink-text-input";
|
|
1704
|
+
import { jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
1705
|
+
var MENU_ITEMS = [
|
|
1706
|
+
{ label: "Change username", value: "change-name" },
|
|
1707
|
+
{ label: "Change API key", value: "change-key" },
|
|
1708
|
+
{ label: "Change active model", value: "change-model" },
|
|
1709
|
+
{ label: "Refresh model list", value: "refresh-models" },
|
|
1710
|
+
{ label: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", value: "sep", disabled: true },
|
|
1711
|
+
{ label: "Back to dashboard", value: "back" }
|
|
1712
|
+
];
|
|
1713
|
+
function SettingsScreen({ onBack, onModelChanged, onUsernameChanged }) {
|
|
1714
|
+
const user = getUser();
|
|
1715
|
+
const cached = getCachedModels();
|
|
1716
|
+
const [view, setView] = useState6("menu");
|
|
1717
|
+
const [nameValue, setNameValue] = useState6(user.name);
|
|
1718
|
+
const [keyValue, setKeyValue] = useState6("");
|
|
1719
|
+
const [models, setModels] = useState6(cached.models);
|
|
1720
|
+
const [status, setStatus] = useState6("");
|
|
1721
|
+
const [error, setError] = useState6("");
|
|
1722
|
+
const handleMenuSelect = async (item) => {
|
|
1723
|
+
if (item.value === "back") {
|
|
1724
|
+
onBack();
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
if (item.value === "sep") return;
|
|
1728
|
+
if (item.value === "refresh-models") {
|
|
1729
|
+
setView("refreshing");
|
|
1730
|
+
try {
|
|
1731
|
+
const client = new OpenRouterClient(user.apiKey);
|
|
1732
|
+
const fresh = await client.fetchFreeModels();
|
|
1733
|
+
setCachedModels(fresh);
|
|
1734
|
+
markModelsSeen(fresh.map((m) => m.id));
|
|
1735
|
+
setModels(fresh);
|
|
1736
|
+
setStatus(`${fresh.length} models refreshed.`);
|
|
1737
|
+
setTimeout(() => {
|
|
1738
|
+
setStatus("");
|
|
1739
|
+
setView("menu");
|
|
1740
|
+
}, 1500);
|
|
1741
|
+
} catch (err) {
|
|
1742
|
+
setError(err instanceof Error ? err.message : "Refresh failed.");
|
|
1743
|
+
setView("menu");
|
|
1744
|
+
}
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
setView(item.value);
|
|
1748
|
+
};
|
|
1749
|
+
const handleNameSubmit = (val) => {
|
|
1750
|
+
const trimmed = val.trim();
|
|
1751
|
+
if (trimmed.length < 2) {
|
|
1752
|
+
setError("Name must be at least 2 characters.");
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
setUserName(trimmed);
|
|
1756
|
+
onUsernameChanged(trimmed);
|
|
1757
|
+
setStatus(`Username updated to "${trimmed}".`);
|
|
1758
|
+
setTimeout(() => {
|
|
1759
|
+
setStatus("");
|
|
1760
|
+
setView("menu");
|
|
1761
|
+
}, 1200);
|
|
1762
|
+
};
|
|
1763
|
+
const handleKeySubmit = async (val) => {
|
|
1764
|
+
const trimmed = val.trim();
|
|
1765
|
+
if (!trimmed.startsWith("sk-or-")) {
|
|
1766
|
+
setError("Invalid key format.");
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
setView("refreshing");
|
|
1770
|
+
try {
|
|
1771
|
+
const client = new OpenRouterClient(trimmed);
|
|
1772
|
+
const { valid, error: ve } = await client.validateKey();
|
|
1773
|
+
if (!valid) {
|
|
1774
|
+
setError(ve ?? "Validation failed.");
|
|
1775
|
+
setView("change-key");
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const fresh = await client.fetchFreeModels();
|
|
1779
|
+
setApiKey(trimmed);
|
|
1780
|
+
setCachedModels(fresh);
|
|
1781
|
+
markModelsSeen(fresh.map((m) => m.id));
|
|
1782
|
+
setModels(fresh);
|
|
1783
|
+
setStatus("Key updated and models refreshed.");
|
|
1784
|
+
setTimeout(() => {
|
|
1785
|
+
setStatus("");
|
|
1786
|
+
setView("menu");
|
|
1787
|
+
}, 1500);
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
setError(err instanceof Error ? err.message : "Failed.");
|
|
1790
|
+
setView("change-key");
|
|
1791
|
+
}
|
|
1792
|
+
};
|
|
1793
|
+
return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", paddingLeft: 2, children: [
|
|
1794
|
+
/* @__PURE__ */ jsxs11(Box11, { marginBottom: 1, children: [
|
|
1795
|
+
/* @__PURE__ */ jsx11(Text11, { color: "cyan", children: "Settings" }),
|
|
1796
|
+
/* @__PURE__ */ jsxs11(Text11, { color: "white", dimColor: true, children: [
|
|
1797
|
+
" \xB7 ",
|
|
1798
|
+
user.name,
|
|
1799
|
+
" \xB7 ",
|
|
1800
|
+
truncate(user.selectedModel.split("/").pop() ?? user.selectedModel, 30)
|
|
1801
|
+
] })
|
|
1802
|
+
] }),
|
|
1803
|
+
/* @__PURE__ */ jsx11(Box11, { marginBottom: 0, children: /* @__PURE__ */ jsxs11(Text11, { color: "white", dimColor: true, children: [
|
|
1804
|
+
" Config: ",
|
|
1805
|
+
getStorePath()
|
|
1806
|
+
] }) }),
|
|
1807
|
+
/* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text11, { color: "white", dimColor: true, children: "\u2500".repeat(44) }) }),
|
|
1808
|
+
status && /* @__PURE__ */ jsx11(Box11, { marginBottom: 1, children: /* @__PURE__ */ jsxs11(Text11, { color: "green", children: [
|
|
1809
|
+
" ",
|
|
1810
|
+
"\u2713 ",
|
|
1811
|
+
status
|
|
1812
|
+
] }) }),
|
|
1813
|
+
error && /* @__PURE__ */ jsx11(Box11, { marginBottom: 1, children: /* @__PURE__ */ jsxs11(Text11, { color: "red", children: [
|
|
1814
|
+
" ",
|
|
1815
|
+
"\u2717 ",
|
|
1816
|
+
error
|
|
1817
|
+
] }) }),
|
|
1818
|
+
view === "menu" && /* @__PURE__ */ jsx11(Box11, { paddingLeft: 2, marginTop: 1, children: /* @__PURE__ */ jsx11(
|
|
1819
|
+
SelectInput2,
|
|
1820
|
+
{
|
|
1821
|
+
items: MENU_ITEMS.filter((i) => !("disabled" in i && i.disabled)),
|
|
1822
|
+
onSelect: handleMenuSelect,
|
|
1823
|
+
itemComponent: ({ isSelected, label }) => /* @__PURE__ */ jsxs11(Text11, { color: isSelected ? "cyan" : "white", dimColor: !isSelected, children: [
|
|
1824
|
+
isSelected ? "\u203A " : " ",
|
|
1825
|
+
label
|
|
1826
|
+
] })
|
|
1827
|
+
}
|
|
1828
|
+
) }),
|
|
1829
|
+
view === "change-name" && /* @__PURE__ */ jsxs11(Box11, { paddingLeft: 2, marginTop: 1, flexDirection: "column", children: [
|
|
1830
|
+
/* @__PURE__ */ jsx11(Text11, { color: "white", dimColor: true, children: "New username:" }),
|
|
1831
|
+
/* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx11(
|
|
1832
|
+
TextInput4,
|
|
1833
|
+
{
|
|
1834
|
+
value: nameValue,
|
|
1835
|
+
onChange: (v) => {
|
|
1836
|
+
setNameValue(v);
|
|
1837
|
+
setError("");
|
|
1838
|
+
},
|
|
1839
|
+
onSubmit: handleNameSubmit,
|
|
1840
|
+
placeholder: user.name
|
|
1841
|
+
}
|
|
1842
|
+
) })
|
|
1843
|
+
] }),
|
|
1844
|
+
view === "change-key" && /* @__PURE__ */ jsxs11(Box11, { paddingLeft: 2, marginTop: 1, flexDirection: "column", children: [
|
|
1845
|
+
/* @__PURE__ */ jsx11(Text11, { color: "white", dimColor: true, children: "New OpenRouter API key:" }),
|
|
1846
|
+
/* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx11(
|
|
1847
|
+
TextInput4,
|
|
1848
|
+
{
|
|
1849
|
+
value: keyValue,
|
|
1850
|
+
onChange: (v) => {
|
|
1851
|
+
setKeyValue(v);
|
|
1852
|
+
setError("");
|
|
1853
|
+
},
|
|
1854
|
+
onSubmit: handleKeySubmit,
|
|
1855
|
+
placeholder: "sk-or-v1-...",
|
|
1856
|
+
mask: "*"
|
|
1857
|
+
}
|
|
1858
|
+
) })
|
|
1859
|
+
] }),
|
|
1860
|
+
view === "change-model" && /* @__PURE__ */ jsx11(
|
|
1861
|
+
SetupModel,
|
|
1862
|
+
{
|
|
1863
|
+
models,
|
|
1864
|
+
isSettings: true,
|
|
1865
|
+
onComplete: (m) => {
|
|
1866
|
+
onModelChanged(m);
|
|
1867
|
+
setStatus(`Model set to ${m.id}`);
|
|
1868
|
+
setTimeout(() => {
|
|
1869
|
+
setStatus("");
|
|
1870
|
+
setView("menu");
|
|
1871
|
+
}, 1e3);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
),
|
|
1875
|
+
view === "refreshing" && /* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsx11(Spinner, { label: "Working..." }) }),
|
|
1876
|
+
view === "menu" && /* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsxs11(Text11, { color: "white", dimColor: true, children: [
|
|
1877
|
+
" ",
|
|
1878
|
+
"Esc to go back"
|
|
1879
|
+
] }) })
|
|
1880
|
+
] });
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// src/ui/screens/ResumeSession.tsx
|
|
1884
|
+
import { useState as useState7 } from "react";
|
|
1885
|
+
import { Box as Box12, Text as Text12 } from "ink";
|
|
1886
|
+
import TextInput5 from "ink-text-input";
|
|
1887
|
+
import { jsx as jsx12, jsxs as jsxs12 } from "react/jsx-runtime";
|
|
1888
|
+
function ResumeSession({ initialId = "", onResume, onBack }) {
|
|
1889
|
+
const [idValue, setIdValue] = useState7(initialId);
|
|
1890
|
+
const [error, setError] = useState7("");
|
|
1891
|
+
const recentSessions = SessionManager.listSessions().slice(0, 8);
|
|
1892
|
+
const handleSubmit = (val) => {
|
|
1893
|
+
const id = val.trim().replace(/^#/, "");
|
|
1894
|
+
if (!id) {
|
|
1895
|
+
setError("Enter a session ID.");
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
const session = SessionManager.getSession(id);
|
|
1899
|
+
if (!session) {
|
|
1900
|
+
setError(`No session found with ID: ${id}`);
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
onResume(session);
|
|
1904
|
+
};
|
|
1905
|
+
return /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", paddingLeft: 2, children: [
|
|
1906
|
+
/* @__PURE__ */ jsx12(Box12, { marginBottom: 1, children: /* @__PURE__ */ jsx12(Text12, { color: "cyan", children: "Resume Session" }) }),
|
|
1907
|
+
/* @__PURE__ */ jsxs12(Box12, { marginBottom: 1, flexDirection: "column", children: [
|
|
1908
|
+
/* @__PURE__ */ jsxs12(Text12, { color: "white", dimColor: true, children: [
|
|
1909
|
+
" ",
|
|
1910
|
+
"Enter a session ID to resume:"
|
|
1911
|
+
] }),
|
|
1912
|
+
/* @__PURE__ */ jsxs12(Box12, { marginTop: 1, children: [
|
|
1913
|
+
/* @__PURE__ */ jsx12(Text12, { color: "white", dimColor: true, children: " id " }),
|
|
1914
|
+
/* @__PURE__ */ jsx12(
|
|
1915
|
+
TextInput5,
|
|
1916
|
+
{
|
|
1917
|
+
value: idValue,
|
|
1918
|
+
onChange: (v) => {
|
|
1919
|
+
setIdValue(v);
|
|
1920
|
+
setError("");
|
|
1921
|
+
},
|
|
1922
|
+
onSubmit: handleSubmit,
|
|
1923
|
+
placeholder: "e.g. abc12345"
|
|
1924
|
+
}
|
|
1925
|
+
)
|
|
1926
|
+
] }),
|
|
1927
|
+
error && /* @__PURE__ */ jsx12(Box12, { marginTop: 1, children: /* @__PURE__ */ jsxs12(Text12, { color: "red", children: [
|
|
1928
|
+
" ",
|
|
1929
|
+
error
|
|
1930
|
+
] }) })
|
|
1931
|
+
] }),
|
|
1932
|
+
recentSessions.length > 0 && /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", marginTop: 1, children: [
|
|
1933
|
+
/* @__PURE__ */ jsx12(Box12, { children: /* @__PURE__ */ jsxs12(Text12, { color: "white", dimColor: true, children: [
|
|
1934
|
+
" ",
|
|
1935
|
+
"\u2500".repeat(44)
|
|
1936
|
+
] }) }),
|
|
1937
|
+
/* @__PURE__ */ jsxs12(Text12, { color: "white", dimColor: true, children: [
|
|
1938
|
+
" ",
|
|
1939
|
+
"Saved sessions:"
|
|
1940
|
+
] }),
|
|
1941
|
+
/* @__PURE__ */ jsx12(Box12, { flexDirection: "column", marginTop: 0, paddingLeft: 2, children: recentSessions.map((s) => /* @__PURE__ */ jsxs12(Box12, { flexDirection: "row", children: [
|
|
1942
|
+
/* @__PURE__ */ jsxs12(Text12, { color: "cyan", children: [
|
|
1943
|
+
"#",
|
|
1944
|
+
s.id,
|
|
1945
|
+
" "
|
|
1946
|
+
] }),
|
|
1947
|
+
/* @__PURE__ */ jsx12(Text12, { color: "white", dimColor: true, children: s.repoUrl.replace("https://github.com/", "").slice(0, 28).padEnd(30) }),
|
|
1948
|
+
/* @__PURE__ */ jsx12(Text12, { color: "white", dimColor: true, children: s.mode !== "standard" ? `[${s.mode}] ` : " " }),
|
|
1949
|
+
/* @__PURE__ */ jsx12(Text12, { color: "white", dimColor: true, children: timeAgo(s.updatedAt) })
|
|
1950
|
+
] }, s.id)) })
|
|
1951
|
+
] }),
|
|
1952
|
+
/* @__PURE__ */ jsx12(Box12, { marginTop: 1, children: /* @__PURE__ */ jsxs12(Text12, { color: "white", dimColor: true, children: [
|
|
1953
|
+
" ",
|
|
1954
|
+
"Esc to go back"
|
|
1955
|
+
] }) })
|
|
1956
|
+
] });
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// src/ui/App.tsx
|
|
1960
|
+
import { jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
|
|
1961
|
+
var VERSION = "1.0.0";
|
|
1962
|
+
function App({ resumeSessionId }) {
|
|
1963
|
+
const { exit } = useApp2();
|
|
1964
|
+
const user = getUser();
|
|
1965
|
+
const cached = getCachedModels();
|
|
1966
|
+
const initialScreen = (() => {
|
|
1967
|
+
if (!user.setupComplete) return user.setupStep === "username" ? "setup-username" : "setup-apikey";
|
|
1968
|
+
if (resumeSessionId) return "resume-session";
|
|
1969
|
+
return "dashboard";
|
|
1970
|
+
})();
|
|
1971
|
+
const [screen, setScreen] = useState8(initialScreen);
|
|
1972
|
+
const [prevScreen, setPrevScreen] = useState8("dashboard");
|
|
1973
|
+
const [username, setUsername] = useState8(user.name);
|
|
1974
|
+
const [activeModel, setActiveModel] = useState8(user.selectedModel);
|
|
1975
|
+
const [freeModels, setFreeModels] = useState8(cached.models);
|
|
1976
|
+
const [newModels, setNewModels] = useState8([]);
|
|
1977
|
+
const [pendingAnalysis, setPendingAnalysis] = useState8(null);
|
|
1978
|
+
const [exitSessionId, setExitSessionId] = useState8(null);
|
|
1979
|
+
const [exitRepoUrl, setExitRepoUrl] = useState8(null);
|
|
1980
|
+
const [resumeId2, setResumeId] = useState8(resumeSessionId ?? "");
|
|
1981
|
+
useEffect2(() => {
|
|
1982
|
+
if (!user.setupComplete || !user.apiKey) return;
|
|
1983
|
+
const refresh = async () => {
|
|
1984
|
+
try {
|
|
1985
|
+
const client = new OpenRouterClient(user.apiKey);
|
|
1986
|
+
if (!OpenRouterClient.isCacheStale(cached.fetchedAt)) return;
|
|
1987
|
+
const fresh = await client.fetchFreeModels();
|
|
1988
|
+
setCachedModels(fresh);
|
|
1989
|
+
setFreeModels(fresh);
|
|
1990
|
+
const unseen = getNewModels(fresh);
|
|
1991
|
+
if (unseen.length > 0) setNewModels(unseen);
|
|
1992
|
+
} catch {
|
|
1993
|
+
}
|
|
1994
|
+
};
|
|
1995
|
+
refresh();
|
|
1996
|
+
}, []);
|
|
1997
|
+
useEffect2(() => {
|
|
1998
|
+
const sessionMgr = new SessionManager();
|
|
1999
|
+
sessionMgr.registerSigintHandler((savedId) => {
|
|
2000
|
+
if (savedId) {
|
|
2001
|
+
process.stdout.write(`
|
|
2002
|
+
|
|
2003
|
+
Session saved: #${savedId}
|
|
2004
|
+
`);
|
|
2005
|
+
process.stdout.write(` Resume: git-reverse --resume ${savedId}
|
|
2006
|
+
|
|
2007
|
+
`);
|
|
2008
|
+
}
|
|
2009
|
+
process.exit(0);
|
|
2010
|
+
});
|
|
2011
|
+
return () => sessionMgr.removeSigintHandler();
|
|
2012
|
+
}, []);
|
|
2013
|
+
useInput4((_, key) => {
|
|
2014
|
+
if (key.escape) {
|
|
2015
|
+
if (screen === "settings" || screen === "resume-session") {
|
|
2016
|
+
navigateTo("dashboard");
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
2020
|
+
const navigateTo = (next) => {
|
|
2021
|
+
setPrevScreen(screen);
|
|
2022
|
+
setScreen(next);
|
|
2023
|
+
};
|
|
2024
|
+
const handleUsernameComplete = (name) => {
|
|
2025
|
+
setUsername(name);
|
|
2026
|
+
navigateTo("setup-apikey");
|
|
2027
|
+
};
|
|
2028
|
+
const handleApiKeyComplete = (models) => {
|
|
2029
|
+
setFreeModels(models);
|
|
2030
|
+
navigateTo("setup-model");
|
|
2031
|
+
};
|
|
2032
|
+
const handleModelComplete = (model) => {
|
|
2033
|
+
setActiveModel(model.id);
|
|
2034
|
+
navigateTo("dashboard");
|
|
2035
|
+
};
|
|
2036
|
+
const handleAnalyze = (repoUrl, query, mode) => {
|
|
2037
|
+
const currentUser = getUser();
|
|
2038
|
+
setPendingAnalysis({
|
|
2039
|
+
repoUrl,
|
|
2040
|
+
query,
|
|
2041
|
+
mode,
|
|
2042
|
+
model: currentUser.selectedModel,
|
|
2043
|
+
apiKey: currentUser.apiKey
|
|
2044
|
+
});
|
|
2045
|
+
navigateTo("analysis");
|
|
2046
|
+
};
|
|
2047
|
+
const handleCommand = (cmd) => {
|
|
2048
|
+
if (cmd === "settings") return navigateTo("settings");
|
|
2049
|
+
if (cmd === "sessions") return navigateTo("resume-session");
|
|
2050
|
+
if (cmd === "quit") {
|
|
2051
|
+
exit();
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
if (cmd.startsWith("resume:")) {
|
|
2055
|
+
const id = cmd.slice(7);
|
|
2056
|
+
setResumeId(id);
|
|
2057
|
+
navigateTo("resume-session");
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
const handleAnalysisComplete = (output, sessionId) => {
|
|
2061
|
+
setExitSessionId(sessionId);
|
|
2062
|
+
setExitRepoUrl(pendingAnalysis?.repoUrl ?? null);
|
|
2063
|
+
};
|
|
2064
|
+
const handleAnalysisBack = () => {
|
|
2065
|
+
setExitSessionId(null);
|
|
2066
|
+
navigateTo("dashboard");
|
|
2067
|
+
};
|
|
2068
|
+
const handleResumeSession = (session) => {
|
|
2069
|
+
setPendingAnalysis({
|
|
2070
|
+
repoUrl: session.repoUrl,
|
|
2071
|
+
query: session.query,
|
|
2072
|
+
mode: session.mode,
|
|
2073
|
+
model: session.model,
|
|
2074
|
+
apiKey: getUser().apiKey
|
|
2075
|
+
});
|
|
2076
|
+
navigateTo("analysis");
|
|
2077
|
+
};
|
|
2078
|
+
const handleModelChanged = (model) => {
|
|
2079
|
+
setActiveModel(model.id);
|
|
2080
|
+
markModelsSeen(newModels.map((m) => m.id));
|
|
2081
|
+
setNewModels([]);
|
|
2082
|
+
};
|
|
2083
|
+
const handleUsernameChanged = (name) => {
|
|
2084
|
+
setUsername(name);
|
|
2085
|
+
};
|
|
2086
|
+
const showHeader = screen !== "analysis";
|
|
2087
|
+
return /* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", children: [
|
|
2088
|
+
showHeader && /* @__PURE__ */ jsx13(
|
|
2089
|
+
Header,
|
|
2090
|
+
{
|
|
2091
|
+
username: screen === "dashboard" || screen === "settings" ? username : void 0,
|
|
2092
|
+
model: activeModel || void 0,
|
|
2093
|
+
version: VERSION,
|
|
2094
|
+
newModelCount: newModels.length
|
|
2095
|
+
}
|
|
2096
|
+
),
|
|
2097
|
+
screen === "setup-username" && /* @__PURE__ */ jsx13(SetupUsername, { onComplete: handleUsernameComplete }),
|
|
2098
|
+
screen === "setup-apikey" && /* @__PURE__ */ jsx13(SetupApiKey, { onComplete: handleApiKeyComplete }),
|
|
2099
|
+
screen === "setup-model" && /* @__PURE__ */ jsx13(SetupModel, { models: freeModels, onComplete: handleModelComplete }),
|
|
2100
|
+
screen === "dashboard" && /* @__PURE__ */ jsx13(
|
|
2101
|
+
Dashboard,
|
|
2102
|
+
{
|
|
2103
|
+
username,
|
|
2104
|
+
model: activeModel,
|
|
2105
|
+
newModels,
|
|
2106
|
+
onAnalyze: handleAnalyze,
|
|
2107
|
+
onCommand: handleCommand
|
|
2108
|
+
}
|
|
2109
|
+
),
|
|
2110
|
+
screen === "analysis" && pendingAnalysis && /* @__PURE__ */ jsx13(
|
|
2111
|
+
AnalysisScreen,
|
|
2112
|
+
{
|
|
2113
|
+
input: pendingAnalysis,
|
|
2114
|
+
onComplete: handleAnalysisComplete,
|
|
2115
|
+
onBack: handleAnalysisBack,
|
|
2116
|
+
onError: () => {
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
),
|
|
2120
|
+
screen === "settings" && /* @__PURE__ */ jsx13(
|
|
2121
|
+
SettingsScreen,
|
|
2122
|
+
{
|
|
2123
|
+
onBack: () => navigateTo("dashboard"),
|
|
2124
|
+
onModelChanged: handleModelChanged,
|
|
2125
|
+
onUsernameChanged: handleUsernameChanged
|
|
2126
|
+
}
|
|
2127
|
+
),
|
|
2128
|
+
screen === "resume-session" && /* @__PURE__ */ jsx13(
|
|
2129
|
+
ResumeSession,
|
|
2130
|
+
{
|
|
2131
|
+
initialId: resumeId2,
|
|
2132
|
+
onResume: handleResumeSession,
|
|
2133
|
+
onBack: () => navigateTo("dashboard")
|
|
2134
|
+
}
|
|
2135
|
+
)
|
|
2136
|
+
] });
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// src/cli.tsx
|
|
2140
|
+
import { jsx as jsx14 } from "react/jsx-runtime";
|
|
2141
|
+
var args = process.argv.slice(2);
|
|
2142
|
+
function getFlag(flag) {
|
|
2143
|
+
const idx = args.findIndex((a) => a === flag || a === `--${flag}`);
|
|
2144
|
+
if (idx !== -1 && args[idx + 1]) return args[idx + 1];
|
|
2145
|
+
const prefixed = args.find((a) => a.startsWith(`--${flag}=`));
|
|
2146
|
+
if (prefixed) return prefixed.split("=")[1];
|
|
2147
|
+
return void 0;
|
|
2148
|
+
}
|
|
2149
|
+
function hasFlag(flag) {
|
|
2150
|
+
return args.includes(`--${flag}`) || args.includes(`-${flag[0]}`);
|
|
2151
|
+
}
|
|
2152
|
+
if (hasFlag("version") || hasFlag("v")) {
|
|
2153
|
+
console.log("git-reverse v1.0.0");
|
|
2154
|
+
process.exit(0);
|
|
2155
|
+
}
|
|
2156
|
+
if (hasFlag("help") || hasFlag("h")) {
|
|
2157
|
+
console.log(`
|
|
2158
|
+
git-reverse \u2014 Reverse-engineer any GitHub repository
|
|
2159
|
+
|
|
2160
|
+
Usage:
|
|
2161
|
+
git-reverse Launch interactive CLI
|
|
2162
|
+
git-reverse --resume <id> Resume a saved session
|
|
2163
|
+
git-reverse --version Show version
|
|
2164
|
+
git-reverse --help Show this help
|
|
2165
|
+
|
|
2166
|
+
In the CLI:
|
|
2167
|
+
Paste a GitHub URL to analyze it.
|
|
2168
|
+
Optionally add a question after the URL.
|
|
2169
|
+
|
|
2170
|
+
Commands (type \\ to see palette):
|
|
2171
|
+
\\settings Modify username / API key / model
|
|
2172
|
+
\\deepdive Switch to deep-dive mode
|
|
2173
|
+
\\compact Summarize current session
|
|
2174
|
+
\\resume <id> Resume a past session
|
|
2175
|
+
\\sessions List all saved sessions
|
|
2176
|
+
\\quit Exit and save session
|
|
2177
|
+
|
|
2178
|
+
Examples:
|
|
2179
|
+
https://github.com/sindresorhus/ora
|
|
2180
|
+
https://github.com/vitejs/vite how does the plugin system work?
|
|
2181
|
+
https://github.com/vercel/next.js can I convert this to an Electron app?
|
|
2182
|
+
|
|
2183
|
+
Sessions are stored locally at:
|
|
2184
|
+
~/.config/git-reverse/ (Linux/Mac)
|
|
2185
|
+
%APPDATA%\\git-reverse\\ (Windows)
|
|
2186
|
+
`);
|
|
2187
|
+
process.exit(0);
|
|
2188
|
+
}
|
|
2189
|
+
var resumeId = getFlag("resume");
|
|
2190
|
+
var { waitUntilExit } = render(
|
|
2191
|
+
/* @__PURE__ */ jsx14(App, { resumeSessionId: resumeId }),
|
|
2192
|
+
{
|
|
2193
|
+
exitOnCtrlC: false,
|
|
2194
|
+
// Handled manually by SessionManager for graceful save
|
|
2195
|
+
debug: false
|
|
2196
|
+
}
|
|
2197
|
+
);
|
|
2198
|
+
waitUntilExit().then(() => {
|
|
2199
|
+
process.exit(0);
|
|
2200
|
+
}).catch(() => {
|
|
2201
|
+
process.exit(1);
|
|
2202
|
+
});
|