vipcode 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +60 -0
- package/README.md +401 -2
- package/dist/agent.js +502 -0
- package/dist/agents.js +174 -0
- package/dist/app.js +1025 -0
- package/dist/auth.js +108 -0
- package/dist/catalog.js +487 -0
- package/dist/commands.js +121 -0
- package/dist/config.js +230 -0
- package/dist/debug.js +32 -0
- package/dist/discovery.js +277 -0
- package/dist/frontmatter.js +45 -0
- package/dist/images.js +81 -0
- package/dist/index.js +623 -0
- package/dist/login.js +157 -0
- package/dist/lsp.js +480 -0
- package/dist/markdown.js +262 -0
- package/dist/match.js +8 -0
- package/dist/mcp.js +225 -0
- package/dist/meta.js +7 -0
- package/dist/paths.js +53 -0
- package/dist/permissions.js +144 -0
- package/dist/prompt.js +123 -0
- package/dist/ranking.js +230 -0
- package/dist/router.js +726 -0
- package/dist/sanitize.js +38 -0
- package/dist/session.js +219 -0
- package/dist/skills.js +374 -0
- package/dist/snapshot.js +199 -0
- package/dist/tools.js +1311 -0
- package/dist/trust.js +171 -0
- package/dist/tui/app.js +1019 -0
- package/dist/tui/dialogs.js +197 -0
- package/dist/tui/editor.js +120 -0
- package/dist/ui.js +184 -0
- package/dist/update.js +237 -0
- package/package.json +64 -4
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Box, Static, Text, render, useAnimation, useApp, useInput, usePaste, useWindowSize } from "ink";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
import { configNotices } from "../config.js";
|
|
9
|
+
import { discoveryIsStale } from "../discovery.js";
|
|
10
|
+
import { isImagePath, readClipboardImage, toDataUrl } from "../images.js";
|
|
11
|
+
import { renderMarkdown } from "../markdown.js";
|
|
12
|
+
import { VERSION } from "../meta.js";
|
|
13
|
+
import { NoSlotError } from "../router.js";
|
|
14
|
+
import { showControl, stripControl, stripUnsafe } from "../sanitize.js";
|
|
15
|
+
import { appendHistory, loadHistory } from "../session.js";
|
|
16
|
+
import { suggestFiles } from "../tools.js";
|
|
17
|
+
import { colorPreview } from "../ui.js";
|
|
18
|
+
import { checkForUpdate } from "../update.js";
|
|
19
|
+
import { PermissionDialog, PromptDialog, SelectDialog } from "./dialogs.js";
|
|
20
|
+
import * as E from "./editor.js";
|
|
21
|
+
// Comandos que se pueden usar mientras el agente trabaja (solo consultan)
|
|
22
|
+
const SAFE_WHILE_BUSY = ["/help", "/usage", "/todos", "/mcp", "/lsp", "/providers", "/skills"];
|
|
23
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
|
+
// Cómo se cuenta cada herramienta en pantalla
|
|
25
|
+
const TOOL_LABEL = {
|
|
26
|
+
read_file: "Lee",
|
|
27
|
+
write_file: "Escribe",
|
|
28
|
+
edit_file: "Edita",
|
|
29
|
+
list_dir: "Lista",
|
|
30
|
+
glob: "Busca ficheros",
|
|
31
|
+
grep: "Busca",
|
|
32
|
+
bash: "Ejecuta",
|
|
33
|
+
webfetch: "Abre",
|
|
34
|
+
websearch: "Busca en internet",
|
|
35
|
+
todowrite: "Tareas",
|
|
36
|
+
todoread: "Tareas",
|
|
37
|
+
diagnostics: "Diagnósticos",
|
|
38
|
+
task: "Subagente",
|
|
39
|
+
skill: "Skill",
|
|
40
|
+
};
|
|
41
|
+
function Spinner(props) {
|
|
42
|
+
const { frame } = useAnimation({ interval: 80 });
|
|
43
|
+
return _jsx(Text, { color: props.color || "cyan", children: SPINNER[frame % SPINNER.length] });
|
|
44
|
+
}
|
|
45
|
+
function formatDuration(ms) {
|
|
46
|
+
if (ms < 1000) {
|
|
47
|
+
return "<1 s";
|
|
48
|
+
}
|
|
49
|
+
const seconds = ms / 1000;
|
|
50
|
+
if (seconds < 10) {
|
|
51
|
+
return `${seconds.toFixed(1).replace(".", ",")} s`;
|
|
52
|
+
}
|
|
53
|
+
if (seconds < 60) {
|
|
54
|
+
return `${Math.round(seconds)} s`;
|
|
55
|
+
}
|
|
56
|
+
return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`;
|
|
57
|
+
}
|
|
58
|
+
function Elapsed(props) {
|
|
59
|
+
useAnimation({ interval: 1000 });
|
|
60
|
+
const ms = Date.now() - props.since;
|
|
61
|
+
if (props.hideUnderMs && ms < props.hideUnderMs) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return _jsx(Text, { dimColor: true, children: formatDuration(ms) });
|
|
65
|
+
}
|
|
66
|
+
function shortTokens(tokens) {
|
|
67
|
+
if (tokens >= 1000000) {
|
|
68
|
+
return `${(tokens / 1000000).toFixed(1)}M`;
|
|
69
|
+
}
|
|
70
|
+
if (tokens >= 1000) {
|
|
71
|
+
return `${Math.round(tokens / 1000)}k`;
|
|
72
|
+
}
|
|
73
|
+
return String(tokens);
|
|
74
|
+
}
|
|
75
|
+
function lastLines(text, count) {
|
|
76
|
+
const lines = text.replace(/\s+$/, "").split("\n");
|
|
77
|
+
return lines.slice(-count).join("\n");
|
|
78
|
+
}
|
|
79
|
+
function firstLine(text, max = 200) {
|
|
80
|
+
return (text.split("\n")[0] || "").slice(0, max);
|
|
81
|
+
}
|
|
82
|
+
function tildify(file) {
|
|
83
|
+
const home = os.homedir();
|
|
84
|
+
if (file === home || file.startsWith(home + path.sep)) {
|
|
85
|
+
return `~${file.slice(home.length)}`;
|
|
86
|
+
}
|
|
87
|
+
return file;
|
|
88
|
+
}
|
|
89
|
+
// Líneas añadidas y quitadas de un diff
|
|
90
|
+
function diffStats(display) {
|
|
91
|
+
if (!display) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
let added = 0;
|
|
95
|
+
let removed = 0;
|
|
96
|
+
for (const line of display.split("\n")) {
|
|
97
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
98
|
+
added++;
|
|
99
|
+
}
|
|
100
|
+
else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
101
|
+
removed++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (added === 0 && removed === 0) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return { added, removed };
|
|
108
|
+
}
|
|
109
|
+
// Debajo de cada herramienta, un resumen corto de lo que pasó
|
|
110
|
+
function toolDetail(event) {
|
|
111
|
+
// La salida viene de ficheros y comandos: sin secuencias de la terminal
|
|
112
|
+
const output = stripControl(event.output || "");
|
|
113
|
+
if (event.isError) {
|
|
114
|
+
return [pc.red(firstLine(output))];
|
|
115
|
+
}
|
|
116
|
+
if (event.display) {
|
|
117
|
+
const lines = colorPreview(event.display).split("\n");
|
|
118
|
+
if (lines.length > 14) {
|
|
119
|
+
return [...lines.slice(0, 14), pc.dim(`… ${lines.length - 14} líneas más del diff`)];
|
|
120
|
+
}
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
const count = output.split("\n").filter((line) => line.trim() !== "").length;
|
|
124
|
+
switch (event.name) {
|
|
125
|
+
case "read_file":
|
|
126
|
+
if (output.startsWith("Imagen")) {
|
|
127
|
+
return [pc.dim(firstLine(output))];
|
|
128
|
+
}
|
|
129
|
+
return [pc.dim(`Leídas ${output.split("\n").length} líneas`)];
|
|
130
|
+
case "glob":
|
|
131
|
+
case "grep":
|
|
132
|
+
if (/^Sin (resultados|coincidencias)/.test(output)) {
|
|
133
|
+
return [pc.dim(firstLine(output))];
|
|
134
|
+
}
|
|
135
|
+
return [pc.dim(`${count} resultados`)];
|
|
136
|
+
case "list_dir":
|
|
137
|
+
return [pc.dim(`${count} entradas`)];
|
|
138
|
+
case "bash": {
|
|
139
|
+
const lines = output.split("\n");
|
|
140
|
+
const header = lines[0] || "";
|
|
141
|
+
const body = lines.slice(1).filter((line) => line.trim() !== "");
|
|
142
|
+
const tail = body.slice(-4).map((line) => pc.dim(line.slice(0, 200)));
|
|
143
|
+
const ok = header === "Código de salida: 0";
|
|
144
|
+
return [ok ? pc.dim(header) : pc.yellow(header), ...tail];
|
|
145
|
+
}
|
|
146
|
+
case "webfetch":
|
|
147
|
+
return [pc.dim(`${output.length} caracteres`)];
|
|
148
|
+
case "websearch":
|
|
149
|
+
return [pc.dim(`${(output.match(/^\d+\. /gm) || []).length} resultados`)];
|
|
150
|
+
case "todowrite":
|
|
151
|
+
return output
|
|
152
|
+
.split("\n")
|
|
153
|
+
.slice(1)
|
|
154
|
+
.map((line) => pc.dim(line));
|
|
155
|
+
case "task":
|
|
156
|
+
return output
|
|
157
|
+
.split("\n")
|
|
158
|
+
.filter((line) => line.trim() !== "")
|
|
159
|
+
.slice(0, 3)
|
|
160
|
+
.map((line) => pc.dim(line.slice(0, 200)));
|
|
161
|
+
case "skill":
|
|
162
|
+
return [pc.dim(event.summary.includes("·") ? "fichero leído" : "instrucciones cargadas")];
|
|
163
|
+
default:
|
|
164
|
+
return [pc.dim(firstLine(output))];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// El resumen sale de lo que pidió el modelo (comandos, rutas): los caracteres de control se ven, no actúan
|
|
168
|
+
function ToolHeader(props) {
|
|
169
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: TOOL_LABEL[props.name] || showControl(props.name) }), props.summary ? _jsxs(Text, { children: [" ", showControl(props.summary)] }) : null] }));
|
|
170
|
+
}
|
|
171
|
+
function ToolView(props) {
|
|
172
|
+
const { event } = props;
|
|
173
|
+
const detail = toolDetail(event);
|
|
174
|
+
const stats = event.isError ? null : diffStats(event.display);
|
|
175
|
+
return (_jsxs(Box, { flexDirection: "column", marginLeft: event.depth > 0 ? 4 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: event.isError ? "red" : "green", children: "\u25CF " }), _jsx(ToolHeader, { name: event.name, summary: event.summary }), stats ? (_jsxs(Text, { children: [" ", _jsxs(Text, { color: "green", children: ["+", stats.added] }), " ", _jsxs(Text, { color: "red", children: ["\u2212", stats.removed] })] })) : null, props.ms >= 1000 ? _jsxs(Text, { dimColor: true, children: [" (", formatDuration(props.ms), ")"] }) : null] }), detail.map((line, index) => (_jsxs(Text, { children: [" ", index === 0 ? pc.dim("⎿ ") : " ", line] }, index)))] }));
|
|
176
|
+
}
|
|
177
|
+
// Pantalla de bienvenida
|
|
178
|
+
function Banner(props) {
|
|
179
|
+
const { app } = props;
|
|
180
|
+
const slots = app.router.slots;
|
|
181
|
+
const providers = new Set(slots.map((slot) => slot.provider.name)).size;
|
|
182
|
+
const first = slots[0];
|
|
183
|
+
const extras = [];
|
|
184
|
+
if (app.skills.length > 0) {
|
|
185
|
+
extras.push(`${app.skills.length} skill${app.skills.length > 1 ? "s" : ""}`);
|
|
186
|
+
}
|
|
187
|
+
const mcp = Object.keys(app.config.mcp || {}).length;
|
|
188
|
+
if (mcp > 0) {
|
|
189
|
+
extras.push(`${mcp} MCP`);
|
|
190
|
+
}
|
|
191
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 2, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "magenta", children: "\u25C6 vipcode" }), _jsxs(Text, { dimColor: true, children: [" v", VERSION, " \u00B7 programa con IAs gratuitas"] })] }), _jsx(Text, { dimColor: true, children: tildify(app.root) }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: slots.length > 0 ? "green" : "red", children: "\u25CF" }), " ", slots.length, " modelo", slots.length === 1 ? "" : "s", " de ", providers, " proveedor", providers === 1 ? "" : "es", extras.length > 0 ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", extras.join(" · ")] }) : null] }), first ? (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "yellow", children: "\u2605" }), " Empieza con ", _jsx(Text, { bold: true, children: first.key }), first.score !== null ? _jsxs(Text, { dimColor: true, children: [" (nota ", first.score, " en LLM Stats)"] }) : null] })) : null, _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Ctrl+P comandos \u00B7 @ adjunta \u00B7 ! terminal \u00B7 Tab cambia de agente \u00B7 /help ayuda" })] }));
|
|
192
|
+
}
|
|
193
|
+
function ItemView(props) {
|
|
194
|
+
const { item, width } = props;
|
|
195
|
+
switch (item.kind) {
|
|
196
|
+
case "banner":
|
|
197
|
+
return _jsx(Banner, { app: props.app });
|
|
198
|
+
case "user": {
|
|
199
|
+
const lines = stripControl(item.text).split("\n");
|
|
200
|
+
let text = lines.join("\n");
|
|
201
|
+
if (lines.length > 20) {
|
|
202
|
+
text = `${lines.slice(0, 20).join("\n")}\n… (${lines.length - 20} líneas más)`;
|
|
203
|
+
}
|
|
204
|
+
return (_jsx(Box, { marginTop: 1, borderStyle: "bold", borderTop: false, borderRight: false, borderBottom: false, borderColor: item.color, paddingLeft: 1, children: _jsxs(Text, { children: [text, item.images > 0 ? pc.dim(` [${item.images} imagen${item.images > 1 ? "es" : ""}]`) : "", item.queued ? pc.dim(" (en cola: se le entrega al acabar el paso)") : ""] }) }));
|
|
205
|
+
}
|
|
206
|
+
case "assistant":
|
|
207
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: item.color, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(Text, { children: renderMarkdown(item.text, Math.max(20, width - 4)) }) })] }));
|
|
208
|
+
case "tool":
|
|
209
|
+
return _jsx(ToolView, { event: item.event, ms: item.ms });
|
|
210
|
+
case "answer":
|
|
211
|
+
return (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u25C6 ", item.slot, item.score !== null ? ` · nota ${item.score}` : "", " \u00B7 ", formatDuration(item.ms), item.tokens > 0 ? ` · ${shortTokens(item.tokens)} tokens` : ""] }) }));
|
|
212
|
+
case "shell": {
|
|
213
|
+
const lines = item.output ? stripControl(item.output).split("\n") : [];
|
|
214
|
+
const shown = lines.slice(-30);
|
|
215
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["$", " "] }), _jsx(Text, { bold: true, children: showControl(item.command) })] }), lines.length > shown.length ? _jsxs(Text, { dimColor: true, children: [" \u2026 ", lines.length - shown.length, " l\u00EDneas antes"] }) : null, shown.length > 0 ? _jsx(Text, { children: shown.map((line) => ` ${line}`).join("\n") }) : null, _jsxs(Text, { dimColor: true, children: [" ", item.code === 0 ? "✓" : "✗", " c\u00F3digo ", item.code ?? "?", " \u00B7 el agente ver\u00E1 la salida con tu siguiente mensaje"] })] }));
|
|
216
|
+
}
|
|
217
|
+
// Avisos y bloques pueden llevar mensajes de los proveedores o datos de fuera: solo se dejan los colores
|
|
218
|
+
case "info":
|
|
219
|
+
return _jsx(Text, { dimColor: true, children: stripUnsafe(item.text) });
|
|
220
|
+
case "notice":
|
|
221
|
+
return _jsxs(Text, { color: "yellow", children: ["\u26A0 ", stripUnsafe(item.text)] });
|
|
222
|
+
case "error":
|
|
223
|
+
return _jsxs(Text, { color: "red", children: ["\u2716 ", stripUnsafe(item.text)] });
|
|
224
|
+
case "block":
|
|
225
|
+
return (_jsx(Box, { marginY: 1, children: _jsx(Text, { children: stripUnsafe(item.text) }) }));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function TodoPanel(props) {
|
|
229
|
+
const open = props.todos.some((todo) => todo.status === "pending" || todo.status === "in_progress");
|
|
230
|
+
if (!open) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const done = props.todos.filter((todo) => todo.status === "completed").length;
|
|
234
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, children: [_jsxs(Text, { dimColor: true, children: ["Tareas (", done, "/", props.todos.length, ")"] }), props.todos.slice(0, 10).map((todo, index) => {
|
|
235
|
+
if (todo.status === "completed") {
|
|
236
|
+
return (_jsxs(Text, { color: "green", strikethrough: true, children: ["\u2611 ", stripControl(todo.content)] }, index));
|
|
237
|
+
}
|
|
238
|
+
if (todo.status === "in_progress") {
|
|
239
|
+
return (_jsxs(Text, { color: "yellow", children: ["\u25D0 ", stripControl(todo.content)] }, index));
|
|
240
|
+
}
|
|
241
|
+
if (todo.status === "cancelled") {
|
|
242
|
+
return (_jsxs(Text, { dimColor: true, strikethrough: true, children: ["\u2612 ", stripControl(todo.content)] }, index));
|
|
243
|
+
}
|
|
244
|
+
return _jsxs(Text, { children: ["\u2610 ", stripControl(todo.content)] }, index);
|
|
245
|
+
}), props.todos.length > 10 ? _jsxs(Text, { dimColor: true, children: ["\u2026 y ", props.todos.length - 10, " m\u00E1s"] }) : null] }));
|
|
246
|
+
}
|
|
247
|
+
function InputBox(props) {
|
|
248
|
+
const { editor } = props;
|
|
249
|
+
// Con ! delante es un comando para la terminal
|
|
250
|
+
const shell = editor.value.startsWith("!");
|
|
251
|
+
const color = shell ? "yellow" : props.color;
|
|
252
|
+
const lines = editor.value.split("\n");
|
|
253
|
+
const { line, column } = E.cursorPosition(editor);
|
|
254
|
+
// Con textos pegados muy largos solo se ve una ventana alrededor del cursor (el cuadro no puede pasar de la pantalla)
|
|
255
|
+
const start = Math.max(0, Math.min(line - Math.floor(props.maxLines / 2), lines.length - props.maxLines));
|
|
256
|
+
const end = Math.min(lines.length, start + props.maxLines);
|
|
257
|
+
const maxChars = Math.max(200, props.width * 3);
|
|
258
|
+
const clip = (text, from) => text.slice(from, from + maxChars);
|
|
259
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: color, paddingX: 1, marginTop: 1, children: [props.attachments > 0 ? (_jsxs(Text, { color: "cyan", children: [Array.from({ length: props.attachments }, (_, i) => `[Imagen ${i + 1}]`).join(" "), _jsx(Text, { dimColor: true, children: " (se env\u00EDan con el pr\u00F3ximo mensaje)" })] })) : null, start > 0 ? _jsxs(Text, { dimColor: true, children: [" \u2191 ", start, " l\u00EDneas m\u00E1s"] }) : null, lines.slice(start, end).map((text, offset) => {
|
|
260
|
+
const index = start + offset;
|
|
261
|
+
// En líneas larguísimas se enseña el trozo donde está el cursor
|
|
262
|
+
const from = index === line ? Math.max(0, column - Math.floor(maxChars / 2)) : 0;
|
|
263
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: color, children: index === 0 ? (shell ? "$ " : "❯ ") : " " }), editor.value === "" ? (_jsxs(_Fragment, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: props.placeholder })] })) : index === line ? (_jsxs(_Fragment, { children: [from > 0 ? "…" : "", text.slice(from, column), _jsx(Text, { inverse: true, children: text[column] || " " }), clip(text, column + 1).slice(0, maxChars - (column - from))] })) : (clip(text, 0) + (text.length > maxChars ? "…" : ""))] }, index));
|
|
264
|
+
}), end < lines.length ? _jsxs(Text, { dimColor: true, children: [" \u2193 ", lines.length - end, " l\u00EDneas m\u00E1s"] }) : null, shell ? _jsxs(Text, { dimColor: true, children: ["Enter lo ejecuta en ", tildify(process.cwd()), " \u00B7 el agente ver\u00E1 la salida"] }) : null] }));
|
|
265
|
+
}
|
|
266
|
+
// Medidor de contexto: ▰▰▰▱▱▱▱▱ 34 %
|
|
267
|
+
function ContextGauge(props) {
|
|
268
|
+
if (props.used <= 0 || props.total <= 0) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const ratio = Math.min(1, props.used / props.total);
|
|
272
|
+
const filled = Math.min(8, Math.ceil(ratio * 8));
|
|
273
|
+
let color = "green";
|
|
274
|
+
if (ratio >= 0.85) {
|
|
275
|
+
color = "red";
|
|
276
|
+
}
|
|
277
|
+
else if (ratio >= 0.6) {
|
|
278
|
+
color = "yellow";
|
|
279
|
+
}
|
|
280
|
+
return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { color: color, children: "▰".repeat(filled) }), _jsxs(Text, { dimColor: true, children: ["▱".repeat(8 - filled), " ", Math.round(ratio * 100), "% de ", shortTokens(props.total)] })] }));
|
|
281
|
+
}
|
|
282
|
+
function StatusBar(props) {
|
|
283
|
+
const { app } = props;
|
|
284
|
+
const agent = app.agents.find((item) => item.name === props.agentName);
|
|
285
|
+
const current = props.model || app.router.pinned || app.agent.lastSlot;
|
|
286
|
+
const slot = app.router.slotFor(current);
|
|
287
|
+
const context = app.router.slotFor(app.agent.lastSlot)?.model.context || 0;
|
|
288
|
+
const available = app.router.availableCount();
|
|
289
|
+
const usage = app.agent.session.usage;
|
|
290
|
+
const spent = usage.prompt + usage.completion;
|
|
291
|
+
const queued = app.agent.queue.length;
|
|
292
|
+
let hint = "ctrl+p comandos · /help";
|
|
293
|
+
if (props.busy) {
|
|
294
|
+
hint = "esc interrumpe · escribe para añadir instrucciones";
|
|
295
|
+
}
|
|
296
|
+
return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: agent?.color || "magenta", children: props.agentName }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", current || "rotación automática", slot && slot.score !== null ? ` ★${slot.score}` : "", app.router.pinned ? " (fijado)" : ""] }), _jsx(ContextGauge, { used: app.agent.lastTokens, total: context }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", available, "/", app.router.slots.length, " modelos", spent > 0 ? ` · ${shortTokens(spent)} tokens` : "", queued > 0 ? ` · ${queued} en cola` : ""] }), app.yolo ? _jsx(Text, { color: "red", children: " \u00B7 yolo" }) : null] }), _jsx(Text, { dimColor: true, wrap: "truncate-start", children: hint })] }));
|
|
297
|
+
}
|
|
298
|
+
function Main(props) {
|
|
299
|
+
const { app } = props;
|
|
300
|
+
const { exit } = useApp();
|
|
301
|
+
const { columns, rows } = useWindowSize();
|
|
302
|
+
const [items, setItems] = useState([{ id: 0, kind: "banner" }]);
|
|
303
|
+
const nextId = useRef(1);
|
|
304
|
+
const [editor, setEditor] = useState(E.EMPTY);
|
|
305
|
+
const [busy, setBusy] = useState(false);
|
|
306
|
+
const [dialog, setDialog] = useState(null);
|
|
307
|
+
const [todos, setTodos] = useState(app.agent.session.todos);
|
|
308
|
+
const [attachments, setAttachments] = useState([]);
|
|
309
|
+
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
|
310
|
+
const [suggestionsClosed, setSuggestionsClosed] = useState(false);
|
|
311
|
+
const [agentName, setAgentName] = useState(app.agent.agent.name);
|
|
312
|
+
const [, setVersion] = useState(0);
|
|
313
|
+
const live = useRef({ text: "", reasoning: "", running: new Map(), model: "", shell: null });
|
|
314
|
+
const dirty = useRef(false);
|
|
315
|
+
const controller = useRef(null);
|
|
316
|
+
const startedAt = useRef(Date.now());
|
|
317
|
+
// Para el pie de cada respuesta: modelo que contestó y tokens al empezar
|
|
318
|
+
const answered = useRef("");
|
|
319
|
+
const tokensAtStart = useRef(0);
|
|
320
|
+
const history = useRef(loadHistory());
|
|
321
|
+
const historyIndex = useRef(null);
|
|
322
|
+
const draft = useRef("");
|
|
323
|
+
const lastCtrlC = useRef(0);
|
|
324
|
+
const dialogRef = useRef(null);
|
|
325
|
+
dialogRef.current = dialog;
|
|
326
|
+
const refresh = () => setVersion((v) => v + 1);
|
|
327
|
+
const push = (item) => {
|
|
328
|
+
const id = nextId.current++;
|
|
329
|
+
setItems((previous) => [...previous, { ...item, id }]);
|
|
330
|
+
};
|
|
331
|
+
const agentColor = () => app.agents.find((item) => item.name === app.agent.agent.name)?.color || "magenta";
|
|
332
|
+
// Pasa el texto en directo al historial fijo de arriba
|
|
333
|
+
const flushText = () => {
|
|
334
|
+
const text = live.current.text;
|
|
335
|
+
if (text.trim() !== "") {
|
|
336
|
+
push({ kind: "assistant", text, color: agentColor() });
|
|
337
|
+
}
|
|
338
|
+
live.current.text = "";
|
|
339
|
+
live.current.reasoning = "";
|
|
340
|
+
dirty.current = true;
|
|
341
|
+
};
|
|
342
|
+
const adapter = useMemo(() => ({
|
|
343
|
+
info: (text) => push({ kind: "info", text }),
|
|
344
|
+
notice: (text) => push({ kind: "notice", text }),
|
|
345
|
+
error: (text) => push({ kind: "error", text }),
|
|
346
|
+
block: (text) => push({ kind: "block", text }),
|
|
347
|
+
select: (title, list) => new Promise((resolve) => {
|
|
348
|
+
setDialog({
|
|
349
|
+
kind: "select",
|
|
350
|
+
title,
|
|
351
|
+
items: list,
|
|
352
|
+
resolve: (value) => {
|
|
353
|
+
setDialog(null);
|
|
354
|
+
resolve(value);
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
}),
|
|
358
|
+
prompt: (title, options) => new Promise((resolve) => {
|
|
359
|
+
setDialog({
|
|
360
|
+
kind: "prompt",
|
|
361
|
+
title,
|
|
362
|
+
mask: options?.mask,
|
|
363
|
+
placeholder: options?.placeholder,
|
|
364
|
+
resolve: (value) => {
|
|
365
|
+
setDialog(null);
|
|
366
|
+
resolve(value);
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}),
|
|
370
|
+
setInput: (text) => setEditor({ value: text, cursor: text.length }),
|
|
371
|
+
}), []);
|
|
372
|
+
// Si se interrumpe con un permiso pendiente, se contesta que no para que el agente no se quede esperando
|
|
373
|
+
const cancelDialog = () => {
|
|
374
|
+
const current = dialogRef.current;
|
|
375
|
+
if (!current) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (current.kind === "permission") {
|
|
379
|
+
current.resolve({ reply: { allow: false } });
|
|
380
|
+
}
|
|
381
|
+
else if (current.kind === "select") {
|
|
382
|
+
current.resolve(null);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
current.resolve(null);
|
|
386
|
+
}
|
|
387
|
+
setDialog(null);
|
|
388
|
+
};
|
|
389
|
+
const quit = async () => {
|
|
390
|
+
controller.current?.abort();
|
|
391
|
+
cancelDialog();
|
|
392
|
+
await app.shutdown();
|
|
393
|
+
exit();
|
|
394
|
+
setTimeout(() => process.exit(0), 100);
|
|
395
|
+
};
|
|
396
|
+
const sessionTokens = () => app.agent.session.usage.prompt + app.agent.session.usage.completion;
|
|
397
|
+
const runAgent = async (input, options) => {
|
|
398
|
+
setBusy(true);
|
|
399
|
+
startedAt.current = Date.now();
|
|
400
|
+
answered.current = "";
|
|
401
|
+
tokensAtStart.current = sessionTokens();
|
|
402
|
+
live.current.model = "";
|
|
403
|
+
const abort = new AbortController();
|
|
404
|
+
controller.current = abort;
|
|
405
|
+
try {
|
|
406
|
+
await app.runPrompt(input, options, abort.signal);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
if (abort.signal.aborted) {
|
|
410
|
+
push({ kind: "info", text: "(interrumpido)" });
|
|
411
|
+
}
|
|
412
|
+
else if (err instanceof NoSlotError) {
|
|
413
|
+
push({ kind: "error", text: err.message });
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
push({ kind: "error", text: err.message || String(err) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
flushText();
|
|
421
|
+
if (answered.current) {
|
|
422
|
+
push({
|
|
423
|
+
kind: "answer",
|
|
424
|
+
slot: answered.current,
|
|
425
|
+
score: app.router.slotFor(answered.current)?.score ?? null,
|
|
426
|
+
ms: Date.now() - startedAt.current,
|
|
427
|
+
tokens: sessionTokens() - tokensAtStart.current,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
live.current.running.clear();
|
|
431
|
+
live.current.model = "";
|
|
432
|
+
controller.current = null;
|
|
433
|
+
setBusy(false);
|
|
434
|
+
setAgentName(app.agent.agent.name);
|
|
435
|
+
setTodos([...app.agent.session.todos]);
|
|
436
|
+
refresh();
|
|
437
|
+
// Lo que se escribió al final y no llegó a entregarse se lanza ahora
|
|
438
|
+
const next = app.agent.queue.shift();
|
|
439
|
+
if (next) {
|
|
440
|
+
void runAgent(next, {});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
// !comando: se ejecuta en la carpeta del proyecto y la salida le llega al agente con el siguiente mensaje
|
|
445
|
+
const runShell = async (command) => {
|
|
446
|
+
setBusy(true);
|
|
447
|
+
startedAt.current = Date.now();
|
|
448
|
+
const abort = new AbortController();
|
|
449
|
+
controller.current = abort;
|
|
450
|
+
live.current.shell = { command, output: "" };
|
|
451
|
+
try {
|
|
452
|
+
const result = await app.runShell(command, (text) => {
|
|
453
|
+
if (live.current.shell) {
|
|
454
|
+
live.current.shell.output = (live.current.shell.output + text).slice(-4000);
|
|
455
|
+
dirty.current = true;
|
|
456
|
+
}
|
|
457
|
+
}, abort.signal);
|
|
458
|
+
push({ kind: "shell", command, output: result.output, code: result.code });
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
live.current.shell = null;
|
|
462
|
+
controller.current = null;
|
|
463
|
+
setBusy(false);
|
|
464
|
+
refresh();
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const submit = async (raw) => {
|
|
468
|
+
const text = raw.trim();
|
|
469
|
+
if (text === "" && attachments.length === 0) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
setEditor(E.EMPTY);
|
|
473
|
+
historyIndex.current = null;
|
|
474
|
+
if (text !== "") {
|
|
475
|
+
appendHistory(text);
|
|
476
|
+
history.current.push(text);
|
|
477
|
+
}
|
|
478
|
+
if (text.startsWith("!")) {
|
|
479
|
+
const command = text.slice(1).trim();
|
|
480
|
+
if (!command) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (busy) {
|
|
484
|
+
push({ kind: "error", text: "Espera a que termine (o pulsa Esc) para ejecutar un comando." });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
void runShell(command);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const images = attachments;
|
|
491
|
+
setAttachments([]);
|
|
492
|
+
if (text.startsWith("/")) {
|
|
493
|
+
const command = text.split(/\s+/)[0];
|
|
494
|
+
if (busy && !SAFE_WHILE_BUSY.includes(command)) {
|
|
495
|
+
push({ kind: "error", text: "Espera a que termine (o pulsa Esc) para usar ese comando." });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
push({ kind: "info", text: `❯ ${text}` });
|
|
499
|
+
try {
|
|
500
|
+
const result = await app.handleCommand(text, adapter);
|
|
501
|
+
setAgentName(app.agent.agent.name);
|
|
502
|
+
setTodos([...app.agent.session.todos]);
|
|
503
|
+
refresh();
|
|
504
|
+
if (result.exit) {
|
|
505
|
+
await quit();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (result.run) {
|
|
509
|
+
void runAgent({ ...result.run.input, images }, result.run.options);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
push({ kind: "error", text: err.message });
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (busy) {
|
|
518
|
+
app.agent.queue.push({ text, images });
|
|
519
|
+
push({ kind: "user", text, images: images.length, queued: true, color: agentColor() });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
push({ kind: "user", text, images: images.length, color: agentColor() });
|
|
523
|
+
void runAgent({ text, images }, {});
|
|
524
|
+
};
|
|
525
|
+
// La primera vez que se van a usar proveedores sin clave, explicamos qué pasa con lo que envías
|
|
526
|
+
const askPrivacy = async () => {
|
|
527
|
+
const choice = await adapter.select(`Antes de empezar: ${app.privacyNotice()}`, [
|
|
528
|
+
{ label: "Entendido, empezar", value: "ok" },
|
|
529
|
+
{ label: "Añadir antes un proveedor con clave", description: "como /connect", value: "connect" },
|
|
530
|
+
{ label: "No usar nunca los que no piden clave", description: 'guarda "keyless": false en tu configuración', value: "off" },
|
|
531
|
+
]);
|
|
532
|
+
if (!choice) {
|
|
533
|
+
// Con Esc se vuelve a preguntar la próxima vez
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
app.acceptPrivacyNotice();
|
|
537
|
+
if (choice === "connect") {
|
|
538
|
+
await app.connect(adapter, "");
|
|
539
|
+
}
|
|
540
|
+
else if (choice === "off") {
|
|
541
|
+
app.disableKeyless();
|
|
542
|
+
push({ kind: "info", text: "No usaré los proveedores sin clave (lo puedes cambiar con \"keyless\" en la configuración)." });
|
|
543
|
+
if (app.router.slots.length === 0) {
|
|
544
|
+
await app.connect(adapter, "");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
refresh();
|
|
548
|
+
};
|
|
549
|
+
// Paleta de comandos (Ctrl+P): todos los comandos, skills y agentes con su descripción
|
|
550
|
+
const openPalette = async () => {
|
|
551
|
+
const commands = app.commandSuggestions();
|
|
552
|
+
const choice = await adapter.select("Comandos y skills", commands.map((command) => ({ label: `/${command.name}`, description: command.description, value: command })));
|
|
553
|
+
if (!choice) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (choice.needsArgs) {
|
|
557
|
+
const value = `/${choice.name} `;
|
|
558
|
+
setEditor({ value, cursor: value.length });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
void submit(`/${choice.name}`);
|
|
562
|
+
};
|
|
563
|
+
// Conectamos el agente con la pantalla
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
app.agent.events = {
|
|
566
|
+
onText: (chunk, depth) => {
|
|
567
|
+
if (depth > 0) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
live.current.text += chunk;
|
|
571
|
+
live.current.reasoning = "";
|
|
572
|
+
dirty.current = true;
|
|
573
|
+
},
|
|
574
|
+
onReasoning: (chunk, depth) => {
|
|
575
|
+
if (depth > 0 || !app.config.showReasoning) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
live.current.reasoning = (live.current.reasoning + chunk).slice(-600);
|
|
579
|
+
dirty.current = true;
|
|
580
|
+
},
|
|
581
|
+
onTextEnd: (depth) => {
|
|
582
|
+
if (depth === 0) {
|
|
583
|
+
flushText();
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
onToolStart: (event) => {
|
|
587
|
+
flushText();
|
|
588
|
+
live.current.running.set(`${event.depth}:${event.id}`, { event, progress: "", since: Date.now() });
|
|
589
|
+
dirty.current = true;
|
|
590
|
+
},
|
|
591
|
+
onToolProgress: (event, text) => {
|
|
592
|
+
const entry = live.current.running.get(`${event.depth}:${event.id}`);
|
|
593
|
+
if (entry) {
|
|
594
|
+
entry.progress = (entry.progress + text).slice(-2000);
|
|
595
|
+
dirty.current = true;
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
onToolEnd: (event) => {
|
|
599
|
+
const key = `${event.depth}:${event.id}`;
|
|
600
|
+
const since = live.current.running.get(key)?.since || Date.now();
|
|
601
|
+
live.current.running.delete(key);
|
|
602
|
+
push({ kind: "tool", event, ms: Date.now() - since });
|
|
603
|
+
dirty.current = true;
|
|
604
|
+
},
|
|
605
|
+
onModel: (slot, depth) => {
|
|
606
|
+
if (depth === 0) {
|
|
607
|
+
live.current.model = slot;
|
|
608
|
+
dirty.current = true;
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
onAnswer: (slot) => {
|
|
612
|
+
answered.current = slot;
|
|
613
|
+
dirty.current = true;
|
|
614
|
+
},
|
|
615
|
+
onNotice: (message) => {
|
|
616
|
+
flushText();
|
|
617
|
+
const text = message.trim();
|
|
618
|
+
// El router avisa cuando cambia de modelo: no es un problema, solo información
|
|
619
|
+
if (text.startsWith("usando ")) {
|
|
620
|
+
push({ kind: "info", text: `↻ sigue con ${text.slice("usando ".length)}` });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
push({ kind: "notice", text });
|
|
624
|
+
},
|
|
625
|
+
onInfo: (message) => {
|
|
626
|
+
if (message.trim().startsWith("↳")) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
push({ kind: "info", text: message });
|
|
630
|
+
},
|
|
631
|
+
onTodos: (list) => setTodos([...list]),
|
|
632
|
+
onStep: () => refresh(),
|
|
633
|
+
};
|
|
634
|
+
app.promptPermission = (request, agent) => new Promise((resolve) => {
|
|
635
|
+
setDialog({
|
|
636
|
+
kind: "permission",
|
|
637
|
+
request,
|
|
638
|
+
agent: agent.name,
|
|
639
|
+
resolve: (choice) => {
|
|
640
|
+
if (choice.grant === "kind") {
|
|
641
|
+
app.grants.allowKind(request.kind);
|
|
642
|
+
}
|
|
643
|
+
else if (choice.grant === "pattern" && choice.pattern) {
|
|
644
|
+
app.grants.allowPattern("bash", choice.pattern);
|
|
645
|
+
}
|
|
646
|
+
setDialog(null);
|
|
647
|
+
resolve(choice.reply);
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
const timer = setInterval(() => {
|
|
652
|
+
if (dirty.current) {
|
|
653
|
+
dirty.current = false;
|
|
654
|
+
refresh();
|
|
655
|
+
}
|
|
656
|
+
}, 60);
|
|
657
|
+
// Tareas de arranque en segundo plano
|
|
658
|
+
let starting = Promise.resolve();
|
|
659
|
+
for (const notice of configNotices) {
|
|
660
|
+
push({ kind: "notice", text: notice });
|
|
661
|
+
}
|
|
662
|
+
const session = app.agent.session;
|
|
663
|
+
if (session.messages.length > 0) {
|
|
664
|
+
push({ kind: "info", text: `Continúas «${session.title}» (${session.turns.length} mensajes tuyos). /new empieza otra.` });
|
|
665
|
+
}
|
|
666
|
+
if (app.router.slots.length === 0) {
|
|
667
|
+
push({
|
|
668
|
+
kind: "notice",
|
|
669
|
+
text: "Todavía no tienes ningún modelo. Elige un proveedor gratuito y pega su clave (puedes repetirlo con /connect).",
|
|
670
|
+
});
|
|
671
|
+
void app.connect(adapter, "").then(refresh);
|
|
672
|
+
}
|
|
673
|
+
else if (app.needsPrivacyNotice()) {
|
|
674
|
+
starting = askPrivacy();
|
|
675
|
+
}
|
|
676
|
+
else if (app.onlyAnonymous()) {
|
|
677
|
+
push({ kind: "info", text: app.anonymousNotice() });
|
|
678
|
+
}
|
|
679
|
+
void app.startMcp(refresh).then(() => {
|
|
680
|
+
for (const server of app.mcp?.status() || []) {
|
|
681
|
+
if (server.state === "error") {
|
|
682
|
+
push({ kind: "notice", text: `MCP ${server.name}: ${server.error}` });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
refresh();
|
|
686
|
+
});
|
|
687
|
+
// Una vez al día mira en GitHub si hay una versión nueva de vipcode
|
|
688
|
+
if (app.config.checkUpdates !== false) {
|
|
689
|
+
void checkForUpdate().then((version) => {
|
|
690
|
+
if (version) {
|
|
691
|
+
push({ kind: "notice", text: `Hay una versión nueva de vipcode (${version}, tienes la ${VERSION}). Escribe /update para instalarla.` });
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
if (app.config.autoUpdateModels && discoveryIsStale()) {
|
|
696
|
+
void app
|
|
697
|
+
.refreshModels()
|
|
698
|
+
.then((summary) => {
|
|
699
|
+
const added = summary.updated.reduce((total, entry) => total + entry.added.length, 0);
|
|
700
|
+
if (added > 0) {
|
|
701
|
+
push({ kind: "info", text: `He encontrado ${added} modelos gratis nuevos (/models para verlos).` });
|
|
702
|
+
}
|
|
703
|
+
refresh();
|
|
704
|
+
})
|
|
705
|
+
.catch(() => { });
|
|
706
|
+
}
|
|
707
|
+
void app.snapshots.cleanup().catch(() => { });
|
|
708
|
+
// El mensaje con el que se abrió (vipcode "tarea") espera a que contestes el aviso de privacidad
|
|
709
|
+
void starting.then(() => {
|
|
710
|
+
if (props.initial.trim() !== "") {
|
|
711
|
+
void submit(props.initial);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
return () => clearInterval(timer);
|
|
715
|
+
}, []);
|
|
716
|
+
const suggestions = useMemo(() => {
|
|
717
|
+
if (dialog || suggestionsClosed) {
|
|
718
|
+
return [];
|
|
719
|
+
}
|
|
720
|
+
const before = editor.value.slice(0, editor.cursor);
|
|
721
|
+
if (editor.value.startsWith("/") && !before.includes(" ") && !editor.value.includes("\n")) {
|
|
722
|
+
const typed = before.slice(1).toLowerCase();
|
|
723
|
+
return app
|
|
724
|
+
.commandSuggestions()
|
|
725
|
+
.filter((command) => command.name.startsWith(typed))
|
|
726
|
+
.slice(0, 8)
|
|
727
|
+
.map((command) => ({
|
|
728
|
+
label: `/${command.name}`,
|
|
729
|
+
description: command.description,
|
|
730
|
+
apply: () => {
|
|
731
|
+
const value = `/${command.name} `;
|
|
732
|
+
return { value, cursor: value.length };
|
|
733
|
+
},
|
|
734
|
+
submit: !command.needsArgs,
|
|
735
|
+
}));
|
|
736
|
+
}
|
|
737
|
+
const mention = E.mentionAtCursor(editor);
|
|
738
|
+
if (mention) {
|
|
739
|
+
return suggestFiles(app.root, mention.text).map((file) => ({
|
|
740
|
+
label: `@${file}`,
|
|
741
|
+
apply: (state) => E.replaceRange(state, mention.start, state.cursor, `@${file}${file.endsWith("/") ? "" : " "}`),
|
|
742
|
+
submit: false,
|
|
743
|
+
}));
|
|
744
|
+
}
|
|
745
|
+
return [];
|
|
746
|
+
}, [editor, dialog, suggestionsClosed]);
|
|
747
|
+
const suggestionsVisible = suggestions.length > 0;
|
|
748
|
+
const selected = Math.min(selectedSuggestion, Math.max(0, suggestions.length - 1));
|
|
749
|
+
const update = (state) => {
|
|
750
|
+
setEditor(state);
|
|
751
|
+
setSuggestionsClosed(false);
|
|
752
|
+
setSelectedSuggestion(0);
|
|
753
|
+
};
|
|
754
|
+
const accept = (andSubmit) => {
|
|
755
|
+
const suggestion = suggestions[selected];
|
|
756
|
+
if (!suggestion) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const next = suggestion.apply(editor);
|
|
760
|
+
if (andSubmit && suggestion.submit) {
|
|
761
|
+
void submit(next.value);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
update(next);
|
|
765
|
+
if (!suggestion.label.endsWith("/")) {
|
|
766
|
+
setSuggestionsClosed(true);
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
const attachImage = (dataUrl, label) => {
|
|
770
|
+
setAttachments((previous) => [...previous, dataUrl]);
|
|
771
|
+
push({ kind: "info", text: `Imagen adjuntada: ${label}` });
|
|
772
|
+
};
|
|
773
|
+
useInput((input, key) => {
|
|
774
|
+
// Ctrl+C funciona siempre: interrumpe, cierra el diálogo, borra lo escrito o sale
|
|
775
|
+
if (key.ctrl && input === "c") {
|
|
776
|
+
if (busy && controller.current) {
|
|
777
|
+
controller.current.abort();
|
|
778
|
+
cancelDialog();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (dialogRef.current) {
|
|
782
|
+
cancelDialog();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (editor.value !== "") {
|
|
786
|
+
update(E.EMPTY);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (Date.now() - lastCtrlC.current < 1500) {
|
|
790
|
+
void quit();
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
lastCtrlC.current = Date.now();
|
|
794
|
+
push({ kind: "info", text: "(pulsa Ctrl+C otra vez para salir)" });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (dialogRef.current) {
|
|
798
|
+
// Los diálogos tienen su propio teclado; Esc en el de permisos ya contesta que no
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (key.escape) {
|
|
802
|
+
if (suggestionsVisible) {
|
|
803
|
+
setSuggestionsClosed(true);
|
|
804
|
+
}
|
|
805
|
+
else if (busy && controller.current) {
|
|
806
|
+
controller.current.abort();
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
// Algunas terminales mandan \n en vez de \r al pulsar Enter
|
|
811
|
+
if (key.return || input === "\n") {
|
|
812
|
+
if (key.meta || key.shift) {
|
|
813
|
+
update(E.insert(editor, "\n"));
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (suggestionsVisible) {
|
|
817
|
+
accept(true);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (editor.value.endsWith("\\") && editor.cursor === editor.value.length) {
|
|
821
|
+
update({ value: `${editor.value.slice(0, -1)}\n`, cursor: editor.value.length });
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
void submit(editor.value);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (key.tab) {
|
|
828
|
+
if (suggestionsVisible) {
|
|
829
|
+
accept(false);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (!busy) {
|
|
833
|
+
const next = app.cycleAgent(key.shift ? -1 : 1);
|
|
834
|
+
setAgentName(next.name);
|
|
835
|
+
}
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (key.upArrow) {
|
|
839
|
+
if (suggestionsVisible) {
|
|
840
|
+
setSelectedSuggestion(Math.max(0, selected - 1));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const moved = E.moveVertical(editor, -1);
|
|
844
|
+
if (moved) {
|
|
845
|
+
setEditor(moved);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const list = history.current;
|
|
849
|
+
if (list.length === 0) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (historyIndex.current === null) {
|
|
853
|
+
draft.current = editor.value;
|
|
854
|
+
historyIndex.current = list.length;
|
|
855
|
+
}
|
|
856
|
+
if (historyIndex.current > 0) {
|
|
857
|
+
historyIndex.current--;
|
|
858
|
+
const value = list[historyIndex.current];
|
|
859
|
+
setEditor({ value, cursor: value.length });
|
|
860
|
+
setSuggestionsClosed(true);
|
|
861
|
+
}
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (key.downArrow) {
|
|
865
|
+
if (suggestionsVisible) {
|
|
866
|
+
setSelectedSuggestion(Math.min(suggestions.length - 1, selected + 1));
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const moved = E.moveVertical(editor, 1);
|
|
870
|
+
if (moved) {
|
|
871
|
+
setEditor(moved);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (historyIndex.current === null) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
historyIndex.current++;
|
|
878
|
+
if (historyIndex.current >= history.current.length) {
|
|
879
|
+
historyIndex.current = null;
|
|
880
|
+
setEditor({ value: draft.current, cursor: draft.current.length });
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
const value = history.current[historyIndex.current];
|
|
884
|
+
setEditor({ value, cursor: value.length });
|
|
885
|
+
}
|
|
886
|
+
setSuggestionsClosed(true);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (key.leftArrow) {
|
|
890
|
+
setEditor(E.moveLeft(editor, key.meta || key.ctrl));
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
if (key.rightArrow) {
|
|
894
|
+
setEditor(E.moveRight(editor, key.meta || key.ctrl));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
if (key.home || (key.ctrl && input === "a")) {
|
|
898
|
+
setEditor(E.lineStart(editor));
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (key.end || (key.ctrl && input === "e")) {
|
|
902
|
+
setEditor(E.lineEnd(editor));
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
if (key.backspace) {
|
|
906
|
+
update(key.meta ? E.deleteWordBack(editor) : E.backspace(editor));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (key.delete) {
|
|
910
|
+
update(E.deleteForward(editor));
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (key.ctrl) {
|
|
914
|
+
if (input === "u") {
|
|
915
|
+
update(E.deleteToLineStart(editor));
|
|
916
|
+
}
|
|
917
|
+
else if (input === "k") {
|
|
918
|
+
update(E.deleteToLineEnd(editor));
|
|
919
|
+
}
|
|
920
|
+
else if (input === "w") {
|
|
921
|
+
update(E.deleteWordBack(editor));
|
|
922
|
+
}
|
|
923
|
+
else if (input === "j") {
|
|
924
|
+
update(E.insert(editor, "\n"));
|
|
925
|
+
}
|
|
926
|
+
else if (input === "p") {
|
|
927
|
+
void openPalette();
|
|
928
|
+
}
|
|
929
|
+
else if (input === "d") {
|
|
930
|
+
if (editor.value === "") {
|
|
931
|
+
void quit();
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
update(E.deleteForward(editor));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
else if (input === "v") {
|
|
938
|
+
void readClipboardImage().then((image) => {
|
|
939
|
+
if (image) {
|
|
940
|
+
attachImage(image, "portapapeles");
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
push({ kind: "info", text: "No hay ninguna imagen en el portapapeles (para texto usa Cmd+V)." });
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (key.meta) {
|
|
950
|
+
if (input === "b") {
|
|
951
|
+
setEditor(E.moveLeft(editor, true));
|
|
952
|
+
}
|
|
953
|
+
else if (input === "f") {
|
|
954
|
+
setEditor(E.moveRight(editor, true));
|
|
955
|
+
}
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (input) {
|
|
959
|
+
// Algunas terminales mandan "texto + Enter" de golpe: el Enter final envía, no es un salto de línea
|
|
960
|
+
if (input.length > 1 && /[\r\n]$/.test(input) && !/[\r\n]/.test(input.slice(0, -1))) {
|
|
961
|
+
void submit(E.insert(editor, input.slice(0, -1)).value);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
update(E.insert(editor, input.replace(/\r\n?/g, "\n")));
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
usePaste((text) => {
|
|
968
|
+
// Arrastrar una imagen a la terminal pega su ruta: la adjuntamos como imagen
|
|
969
|
+
const candidate = text.trim().replace(/^['"]|['"]$/g, "").replace(/\\ /g, " ");
|
|
970
|
+
if (isImagePath(candidate)) {
|
|
971
|
+
const file = path.resolve(app.root, candidate);
|
|
972
|
+
if (fs.existsSync(file)) {
|
|
973
|
+
try {
|
|
974
|
+
attachImage(toDataUrl(file), path.basename(file));
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
push({ kind: "error", text: err.message });
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
update(E.insert(editor, text.replace(/\r\n?/g, "\n")));
|
|
983
|
+
}, { isActive: !dialog });
|
|
984
|
+
const agent = app.agents.find((item) => item.name === agentName);
|
|
985
|
+
const color = agent?.color || "magenta";
|
|
986
|
+
// La zona en directo no puede pasar del alto de la pantalla (Ink no sabe borrar lo que se ha ido hacia arriba)
|
|
987
|
+
const liveRoom = Math.max(4, rows - 14);
|
|
988
|
+
const liveText = live.current.text ? lastLines(renderMarkdown(live.current.text, Math.max(20, columns - 4)), liveRoom) : "";
|
|
989
|
+
const running = [...live.current.running.values()];
|
|
990
|
+
const shell = live.current.shell;
|
|
991
|
+
let dialogView = null;
|
|
992
|
+
if (dialog?.kind === "permission") {
|
|
993
|
+
const lines = dialog.request.preview.split("\n");
|
|
994
|
+
const room = Math.max(6, rows - 16);
|
|
995
|
+
let request = dialog.request;
|
|
996
|
+
if (lines.length > room) {
|
|
997
|
+
request = { ...request, preview: `${lines.slice(0, room).join("\n")}\n… ${lines.length - room} líneas más` };
|
|
998
|
+
}
|
|
999
|
+
dialogView = _jsx(PermissionDialog, { request: request, agent: dialog.agent, onDone: dialog.resolve }, "permission");
|
|
1000
|
+
}
|
|
1001
|
+
else if (dialog?.kind === "select") {
|
|
1002
|
+
dialogView = _jsx(SelectDialog, { title: dialog.title, items: dialog.items, onDone: dialog.resolve }, "select");
|
|
1003
|
+
}
|
|
1004
|
+
else if (dialog?.kind === "prompt") {
|
|
1005
|
+
dialogView = _jsx(PromptDialog, { title: dialog.title, mask: dialog.mask, placeholder: dialog.placeholder, onDone: dialog.resolve }, "prompt");
|
|
1006
|
+
}
|
|
1007
|
+
let placeholder = "Escribe tu mensaje… (@ ficheros · / comandos · ! terminal · Ctrl+P paleta)";
|
|
1008
|
+
if (busy) {
|
|
1009
|
+
placeholder = "Escribe para añadir instrucciones mientras trabaja…";
|
|
1010
|
+
}
|
|
1011
|
+
return (_jsxs(_Fragment, { children: [_jsx(Static, { items: items, children: (item) => _jsx(ItemView, { item: item, app: app, width: columns }, item.id) }), _jsxs(Box, { flexDirection: "column", children: [live.current.reasoning ? (_jsxs(Text, { dimColor: true, italic: true, wrap: "truncate-end", children: ["\uD83D\uDCAD ", lastLines(stripControl(live.current.reasoning).replace(/\s+/g, " "), 1).slice(-Math.max(20, columns - 6))] })) : null, liveText ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: color, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsxs(Text, { children: [liveText, _jsx(Text, { color: color, children: "\u258D" })] }) })] })) : null, running.map(({ event, progress, since }) => (_jsxs(Box, { flexDirection: "column", marginLeft: event.depth > 0 ? 4 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Spinner, {}), " ", _jsx(ToolHeader, { name: event.name, summary: event.summary }), " ", _jsx(Elapsed, { since: since, hideUnderMs: 2000 })] }), progress ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", lastLines(stripControl(progress), 1).slice(0, Math.max(20, columns - 8))] })) : null] }, `${event.depth}:${event.id}`))), shell ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Spinner, { color: "yellow" }), " ", _jsx(Text, { bold: true, children: showControl(shell.command) }), " ", _jsx(Elapsed, { since: startedAt.current, hideUnderMs: 1000 })] }), shell.output ? _jsx(Text, { dimColor: true, children: lastLines(stripControl(shell.output), Math.min(8, liveRoom)).replace(/^/gm, " ") }) : null] })) : null, _jsx(TodoPanel, { todos: todos }), busy && !shell && running.length === 0 && !liveText && !answered.current ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Spinner, {}), " ", _jsxs(Text, { dimColor: true, children: ["Pensando", live.current.model ? ` con ${live.current.model}` : "", "\u2026 "] }), _jsx(Elapsed, { since: startedAt.current, hideUnderMs: 1000 })] }) })) : null, dialogView || (_jsx(InputBox, { editor: editor, color: color, placeholder: placeholder, attachments: attachments.length, maxLines: Math.max(3, Math.floor(rows / 2) - 4), width: columns })), suggestionsVisible && !dialog ? (_jsx(Box, { flexDirection: "column", paddingX: 2, children: suggestions.map((suggestion, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, wrap: "truncate-end", children: [index === selected ? "❯ " : " ", suggestion.label, suggestion.description ? _jsxs(Text, { dimColor: true, children: [" ", suggestion.description] }) : null] }, `${index}-${suggestion.label}`))) })) : null, _jsx(StatusBar, { app: app, agentName: agentName, busy: busy, model: busy ? live.current.model : "" })] })] }));
|
|
1012
|
+
}
|
|
1013
|
+
export async function startTui(app, options = {}) {
|
|
1014
|
+
const instance = render(_jsx(Main, { app: app, initial: options.initial || "" }), {
|
|
1015
|
+
exitOnCtrlC: false,
|
|
1016
|
+
kittyKeyboard: { mode: "auto" },
|
|
1017
|
+
});
|
|
1018
|
+
await instance.waitUntilExit();
|
|
1019
|
+
}
|