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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/assets/screenshot.png +0 -0
  4. package/bun.lock +159 -0
  5. package/package.json +39 -0
  6. package/src/agent/compaction.ts +114 -0
  7. package/src/agent/loop.ts +94 -0
  8. package/src/agent/prompt.ts +89 -0
  9. package/src/agent/subagent.ts +64 -0
  10. package/src/auth.ts +50 -0
  11. package/src/cli-plain.ts +274 -0
  12. package/src/cli.ts +87 -0
  13. package/src/commands/index.ts +184 -0
  14. package/src/config-file.ts +109 -0
  15. package/src/config.ts +212 -0
  16. package/src/context.ts +96 -0
  17. package/src/cost.ts +54 -0
  18. package/src/git.ts +22 -0
  19. package/src/permissions.ts +135 -0
  20. package/src/provider.ts +58 -0
  21. package/src/session/__tests__/session.test.ts +180 -0
  22. package/src/session/snapshot.ts +122 -0
  23. package/src/session/store.ts +120 -0
  24. package/src/skills.ts +177 -0
  25. package/src/tools/__tests__/mutating.test.ts +324 -0
  26. package/src/tools/__tests__/question.test.ts +53 -0
  27. package/src/tools/__tests__/todowrite.test.ts +57 -0
  28. package/src/tools/__tests__/tools.test.ts +217 -0
  29. package/src/tools/_fs.ts +12 -0
  30. package/src/tools/bash.ts +104 -0
  31. package/src/tools/edit.ts +98 -0
  32. package/src/tools/glob.ts +40 -0
  33. package/src/tools/grep.ts +187 -0
  34. package/src/tools/index.ts +21 -0
  35. package/src/tools/ls.ts +70 -0
  36. package/src/tools/question.ts +81 -0
  37. package/src/tools/read.ts +61 -0
  38. package/src/tools/registry.ts +36 -0
  39. package/src/tools/task.ts +71 -0
  40. package/src/tools/todowrite.ts +84 -0
  41. package/src/tools/webfetch.ts +111 -0
  42. package/src/tools/write.ts +51 -0
  43. package/src/tui/App.tsx +738 -0
  44. package/src/tui/ConfirmDialog.tsx +46 -0
  45. package/src/tui/DiffView.tsx +88 -0
  46. package/src/tui/MarkdownText.tsx +63 -0
  47. package/src/tui/Message.tsx +26 -0
  48. package/src/tui/ModelPicker.tsx +44 -0
  49. package/src/tui/Panel.tsx +39 -0
  50. package/src/tui/ProviderPicker.tsx +111 -0
  51. package/src/tui/QuestionDialog.tsx +64 -0
  52. package/src/tui/SessionList.tsx +72 -0
  53. package/src/tui/SlashAutocomplete.tsx +33 -0
  54. package/src/tui/StatusFooter.tsx +71 -0
  55. package/src/tui/ThinkingPicker.tsx +57 -0
  56. package/src/tui/Toast.tsx +64 -0
  57. package/src/tui/TodoList.tsx +49 -0
  58. package/src/tui/ToolStep.tsx +184 -0
  59. package/src/tui/Welcome.tsx +87 -0
  60. package/src/tui/__tests__/tui-render.test.tsx +59 -0
  61. package/src/tui/theme.ts +16 -0
  62. package/src/tui/wordmark.ts +7 -0
  63. package/tsconfig.json +23 -0
@@ -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
+ }