vipcode 0.2.0 → 0.2.2

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/dist/trust.js ADDED
@@ -0,0 +1,171 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { parseFrontmatter } from "./frontmatter.js";
5
+ import { TRUST_PATH, ensureHomeDir } from "./paths.js";
6
+ // Lo que un proyecto puede traer y que cambia qué ejecuta vipcode o a dónde manda tus claves: servidores MCP y
7
+ // de lenguaje, proveedores, permisos, agentes con permisos propios y comandos que lanzan órdenes. No se usa hasta
8
+ // que confías en la carpeta, y si cambia (por ejemplo tras un git pull) se vuelve a preguntar.
9
+ const CONFIG_FILES = ["vipcode.json", path.join(".vipcode", "config.json")];
10
+ const AGENT_DIRS = [path.join(".vipcode", "agents"), path.join(".opencode", "agent"), path.join(".opencode", "agents")];
11
+ const COMMAND_DIRS = [path.join(".vipcode", "commands"), path.join(".opencode", "command"), path.join(".opencode", "commands")];
12
+ function isObject(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ function short(value) {
16
+ const text = typeof value === "string" ? value : JSON.stringify(value);
17
+ return text.length > 100 ? `${text.slice(0, 99)}…` : text;
18
+ }
19
+ function readText(file) {
20
+ try {
21
+ return fs.statSync(file).isFile() ? fs.readFileSync(file, "utf8") : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function markdownFiles(root, dirs) {
28
+ const files = [];
29
+ for (const dir of dirs) {
30
+ try {
31
+ for (const name of fs.readdirSync(path.join(root, dir)).sort()) {
32
+ if (name.endsWith(".md")) {
33
+ files.push(path.join(dir, name));
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ // la carpeta no existe
39
+ }
40
+ }
41
+ return files;
42
+ }
43
+ function describeConfig(data) {
44
+ const changes = [];
45
+ const keys = Object.keys(data).filter((key) => !key.startsWith("$"));
46
+ const rest = [];
47
+ for (const key of keys) {
48
+ const value = data[key];
49
+ if (key === "providers" && Array.isArray(value)) {
50
+ const names = value.map((item) => (isObject(item) ? String(item.name || "?") : "?"));
51
+ changes.push(`proveedores: ${names.join(", ")} (pueden cambiar a dónde se mandan tus claves)`);
52
+ }
53
+ else if (key === "mcp" && isObject(value)) {
54
+ const servers = Object.entries(value).map(([name, server]) => {
55
+ const detail = isObject(server) ? server.command || server.url : server;
56
+ return `${name} (${short(detail)})`;
57
+ });
58
+ changes.push(`servidores MCP que se arrancan solos: ${servers.join(", ")}`);
59
+ }
60
+ else if (key === "lsp" && isObject(value)) {
61
+ changes.push(`servidores de lenguaje: ${short(value)}`);
62
+ }
63
+ else if (key === "permission") {
64
+ changes.push(`permisos: ${short(value)}`);
65
+ }
66
+ else if (key === "agents" && isObject(value)) {
67
+ changes.push(`agentes: ${Object.keys(value).join(", ")}`);
68
+ }
69
+ else if (key === "commands" && isObject(value)) {
70
+ changes.push(`comandos: ${Object.keys(value).join(", ")}`);
71
+ }
72
+ else {
73
+ rest.push(key);
74
+ }
75
+ }
76
+ if (rest.length > 0) {
77
+ changes.push(`otros ajustes: ${rest.join(", ")}`);
78
+ }
79
+ return changes;
80
+ }
81
+ function readStore() {
82
+ try {
83
+ const data = JSON.parse(fs.readFileSync(TRUST_PATH, "utf8"));
84
+ return isObject(data) ? data : {};
85
+ }
86
+ catch {
87
+ return {};
88
+ }
89
+ }
90
+ function key(root) {
91
+ try {
92
+ return fs.realpathSync(root);
93
+ }
94
+ catch {
95
+ return path.resolve(root);
96
+ }
97
+ }
98
+ export function projectTrust(root) {
99
+ const files = [];
100
+ const changes = [];
101
+ const parts = [];
102
+ const add = (relative, text, change) => {
103
+ files.push(relative);
104
+ parts.push(`${relative}\u0000${text}`);
105
+ changes.push(...change.map((item) => `${relative}: ${item}`));
106
+ };
107
+ for (const relative of CONFIG_FILES) {
108
+ const text = readText(path.join(root, relative));
109
+ if (text === null) {
110
+ continue;
111
+ }
112
+ let data = {};
113
+ try {
114
+ data = JSON.parse(text);
115
+ }
116
+ catch {
117
+ data = {};
118
+ }
119
+ const described = isObject(data) ? describeConfig(data) : [];
120
+ add(relative, text, described.length > 0 ? described : ["configuración de vipcode"]);
121
+ }
122
+ for (const relative of markdownFiles(root, AGENT_DIRS)) {
123
+ const text = readText(path.join(root, relative)) || "";
124
+ const { data } = parseFrontmatter(text);
125
+ // Un agente que solo trae instrucciones es como un AGENTS.md: no hace falta confiar
126
+ if (data.permission !== undefined || data.readOnly !== undefined) {
127
+ add(relative, text, [`agente con permisos propios: ${short({ permission: data.permission, readOnly: data.readOnly })}`]);
128
+ }
129
+ }
130
+ for (const relative of markdownFiles(root, COMMAND_DIRS)) {
131
+ const text = readText(path.join(root, relative)) || "";
132
+ if (/!`[^`]+`/.test(text)) {
133
+ add(relative, text, ["comando que ejecuta órdenes en la terminal al usarlo"]);
134
+ }
135
+ }
136
+ if (files.length === 0) {
137
+ return { files, changes, fingerprint: null, trusted: true };
138
+ }
139
+ const fingerprint = crypto.createHash("sha256").update(parts.join("\u0000\u0000")).digest("hex");
140
+ return { files, changes, fingerprint, trusted: readStore()[key(root)]?.fingerprint === fingerprint };
141
+ }
142
+ // Confías en la configuración que trae la carpeta tal como está ahora
143
+ export function trustProject(root) {
144
+ const trust = projectTrust(root);
145
+ if (!trust.fingerprint) {
146
+ return trust;
147
+ }
148
+ const store = readStore();
149
+ store[key(root)] = { fingerprint: trust.fingerprint, date: new Date().toISOString() };
150
+ ensureHomeDir();
151
+ fs.writeFileSync(TRUST_PATH, JSON.stringify(store, null, 2), { mode: 0o600 });
152
+ return { ...trust, trusted: true };
153
+ }
154
+ export function untrustProject(root) {
155
+ const store = readStore();
156
+ if (!store[key(root)]) {
157
+ return false;
158
+ }
159
+ delete store[key(root)];
160
+ ensureHomeDir();
161
+ fs.writeFileSync(TRUST_PATH, JSON.stringify(store, null, 2), { mode: 0o600 });
162
+ return true;
163
+ }
164
+ // Ficheros de un agente o comando que están dentro del proyecto (los tuyos de ~/.vipcode no cuentan)
165
+ export function isProjectFile(root, file) {
166
+ if (!file) {
167
+ return false;
168
+ }
169
+ const relative = path.relative(root, file);
170
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
171
+ }
package/dist/tui/app.js CHANGED
@@ -8,35 +8,22 @@ import { useEffect, useMemo, useRef, useState } from "react";
8
8
  import { configNotices } from "../config.js";
9
9
  import { discoveryIsStale } from "../discovery.js";
10
10
  import { isImagePath, readClipboardImage, toDataUrl } from "../images.js";
11
+ import { toolLabel } from "../labels.js";
11
12
  import { renderMarkdown } from "../markdown.js";
12
13
  import { VERSION } from "../meta.js";
13
14
  import { NoSlotError } from "../router.js";
14
- import { appendHistory, loadHistory } from "../session.js";
15
+ import { showControl, stripControl, stripUnsafe } from "../sanitize.js";
16
+ import { appendHistory, loadHistory, sessionMarkdownPath } from "../session.js";
15
17
  import { suggestFiles } from "../tools.js";
16
18
  import { colorPreview } from "../ui.js";
17
19
  import { checkForUpdate } from "../update.js";
18
20
  import { PermissionDialog, PromptDialog, SelectDialog } from "./dialogs.js";
19
21
  import * as E from "./editor.js";
22
+ // Al volver a una sesión se enseñan sus últimos pasos (el .md los tiene todos)
23
+ const REPLAY_MAX = 300;
20
24
  // Comandos que se pueden usar mientras el agente trabaja (solo consultan)
21
25
  const SAFE_WHILE_BUSY = ["/help", "/usage", "/todos", "/mcp", "/lsp", "/providers", "/skills"];
22
26
  const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
23
- // Cómo se cuenta cada herramienta en pantalla
24
- const TOOL_LABEL = {
25
- read_file: "Lee",
26
- write_file: "Escribe",
27
- edit_file: "Edita",
28
- list_dir: "Lista",
29
- glob: "Busca ficheros",
30
- grep: "Busca",
31
- bash: "Ejecuta",
32
- webfetch: "Abre",
33
- websearch: "Busca en internet",
34
- todowrite: "Tareas",
35
- todoread: "Tareas",
36
- diagnostics: "Diagnósticos",
37
- task: "Subagente",
38
- skill: "Skill",
39
- };
40
27
  function Spinner(props) {
41
28
  const { frame } = useAnimation({ interval: 80 });
42
29
  return _jsx(Text, { color: props.color || "cyan", children: SPINNER[frame % SPINNER.length] });
@@ -107,7 +94,8 @@ function diffStats(display) {
107
94
  }
108
95
  // Debajo de cada herramienta, un resumen corto de lo que pasó
109
96
  function toolDetail(event) {
110
- const output = event.output || "";
97
+ // La salida viene de ficheros y comandos: sin secuencias de la terminal
98
+ const output = stripControl(event.output || "");
111
99
  if (event.isError) {
112
100
  return [pc.red(firstLine(output))];
113
101
  }
@@ -162,8 +150,9 @@ function toolDetail(event) {
162
150
  return [pc.dim(firstLine(output))];
163
151
  }
164
152
  }
153
+ // El resumen sale de lo que pidió el modelo (comandos, rutas): los caracteres de control se ven, no actúan
165
154
  function ToolHeader(props) {
166
- return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: TOOL_LABEL[props.name] || props.name }), props.summary ? _jsxs(Text, { children: [" ", props.summary] }) : null] }));
155
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: showControl(toolLabel(props.name)) }), props.summary ? _jsxs(Text, { children: [" ", showControl(props.summary)] }) : null] }));
167
156
  }
168
157
  function ToolView(props) {
169
158
  const { event } = props;
@@ -193,8 +182,8 @@ function ItemView(props) {
193
182
  case "banner":
194
183
  return _jsx(Banner, { app: props.app });
195
184
  case "user": {
196
- const lines = item.text.split("\n");
197
- let text = item.text;
185
+ const lines = stripControl(item.text).split("\n");
186
+ let text = lines.join("\n");
198
187
  if (lines.length > 20) {
199
188
  text = `${lines.slice(0, 20).join("\n")}\n… (${lines.length - 20} líneas más)`;
200
189
  }
@@ -207,18 +196,19 @@ function ItemView(props) {
207
196
  case "answer":
208
197
  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` : ""] }) }));
209
198
  case "shell": {
210
- const lines = item.output ? item.output.split("\n") : [];
199
+ const lines = item.output ? stripControl(item.output).split("\n") : [];
211
200
  const shown = lines.slice(-30);
212
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["$", " "] }), _jsx(Text, { bold: true, children: 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"] })] }));
201
+ 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"] })] }));
213
202
  }
203
+ // Avisos y bloques pueden llevar mensajes de los proveedores o datos de fuera: solo se dejan los colores
214
204
  case "info":
215
- return _jsx(Text, { dimColor: true, children: item.text });
205
+ return _jsx(Text, { dimColor: true, children: stripUnsafe(item.text) });
216
206
  case "notice":
217
- return _jsxs(Text, { color: "yellow", children: ["\u26A0 ", item.text] });
207
+ return _jsxs(Text, { color: "yellow", children: ["\u26A0 ", stripUnsafe(item.text)] });
218
208
  case "error":
219
- return _jsxs(Text, { color: "red", children: ["\u2716 ", item.text] });
209
+ return _jsxs(Text, { color: "red", children: ["\u2716 ", stripUnsafe(item.text)] });
220
210
  case "block":
221
- return (_jsx(Box, { marginY: 1, children: _jsx(Text, { children: item.text }) }));
211
+ return (_jsx(Box, { marginY: 1, children: _jsx(Text, { children: stripUnsafe(item.text) }) }));
222
212
  }
223
213
  }
224
214
  function TodoPanel(props) {
@@ -229,15 +219,15 @@ function TodoPanel(props) {
229
219
  const done = props.todos.filter((todo) => todo.status === "completed").length;
230
220
  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) => {
231
221
  if (todo.status === "completed") {
232
- return (_jsxs(Text, { color: "green", strikethrough: true, children: ["\u2611 ", todo.content] }, index));
222
+ return (_jsxs(Text, { color: "green", strikethrough: true, children: ["\u2611 ", stripControl(todo.content)] }, index));
233
223
  }
234
224
  if (todo.status === "in_progress") {
235
- return (_jsxs(Text, { color: "yellow", children: ["\u25D0 ", todo.content] }, index));
225
+ return (_jsxs(Text, { color: "yellow", children: ["\u25D0 ", stripControl(todo.content)] }, index));
236
226
  }
237
227
  if (todo.status === "cancelled") {
238
- return (_jsxs(Text, { dimColor: true, strikethrough: true, children: ["\u2612 ", todo.content] }, index));
228
+ return (_jsxs(Text, { dimColor: true, strikethrough: true, children: ["\u2612 ", stripControl(todo.content)] }, index));
239
229
  }
240
- return _jsxs(Text, { children: ["\u2610 ", todo.content] }, index);
230
+ return _jsxs(Text, { children: ["\u2610 ", stripControl(todo.content)] }, index);
241
231
  }), props.todos.length > 10 ? _jsxs(Text, { dimColor: true, children: ["\u2026 y ", props.todos.length - 10, " m\u00E1s"] }) : null] }));
242
232
  }
243
233
  function InputBox(props) {
@@ -501,6 +491,9 @@ function Main(props) {
501
491
  await quit();
502
492
  return;
503
493
  }
494
+ if (result.loaded) {
495
+ replay(app.agent.session);
496
+ }
504
497
  if (result.run) {
505
498
  void runAgent({ ...result.run.input, images }, result.run.options);
506
499
  }
@@ -542,6 +535,35 @@ function Main(props) {
542
535
  }
543
536
  refresh();
544
537
  };
538
+ // La conversación de una sesión a la que vuelves, tal como la viste
539
+ const replay = (session) => {
540
+ const entries = session.log;
541
+ if (entries.length === 0) {
542
+ push({ kind: "info", text: `Sesión «${session.title}» cargada.` });
543
+ return;
544
+ }
545
+ const shown = entries.slice(-REPLAY_MAX);
546
+ push({ kind: "info", text: `── Conversación anterior: «${session.title}» · ${new Date(session.updated).toLocaleString()} ──` });
547
+ if (entries.length > shown.length) {
548
+ push({ kind: "info", text: `… ${entries.length - shown.length} pasos antes; la conversación entera está en ${sessionMarkdownPath(session)}` });
549
+ }
550
+ shown.forEach((entry, index) => {
551
+ if (entry.role === "user") {
552
+ push({ kind: "user", text: entry.text, images: entry.images || 0, color: agentColor() });
553
+ }
554
+ else if (entry.role === "assistant") {
555
+ push({ kind: "assistant", text: entry.text, color: agentColor() });
556
+ }
557
+ else if (entry.role === "tool" && entry.tool) {
558
+ const event = { id: `historial-${index}`, name: entry.tool.name, summary: entry.tool.summary, depth: 0, agent: "" };
559
+ push({ kind: "tool", event: { ...event, output: entry.text, display: entry.tool.display, isError: entry.tool.isError }, ms: 0 });
560
+ }
561
+ else {
562
+ push({ kind: "info", text: entry.text });
563
+ }
564
+ });
565
+ push({ kind: "info", text: "── Sigues desde aquí (/new empieza otra conversación) ──" });
566
+ };
545
567
  // Paleta de comandos (Ctrl+P): todos los comandos, skills y agentes con su descripción
546
568
  const openPalette = async () => {
547
569
  const commands = app.commandSuggestions();
@@ -655,9 +677,8 @@ function Main(props) {
655
677
  for (const notice of configNotices) {
656
678
  push({ kind: "notice", text: notice });
657
679
  }
658
- const session = app.agent.session;
659
- if (session.messages.length > 0) {
660
- push({ kind: "info", text: `Continúas «${session.title}» (${session.turns.length} mensajes tuyos). /new empieza otra.` });
680
+ if (app.agent.session.log.length > 0) {
681
+ replay(app.agent.session);
661
682
  }
662
683
  if (app.router.slots.length === 0) {
663
684
  push({
@@ -1004,7 +1025,7 @@ function Main(props) {
1004
1025
  if (busy) {
1005
1026
  placeholder = "Escribe para añadir instrucciones mientras trabaja…";
1006
1027
  }
1007
- 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(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(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: shell.command }), " ", _jsx(Elapsed, { since: startedAt.current, hideUnderMs: 1000 })] }), shell.output ? _jsx(Text, { dimColor: true, children: lastLines(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 : "" })] })] }));
1028
+ 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 : "" })] })] }));
1008
1029
  }
1009
1030
  export async function startTui(app, options = {}) {
1010
1031
  const instance = render(_jsx(Main, { app: app, initial: options.initial || "" }), {
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Box, Text, useInput, usePaste } from "ink";
3
3
  import { useMemo, useState } from "react";
4
4
  import { suggestPattern } from "../permissions.js";
5
+ import { showControl, stripControl } from "../sanitize.js";
5
6
  import { colorPreview } from "../ui.js";
6
7
  import { EMPTY, backspace, deleteWordBack, insert, moveLeft, moveRight, splitEnter } from "./editor.js";
7
8
  const KIND_LABEL = {
@@ -96,7 +97,7 @@ export function PermissionDialog(props) {
96
97
  const lines = request.preview.split("\n");
97
98
  const shown = lines.slice(0, 30).join("\n");
98
99
  const hidden = lines.length - 30;
99
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["? ", request.title, props.agent !== "build" ? _jsxs(Text, { dimColor: true, children: [" (", props.agent, ")"] }) : null] }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { children: colorPreview(shown) }), hidden > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", hidden, " l\u00EDneas m\u00E1s"] }) : null] }), explaining ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "\u00BFQu\u00E9 deber\u00EDa hacer en su lugar? (Enter env\u00EDa, Esc vuelve)" }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u276F " }), feedback.value.slice(0, feedback.cursor), _jsx(Text, { inverse: true, children: feedback.value[feedback.cursor] || " " }), feedback.value.slice(feedback.cursor + 1)] })] })) : (_jsxs(_Fragment, { children: [options.map((option, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, children: [index === selected ? "❯ " : " ", _jsxs(Text, { dimColor: true, children: ["[", option.key, "]"] }), " ", option.label] }, option.key))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 elige \u00B7 Enter acepta \u00B7 la letra es un atajo \u00B7 Esc = no" })] }))] }));
100
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["? ", showControl(request.title), props.agent !== "build" ? _jsxs(Text, { dimColor: true, children: [" (", props.agent, ")"] }) : null] }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { children: colorPreview(shown) }), hidden > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", hidden, " l\u00EDneas m\u00E1s"] }) : null] }), explaining ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "\u00BFQu\u00E9 deber\u00EDa hacer en su lugar? (Enter env\u00EDa, Esc vuelve)" }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u276F " }), feedback.value.slice(0, feedback.cursor), _jsx(Text, { inverse: true, children: feedback.value[feedback.cursor] || " " }), feedback.value.slice(feedback.cursor + 1)] })] })) : (_jsxs(_Fragment, { children: [options.map((option, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, children: [index === selected ? "❯ " : " ", _jsxs(Text, { dimColor: true, children: ["[", option.key, "]"] }), " ", option.label] }, option.key))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 elige \u00B7 Enter acepta \u00B7 la letra es un atajo \u00B7 Esc = no" })] }))] }));
100
101
  }
101
102
  function matches(item, query) {
102
103
  return `${item.label} ${item.description || ""}`.toLowerCase().includes(query);
@@ -155,7 +156,7 @@ export function SelectDialog(props) {
155
156
  const visible = filtered.slice(start, start + windowSize);
156
157
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: props.title }), _jsx(Text, { dimColor: true, children: filter ? `Filtro: ${filter}` : "Escribe para filtrar · ↑↓ elegir · Enter aceptar · Esc salir" }), visible.length === 0 ? _jsx(Text, { dimColor: true, children: "(nada coincide)" }) : null, visible.map((item, index) => {
157
158
  const active = start + index === selected;
158
- return (_jsxs(Text, { color: active ? "cyan" : undefined, wrap: "truncate-end", children: [active ? "❯ " : " ", item.label, item.description ? _jsxs(Text, { dimColor: true, children: [" ", item.description] }) : null] }, `${start + index}-${item.label}`));
159
+ return (_jsxs(Text, { color: active ? "cyan" : undefined, wrap: "truncate-end", children: [active ? "❯ " : " ", stripControl(item.label), item.description ? _jsxs(Text, { dimColor: true, children: [" ", stripControl(item.description)] }) : null] }, `${start + index}-${item.label}`));
159
160
  }), filtered.length > windowSize ? (_jsxs(Text, { dimColor: true, children: [selected + 1, "/", filtered.length] })) : null] }));
160
161
  }
161
162
  // Pide un texto (las claves se ven como •••)
package/dist/ui.js CHANGED
@@ -1,22 +1,27 @@
1
1
  import pc from "picocolors";
2
+ import { toolLabel } from "./labels.js";
3
+ import { renderMarkdown } from "./markdown.js";
2
4
  import { suggestPattern } from "./permissions.js";
5
+ import { showControl, stripControl, stripUnsafe } from "./sanitize.js";
6
+ import { sessionMarkdownPath } from "./session.js";
3
7
  import { formatTodos } from "./tools.js";
4
8
  export const ui = {
5
9
  text(chunk) {
6
10
  process.stdout.write(chunk);
7
11
  },
12
+ // Los mensajes pueden llevar texto de los proveedores o del modelo: sin secuencias de la terminal
8
13
  info(message) {
9
- console.log(pc.dim(message));
14
+ console.log(pc.dim(stripUnsafe(message)));
10
15
  },
11
16
  notice(message) {
12
- console.log(pc.yellow(`⚠ ${message}`));
17
+ console.log(pc.yellow(`⚠ ${stripUnsafe(message)}`));
13
18
  },
14
19
  error(message) {
15
- console.log(pc.red(`✖ ${message}`));
20
+ console.log(pc.red(`✖ ${stripUnsafe(message)}`));
16
21
  },
17
22
  tool(name, summary, depth = 0) {
18
23
  const indent = depth > 0 ? " " : " ";
19
- console.log(pc.cyan(`${indent}● ${name}`) + pc.dim(` ${summary}`));
24
+ console.log(pc.cyan(`${indent}● ${showControl(name)}`) + pc.dim(` ${showControl(summary)}`));
20
25
  },
21
26
  banner(agent, slots) {
22
27
  console.log(pc.bold(pc.magenta("vipcode")) + pc.dim(` · agente ${agent} · ${slots} modelos · /help para ayuda`));
@@ -31,9 +36,10 @@ export const ui = {
31
36
  return pc.magenta("❯ ");
32
37
  },
33
38
  };
34
- // Líneas quitadas en rojo, añadidas en verde, cabeceras @@ en cian y el contexto atenuado
39
+ // Líneas quitadas en rojo, añadidas en verde, cabeceras @@ en cian y el contexto atenuado.
40
+ // Es lo que apruebas: los caracteres de control se ven (␛) en vez de actuar
35
41
  export function colorPreview(preview) {
36
- return preview
42
+ return showControl(preview)
37
43
  .split("\n")
38
44
  .map((line) => {
39
45
  if (line.startsWith("+ ")) {
@@ -75,7 +81,7 @@ export function plainEvents(options) {
75
81
  return;
76
82
  }
77
83
  flushReasoning();
78
- process.stdout.write(chunk);
84
+ process.stdout.write(stripControl(chunk));
79
85
  },
80
86
  onTextEnd: (depth) => {
81
87
  if (depth === 0) {
@@ -92,7 +98,7 @@ export function plainEvents(options) {
92
98
  process.stdout.write(pc.dim(" 💭 "));
93
99
  reasoningShown = true;
94
100
  }
95
- const piece = chunk.replace(/\s+/g, " ").slice(0, 240 - reasoning.length);
101
+ const piece = stripControl(chunk).replace(/\s+/g, " ").slice(0, 240 - reasoning.length);
96
102
  reasoning += piece;
97
103
  process.stdout.write(pc.dim(piece));
98
104
  if (reasoning.length >= 240) {
@@ -107,7 +113,7 @@ export function plainEvents(options) {
107
113
  onToolEnd: (event) => {
108
114
  const pad = event.depth > 0 ? " " : " ";
109
115
  if (event.isError) {
110
- console.log(pc.red(`${pad}${event.output.split("\n")[0].slice(0, 200)}`));
116
+ console.log(pc.red(`${pad}${stripControl(event.output.split("\n")[0]).slice(0, 200)}`));
111
117
  }
112
118
  else if (event.display && event.depth === 0) {
113
119
  console.log(indent(colorPreview(event.display), pad));
@@ -129,7 +135,7 @@ export function plainPermissionPrompt(getReadline, grants) {
129
135
  return { allow: false, feedback: "no hay nadie para aprobarlo (usa --yolo o ajusta los permisos)" };
130
136
  }
131
137
  console.log("");
132
- console.log(pc.bold(pc.yellow(`? ${request.title}`)));
138
+ console.log(pc.bold(pc.yellow(`? ${showControl(request.title)}`)));
133
139
  console.log(colorPreview(request.preview));
134
140
  let options = "[s]í / [n]o / [t]odo en esta sesión / [e] no y explicar";
135
141
  let pattern = "";
@@ -172,10 +178,37 @@ export function plainPermissionPrompt(getReadline, grants) {
172
178
  }
173
179
  };
174
180
  }
181
+ // La conversación de una sesión a la que vuelves, en el modo de texto simple
182
+ export function printLog(session, max = 300) {
183
+ const entries = session.log;
184
+ if (entries.length === 0) {
185
+ return;
186
+ }
187
+ const shown = entries.slice(-max);
188
+ console.log(pc.dim(`── Conversación anterior: «${stripControl(session.title)}» · ${new Date(session.updated).toLocaleString()} ──`));
189
+ if (entries.length > shown.length) {
190
+ console.log(pc.dim(`… ${entries.length - shown.length} pasos antes; la conversación entera está en ${sessionMarkdownPath(session)}`));
191
+ }
192
+ for (const entry of shown) {
193
+ if (entry.role === "user") {
194
+ console.log(`\n${pc.magenta("❯")} ${stripControl(entry.text)}`);
195
+ }
196
+ else if (entry.role === "assistant") {
197
+ console.log(`\n${renderMarkdown(entry.text, process.stdout.columns || 80)}`);
198
+ }
199
+ else if (entry.role === "tool" && entry.tool) {
200
+ ui.tool(toolLabel(entry.tool.name), entry.tool.summary);
201
+ }
202
+ else {
203
+ console.log(pc.dim(stripControl(entry.text)));
204
+ }
205
+ }
206
+ console.log(pc.dim("\n── Sigues desde aquí (/new empieza otra conversación) ──"));
207
+ }
175
208
  // Interfaz mínima para los comandos / en el modo de texto
176
209
  export const plainAdapter = {
177
210
  info: (text) => ui.info(text),
178
211
  notice: (text) => ui.notice(text),
179
212
  error: (text) => ui.error(text),
180
- block: (text) => console.log(text),
213
+ block: (text) => console.log(stripUnsafe(text)),
181
214
  };
package/dist/update.js CHANGED
@@ -2,13 +2,12 @@ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
5
  import { PACKAGE_NAME, REPO_URL, VERSION } from "./meta.js";
7
- import { HOME_DIR, UPDATE_PATH } from "./paths.js";
6
+ import { INSTALL_DIR, UPDATE_PATH, ensureHomeDir } from "./paths.js";
8
7
  const DAY_MS = 24 * 60 * 60 * 1000;
9
8
  // Carpeta donde está instalado vipcode (la que contiene dist/). VIPCODE_INSTALL_ROOT permite fingir otra en las pruebas
10
9
  export function packageRoot() {
11
- return process.env.VIPCODE_INSTALL_ROOT || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
10
+ return process.env.VIPCODE_INSTALL_ROOT || INSTALL_DIR;
12
11
  }
13
12
  // "v0.10.1" frente a "0.9.3": positivo si a es más nueva
14
13
  export function compareVersions(a, b) {
@@ -102,7 +101,7 @@ export async function checkForUpdate() {
102
101
  try {
103
102
  const release = await latestRelease();
104
103
  cache = { checked: Date.now(), latest: release?.version || VERSION };
105
- fs.mkdirSync(HOME_DIR, { recursive: true });
104
+ ensureHomeDir();
106
105
  fs.writeFileSync(UPDATE_PATH, JSON.stringify(cache, null, 2));
107
106
  }
108
107
  catch {
@@ -218,6 +217,9 @@ async function updateFromRelease(root, log) {
218
217
  message: `Hay una versión nueva (${release.version}, tienes la ${VERSION}), pero este vipcode no está instalado con «npm install -g». Instálala con:\n npm install -g ${release.spec}`,
219
218
  };
220
219
  }
220
+ if (!/^[\w@.:/+~%-]+$/.test(release.spec) || release.spec.startsWith("-")) {
221
+ return { status: "error", message: `La última versión publicada tiene un nombre de paquete raro (${release.spec}): no la instalo.` };
222
+ }
221
223
  log(`Instalando vipcode ${release.version} desde ${release.source}…`);
222
224
  // Desde la carpeta temporal, para que no se mezcle con la configuración de npm del proyecto en el que estás
223
225
  const install = await run(NPM, ["install", "-g", release.spec], os.tmpdir());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vipcode",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Agente de programación para la terminal que usa IAs gratuitas y salta a otro proveedor cuando uno se agota",
5
5
  "keywords": [
6
6
  "ai",
@@ -23,7 +23,7 @@
23
23
  "url": "git+https://github.com/PabloVip/vipcode.git"
24
24
  },
25
25
  "license": "MIT",
26
- "author": "Pablo Vicente (https://github.com/PabloVip)",
26
+ "author": "Pablo Vicente Juan (https://github.com/PabloVip)",
27
27
  "type": "module",
28
28
  "bin": {
29
29
  "vipcode": "dist/index.js"