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/README.md +29 -8
- package/package.json +22 -3
- package/src/__tests__/version.test.ts +24 -0
- package/src/auth.ts +8 -10
- package/src/cli-plain.ts +3 -3
- package/src/cli.ts +8 -0
- package/src/commands/index.ts +18 -0
- package/src/config.ts +29 -18
- package/src/cost.ts +2 -0
- package/src/paths.ts +25 -0
- package/src/session/__tests__/session.test.ts +9 -0
- package/src/session/store.ts +4 -5
- package/src/skills.ts +9 -9
- package/src/tools/__tests__/mutating.test.ts +1 -1
- package/src/tools/bash.ts +25 -6
- package/src/tools/grep.ts +27 -21
- package/src/tui/App.tsx +73 -30
- package/src/tui/ConfirmDialog.tsx +13 -4
- package/src/tui/DiffView.tsx +17 -17
- package/src/tui/MarkdownText.tsx +25 -3
- package/src/tui/ModelPicker.tsx +8 -7
- package/src/tui/Panel.tsx +8 -4
- package/src/tui/SelectRow.tsx +40 -0
- package/src/tui/SessionList.tsx +6 -7
- package/src/tui/Spinner.tsx +43 -0
- package/src/tui/ThinkingPicker.tsx +8 -8
- package/src/tui/ToolStep.tsx +130 -53
- package/src/tui/Welcome.tsx +20 -5
- package/src/tui/__tests__/reasoning.test.ts +24 -0
- package/src/tui/__tests__/syntax.test.ts +33 -0
- package/src/tui/__tests__/tui-render.test.tsx +30 -1
- package/src/tui/placeholders.ts +22 -0
- package/src/tui/reasoning.ts +23 -0
- package/src/tui/syntax.ts +58 -0
- package/src/tui/theme.ts +13 -1
- package/src/version.ts +79 -0
- package/assets/screenshot.png +0 -0
- package/bun.lock +0 -159
- package/tsconfig.json +0 -23
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 "
|
|
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 {
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
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)` :
|
|
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=
|
|
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
|
-
|
|
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
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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
|
|
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
|
|
37
|
-
{selected === "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
|
|
40
|
-
{selected === "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>
|
package/src/tui/DiffView.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
+
add(nw);
|
|
25
31
|
ni++;
|
|
26
32
|
continue;
|
|
27
33
|
}
|
|
28
34
|
if (oldEnd.ni === ni) {
|
|
29
|
-
|
|
35
|
+
rem(old);
|
|
30
36
|
oi++;
|
|
31
37
|
continue;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
while (oi < oldEnd.oi) {
|
|
35
|
-
|
|
41
|
+
rem(oldLines[oi]!);
|
|
36
42
|
oi++;
|
|
37
43
|
}
|
|
38
44
|
while (ni < oldEnd.ni) {
|
|
39
|
-
|
|
45
|
+
add(newLines[ni]!);
|
|
40
46
|
ni++;
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
while (oi < oldLines.length) {
|
|
45
|
-
|
|
51
|
+
rem(oldLines[oi]!);
|
|
46
52
|
oi++;
|
|
47
53
|
}
|
|
48
54
|
while (ni < newLines.length) {
|
|
49
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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");
|
package/src/tui/MarkdownText.tsx
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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);
|
package/src/tui/ModelPicker.tsx
CHANGED
|
@@ -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
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{m.id === current
|
|
39
|
-
|
|
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 {
|
|
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={
|
|
30
|
+
<Text key={i} backgroundColor={bg} color="white" bold={bold}>
|
|
27
31
|
{bar && i === 0 ? (
|
|
28
|
-
<Text backgroundColor={
|
|
32
|
+
<Text backgroundColor={bg} color={barColor} bold>
|
|
29
33
|
{prefix}
|
|
30
34
|
</Text>
|
|
31
35
|
) : (
|
|
32
|
-
<Text backgroundColor={
|
|
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
|
+
}
|
package/src/tui/SessionList.tsx
CHANGED
|
@@ -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
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
{l === current
|
|
49
|
-
|
|
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>
|