interference-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/assets/screenshot.png +0 -0
- package/bun.lock +159 -0
- package/package.json +39 -0
- package/src/agent/compaction.ts +114 -0
- package/src/agent/loop.ts +94 -0
- package/src/agent/prompt.ts +89 -0
- package/src/agent/subagent.ts +64 -0
- package/src/auth.ts +50 -0
- package/src/cli-plain.ts +274 -0
- package/src/cli.ts +87 -0
- package/src/commands/index.ts +184 -0
- package/src/config-file.ts +109 -0
- package/src/config.ts +212 -0
- package/src/context.ts +96 -0
- package/src/cost.ts +54 -0
- package/src/git.ts +22 -0
- package/src/permissions.ts +135 -0
- package/src/provider.ts +58 -0
- package/src/session/__tests__/session.test.ts +180 -0
- package/src/session/snapshot.ts +122 -0
- package/src/session/store.ts +120 -0
- package/src/skills.ts +177 -0
- package/src/tools/__tests__/mutating.test.ts +324 -0
- package/src/tools/__tests__/question.test.ts +53 -0
- package/src/tools/__tests__/todowrite.test.ts +57 -0
- package/src/tools/__tests__/tools.test.ts +217 -0
- package/src/tools/_fs.ts +12 -0
- package/src/tools/bash.ts +104 -0
- package/src/tools/edit.ts +98 -0
- package/src/tools/glob.ts +40 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/index.ts +21 -0
- package/src/tools/ls.ts +70 -0
- package/src/tools/question.ts +81 -0
- package/src/tools/read.ts +61 -0
- package/src/tools/registry.ts +36 -0
- package/src/tools/task.ts +71 -0
- package/src/tools/todowrite.ts +84 -0
- package/src/tools/webfetch.ts +111 -0
- package/src/tools/write.ts +51 -0
- package/src/tui/App.tsx +738 -0
- package/src/tui/ConfirmDialog.tsx +46 -0
- package/src/tui/DiffView.tsx +88 -0
- package/src/tui/MarkdownText.tsx +63 -0
- package/src/tui/Message.tsx +26 -0
- package/src/tui/ModelPicker.tsx +44 -0
- package/src/tui/Panel.tsx +39 -0
- package/src/tui/ProviderPicker.tsx +111 -0
- package/src/tui/QuestionDialog.tsx +64 -0
- package/src/tui/SessionList.tsx +72 -0
- package/src/tui/SlashAutocomplete.tsx +33 -0
- package/src/tui/StatusFooter.tsx +71 -0
- package/src/tui/ThinkingPicker.tsx +57 -0
- package/src/tui/Toast.tsx +64 -0
- package/src/tui/TodoList.tsx +49 -0
- package/src/tui/ToolStep.tsx +184 -0
- package/src/tui/Welcome.tsx +87 -0
- package/src/tui/__tests__/tui-render.test.tsx +59 -0
- package/src/tui/theme.ts +16 -0
- package/src/tui/wordmark.ts +7 -0
- package/tsconfig.json +23 -0
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { Box, Text, Static, useInput, useApp } from "ink";
|
|
3
|
+
import { Spinner } from "@inkjs/ui";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import type { ModelMessage } from "ai";
|
|
6
|
+
import { runTurn } from "../agent/loop.ts";
|
|
7
|
+
import type { Chunk } from "../agent/loop.ts";
|
|
8
|
+
import { MissingApiKeyError } from "../provider.ts";
|
|
9
|
+
import { setConfirmHandler } from "../permissions.ts";
|
|
10
|
+
import type { ConfirmHandler } from "../permissions.ts";
|
|
11
|
+
import { currentMode, setMode, currentThinking, setThinking, currentModel, currentProvider } from "../config.ts";
|
|
12
|
+
import { ThinkingPicker } from "./ThinkingPicker.tsx";
|
|
13
|
+
import { ModelPicker } from "./ModelPicker.tsx";
|
|
14
|
+
import { ProviderPicker } from "./ProviderPicker.tsx";
|
|
15
|
+
import { saveSession, loadSession } from "../session/store.ts";
|
|
16
|
+
import type { Session } from "../session/store.ts";
|
|
17
|
+
import { nextTurn, undo, redo, finalizeSnapshots } from "../session/snapshot.ts";
|
|
18
|
+
import { dispatch, isSlashCommand } from "../commands/index.ts";
|
|
19
|
+
import { matchSkills, getCachedRegistry, loadSkillBody } from "../skills.ts";
|
|
20
|
+
import { shouldCompact, compactMessages, getUsagePercent } from "../agent/compaction.ts";
|
|
21
|
+
import { computeDiff, type DiffLine } from "./DiffView.tsx";
|
|
22
|
+
import { formatCost, getTotalCost, getUsageStats } from "../cost.ts";
|
|
23
|
+
import { getGitBranch } from "../git.ts";
|
|
24
|
+
import { StatusFooter } from "./StatusFooter.tsx";
|
|
25
|
+
import { ConfirmDialog } from "./ConfirmDialog.tsx";
|
|
26
|
+
import { SlashAutocomplete } from "./SlashAutocomplete.tsx";
|
|
27
|
+
import { SessionList } from "./SessionList.tsx";
|
|
28
|
+
import { useToast, ToastContainer } from "./Toast.tsx";
|
|
29
|
+
import { Welcome } from "./Welcome.tsx";
|
|
30
|
+
import { matchCommands } from "../commands/index.ts";
|
|
31
|
+
import { TodoList } from "./TodoList.tsx";
|
|
32
|
+
import { getTodos, setTodos, subscribeTodos, type Todo } from "../tools/todowrite.ts";
|
|
33
|
+
import { QuestionDialog } from "./QuestionDialog.tsx";
|
|
34
|
+
import { setAnswerHandler, type QuestionSpec, type Answers } from "../tools/question.ts";
|
|
35
|
+
import { ToolStep } from "./ToolStep.tsx";
|
|
36
|
+
import { MarkdownText } from "./MarkdownText.tsx";
|
|
37
|
+
import { USER_BAR, ASSISTANT_BAR } from "./theme.ts";
|
|
38
|
+
import { Panel } from "./Panel.tsx";
|
|
39
|
+
|
|
40
|
+
type HistoryItem = {
|
|
41
|
+
id: number;
|
|
42
|
+
role: "user" | "assistant";
|
|
43
|
+
content: string;
|
|
44
|
+
reasoning?: string;
|
|
45
|
+
reasoningMs?: number;
|
|
46
|
+
durationMs?: number;
|
|
47
|
+
mode?: string;
|
|
48
|
+
model?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type ToolEntry = {
|
|
52
|
+
id: number;
|
|
53
|
+
toolName: string;
|
|
54
|
+
input: unknown;
|
|
55
|
+
output?: string;
|
|
56
|
+
isError?: boolean;
|
|
57
|
+
diff?: DiffLine[] | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default function App({ session }: { session: Session }) {
|
|
61
|
+
const { exit } = useApp();
|
|
62
|
+
const [history, setHistory] = useState<HistoryItem[]>([]);
|
|
63
|
+
const [streaming, setStreaming] = useState("");
|
|
64
|
+
const [reasoning, setReasoning] = useState("");
|
|
65
|
+
const [toolSteps, setToolSteps] = useState<ToolEntry[]>([]);
|
|
66
|
+
const [busy, setBusy] = useState(false);
|
|
67
|
+
const [confirmPreview, setConfirmPreview] = useState<string | null>(null);
|
|
68
|
+
const [confirmTool, setConfirmTool] = useState<string>("");
|
|
69
|
+
const [statusText, setStatusText] = useState<string>("");
|
|
70
|
+
const [showSessions, setShowSessions] = useState(false);
|
|
71
|
+
const [showThinking, setShowThinking] = useState(false);
|
|
72
|
+
const [showModel, setShowModel] = useState(false);
|
|
73
|
+
const [showProvider, setShowProvider] = useState(false);
|
|
74
|
+
const [acIdx, setAcIdx] = useState(0);
|
|
75
|
+
const [draft, setDraft] = useState("");
|
|
76
|
+
const [messageQueue, setMessageQueue] = useState<string[]>([]);
|
|
77
|
+
const [gitBranch, setGitBranch] = useState("");
|
|
78
|
+
const [todos, setTodosState] = useState<Todo[]>(session.todos ?? []);
|
|
79
|
+
const [questions, setQuestions] = useState<QuestionSpec[] | null>(null);
|
|
80
|
+
const { toasts, addToast } = useToast();
|
|
81
|
+
const confirmResolveRef = useRef<((v: boolean) => void) | null>(null);
|
|
82
|
+
const answerResolveRef = useRef<((a: Answers) => void) | null>(null);
|
|
83
|
+
const messagesRef = useRef<ModelMessage[]>(session.messages);
|
|
84
|
+
const aborterRef = useRef<AbortController | null>(null);
|
|
85
|
+
const sessionRef = useRef(session);
|
|
86
|
+
|
|
87
|
+
useEffect(() => { sessionRef.current = session; }, [session]);
|
|
88
|
+
|
|
89
|
+
// Todos: ripristina dalla sessione e ri-renderizza ad ogni update del tool.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setTodos(session.todos ?? []);
|
|
92
|
+
setTodosState(session.todos ?? []);
|
|
93
|
+
const unsub = subscribeTodos((t) => setTodosState([...t]));
|
|
94
|
+
return unsub;
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const handler: ConfirmHandler = async (tool, preview) => {
|
|
99
|
+
setConfirmTool(tool);
|
|
100
|
+
setConfirmPreview(preview);
|
|
101
|
+
return new Promise<boolean>((resolve) => {
|
|
102
|
+
confirmResolveRef.current = resolve;
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
setConfirmHandler(handler);
|
|
106
|
+
return () => setConfirmHandler(null);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
// Question tool (RF-15): handler event-driven, stesso pattern della conferma.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
setAnswerHandler(async (qs) => {
|
|
112
|
+
setQuestions(qs);
|
|
113
|
+
return new Promise<Answers>((resolve) => {
|
|
114
|
+
answerResolveRef.current = resolve;
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
return () => setAnswerHandler(null);
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
useInput((input, key) => {
|
|
121
|
+
if (questions) return;
|
|
122
|
+
if (!confirmResolveRef.current || confirmPreview) return;
|
|
123
|
+
const c = input.toLowerCase();
|
|
124
|
+
if (c === "y" || key.return) {
|
|
125
|
+
const r = confirmResolveRef.current;
|
|
126
|
+
confirmResolveRef.current = null;
|
|
127
|
+
setConfirmTool("");
|
|
128
|
+
setConfirmPreview(null);
|
|
129
|
+
r(true);
|
|
130
|
+
} else if (c === "n" || key.escape) {
|
|
131
|
+
const r = confirmResolveRef.current;
|
|
132
|
+
confirmResolveRef.current = null;
|
|
133
|
+
setConfirmTool("");
|
|
134
|
+
setConfirmPreview(null);
|
|
135
|
+
r(false);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Navigazione autocomplete: frecce ↑↓ muovono la selezione quando il draft è "/…".
|
|
140
|
+
// L'Invio (TextInput.onSubmit) esegue il comando evidenziato → niente conflitto.
|
|
141
|
+
const acLastKey = useRef(0);
|
|
142
|
+
const cmdHistory = useRef<string[]>([]);
|
|
143
|
+
const cmdHistoryIdx = useRef(-1);
|
|
144
|
+
|
|
145
|
+
useInput((_input, key) => {
|
|
146
|
+
if (confirmPreview || questions || showThinking || showSessions || showModel || showProvider) return;
|
|
147
|
+
|
|
148
|
+
// Command history: freccia su/giù senza slash attivo
|
|
149
|
+
if (!draft.startsWith("/")) {
|
|
150
|
+
if (key.upArrow && cmdHistory.current.length > 0) {
|
|
151
|
+
cmdHistoryIdx.current = Math.min(
|
|
152
|
+
cmdHistoryIdx.current + 1,
|
|
153
|
+
cmdHistory.current.length - 1,
|
|
154
|
+
);
|
|
155
|
+
setDraft(cmdHistory.current[cmdHistoryIdx.current]!);
|
|
156
|
+
setAcIdx(0);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (key.downArrow) {
|
|
160
|
+
if (cmdHistoryIdx.current > 0) {
|
|
161
|
+
cmdHistoryIdx.current--;
|
|
162
|
+
setDraft(cmdHistory.current[cmdHistoryIdx.current]!);
|
|
163
|
+
} else {
|
|
164
|
+
cmdHistoryIdx.current = -1;
|
|
165
|
+
setDraft("");
|
|
166
|
+
}
|
|
167
|
+
setAcIdx(0);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Autocomplete: frecce quando draft è "/…"
|
|
173
|
+
if (!draft.startsWith("/")) return;
|
|
174
|
+
const ms = matchCommands(draft.slice(1));
|
|
175
|
+
if (ms.length === 0) return;
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
if (now - acLastKey.current < 120) return;
|
|
178
|
+
acLastKey.current = now;
|
|
179
|
+
if (key.upArrow) setAcIdx((i) => (i > 0 ? i - 1 : ms.length - 1));
|
|
180
|
+
else if (key.downArrow) setAcIdx((i) => (i < ms.length - 1 ? i + 1 : 0));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const nextId = (): number => Date.now() + Math.random();
|
|
184
|
+
|
|
185
|
+
async function doCompact() {
|
|
186
|
+
const pct = getUsagePercent(messagesRef.current);
|
|
187
|
+
if (!shouldCompact(messagesRef.current)) {
|
|
188
|
+
setStatusText(`Context at ${pct}%. No compaction needed.`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
setStatusText(`Compacting…`);
|
|
192
|
+
setBusy(true);
|
|
193
|
+
const compacted = await compactMessages(messagesRef.current);
|
|
194
|
+
messagesRef.current.length = 0;
|
|
195
|
+
messagesRef.current.push(...compacted);
|
|
196
|
+
sessionRef.current.messages = messagesRef.current;
|
|
197
|
+
await saveSession(sessionRef.current);
|
|
198
|
+
setBusy(false);
|
|
199
|
+
const newPct = getUsagePercent(messagesRef.current);
|
|
200
|
+
setStatusText(`${pct}% → ${newPct}%`);
|
|
201
|
+
addToast(`Compacted: ${pct}% → ${newPct}%`, "info");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function doTurn(userText: string, skillBodies?: string[]) {
|
|
205
|
+
setStatusText("");
|
|
206
|
+
setBusy(true);
|
|
207
|
+
setStreaming("");
|
|
208
|
+
setReasoning("");
|
|
209
|
+
setToolSteps([]);
|
|
210
|
+
|
|
211
|
+
const userMsg: HistoryItem = {
|
|
212
|
+
id: nextId(),
|
|
213
|
+
role: "user",
|
|
214
|
+
content: userText,
|
|
215
|
+
};
|
|
216
|
+
setHistory((h) => [...h, userMsg]);
|
|
217
|
+
|
|
218
|
+
nextTurn();
|
|
219
|
+
messagesRef.current.push({ role: "user", content: userText });
|
|
220
|
+
aborterRef.current = new AbortController();
|
|
221
|
+
let acc = "";
|
|
222
|
+
let reasoningAcc = "";
|
|
223
|
+
let currentToolId = 0;
|
|
224
|
+
const turnStart = Date.now();
|
|
225
|
+
let reasoningStart = 0;
|
|
226
|
+
let reasoningMs = 0;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const chunks = runTurn(messagesRef.current, aborterRef.current.signal, undefined, skillBodies);
|
|
230
|
+
|
|
231
|
+
for await (const chunk of chunks) {
|
|
232
|
+
switch (chunk.type) {
|
|
233
|
+
case "reasoning":
|
|
234
|
+
if (!reasoningStart) reasoningStart = Date.now();
|
|
235
|
+
reasoningAcc += chunk.text;
|
|
236
|
+
setReasoning(reasoningAcc);
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case "text":
|
|
240
|
+
if (reasoningStart && !reasoningMs) reasoningMs = Date.now() - reasoningStart;
|
|
241
|
+
acc += chunk.text;
|
|
242
|
+
setStreaming(acc);
|
|
243
|
+
break;
|
|
244
|
+
|
|
245
|
+
case "tool-call":
|
|
246
|
+
currentToolId = nextId();
|
|
247
|
+
setToolSteps((ts) => [
|
|
248
|
+
...ts,
|
|
249
|
+
{
|
|
250
|
+
id: currentToolId,
|
|
251
|
+
toolName: chunk.toolName,
|
|
252
|
+
input: chunk.input,
|
|
253
|
+
},
|
|
254
|
+
]);
|
|
255
|
+
break;
|
|
256
|
+
|
|
257
|
+
case "tool-result":
|
|
258
|
+
setToolSteps((ts) =>
|
|
259
|
+
ts.map((t) => {
|
|
260
|
+
if (t.id !== currentToolId) return t;
|
|
261
|
+
const diff = computeToolDiff(t.toolName, t.input as Record<string, unknown>, chunk.isError ? null : chunk.output);
|
|
262
|
+
return {
|
|
263
|
+
...t,
|
|
264
|
+
output: chunk.output,
|
|
265
|
+
isError: chunk.isError,
|
|
266
|
+
diff,
|
|
267
|
+
};
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (acc || reasoningAcc) {
|
|
275
|
+
if (reasoningStart && !reasoningMs) reasoningMs = Date.now() - reasoningStart;
|
|
276
|
+
setHistory((h) => [
|
|
277
|
+
...h,
|
|
278
|
+
{
|
|
279
|
+
id: nextId(),
|
|
280
|
+
role: "assistant",
|
|
281
|
+
content: acc,
|
|
282
|
+
reasoning: reasoningAcc || undefined,
|
|
283
|
+
reasoningMs: reasoningMs || undefined,
|
|
284
|
+
durationMs: Date.now() - turnStart,
|
|
285
|
+
mode: currentMode(),
|
|
286
|
+
model: currentModel(),
|
|
287
|
+
},
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
sessionRef.current.meta.turnCount++;
|
|
292
|
+
sessionRef.current.todos = getTodos();
|
|
293
|
+
await finalizeSnapshots();
|
|
294
|
+
await saveSession(sessionRef.current);
|
|
295
|
+
addToast("Session saved", "success");
|
|
296
|
+
|
|
297
|
+
if (shouldCompact(messagesRef.current)) {
|
|
298
|
+
const pct = getUsagePercent(messagesRef.current);
|
|
299
|
+
setStatusText(`Compacting…`);
|
|
300
|
+
setBusy(true);
|
|
301
|
+
const compacted = await compactMessages(messagesRef.current);
|
|
302
|
+
messagesRef.current.length = 0;
|
|
303
|
+
messagesRef.current.push(...compacted);
|
|
304
|
+
setBusy(false);
|
|
305
|
+
setStatusText(`${pct}% → ${getUsagePercent(messagesRef.current)}%`);
|
|
306
|
+
addToast(`Compacted: ${pct}% → ${getUsagePercent(messagesRef.current)}%`, "info");
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
messagesRef.current.pop();
|
|
310
|
+
if (err instanceof MissingApiKeyError) {
|
|
311
|
+
setStreaming(`\n${err.message}`);
|
|
312
|
+
} else if (aborterRef.current === null) {
|
|
313
|
+
// interrupted
|
|
314
|
+
} else {
|
|
315
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
316
|
+
setStreaming(`\n[error] ${msg}`);
|
|
317
|
+
}
|
|
318
|
+
} finally {
|
|
319
|
+
setStreaming("");
|
|
320
|
+
setReasoning("");
|
|
321
|
+
setToolSteps([]);
|
|
322
|
+
setBusy(false);
|
|
323
|
+
aborterRef.current = null;
|
|
324
|
+
|
|
325
|
+
if (messageQueue.length > 0) {
|
|
326
|
+
const next = messageQueue[0];
|
|
327
|
+
setMessageQueue((q) => q.slice(1));
|
|
328
|
+
if (next) setTimeout(() => doTurn(next), 0);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const onSubmit = useCallback(
|
|
334
|
+
(value: string) => {
|
|
335
|
+
let v = value.trim();
|
|
336
|
+
if (!v) return;
|
|
337
|
+
setDraft("");
|
|
338
|
+
if (busy) {
|
|
339
|
+
setMessageQueue((q) => [...q, v]);
|
|
340
|
+
addToast(`Queued (${messageQueue.length + 1} pending)`, "info");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// Se è uno slash parziale senza argomenti, esegui il comando EVIDENZIATO
|
|
344
|
+
// nell'autocomplete (frecce) invece di richiedere il nome completo.
|
|
345
|
+
if (v.startsWith("/") && !v.includes(" ")) {
|
|
346
|
+
const ms = matchCommands(v.slice(1));
|
|
347
|
+
if (ms.length > 0) {
|
|
348
|
+
const sel = ms[((acIdx % ms.length) + ms.length) % ms.length];
|
|
349
|
+
if (sel) v = "/" + sel.name;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
setAcIdx(0);
|
|
353
|
+
if (!v.startsWith("/")) {
|
|
354
|
+
cmdHistory.current.unshift(v);
|
|
355
|
+
if (cmdHistory.current.length > 100) cmdHistory.current.pop();
|
|
356
|
+
}
|
|
357
|
+
cmdHistoryIdx.current = -1;
|
|
358
|
+
if (v === "/exit" || v === "/quit") return exit();
|
|
359
|
+
if (v === "/sessions") { setShowSessions(true); return; }
|
|
360
|
+
if (v === "/thinking") { setShowThinking(true); return; }
|
|
361
|
+
if (v === "/model") { setShowModel(true); return; }
|
|
362
|
+
if (v === "/provider") { setShowProvider(true); return; }
|
|
363
|
+
if (v === "/compact") {
|
|
364
|
+
doCompact();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (isSlashCommand(v)) {
|
|
369
|
+
dispatch(v, {
|
|
370
|
+
setMode: (m) => { setMode(m); sessionRef.current.meta.mode = m; },
|
|
371
|
+
clearMessages: () => {
|
|
372
|
+
messagesRef.current = [];
|
|
373
|
+
setHistory([]);
|
|
374
|
+
setTodos([]);
|
|
375
|
+
setStatusText("Conversation cleared.");
|
|
376
|
+
},
|
|
377
|
+
doInit: async (args) => {
|
|
378
|
+
setBusy(true);
|
|
379
|
+
try {
|
|
380
|
+
const template = `Generate or update the AGENTS.md file at the project root.
|
|
381
|
+
|
|
382
|
+
Follow the bundled agents-setup skill (see system prompt). Key sections:
|
|
383
|
+
- Project overview, stack, directory structure
|
|
384
|
+
- Build/test commands, code conventions
|
|
385
|
+
- Agent skills and triggers
|
|
386
|
+
- Non-negotiable rules
|
|
387
|
+
|
|
388
|
+
How to proceed:
|
|
389
|
+
1. Use ls, glob, grep, and read to explore the project thoroughly
|
|
390
|
+
2. Identify languages, frameworks, build system, test setup, conventions
|
|
391
|
+
3. Write AGENTS.md at the project root using the write tool
|
|
392
|
+
4. Confirm the file was created and summarize its contents
|
|
393
|
+
|
|
394
|
+
${args ? `Additional context: ${args}` : ""}`;
|
|
395
|
+
nextTurn();
|
|
396
|
+
messagesRef.current.push({ role: "user" as const, content: template });
|
|
397
|
+
const aborter = new AbortController();
|
|
398
|
+
const chunks = runTurn(messagesRef.current, aborter.signal);
|
|
399
|
+
for await (const chunk of chunks) {}
|
|
400
|
+
sessionRef.current.meta.turnCount++;
|
|
401
|
+
await finalizeSnapshots();
|
|
402
|
+
await saveSession(sessionRef.current);
|
|
403
|
+
return "AGENTS.md generated successfully.";
|
|
404
|
+
} catch (err) {
|
|
405
|
+
messagesRef.current.pop();
|
|
406
|
+
return `Init failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
407
|
+
} finally {
|
|
408
|
+
setBusy(false);
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
doSkill: async (name, body) => {
|
|
412
|
+
setBusy(true);
|
|
413
|
+
setStreaming("");
|
|
414
|
+
setReasoning("");
|
|
415
|
+
setToolSteps([]);
|
|
416
|
+
try {
|
|
417
|
+
nextTurn();
|
|
418
|
+
messagesRef.current.push({ role: "user" as const, content: `Help with this task. Use the skill context provided in the system prompt.` });
|
|
419
|
+
const aborter = new AbortController();
|
|
420
|
+
const chunks = runTurn(messagesRef.current, aborter.signal, undefined, [body]);
|
|
421
|
+
let acc = "";
|
|
422
|
+
for await (const chunk of chunks) {
|
|
423
|
+
if (chunk.type === "text") { acc += chunk.text; setStreaming(acc); }
|
|
424
|
+
}
|
|
425
|
+
if (acc) {
|
|
426
|
+
setHistory((h) => [...h, { id: nextId(), role: "assistant", content: acc }]);
|
|
427
|
+
}
|
|
428
|
+
sessionRef.current.meta.turnCount++;
|
|
429
|
+
await finalizeSnapshots();
|
|
430
|
+
await saveSession(sessionRef.current);
|
|
431
|
+
return `Skill '${name}' executed.`;
|
|
432
|
+
} catch (err) {
|
|
433
|
+
messagesRef.current.pop();
|
|
434
|
+
return `Skill failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
435
|
+
} finally {
|
|
436
|
+
setStreaming("");
|
|
437
|
+
setReasoning("");
|
|
438
|
+
setToolSteps([]);
|
|
439
|
+
setBusy(false);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
doSessions: async () => {
|
|
443
|
+
setShowSessions(true);
|
|
444
|
+
return "";
|
|
445
|
+
},
|
|
446
|
+
doRename: async (name) => {
|
|
447
|
+
sessionRef.current.meta.id = name;
|
|
448
|
+
await saveSession(sessionRef.current);
|
|
449
|
+
return `Session renamed to '${name}'.`;
|
|
450
|
+
},
|
|
451
|
+
doCompact: async () => {
|
|
452
|
+
doCompact();
|
|
453
|
+
return "";
|
|
454
|
+
},
|
|
455
|
+
}).then((result) => {
|
|
456
|
+
if (result) setStatusText(result);
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function runWithSkills() {
|
|
462
|
+
const matchedSkills = matchSkills(v, getCachedRegistry());
|
|
463
|
+
const skillBodies: string[] = [];
|
|
464
|
+
for (const name of matchedSkills) {
|
|
465
|
+
const body = await loadSkillBody(name);
|
|
466
|
+
if (body) skillBodies.push(body);
|
|
467
|
+
}
|
|
468
|
+
if (skillBodies.length > 0) {
|
|
469
|
+
setStatusText(`Skills matched: ${matchedSkills.join(", ")}`);
|
|
470
|
+
}
|
|
471
|
+
doTurn(v, skillBodies.length > 0 ? skillBodies : undefined);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
runWithSkills();
|
|
475
|
+
},
|
|
476
|
+
[busy, exit, acIdx],
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
return (
|
|
480
|
+
<Box flexDirection="column" padding={1}>
|
|
481
|
+
{showSessions && (
|
|
482
|
+
<SessionList
|
|
483
|
+
onSelect={async (id) => {
|
|
484
|
+
setShowSessions(false);
|
|
485
|
+
const loaded = await loadSession(id);
|
|
486
|
+
if (loaded) {
|
|
487
|
+
messagesRef.current = loaded.messages;
|
|
488
|
+
sessionRef.current = loaded;
|
|
489
|
+
setTodos(loaded.todos ?? []);
|
|
490
|
+
// Rebuild history from messages
|
|
491
|
+
const items: HistoryItem[] = [];
|
|
492
|
+
let nid = Date.now();
|
|
493
|
+
for (const m of loaded.messages) {
|
|
494
|
+
if (m.role === "user" || m.role === "assistant") {
|
|
495
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
496
|
+
items.push({ id: nid++, role: m.role as "user" | "assistant", content });
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
setHistory(items);
|
|
500
|
+
setStreaming("");
|
|
501
|
+
setReasoning("");
|
|
502
|
+
setToolSteps([]);
|
|
503
|
+
addToast(`Resumed session ${id.slice(0, 12)} (${loaded.meta.turnCount} turns)`, "success");
|
|
504
|
+
} else {
|
|
505
|
+
addToast(`Session ${id.slice(0, 12)} not found`, "error");
|
|
506
|
+
}
|
|
507
|
+
}}
|
|
508
|
+
onCancel={() => setShowSessions(false)}
|
|
509
|
+
/>
|
|
510
|
+
)}
|
|
511
|
+
|
|
512
|
+
{showThinking && (
|
|
513
|
+
<ThinkingPicker
|
|
514
|
+
onSelect={(level) => {
|
|
515
|
+
setShowThinking(false);
|
|
516
|
+
setThinking(level);
|
|
517
|
+
addToast(`Thinking set to ${level}`, "success");
|
|
518
|
+
}}
|
|
519
|
+
onCancel={() => setShowThinking(false)}
|
|
520
|
+
/>
|
|
521
|
+
)}
|
|
522
|
+
|
|
523
|
+
{showModel && (
|
|
524
|
+
<ModelPicker onCancel={() => setShowModel(false)} />
|
|
525
|
+
)}
|
|
526
|
+
|
|
527
|
+
{showProvider && (
|
|
528
|
+
<ProviderPicker onClose={() => setShowProvider(false)} />
|
|
529
|
+
)}
|
|
530
|
+
|
|
531
|
+
{!showThinking && !showSessions && !showModel && !showProvider && (
|
|
532
|
+
<>
|
|
533
|
+
<ToastContainer toasts={toasts} />
|
|
534
|
+
|
|
535
|
+
{history.length === 0 && !busy && (
|
|
536
|
+
<Welcome
|
|
537
|
+
provider={currentProvider().label}
|
|
538
|
+
model={currentModel()}
|
|
539
|
+
sessionCount={0}
|
|
540
|
+
/>
|
|
541
|
+
)}
|
|
542
|
+
|
|
543
|
+
<Static items={history}>
|
|
544
|
+
{(m) => <MsgBlock key={m.id} item={m} />}
|
|
545
|
+
</Static>
|
|
546
|
+
|
|
547
|
+
<TodoList todos={todos} />
|
|
548
|
+
|
|
549
|
+
{reasoning && <ReasoningBlock text={reasoning} live />}
|
|
550
|
+
|
|
551
|
+
{streaming && <RoleBlock color={ASSISTANT_BAR} content={streaming} markdown />}
|
|
552
|
+
|
|
553
|
+
{toolSteps.map((t) => (
|
|
554
|
+
<ToolStep key={t.id} tool={t} />
|
|
555
|
+
))}
|
|
556
|
+
|
|
557
|
+
{busy && !streaming && toolSteps.length === 0 && !confirmPreview && (
|
|
558
|
+
<Box marginBottom={1}>
|
|
559
|
+
<Spinner label="thinking" />
|
|
560
|
+
</Box>
|
|
561
|
+
)}
|
|
562
|
+
|
|
563
|
+
{confirmPreview && (
|
|
564
|
+
<ConfirmDialog
|
|
565
|
+
tool={confirmTool}
|
|
566
|
+
preview={confirmPreview}
|
|
567
|
+
onResolve={(allowed) => {
|
|
568
|
+
if (confirmResolveRef.current) {
|
|
569
|
+
confirmResolveRef.current(allowed);
|
|
570
|
+
confirmResolveRef.current = null;
|
|
571
|
+
}
|
|
572
|
+
setConfirmTool("");
|
|
573
|
+
setConfirmPreview(null);
|
|
574
|
+
}}
|
|
575
|
+
/>
|
|
576
|
+
)}
|
|
577
|
+
|
|
578
|
+
{questions && (
|
|
579
|
+
<QuestionDialog
|
|
580
|
+
questions={questions}
|
|
581
|
+
onResolve={(answers) => {
|
|
582
|
+
if (answerResolveRef.current) {
|
|
583
|
+
answerResolveRef.current(answers);
|
|
584
|
+
answerResolveRef.current = null;
|
|
585
|
+
}
|
|
586
|
+
setQuestions(null);
|
|
587
|
+
}}
|
|
588
|
+
/>
|
|
589
|
+
)}
|
|
590
|
+
|
|
591
|
+
{draft.startsWith("/") && !questions && (
|
|
592
|
+
<SlashAutocomplete filter={draft.slice(1)} selected={acIdx} />
|
|
593
|
+
)}
|
|
594
|
+
|
|
595
|
+
{!confirmPreview && !questions && !showSessions && (
|
|
596
|
+
<Box borderStyle="round" borderColor="gray" paddingX={1}>
|
|
597
|
+
<Text color="white" bold>{"› "}</Text>
|
|
598
|
+
<TextInput
|
|
599
|
+
value={draft}
|
|
600
|
+
onChange={(val: string) => {
|
|
601
|
+
if (val !== draft) setAcIdx(0);
|
|
602
|
+
setDraft(val);
|
|
603
|
+
}}
|
|
604
|
+
placeholder={busy ? `working… (${messageQueue.length} queued)` : "Type a message (/ for commands)"}
|
|
605
|
+
onSubmit={onSubmit}
|
|
606
|
+
/>
|
|
607
|
+
</Box>
|
|
608
|
+
)}
|
|
609
|
+
|
|
610
|
+
<StatusFooter
|
|
611
|
+
mode={sessionRef.current.meta.mode}
|
|
612
|
+
model={currentModel()}
|
|
613
|
+
provider={currentProvider().label}
|
|
614
|
+
thinking={currentThinking()}
|
|
615
|
+
contextPct={messagesRef.current.length > 0 ? getUsagePercent(messagesRef.current) : 0}
|
|
616
|
+
busy={busy}
|
|
617
|
+
statusLine={statusText}
|
|
618
|
+
turnCount={sessionRef.current.meta.turnCount}
|
|
619
|
+
cost={formatCost(getTotalCost())}
|
|
620
|
+
gitBranch={gitBranch}
|
|
621
|
+
inputTokens={getUsageStats().inputTokens}
|
|
622
|
+
outputTokens={getUsageStats().outputTokens}
|
|
623
|
+
/>
|
|
624
|
+
</>
|
|
625
|
+
)}
|
|
626
|
+
</Box>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Risposta assistant: barra laterale grigia (bordo sinistro) su sfondo nudo.
|
|
631
|
+
// Il contrasto col messaggio utente (pannello pieno) li differenzia.
|
|
632
|
+
function RoleBlock({
|
|
633
|
+
color,
|
|
634
|
+
content,
|
|
635
|
+
markdown,
|
|
636
|
+
footer,
|
|
637
|
+
}: {
|
|
638
|
+
color: string;
|
|
639
|
+
content: string;
|
|
640
|
+
markdown?: boolean;
|
|
641
|
+
footer?: string;
|
|
642
|
+
}) {
|
|
643
|
+
return (
|
|
644
|
+
<Box
|
|
645
|
+
flexDirection="column"
|
|
646
|
+
borderStyle="round"
|
|
647
|
+
borderColor={color}
|
|
648
|
+
borderTop={false}
|
|
649
|
+
borderRight={false}
|
|
650
|
+
borderBottom={false}
|
|
651
|
+
paddingLeft={1}
|
|
652
|
+
marginBottom={1}
|
|
653
|
+
>
|
|
654
|
+
{markdown ? <MarkdownText content={content} /> : <Text>{content}</Text>}
|
|
655
|
+
{footer && <Text dimColor>{footer}</Text>}
|
|
656
|
+
</Box>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function ReasoningBlock({ text, live }: { text: string; live?: boolean }) {
|
|
661
|
+
const [elapsed, setElapsed] = useState(0);
|
|
662
|
+
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
if (!live) return;
|
|
665
|
+
const start = Date.now();
|
|
666
|
+
const timer = setInterval(() => setElapsed(Math.round((Date.now() - start) / 1000)), 1000);
|
|
667
|
+
return () => clearInterval(timer);
|
|
668
|
+
}, [live]);
|
|
669
|
+
|
|
670
|
+
const shown = live
|
|
671
|
+
? text.length > 500 ? "…" + text.slice(-500) : text
|
|
672
|
+
: text.length > 600 ? text.slice(0, 600) + " …" : text;
|
|
673
|
+
|
|
674
|
+
return (
|
|
675
|
+
<Box
|
|
676
|
+
flexDirection="column"
|
|
677
|
+
borderStyle="round"
|
|
678
|
+
borderColor="gray"
|
|
679
|
+
borderTop={false}
|
|
680
|
+
borderRight={false}
|
|
681
|
+
borderBottom={false}
|
|
682
|
+
paddingLeft={1}
|
|
683
|
+
marginBottom={1}
|
|
684
|
+
>
|
|
685
|
+
<Text dimColor bold>
|
|
686
|
+
┄ thinking{live && elapsed > 0 ? ` ${elapsed}s` : ""}
|
|
687
|
+
</Text>
|
|
688
|
+
<Text dimColor>{shown}</Text>
|
|
689
|
+
</Box>
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function MsgBlock({ item }: { item: HistoryItem }) {
|
|
694
|
+
const isUser = item.role === "user";
|
|
695
|
+
const secs = (ms?: number) => (ms ? `${(ms / 1000).toFixed(1)}s` : "");
|
|
696
|
+
const footer =
|
|
697
|
+
!isUser && item.model
|
|
698
|
+
? `▣ ${item.mode ?? ""} · ${item.model}${item.durationMs ? ` · ${secs(item.durationMs)}` : ""}`
|
|
699
|
+
: undefined;
|
|
700
|
+
if (isUser) {
|
|
701
|
+
return <Panel content={item.content} bar="▌" barColor={USER_BAR} />;
|
|
702
|
+
}
|
|
703
|
+
return (
|
|
704
|
+
<Box flexDirection="column">
|
|
705
|
+
{item.reasoning && (
|
|
706
|
+
<Text dimColor>┄ thought{item.reasoningMs ? ` · ${secs(item.reasoningMs)}` : ""}</Text>
|
|
707
|
+
)}
|
|
708
|
+
<RoleBlock
|
|
709
|
+
color={ASSISTANT_BAR}
|
|
710
|
+
content={item.content}
|
|
711
|
+
markdown
|
|
712
|
+
footer={footer}
|
|
713
|
+
/>
|
|
714
|
+
</Box>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
function computeToolDiff(
|
|
720
|
+
toolName: string,
|
|
721
|
+
input: Record<string, unknown> | undefined,
|
|
722
|
+
output: string | null,
|
|
723
|
+
): DiffLine[] | null {
|
|
724
|
+
if (!input || output === null || (output && output.startsWith("Error"))) return null;
|
|
725
|
+
|
|
726
|
+
if (toolName === "edit" && typeof input.oldString === "string" && typeof input.newString === "string") {
|
|
727
|
+
return computeDiff(
|
|
728
|
+
(input.oldString as string).split("\n"),
|
|
729
|
+
(input.newString as string).split("\n"),
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (toolName === "write" && typeof input.content === "string") {
|
|
734
|
+
return computeDiff([], (input.content as string).split("\n"));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return null;
|
|
738
|
+
}
|