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,46 @@
1
+ import { useState, type FC } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+
4
+ interface Props {
5
+ tool: string;
6
+ preview: string;
7
+ onResolve: (allowed: boolean) => void;
8
+ }
9
+
10
+ export const ConfirmDialog: FC<Props> = ({ tool, preview, onResolve }) => {
11
+ const [selected, setSelected] = useState<"allow" | "deny">("deny");
12
+
13
+ useInput((input, key) => {
14
+ if (key.leftArrow || key.rightArrow) {
15
+ setSelected((s) => (s === "allow" ? "deny" : "allow"));
16
+ }
17
+ if (key.return) {
18
+ onResolve(selected === "allow");
19
+ }
20
+ const c = input.toLowerCase();
21
+ if (c === "y") onResolve(true);
22
+ if (c === "n" || key.escape) onResolve(false);
23
+ }, { isActive: true });
24
+
25
+ return (
26
+ <Box flexDirection="column" borderStyle="round" borderColor="yellow" padding={1}>
27
+ <Box marginBottom={1}>
28
+ <Text color="yellow" bold>
29
+ Allow {tool}?
30
+ </Text>
31
+ </Box>
32
+ <Box marginBottom={1}>
33
+ <Text dimColor>{preview.slice(0, 300)}</Text>
34
+ </Box>
35
+ <Box gap={2}>
36
+ <Text color={selected === "allow" ? "green" : undefined} bold={selected === "allow"}>
37
+ {selected === "allow" ? "▸ Allow" : " Allow"}
38
+ </Text>
39
+ <Text color={selected === "deny" ? "red" : undefined} bold={selected === "deny"}>
40
+ {selected === "deny" ? "▸ Deny" : " Deny"}
41
+ </Text>
42
+ <Text dimColor>(←→ arrows, Enter, y/n)</Text>
43
+ </Box>
44
+ </Box>
45
+ );
46
+ };
@@ -0,0 +1,88 @@
1
+ export interface DiffLine {
2
+ type: "same" | "add" | "remove";
3
+ text: string;
4
+ }
5
+
6
+ export function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
7
+ const result: DiffLine[] = [];
8
+ let oi = 0;
9
+ let ni = 0;
10
+
11
+ while (oi < oldLines.length && ni < newLines.length) {
12
+ const old = oldLines[oi]!;
13
+ const nw = newLines[ni]!;
14
+
15
+ if (old === nw) {
16
+ result.push({ type: "same", text: old });
17
+ oi++;
18
+ ni++;
19
+ continue;
20
+ }
21
+
22
+ const oldEnd = findEnd(oldLines, newLines, oi, ni);
23
+ if (oldEnd.oi === oi) {
24
+ result.push({ type: "add", text: nw });
25
+ ni++;
26
+ continue;
27
+ }
28
+ if (oldEnd.ni === ni) {
29
+ result.push({ type: "remove", text: old });
30
+ oi++;
31
+ continue;
32
+ }
33
+
34
+ while (oi < oldEnd.oi) {
35
+ result.push({ type: "remove", text: oldLines[oi]! });
36
+ oi++;
37
+ }
38
+ while (ni < oldEnd.ni) {
39
+ result.push({ type: "add", text: newLines[ni]! });
40
+ ni++;
41
+ }
42
+ }
43
+
44
+ while (oi < oldLines.length) {
45
+ result.push({ type: "remove", text: oldLines[oi]! });
46
+ oi++;
47
+ }
48
+ while (ni < newLines.length) {
49
+ result.push({ type: "add", text: newLines[ni]! });
50
+ ni++;
51
+ }
52
+
53
+ return result;
54
+ }
55
+
56
+ function findEnd(
57
+ oldLines: string[],
58
+ newLines: string[],
59
+ oi: number,
60
+ ni: number,
61
+ ): { oi: number; ni: number } {
62
+ for (let o = oi + 1; o <= oldLines.length; o++) {
63
+ for (let n = ni; n < newLines.length; n++) {
64
+ if (oldLines[o] === newLines[n]) {
65
+ return { oi: o, ni: n };
66
+ }
67
+ }
68
+ }
69
+ return { oi: oldLines.length, ni: newLines.length };
70
+ }
71
+
72
+ export function formatDiff(diff: DiffLine[]): string {
73
+ const lines: string[] = [];
74
+ 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
+ }
85
+ }
86
+ if (diff.length > 80) lines.push(`… and ${diff.length - 80} more lines`);
87
+ return lines.join("\n");
88
+ }
@@ -0,0 +1,63 @@
1
+ import { Box, Text } from "ink";
2
+ import type { ReactNode } from "react";
3
+
4
+ // Rendering markdown minimale per il terminale (no dipendenze):
5
+ // - fenced code ``` → blocco dim
6
+ // - heading #..###### → bold
7
+ // - bullet -, * → •
8
+ // - inline **bold** e `code`
9
+ // Volutamente conservativo: meglio testo pulito che parsing fragile.
10
+
11
+ function renderInline(text: string, keyBase: string): ReactNode[] {
12
+ const out: ReactNode[] = [];
13
+ const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
14
+ let last = 0;
15
+ let m: RegExpExecArray | null;
16
+ let k = 0;
17
+ while ((m = re.exec(text)) !== null) {
18
+ if (m.index > last) out.push(text.slice(last, m.index));
19
+ const tok = m[0];
20
+ if (tok.startsWith("**")) {
21
+ out.push(<Text key={`${keyBase}-b${k++}`} bold>{tok.slice(2, -2)}</Text>);
22
+ } else {
23
+ out.push(<Text key={`${keyBase}-c${k++}`} color="cyan">{tok.slice(1, -1)}</Text>);
24
+ }
25
+ last = m.index + tok.length;
26
+ }
27
+ if (last < text.length) out.push(text.slice(last));
28
+ return out;
29
+ }
30
+
31
+ export function MarkdownText({ content }: { content: string }) {
32
+ const lines = content.split("\n");
33
+ const blocks: ReactNode[] = [];
34
+ let inFence = false;
35
+
36
+ lines.forEach((line, i) => {
37
+ if (line.trimStart().startsWith("```")) {
38
+ inFence = !inFence;
39
+ return; // nascondi i marker di fence
40
+ }
41
+ if (inFence) {
42
+ blocks.push(<Text key={i} dimColor> {line}</Text>);
43
+ return;
44
+ }
45
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
46
+ if (heading) {
47
+ blocks.push(<Text key={i} bold>{heading[2]}</Text>);
48
+ return;
49
+ }
50
+ const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line);
51
+ if (bullet) {
52
+ blocks.push(
53
+ <Text key={i}>
54
+ {bullet[1]}• {renderInline(bullet[2] ?? "", `l${i}`)}
55
+ </Text>,
56
+ );
57
+ return;
58
+ }
59
+ blocks.push(<Text key={i}>{renderInline(line, `l${i}`)}</Text>);
60
+ });
61
+
62
+ return <Box flexDirection="column">{blocks}</Box>;
63
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+ import { Box, Text } from "ink";
3
+
4
+ interface Props {
5
+ role: "user" | "assistant";
6
+ children: ReactNode;
7
+ }
8
+
9
+ export function Message({ role, children }: Props) {
10
+ return (
11
+ <Box flexDirection="row" marginBottom={1}>
12
+ <Text color={role === "user" ? "cyan" : "green"} bold>
13
+ {role === "user" ? "› " : "· "}
14
+ </Text>
15
+ <Text>{children}</Text>
16
+ </Box>
17
+ );
18
+ }
19
+
20
+ export function ReasoningBlock({ text }: { text: string }) {
21
+ return (
22
+ <Text dimColor>
23
+ ┄ {text}
24
+ </Text>
25
+ );
26
+ }
@@ -0,0 +1,44 @@
1
+ import { useState, type FC } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { currentModel, setModel, setProvider, PROVIDERS, type ProviderId } from "../config.ts";
4
+
5
+ export const ModelPicker: FC<{ onCancel: () => void }> = ({ onCancel }) => {
6
+ const current = currentModel();
7
+ const flat: { id: string; label: string; provider: string; providerId: ProviderId }[] = [];
8
+ for (const [pid, def] of Object.entries(PROVIDERS)) {
9
+ for (const m of def.models) {
10
+ flat.push({ id: m.id, label: m.label, provider: def.label, providerId: pid as ProviderId });
11
+ }
12
+ }
13
+
14
+ const [idx, setIdx] = useState(Math.max(0, flat.findIndex((m) => m.id === current)));
15
+
16
+ useInput(
17
+ (input, key) => {
18
+ if (key.upArrow || input === "k") setIdx((i) => (i > 0 ? i - 1 : flat.length - 1));
19
+ else if (key.downArrow || input === "j") setIdx((i) => (i < flat.length - 1 ? i + 1 : 0));
20
+ else if (key.return) {
21
+ const m = flat[idx];
22
+ if (m) { setProvider(m.providerId); setModel(m.id); onCancel(); }
23
+ }
24
+ else if (key.escape || input === "q") onCancel();
25
+ },
26
+ { isActive: true },
27
+ );
28
+
29
+ return (
30
+ <Box flexDirection="column" borderStyle="round" borderColor="blue" padding={1}>
31
+ <Box marginBottom={1}><Text bold>Select model</Text></Box>
32
+ {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>
40
+ ))}
41
+ <Box marginTop={1}><Text dimColor>↑↓ j/k navigate · Enter select · Esc cancel</Text></Box>
42
+ </Box>
43
+ );
44
+ };
@@ -0,0 +1,39 @@
1
+ import { Box, Text, useStdout } from "ink";
2
+ import { PANEL, padRight, panelWidth } from "./theme.ts";
3
+
4
+ // Pannello con sfondo REALE: ogni riga è un <Text backgroundColor> riempito di
5
+ // spazi fino alla larghezza, così il colore si vede (il bg dei <Box> in Ink non
6
+ // riempie il padding). Barra laterale opzionale come primo carattere della riga.
7
+ export function Panel({
8
+ content,
9
+ bar,
10
+ barColor,
11
+ bold,
12
+ }: {
13
+ content: string;
14
+ bar?: string;
15
+ barColor?: string;
16
+ bold?: boolean;
17
+ }) {
18
+ const { stdout } = useStdout();
19
+ const w = panelWidth(stdout?.columns);
20
+ const prefix = bar ? `${bar} ` : "";
21
+ const lines = content.split("\n");
22
+
23
+ return (
24
+ <Box flexDirection="column" marginBottom={1}>
25
+ {lines.map((ln, i) => (
26
+ <Text key={i} backgroundColor={PANEL} color="white" bold={bold}>
27
+ {bar && i === 0 ? (
28
+ <Text backgroundColor={PANEL} color={barColor} bold>
29
+ {prefix}
30
+ </Text>
31
+ ) : (
32
+ <Text backgroundColor={PANEL}>{bar ? " " : ""}</Text>
33
+ )}
34
+ {padRight(ln, w - prefix.length)}
35
+ </Text>
36
+ ))}
37
+ </Box>
38
+ );
39
+ }
@@ -0,0 +1,111 @@
1
+ import { useState, useEffect, type FC } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import { PROVIDERS, type ProviderId } from "../config.ts";
5
+ import { loadAuth, setProviderKey, removeProviderKey, applyAuthToEnv } from "../auth.ts";
6
+
7
+ export const ProviderPicker: FC<{ onClose: () => void }> = ({ onClose }) => {
8
+ const providers = Object.entries(PROVIDERS) as [ProviderId, typeof PROVIDERS[ProviderId]][];
9
+ const [idx, setIdx] = useState(0);
10
+ const [phase, setPhase] = useState<"list" | "add" | "remove">("list");
11
+ const [keyInput, setKeyInput] = useState("");
12
+ const [connected, setConnected] = useState<Set<string>>(new Set());
13
+ const [status, setStatus] = useState("");
14
+
15
+ useEffect(() => {
16
+ loadAuth().then((auth) => {
17
+ const c = new Set(Object.keys(auth).filter((k) => !!auth[k]));
18
+ setConnected(c);
19
+ });
20
+ }, []);
21
+
22
+ async function handleSelect() {
23
+ const [pid, def] = providers[idx]!;
24
+ if (connected.has(pid)) {
25
+ setPhase("remove");
26
+ setStatus(`Remove key for ${def.label}? (y/n)`);
27
+ } else {
28
+ setPhase("add");
29
+ setKeyInput("");
30
+ setStatus(`Enter API key for ${def.label}:`);
31
+ }
32
+ }
33
+
34
+ async function submitKey() {
35
+ const [pid] = providers[idx]!;
36
+ const key = keyInput.trim();
37
+ if (!key) { setPhase("list"); return; }
38
+ await setProviderKey(pid, key);
39
+ applyAuthToEnv(await loadAuth(), Object.fromEntries(
40
+ Object.entries(PROVIDERS).map(([pid, def]) => [pid, { label: def.label, envKey: def.envKey }])
41
+ ));
42
+ setConnected((c) => new Set([...c, pid]));
43
+ setPhase("list");
44
+ setStatus(`Connected to ${PROVIDERS[pid].label}.`);
45
+ setTimeout(() => setStatus(""), 2000);
46
+ }
47
+
48
+ async function confirmRemove() {
49
+ const [pid] = providers[idx]!;
50
+ await removeProviderKey(pid);
51
+ delete process.env[PROVIDERS[pid].envKey];
52
+ setConnected((c) => { const n = new Set(c); n.delete(pid); return n; });
53
+ setPhase("list");
54
+ setStatus(`Disconnected from ${PROVIDERS[pid].label}.`);
55
+ setTimeout(() => setStatus(""), 2000);
56
+ }
57
+
58
+ useInput(
59
+ (input, key) => {
60
+ if (phase === "remove") {
61
+ if (input === "y" || key.return) confirmRemove();
62
+ else { setPhase("list"); setStatus(""); }
63
+ return;
64
+ }
65
+ if (phase === "add") return;
66
+ if (key.upArrow || input === "k") setIdx((i) => (i > 0 ? i - 1 : providers.length - 1));
67
+ else if (key.downArrow || input === "j") setIdx((i) => (i < providers.length - 1 ? i + 1 : 0));
68
+ else if (key.return) handleSelect();
69
+ else if (key.escape || input === "q") onClose();
70
+ },
71
+ { isActive: true },
72
+ );
73
+
74
+ return (
75
+ <Box flexDirection="column" borderStyle="round" borderColor="blue" padding={1}>
76
+ <Box marginBottom={1}><Text bold>Providers</Text></Box>
77
+
78
+ {phase === "list" && (
79
+ <>
80
+ {providers.map(([pid, def], i) => (
81
+ <Box key={pid}>
82
+ <Text color={i === idx ? "cyan" : undefined} bold={i === idx}>
83
+ {i === idx ? "▸ " : " "}{def.label}
84
+ </Text>
85
+ <Text dimColor> ({def.models[0]?.id ?? def.defaultModel})</Text>
86
+ {connected.has(pid)
87
+ ? <Text color="green"> ● connected</Text>
88
+ : <Text dimColor> ○ not connected</Text>}
89
+ </Box>
90
+ ))}
91
+ <Box marginTop={1}>
92
+ <Text dimColor>↑↓ navigate · Enter {connected.has(providers[idx]![0]) ? "disconnect" : "add key"} · Esc close</Text>
93
+ </Box>
94
+ {status && <Box marginTop={1}><Text>{status}</Text></Box>}
95
+ </>
96
+ )}
97
+
98
+ {phase === "add" && (
99
+ <Box flexDirection="column">
100
+ <Text>{status}</Text>
101
+ <Box><Text dimColor>Key: </Text><TextInput value={keyInput} onChange={setKeyInput} onSubmit={submitKey} /></Box>
102
+ <Text dimColor>Enter to confirm · Esc to cancel</Text>
103
+ </Box>
104
+ )}
105
+
106
+ {phase === "remove" && (
107
+ <Text>{status}</Text>
108
+ )}
109
+ </Box>
110
+ );
111
+ };
@@ -0,0 +1,64 @@
1
+ import { useState, type FC } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { Select, MultiSelect } from "@inkjs/ui";
4
+ import type { QuestionSpec, Answers } from "../tools/question.ts";
5
+
6
+ interface Props {
7
+ questions: QuestionSpec[];
8
+ onResolve: (answers: Answers) => void;
9
+ }
10
+
11
+ export const QuestionDialog: FC<Props> = ({ questions, onResolve }) => {
12
+ const [qIdx, setQIdx] = useState(0);
13
+ // Risposte accumulate (label selezionate) per domanda.
14
+ const [acc, setAcc] = useState<Answers>(() => questions.map(() => []));
15
+
16
+ const q = questions[qIdx]!;
17
+ const multiple = q.multiple ?? false;
18
+ const total = questions.length;
19
+
20
+ // value = indice opzione (stringa); risaliamo alla label al commit.
21
+ const options = q.options.map((o, i) => ({
22
+ label: o.description ? `${o.label} — ${o.description}` : o.label,
23
+ value: String(i),
24
+ }));
25
+
26
+ function commit(values: string[]) {
27
+ const labels = values.map((v) => q.options[Number(v)]!.label);
28
+ const next = acc.map((a, i) => (i === qIdx ? labels : a));
29
+ if (qIdx + 1 < total) {
30
+ setAcc(next);
31
+ setQIdx(qIdx + 1);
32
+ } else {
33
+ onResolve(next);
34
+ }
35
+ }
36
+
37
+ // Esc salta tutto. Le altre frecce/spazio/Invio sono gestiti da Select/MultiSelect.
38
+ useInput((_i, key) => {
39
+ if (key.escape) onResolve(questions.map(() => []));
40
+ }, { isActive: true });
41
+
42
+ return (
43
+ <Box flexDirection="column" borderStyle="round" borderColor="cyan" padding={1} marginBottom={1}>
44
+ <Box>
45
+ {q.header ? <Text color="cyan" bold>[{q.header}] </Text> : null}
46
+ <Text bold>{q.question}</Text>
47
+ {total > 1 ? <Text dimColor> ({qIdx + 1}/{total})</Text> : null}
48
+ </Box>
49
+ <Box marginTop={1}>
50
+ {multiple ? (
51
+ // key={qIdx}: remount per domanda → stato interno azzerato
52
+ <MultiSelect key={qIdx} options={options} onSubmit={commit} />
53
+ ) : (
54
+ <Select key={qIdx} options={options} onChange={(v) => commit([v])} />
55
+ )}
56
+ </Box>
57
+ <Box marginTop={1}>
58
+ <Text dimColor>
59
+ ↑↓ move · {multiple ? "space toggle · Enter confirm" : "Enter select"} · Esc skip
60
+ </Text>
61
+ </Box>
62
+ </Box>
63
+ );
64
+ };
@@ -0,0 +1,72 @@
1
+ import { useState, useEffect, useCallback, type FC } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { listSessions, loadSession, type SessionMeta } from "../session/store.ts";
4
+
5
+ interface Props {
6
+ onSelect: (sessionId: string) => void;
7
+ onCancel: () => void;
8
+ }
9
+
10
+ const PAGE_SIZE = 15;
11
+
12
+ export const SessionList: FC<Props> = ({ onSelect, onCancel }) => {
13
+ const [sessions, setSessions] = useState<SessionMeta[]>([]);
14
+ const [idx, setIdx] = useState(0);
15
+ const [loading, setLoading] = useState(true);
16
+
17
+ useEffect(() => {
18
+ listSessions().then((list) => {
19
+ setSessions(list);
20
+ setLoading(false);
21
+ });
22
+ }, []);
23
+
24
+ useInput((input, key) => {
25
+ if (key.upArrow || input === "k") {
26
+ setIdx((i) => (i > 0 ? i - 1 : Math.max(0, sessions.length - 1)));
27
+ } else if (key.downArrow || input === "j") {
28
+ setIdx((i) => (i < sessions.length - 1 ? i + 1 : 0));
29
+ } else if (key.return) {
30
+ const s = sessions[idx];
31
+ if (s) onSelect(s.id);
32
+ } else if (key.escape || input === "q") {
33
+ onCancel();
34
+ }
35
+ }, { isActive: true });
36
+
37
+ if (loading) {
38
+ return (
39
+ <Box flexDirection="column" borderStyle="round" borderColor="blue" padding={1}>
40
+ <Text>Loading sessions...</Text>
41
+ </Box>
42
+ );
43
+ }
44
+
45
+ if (sessions.length === 0) {
46
+ return (
47
+ <Box flexDirection="column" borderStyle="round" borderColor="blue" padding={1}>
48
+ <Text>No sessions found.</Text>
49
+ </Box>
50
+ );
51
+ }
52
+
53
+ return (
54
+ <Box flexDirection="column" borderStyle="round" borderColor="blue" padding={1}>
55
+ <Box marginBottom={1}>
56
+ <Text bold>Sessions ({sessions.length})</Text>
57
+ </Box>
58
+ {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>
66
+ ))}
67
+ <Box marginTop={1}>
68
+ <Text dimColor>↑↓ j/k navigate · Enter select · Esc/q cancel</Text>
69
+ </Box>
70
+ </Box>
71
+ );
72
+ };
@@ -0,0 +1,33 @@
1
+ import { Box, Text } from "ink";
2
+ import { matchCommands } from "../commands/index.ts";
3
+
4
+ interface Props {
5
+ filter: string;
6
+ /** Indice selezionato (navigato con ↑↓ da App). */
7
+ selected: number;
8
+ }
9
+
10
+ // Hint con selezione: le frecce (gestite da App) muovono `selected`; l'Invio
11
+ // (gestito dal TextInput) esegue il comando evidenziato. Niente useInput qui
12
+ // per non confliggere col campo di testo sull'Invio.
13
+ export function SlashAutocomplete({ filter, selected }: Props) {
14
+ const matches = matchCommands(filter);
15
+ if (matches.length === 0) return null;
16
+
17
+ const shown = matches.slice(0, 8);
18
+ const sel = ((selected % matches.length) + matches.length) % matches.length;
19
+
20
+ return (
21
+ <Box flexDirection="column" marginBottom={1} borderStyle="round" borderColor="blue" paddingX={1}>
22
+ {shown.map((c, i) => (
23
+ <Box key={c.name}>
24
+ <Text color={i === sel ? "cyan" : undefined} bold={i === sel}>
25
+ {i === sel ? "▸ " : " "}/{c.name}
26
+ </Text>
27
+ <Text dimColor> {c.description.slice(0, 70)}</Text>
28
+ </Box>
29
+ ))}
30
+ <Text dimColor>↑↓ navigate · Enter run · type to filter</Text>
31
+ </Box>
32
+ );
33
+ }
@@ -0,0 +1,71 @@
1
+ import { Box, Text } from "ink";
2
+
3
+ interface Props {
4
+ mode: string;
5
+ model: string;
6
+ provider: string;
7
+ thinking: string;
8
+ contextPct: number;
9
+ busy: boolean;
10
+ statusLine: string;
11
+ turnCount: number;
12
+ cost: string;
13
+ gitBranch: string;
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ }
17
+
18
+ function fmt(n: number): string {
19
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
20
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
21
+ return String(n);
22
+ }
23
+
24
+ export function StatusFooter({
25
+ mode,
26
+ model,
27
+ provider,
28
+ thinking,
29
+ contextPct,
30
+ busy,
31
+ statusLine,
32
+ turnCount,
33
+ cost,
34
+ gitBranch,
35
+ inputTokens,
36
+ outputTokens,
37
+ }: Props) {
38
+ const modeColor = mode === "build" ? "yellow" : "blue";
39
+ const tokenTotal = inputTokens + outputTokens;
40
+ const contextColor = contextPct > 80 ? "yellow" : contextPct > 60 ? undefined : undefined;
41
+
42
+ return (
43
+ <Box flexDirection="column">
44
+ <Box flexDirection="row" justifyContent="space-between">
45
+ <Box gap={1}>
46
+ {busy && <Text color="yellow">◉</Text>}
47
+ <Text dimColor>{provider}</Text>
48
+ <Text dimColor>·</Text>
49
+ <Text>{model}</Text>
50
+ <Text dimColor>·</Text>
51
+ <Text color={modeColor} bold>{mode.toUpperCase()}</Text>
52
+ {thinking && thinking !== "off" && (
53
+ <Text dimColor>◇ {thinking}</Text>
54
+ )}
55
+ <Text dimColor>·</Text>
56
+ <Text dimColor color={contextColor}>
57
+ {fmt(tokenTotal)} tok {contextPct}%
58
+ </Text>
59
+ <Text dimColor>·</Text>
60
+ <Text dimColor>{cost}</Text>
61
+ <Text dimColor>·</Text>
62
+ <Text dimColor>#{turnCount}</Text>
63
+ {gitBranch && <Text dimColor>⎇ {gitBranch}</Text>}
64
+ </Box>
65
+ <Box>
66
+ {statusLine && <Text dimColor>{statusLine}</Text>}
67
+ </Box>
68
+ </Box>
69
+ </Box>
70
+ );
71
+ }