interference-agent 0.1.0 → 0.2.3

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/src/tui/App.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useState, useRef, useEffect, useCallback } from "react";
2
2
  import { Box, Text, Static, useInput, useApp } from "ink";
3
- import { Spinner } from "@inkjs/ui";
3
+ import { Spinner } from "./Spinner.tsx";
4
4
  import TextInput from "ink-text-input";
5
5
  import type { ModelMessage } from "ai";
6
6
  import { runTurn } from "../agent/loop.ts";
@@ -34,7 +34,10 @@ import { QuestionDialog } from "./QuestionDialog.tsx";
34
34
  import { setAnswerHandler, type QuestionSpec, type Answers } from "../tools/question.ts";
35
35
  import { ToolStep } from "./ToolStep.tsx";
36
36
  import { MarkdownText } from "./MarkdownText.tsx";
37
- import { USER_BAR, ASSISTANT_BAR } from "./theme.ts";
37
+ import { reasoningSummary } from "./reasoning.ts";
38
+ import { placeholderFor } from "./placeholders.ts";
39
+ import { checkForUpdate, CURRENT_VERSION } from "../version.ts";
40
+ import { USER_BAR, ASSISTANT_BAR, THINKING, THINKING_BODY } from "./theme.ts";
38
41
  import { Panel } from "./Panel.tsx";
39
42
 
40
43
  type HistoryItem = {
@@ -77,6 +80,8 @@ export default function App({ session }: { session: Session }) {
77
80
  const [gitBranch, setGitBranch] = useState("");
78
81
  const [todos, setTodosState] = useState<Todo[]>(session.todos ?? []);
79
82
  const [questions, setQuestions] = useState<QuestionSpec[] | null>(null);
83
+ const [phIdx, setPhIdx] = useState(0); // indice esempio placeholder (it. 25)
84
+ const [update, setUpdate] = useState<string | null>(null); // versione più recente (it. 28)
80
85
  const { toasts, addToast } = useToast();
81
86
  const confirmResolveRef = useRef<((v: boolean) => void) | null>(null);
82
87
  const answerResolveRef = useRef<((a: Answers) => void) | null>(null);
@@ -86,6 +91,11 @@ export default function App({ session }: { session: Session }) {
86
91
 
87
92
  useEffect(() => { sessionRef.current = session; }, [session]);
88
93
 
94
+ // Controllo aggiornamenti (it. 28): non bloccante, throttled, silenzioso offline.
95
+ useEffect(() => {
96
+ checkForUpdate().then(setUpdate).catch(() => {});
97
+ }, []);
98
+
89
99
  // Todos: ripristina dalla sessione e ri-renderizza ad ogni update del tool.
90
100
  useEffect(() => {
91
101
  setTodos(session.todos ?? []);
@@ -182,6 +192,12 @@ export default function App({ session }: { session: Session }) {
182
192
 
183
193
  const nextId = (): number => Date.now() + Math.random();
184
194
 
195
+ // Titolo leggibile dal primo messaggio (collassa spazi, cap ~40 char).
196
+ const deriveTitle = (text: string): string => {
197
+ const t = text.replace(/\s+/g, " ").trim();
198
+ return t.length > 40 ? t.slice(0, 40).trimEnd() + "…" : t;
199
+ };
200
+
185
201
  async function doCompact() {
186
202
  const pct = getUsagePercent(messagesRef.current);
187
203
  if (!shouldCompact(messagesRef.current)) {
@@ -216,6 +232,10 @@ export default function App({ session }: { session: Session }) {
216
232
  setHistory((h) => [...h, userMsg]);
217
233
 
218
234
  nextTurn();
235
+ // Auto-title alla prima interazione (se non già rinominata dall'utente).
236
+ if (!sessionRef.current.meta.title) {
237
+ sessionRef.current.meta.title = deriveTitle(userText);
238
+ }
219
239
  messagesRef.current.push({ role: "user", content: userText });
220
240
  aborterRef.current = new AbortController();
221
241
  let acc = "";
@@ -320,6 +340,7 @@ export default function App({ session }: { session: Session }) {
320
340
  setReasoning("");
321
341
  setToolSteps([]);
322
342
  setBusy(false);
343
+ setPhIdx((i) => i + 1); // ruota l'esempio del placeholder
323
344
  aborterRef.current = null;
324
345
 
325
346
  if (messageQueue.length > 0) {
@@ -444,7 +465,7 @@ ${args ? `Additional context: ${args}` : ""}`;
444
465
  return "";
445
466
  },
446
467
  doRename: async (name) => {
447
- sessionRef.current.meta.id = name;
468
+ sessionRef.current.meta.title = name;
448
469
  await saveSession(sessionRef.current);
449
470
  return `Session renamed to '${name}'.`;
450
471
  },
@@ -487,20 +508,36 @@ ${args ? `Additional context: ${args}` : ""}`;
487
508
  messagesRef.current = loaded.messages;
488
509
  sessionRef.current = loaded;
489
510
  setTodos(loaded.todos ?? []);
490
- // Rebuild history from messages
511
+ // Ricostruisci la history dai messaggi salvati. Il content può essere
512
+ // una stringa o un ARRAY di parti ({type:"text"|"reasoning"|...}):
513
+ // estraiamo testo e reasoning invece di stringificare (era reso come JSON grezzo).
491
514
  const items: HistoryItem[] = [];
492
515
  let nid = Date.now();
493
516
  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 });
517
+ if (m.role !== "user" && m.role !== "assistant") continue;
518
+ let text = "";
519
+ let reasoning = "";
520
+ if (typeof m.content === "string") {
521
+ text = m.content;
522
+ } else if (Array.isArray(m.content)) {
523
+ for (const p of m.content as Array<{ type?: string; text?: string }>) {
524
+ if (p?.type === "text" && p.text) text += p.text;
525
+ else if (p?.type === "reasoning" && p.text) reasoning += p.text;
526
+ }
497
527
  }
528
+ if (!text && !reasoning) continue; // salta i messaggi solo-tool
529
+ items.push({
530
+ id: nid++,
531
+ role: m.role as "user" | "assistant",
532
+ content: text,
533
+ reasoning: reasoning || undefined,
534
+ });
498
535
  }
499
536
  setHistory(items);
500
537
  setStreaming("");
501
538
  setReasoning("");
502
539
  setToolSteps([]);
503
- addToast(`Resumed session ${id.slice(0, 12)} (${loaded.meta.turnCount} turns)`, "success");
540
+ addToast(`Resumed '${loaded.meta.title || id.slice(0, 12)}' (${loaded.meta.turnCount} turns)`, "success");
504
541
  } else {
505
542
  addToast(`Session ${id.slice(0, 12)} not found`, "error");
506
543
  }
@@ -537,6 +574,7 @@ ${args ? `Additional context: ${args}` : ""}`;
537
574
  provider={currentProvider().label}
538
575
  model={currentModel()}
539
576
  sessionCount={0}
577
+ update={update}
540
578
  />
541
579
  )}
542
580
 
@@ -592,6 +630,13 @@ ${args ? `Additional context: ${args}` : ""}`;
592
630
  <SlashAutocomplete filter={draft.slice(1)} selected={acIdx} />
593
631
  )}
594
632
 
633
+ {/* Avviso comandi/stato: subito SOPRA l'input (stile opencode), non nel footer */}
634
+ {statusText && !confirmPreview && !questions && !showSessions && (
635
+ <Box paddingLeft={1}>
636
+ <Text dimColor>{statusText}</Text>
637
+ </Box>
638
+ )}
639
+
595
640
  {!confirmPreview && !questions && !showSessions && (
596
641
  <Box borderStyle="round" borderColor="gray" paddingX={1}>
597
642
  <Text color="white" bold>{"› "}</Text>
@@ -601,7 +646,7 @@ ${args ? `Additional context: ${args}` : ""}`;
601
646
  if (val !== draft) setAcIdx(0);
602
647
  setDraft(val);
603
648
  }}
604
- placeholder={busy ? `working… (${messageQueue.length} queued)` : "Type a message (/ for commands)"}
649
+ placeholder={busy ? `working… (${messageQueue.length} queued)` : placeholderFor(sessionRef.current.meta.mode, phIdx)}
605
650
  onSubmit={onSubmit}
606
651
  />
607
652
  </Box>
@@ -614,7 +659,7 @@ ${args ? `Additional context: ${args}` : ""}`;
614
659
  thinking={currentThinking()}
615
660
  contextPct={messagesRef.current.length > 0 ? getUsagePercent(messagesRef.current) : 0}
616
661
  busy={busy}
617
- statusLine={statusText}
662
+ statusLine=""
618
663
  turnCount={sessionRef.current.meta.turnCount}
619
664
  cost={formatCost(getTotalCost())}
620
665
  gitBranch={gitBranch}
@@ -657,7 +702,9 @@ function RoleBlock({
657
702
  );
658
703
  }
659
704
 
660
- function ReasoningBlock({ text, live }: { text: string; live?: boolean }) {
705
+ // Fase PENSIERO: header ambra "✻ Thinking/Thought · durata" + corpo attenuato.
706
+ // Distinta dall'esecuzione (icone tool, bianco) e dalla risposta (markdown bianco pieno).
707
+ function ReasoningBlock({ text, live, ms }: { text: string; live?: boolean; ms?: number }) {
661
708
  const [elapsed, setElapsed] = useState(0);
662
709
 
663
710
  useEffect(() => {
@@ -667,25 +714,23 @@ function ReasoningBlock({ text, live }: { text: string; live?: boolean }) {
667
714
  return () => clearInterval(timer);
668
715
  }, [live]);
669
716
 
670
- const shown = live
671
- ? text.length > 500 ? "…" + text.slice(-500) : text
672
- : text.length > 600 ? text.slice(0, 600) + " …" : text;
717
+ const title = reasoningSummary(text);
718
+ const titlePart = title ? `: ${title}` : live ? "…" : "";
719
+ const header = live
720
+ ? `✻ Thinking${titlePart}${elapsed > 0 ? ` · ${elapsed}s` : ""}`
721
+ : `✻ Thought${titlePart}${ms ? ` · ${(ms / 1000).toFixed(1)}s` : ""}`;
722
+
723
+ // Live: coda in streaming. History: testo dall'inizio, capato.
724
+ const body = live
725
+ ? text.length > 400 ? "…" + text.slice(-400) : text
726
+ : text.length > 700 ? text.slice(0, 700) + " …" : text;
673
727
 
674
728
  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` : ""}
729
+ <Box flexDirection="column" paddingLeft={3} marginBottom={1}>
730
+ <Text color={THINKING} bold>
731
+ {header}
687
732
  </Text>
688
- <Text dimColor>{shown}</Text>
733
+ <Text color={THINKING_BODY}>{body}</Text>
689
734
  </Box>
690
735
  );
691
736
  }
@@ -702,9 +747,7 @@ function MsgBlock({ item }: { item: HistoryItem }) {
702
747
  }
703
748
  return (
704
749
  <Box flexDirection="column">
705
- {item.reasoning && (
706
- <Text dimColor>┄ thought{item.reasoningMs ? ` · ${secs(item.reasoningMs)}` : ""}</Text>
707
- )}
750
+ {item.reasoning && <ReasoningBlock text={item.reasoning} ms={item.reasoningMs} />}
708
751
  <RoleBlock
709
752
  color={ASSISTANT_BAR}
710
753
  content={item.content}
@@ -1,5 +1,6 @@
1
1
  import { useState, type FC } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
+ import { BG_ELEMENT } from "./theme.ts";
3
4
 
4
5
  interface Props {
5
6
  tool: string;
@@ -33,11 +34,19 @@ export const ConfirmDialog: FC<Props> = ({ tool, preview, onResolve }) => {
33
34
  <Text dimColor>{preview.slice(0, 300)}</Text>
34
35
  </Box>
35
36
  <Box gap={2}>
36
- <Text color={selected === "allow" ? "green" : undefined} bold={selected === "allow"}>
37
- {selected === "allow" ? "▸ Allow" : " Allow"}
37
+ <Text
38
+ backgroundColor={selected === "allow" ? BG_ELEMENT : undefined}
39
+ color={selected === "allow" ? "green" : undefined}
40
+ bold={selected === "allow"}
41
+ >
42
+ {" Allow "}
38
43
  </Text>
39
- <Text color={selected === "deny" ? "red" : undefined} bold={selected === "deny"}>
40
- {selected === "deny" ? "▸ Deny" : " Deny"}
44
+ <Text
45
+ backgroundColor={selected === "deny" ? BG_ELEMENT : undefined}
46
+ color={selected === "deny" ? "red" : undefined}
47
+ bold={selected === "deny"}
48
+ >
49
+ {" Deny "}
41
50
  </Text>
42
51
  <Text dimColor>(←→ arrows, Enter, y/n)</Text>
43
52
  </Box>
@@ -1,19 +1,25 @@
1
1
  export interface DiffLine {
2
2
  type: "same" | "add" | "remove";
3
3
  text: string;
4
+ oldNo?: number; // numero riga nel file vecchio (1-based) — same/remove
5
+ newNo?: number; // numero riga nel file nuovo (1-based) — same/add
4
6
  }
5
7
 
6
8
  export function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
7
9
  const result: DiffLine[] = [];
8
10
  let oi = 0;
9
11
  let ni = 0;
12
+ // Numeri di riga 1-based: oi/ni sono indici 0-based → +1 al push.
13
+ const same = (t: string) => result.push({ type: "same", text: t, oldNo: oi + 1, newNo: ni + 1 });
14
+ const rem = (t: string) => result.push({ type: "remove", text: t, oldNo: oi + 1 });
15
+ const add = (t: string) => result.push({ type: "add", text: t, newNo: ni + 1 });
10
16
 
11
17
  while (oi < oldLines.length && ni < newLines.length) {
12
18
  const old = oldLines[oi]!;
13
19
  const nw = newLines[ni]!;
14
20
 
15
21
  if (old === nw) {
16
- result.push({ type: "same", text: old });
22
+ same(old);
17
23
  oi++;
18
24
  ni++;
19
25
  continue;
@@ -21,32 +27,32 @@ export function computeDiff(oldLines: string[], newLines: string[]): DiffLine[]
21
27
 
22
28
  const oldEnd = findEnd(oldLines, newLines, oi, ni);
23
29
  if (oldEnd.oi === oi) {
24
- result.push({ type: "add", text: nw });
30
+ add(nw);
25
31
  ni++;
26
32
  continue;
27
33
  }
28
34
  if (oldEnd.ni === ni) {
29
- result.push({ type: "remove", text: old });
35
+ rem(old);
30
36
  oi++;
31
37
  continue;
32
38
  }
33
39
 
34
40
  while (oi < oldEnd.oi) {
35
- result.push({ type: "remove", text: oldLines[oi]! });
41
+ rem(oldLines[oi]!);
36
42
  oi++;
37
43
  }
38
44
  while (ni < oldEnd.ni) {
39
- result.push({ type: "add", text: newLines[ni]! });
45
+ add(newLines[ni]!);
40
46
  ni++;
41
47
  }
42
48
  }
43
49
 
44
50
  while (oi < oldLines.length) {
45
- result.push({ type: "remove", text: oldLines[oi]! });
51
+ rem(oldLines[oi]!);
46
52
  oi++;
47
53
  }
48
54
  while (ni < newLines.length) {
49
- result.push({ type: "add", text: newLines[ni]! });
55
+ add(newLines[ni]!);
50
56
  ni++;
51
57
  }
52
58
 
@@ -71,17 +77,11 @@ function findEnd(
71
77
 
72
78
  export function formatDiff(diff: DiffLine[]): string {
73
79
  const lines: string[] = [];
80
+ const no = (n?: number) => String(n ?? "").padStart(4, " ");
74
81
  for (const d of diff.slice(0, 80)) {
75
- switch (d.type) {
76
- case "add":
77
- lines.push(`+ ${d.text}`);
78
- break;
79
- case "remove":
80
- lines.push(`- ${d.text}`);
81
- break;
82
- default:
83
- lines.push(` ${d.text}`);
84
- }
82
+ const num = d.type === "add" ? no(d.newNo) : no(d.oldNo);
83
+ const mark = d.type === "add" ? "+" : d.type === "remove" ? "-" : " ";
84
+ lines.push(`${num} ${mark} ${d.text}`);
85
85
  }
86
86
  if (diff.length > 80) lines.push(`… and ${diff.length - 80} more lines`);
87
87
  return lines.join("\n");
@@ -1,5 +1,6 @@
1
1
  import { Box, Text } from "ink";
2
2
  import type { ReactNode } from "react";
3
+ import { tokenizeLine, normalizeLang } from "./syntax.ts";
3
4
 
4
5
  // Rendering markdown minimale per il terminale (no dipendenze):
5
6
  // - fenced code ``` → blocco dim
@@ -32,14 +33,35 @@ export function MarkdownText({ content }: { content: string }) {
32
33
  const lines = content.split("\n");
33
34
  const blocks: ReactNode[] = [];
34
35
  let inFence = false;
36
+ let lang = "";
37
+ let fenceLines = 0;
35
38
 
36
39
  lines.forEach((line, i) => {
37
- if (line.trimStart().startsWith("```")) {
38
- inFence = !inFence;
40
+ const t = line.trimStart();
41
+ if (t.startsWith("```")) {
42
+ if (!inFence) {
43
+ inFence = true;
44
+ lang = normalizeLang(t.slice(3));
45
+ fenceLines = 0;
46
+ } else {
47
+ inFence = false;
48
+ lang = "";
49
+ }
39
50
  return; // nascondi i marker di fence
40
51
  }
41
52
  if (inFence) {
42
- blocks.push(<Text key={i} dimColor> {line}</Text>);
53
+ if (++fenceLines > 200) return; // cap di sicurezza
54
+ const toks = tokenizeLine(line, lang);
55
+ blocks.push(
56
+ <Text key={i}>
57
+ {" "}
58
+ {toks.map((tk, j) => (
59
+ <Text key={j} color={tk.color} dimColor={tk.dim}>
60
+ {tk.text}
61
+ </Text>
62
+ ))}
63
+ </Text>,
64
+ );
43
65
  return;
44
66
  }
45
67
  const heading = /^(#{1,6})\s+(.*)$/.exec(line);
@@ -1,6 +1,7 @@
1
1
  import { useState, type FC } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { currentModel, setModel, setProvider, PROVIDERS, type ProviderId } from "../config.ts";
4
+ import { SelectRow } from "./SelectRow.tsx";
4
5
 
5
6
  export const ModelPicker: FC<{ onCancel: () => void }> = ({ onCancel }) => {
6
7
  const current = currentModel();
@@ -30,13 +31,13 @@ export const ModelPicker: FC<{ onCancel: () => void }> = ({ onCancel }) => {
30
31
  <Box flexDirection="column" borderStyle="round" borderColor="blue" padding={1}>
31
32
  <Box marginBottom={1}><Text bold>Select model</Text></Box>
32
33
  {flat.map((m, i) => (
33
- <Box key={m.id}>
34
- <Text color={i === idx ? "cyan" : undefined} bold={i === idx}>
35
- {i === idx ? "▸ " : " "}{m.label}
36
- </Text>
37
- <Text dimColor> · {m.provider}</Text>
38
- {m.id === current && <Text color="cyan"> (active)</Text>}
39
- </Box>
34
+ <SelectRow
35
+ key={m.id}
36
+ label={m.label}
37
+ meta={`· ${m.provider}`}
38
+ selected={i === idx}
39
+ current={m.id === current}
40
+ />
40
41
  ))}
41
42
  <Box marginTop={1}><Text dimColor>↑↓ j/k navigate · Enter select · Esc cancel</Text></Box>
42
43
  </Box>
package/src/tui/Panel.tsx CHANGED
@@ -1,35 +1,39 @@
1
1
  import { Box, Text, useStdout } from "ink";
2
- import { PANEL, padRight, panelWidth } from "./theme.ts";
2
+ import { BG_PANEL, BG_ELEMENT, padRight, panelWidth } from "./theme.ts";
3
3
 
4
4
  // Pannello con sfondo REALE: ogni riga è un <Text backgroundColor> riempito di
5
5
  // spazi fino alla larghezza, così il colore si vede (il bg dei <Box> in Ink non
6
6
  // riempie il padding). Barra laterale opzionale come primo carattere della riga.
7
+ // level: "element" (#1e1e1e, messaggi/selezione) | "panel" (#141414, blocchi/dialog).
7
8
  export function Panel({
8
9
  content,
9
10
  bar,
10
11
  barColor,
11
12
  bold,
13
+ level = "element",
12
14
  }: {
13
15
  content: string;
14
16
  bar?: string;
15
17
  barColor?: string;
16
18
  bold?: boolean;
19
+ level?: "panel" | "element";
17
20
  }) {
18
21
  const { stdout } = useStdout();
19
22
  const w = panelWidth(stdout?.columns);
23
+ const bg = level === "panel" ? BG_PANEL : BG_ELEMENT;
20
24
  const prefix = bar ? `${bar} ` : "";
21
25
  const lines = content.split("\n");
22
26
 
23
27
  return (
24
28
  <Box flexDirection="column" marginBottom={1}>
25
29
  {lines.map((ln, i) => (
26
- <Text key={i} backgroundColor={PANEL} color="white" bold={bold}>
30
+ <Text key={i} backgroundColor={bg} color="white" bold={bold}>
27
31
  {bar && i === 0 ? (
28
- <Text backgroundColor={PANEL} color={barColor} bold>
32
+ <Text backgroundColor={bg} color={barColor} bold>
29
33
  {prefix}
30
34
  </Text>
31
35
  ) : (
32
- <Text backgroundColor={PANEL}>{bar ? " " : ""}</Text>
36
+ <Text backgroundColor={bg}>{bar ? " " : ""}</Text>
33
37
  )}
34
38
  {padRight(ln, w - prefix.length)}
35
39
  </Text>
@@ -0,0 +1,40 @@
1
+ import { Text, useStdout } from "ink";
2
+ import { BG_ELEMENT, padRight, panelWidth } from "./theme.ts";
3
+
4
+ // Riga di selezione stile opencode (it. 24): selezione = sfondo a riga piena,
5
+ // `●` per il valore corrente (vs ` `). Niente pointer `▸`.
6
+ export function SelectRow({
7
+ label,
8
+ meta,
9
+ selected,
10
+ current,
11
+ color = "white",
12
+ }: {
13
+ label: string;
14
+ meta?: string;
15
+ selected: boolean;
16
+ current?: boolean;
17
+ color?: string;
18
+ }) {
19
+ const { stdout } = useStdout();
20
+ const w = Math.min(panelWidth(stdout?.columns), 72);
21
+ const marker = current ? "● " : " ";
22
+
23
+ if (selected) {
24
+ const text = `${marker}${label}${meta ? " " + meta : ""}`;
25
+ return (
26
+ <Text backgroundColor={BG_ELEMENT} color="white" bold>
27
+ {padRight(text, w)}
28
+ </Text>
29
+ );
30
+ }
31
+ return (
32
+ <Text>
33
+ <Text color={current ? color : undefined} bold={current}>
34
+ {marker}
35
+ {label}
36
+ </Text>
37
+ {meta ? <Text dimColor>{" " + meta}</Text> : null}
38
+ </Text>
39
+ );
40
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback, type FC } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { listSessions, loadSession, type SessionMeta } from "../session/store.ts";
4
+ import { SelectRow } from "./SelectRow.tsx";
4
5
 
5
6
  interface Props {
6
7
  onSelect: (sessionId: string) => void;
@@ -56,13 +57,11 @@ export const SessionList: FC<Props> = ({ onSelect, onCancel }) => {
56
57
  <Text bold>Sessions ({sessions.length})</Text>
57
58
  </Box>
58
59
  {sessions.slice(0, PAGE_SIZE).map((s, i) => (
59
- <Box key={s.id}>
60
- <Text color={i === idx ? "cyan" : undefined} bold={i === idx}>
61
- {i === idx ? "▸" : " "}
62
- </Text>
63
- <Text dimColor>{s.id.slice(0, 12)}</Text>
64
- <Text> {s.mode} · {s.turnCount}t · {s.updatedAt.slice(0, 10)}</Text>
65
- </Box>
60
+ <SelectRow
61
+ key={s.id}
62
+ label={s.title || `(untitled ${s.id.slice(0, 8)})`}
63
+ selected={i === idx}
64
+ />
66
65
  ))}
67
66
  <Box marginTop={1}>
68
67
  <Text dimColor>↑↓ j/k navigate · Enter select · Esc/q cancel</Text>
@@ -0,0 +1,43 @@
1
+ import { useState, useEffect } from "react";
2
+ import { Text } from "ink";
3
+
4
+ // Spinner d'interferenza (it. 26), eco del mark animato del brand
5
+ // (logo/interference-mark-animated.svg): due sorgenti `◉` i cui fronti d'onda `‹ ›`
6
+ // si espandono verso il centro e interferiscono in `✕`, poi svaniscono e ripartono.
7
+ // Tutti i frame larghi 7 → niente jitter.
8
+ const FRAMES = [
9
+ "◉ ◉",
10
+ "◉‹ ›◉",
11
+ "◉‹‹ ››◉",
12
+ "◉‹‹✕››◉",
13
+ "◉‹ ✕ ›◉",
14
+ "◉ ✕ ◉",
15
+ "◉ · ◉",
16
+ ];
17
+
18
+ // Variante compatta (3 col) per le righe inline dei tool.
19
+ const FRAMES_INLINE = ["‹✕›", "·✕·", " ✕ ", "‹ ›"];
20
+
21
+ function useFrame(frames: string[], ms: number): string {
22
+ const [i, setI] = useState(0);
23
+ useEffect(() => {
24
+ const t = setInterval(() => setI((x) => (x + 1) % frames.length), ms);
25
+ return () => clearInterval(t);
26
+ }, [frames, ms]);
27
+ return frames[i]!;
28
+ }
29
+
30
+ export function Spinner({ label }: { label?: string }) {
31
+ const frame = useFrame(FRAMES, 110);
32
+ return (
33
+ <Text>
34
+ <Text color="white">{frame}</Text>
35
+ {label ? <Text dimColor>{" " + label}</Text> : null}
36
+ </Text>
37
+ );
38
+ }
39
+
40
+ export function SpinnerInline() {
41
+ const frame = useFrame(FRAMES_INLINE, 130);
42
+ return <Text dimColor>{frame}</Text>;
43
+ }
@@ -1,6 +1,7 @@
1
1
  import { useState, type FC } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { currentProvider, currentThinking, type ThinkingLevel } from "../config.ts";
4
+ import { SelectRow } from "./SelectRow.tsx";
4
5
 
5
6
  interface Props {
6
7
  onSelect: (level: ThinkingLevel) => void;
@@ -40,14 +41,13 @@ export const ThinkingPicker: FC<Props> = ({ onSelect, onCancel }) => {
40
41
  <Text bold>Thinking level · {provider.label}</Text>
41
42
  </Box>
42
43
  {levels.map((l, i) => (
43
- <Box key={l}>
44
- <Text color={i === idx ? "magenta" : undefined} bold={i === idx}>
45
- {i === idx ? "▸ " : " "}
46
- {l}
47
- </Text>
48
- {l === current && <Text dimColor> (current)</Text>}
49
- {HINT[l] && <Text dimColor> — {HINT[l]}</Text>}
50
- </Box>
44
+ <SelectRow
45
+ key={l}
46
+ label={l}
47
+ meta={HINT[l]}
48
+ selected={i === idx}
49
+ current={l === current}
50
+ />
51
51
  ))}
52
52
  <Box marginTop={1}>
53
53
  <Text dimColor>↑↓ j/k navigate · Enter select · Esc cancel</Text>