zyn-ai 1.2.0 → 1.3.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.
@@ -7,6 +7,16 @@ const fsp = fs.promises;
7
7
  const {
8
8
  MAX_FILE_LINES,
9
9
  } = require('../config');
10
+ const {
11
+ buildApiHeaders,
12
+ buildCloneUrl,
13
+ getApiBaseUrl,
14
+ listGitSecrets,
15
+ normalizeProfileName,
16
+ removeGitSecret,
17
+ resolveGitProfile,
18
+ upsertGitSecret,
19
+ } = require('../utils/secretStorage');
10
20
  const { resolveInputPath } = require('../utils/pathUtils');
11
21
  const {
12
22
  formatLineRange,
@@ -28,7 +38,9 @@ const TOOL_DEFINITIONS = [
28
38
  { name: 'fetch_url', usage: '{ url, selector?, attribute?, limit?, headers? }' },
29
39
  { name: 'web_search', usage: '{ query }' },
30
40
  { name: 'web_read', usage: '{ url }' },
41
+ { name: 'create_canvas_image', usage: '{ width, height, background?, elements?, format?, outputPath? }' },
31
42
  ];
43
+ const REGISTERED_TOOLS = new Set(TOOL_DEFINITIONS.map(tool => tool.name));
32
44
 
33
45
  function getToolPromptText() {
34
46
  return [
@@ -94,6 +106,17 @@ function getToolPromptText() {
94
106
  ' Descarga una pagina web y la convierte a texto legible (sin HTML).',
95
107
  ' Ideal para leer articulos, documentacion o contenido de paginas.',
96
108
  ' Ejemplo: {"type":"tool","tool":"web_read","args":{"url":"https://docs.example.com/guide"}}',
109
+ '',
110
+ '## Imagen profesional con Jimp',
111
+ '',
112
+ 'create_canvas_image { width, height, background?, elements?, format?, outputPath? }',
113
+ ' Crea imagenes desde cero usando Jimp con composicion por elementos.',
114
+ ' width/height son obligatorios. background puede ser color HEX (#RRGGBB o #RRGGBBAA).',
115
+ ' elements permite combinar rect, circle/ellipse, line, text e image.',
116
+ ' Usa este flujo profesional: definir lienzo -> capas base -> tipografia -> detalles -> exportacion.',
117
+ ' Ejemplo:',
118
+ ' {"type":"tool","tool":"create_canvas_image","args":{"width":1200,"height":628,"background":"#0f172a","format":"png","outputPath":"generated/cover.png","elements":[{"type":"rect","x":48,"y":48,"w":1104,"h":532,"radius":24,"fill":"#111827"},{"type":"text","x":96,"y":120,"fontSize":32,"text":"Quarterly Business Report"}]}}',
119
+ '',
97
120
  ].join('\n');
98
121
  }
99
122
 
@@ -137,6 +160,8 @@ function describeToolCall(call) {
137
160
  const readUrl = cleanUrl(call.args.url || '');
138
161
  return `Leyendo ${shortText(readUrl, 60)}`;
139
162
  }
163
+ case 'create_canvas_image':
164
+ return `Creando imagen ${call.args.width || '?'}x${call.args.height || '?'}`;
140
165
  default:
141
166
  return call.tool;
142
167
  }
@@ -751,6 +776,9 @@ async function webReadTool(args, state, paint) {
751
776
  }
752
777
 
753
778
  async function executeToolCall(call, state, ui) {
779
+ if (!call || typeof call.tool !== 'string' || !REGISTERED_TOOLS.has(call.tool)) {
780
+ throw new Error(`Herramienta no registrada: ${call?.tool || 'desconocida'}`);
781
+ }
754
782
  ui.logEvent(state, 'tool', describeToolCall(call));
755
783
 
756
784
  const startTime = Date.now();
@@ -796,6 +824,9 @@ async function executeToolCall(call, state, ui) {
796
824
  case 'web_read':
797
825
  result = await webReadTool(call.args, state, ui.paint);
798
826
  break;
827
+ case 'create_canvas_image':
828
+ result = await createCanvasImageTool(call.args, state, ui.paint);
829
+ break;
799
830
  default:
800
831
  throw new Error(`Herramienta no soportada: ${call.tool}`);
801
832
  }
@@ -819,6 +850,9 @@ function buildOllamaInstallCommand() {
819
850
 
820
851
  function parseDirectAction(input) {
821
852
  const text = input.trim();
853
+ if (/^(git|npm|node|pnpm|yarn)\s+/.test(text)) {
854
+ return { tool: 'run_command', args: { command: text } };
855
+ }
822
856
 
823
857
  const runMatch = text.match(/^(?:ejecuta|corre)\s+(?:el\s+)?comando\s+([\s\S]+)$/i);
824
858
  if (runMatch) {
@@ -1002,3 +1036,245 @@ module.exports = {
1002
1036
  parseDirectAction,
1003
1037
  printTools,
1004
1038
  };
1039
+
1040
+
1041
+ function getGitSecretLabel(provider, name = '') {
1042
+ const key = normalizeProfileName(provider);
1043
+ if (key === 'custom') return name ? `custom:${name}` : 'custom';
1044
+ return key;
1045
+ }
1046
+
1047
+ function parseColor(value, fallback = 0x000000ff) {
1048
+ if (typeof value !== 'string' || !value.trim()) return fallback;
1049
+ const raw = value.trim().replace('#', '');
1050
+ if (/^[0-9a-f]{6}$/i.test(raw)) return Number.parseInt(`${raw}ff`, 16) >>> 0;
1051
+ if (/^[0-9a-f]{8}$/i.test(raw)) return Number.parseInt(raw, 16) >>> 0;
1052
+ return fallback;
1053
+ }
1054
+
1055
+ function drawRect(image, x, y, w, h, color) {
1056
+ const left = Math.max(0, Math.floor(Number(x) || 0));
1057
+ const top = Math.max(0, Math.floor(Number(y) || 0));
1058
+ const width = Math.max(1, Math.floor(Number(w) || 0));
1059
+ const height = Math.max(1, Math.floor(Number(h) || 0));
1060
+ image.scan(left, top, width, height, function (px, py, idx) {
1061
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1062
+ });
1063
+ }
1064
+
1065
+ function drawRoundRect(image, x, y, w, h, radius, color) {
1066
+ const left = Math.max(0, Math.floor(Number(x) || 0));
1067
+ const top = Math.max(0, Math.floor(Number(y) || 0));
1068
+ const width = Math.max(1, Math.floor(Number(w) || 0));
1069
+ const height = Math.max(1, Math.floor(Number(h) || 0));
1070
+ const r = Math.max(0, Math.min(Number(radius) || 0, Math.floor(width / 2), Math.floor(height / 2)));
1071
+ if (r <= 0) return drawRect(image, left, top, width, height, color);
1072
+
1073
+ image.scan(left, top, width, height, function (px, py, idx) {
1074
+ const cx = px < left + r ? left + r : px >= left + width - r ? left + width - r - 1 : px;
1075
+ const cy = py < top + r ? top + r : py >= top + height - r ? top + height - r - 1 : py;
1076
+ const dx = px - cx;
1077
+ const dy = py - cy;
1078
+ if ((dx * dx) + (dy * dy) <= r * r) {
1079
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1080
+ }
1081
+ });
1082
+ }
1083
+
1084
+ function drawLine(image, x1, y1, x2, y2, color) {
1085
+ const sx = Number(x1 || 0);
1086
+ const sy = Number(y1 || 0);
1087
+ const tx = Number(x2 || 0);
1088
+ const ty = Number(y2 || 0);
1089
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(tx - sx), Math.abs(ty - sy))));
1090
+ for (let i = 0; i <= steps; i++) {
1091
+ const x = Math.round(sx + ((tx - sx) * i / steps));
1092
+ const y = Math.round(sy + ((ty - sy) * i / steps));
1093
+ if (x >= 0 && y >= 0 && x < image.bitmap.width && y < image.bitmap.height) {
1094
+ image.setPixelColor(color, x, y);
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ async function createCanvasImageTool(args, state, paint) {
1100
+ const { Jimp, loadFont } = require('jimp');
1101
+ const pluginPrintMain = require.resolve('@jimp/plugin-print');
1102
+ const fontsPath = path.join(path.dirname(pluginPrintMain), 'fonts.js');
1103
+ const fonts = require(fontsPath);
1104
+ const width = Math.max(1, Number(args.width || 0));
1105
+ const height = Math.max(1, Number(args.height || 0));
1106
+ if (!width || !height) {
1107
+ throw new Error('create_canvas_image requiere width y height');
1108
+ }
1109
+
1110
+ const format = String(args.format || 'png').toLowerCase();
1111
+ const safeFormat = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'gif', 'tiff'].includes(format) ? format : 'png';
1112
+ const fileExt = safeFormat === 'jpeg' ? 'jpg' : safeFormat;
1113
+ const outputPath = resolveInputPath(args.outputPath || path.join('generated', `image-${Date.now()}.${fileExt}`), state.cwd);
1114
+
1115
+ const allowed = await askConfirmation(state.rl, 'Crear imagen', `${width}x${height}\nFormato: ${safeFormat}\nSalida: ${outputPath}`, paint, state);
1116
+ if (!allowed) return 'Creacion de imagen cancelada por el usuario.';
1117
+
1118
+ const bg = args.background && typeof args.background === 'object'
1119
+ ? args.background.color || args.background.fill || '#ffffff'
1120
+ : args.background || '#ffffff';
1121
+ const image = new Jimp({ width, height, color: parseColor(bg, 0xffffffff) });
1122
+ const elements = Array.isArray(args.elements) ? args.elements.filter(Boolean) : [];
1123
+
1124
+ for (const element of elements) {
1125
+ if (!element || typeof element !== 'object') continue;
1126
+ const type = String(element.type || 'text').toLowerCase();
1127
+ if (type === 'rect') {
1128
+ const color = parseColor(element.fill || element.color || '#000000', 0x000000ff);
1129
+ if (element.radius) drawRoundRect(image, element.x || 0, element.y || 0, element.w || element.width || 0, element.h || element.height || 0, element.radius || 0, color);
1130
+ else drawRect(image, element.x || 0, element.y || 0, element.w || element.width || 0, element.h || element.height || 0, color);
1131
+ continue;
1132
+ }
1133
+ if (type === 'line') {
1134
+ drawLine(image, element.x1 || 0, element.y1 || 0, element.x2 || 0, element.y2 || 0, parseColor(element.stroke || '#000000', 0x000000ff));
1135
+ continue;
1136
+ }
1137
+ if (type === 'circle' || type === 'ellipse') {
1138
+ const color = parseColor(element.fill || element.color || '#000000', 0x000000ff);
1139
+ const cx = Number(element.x || 0);
1140
+ const cy = Number(element.y || 0);
1141
+ const rx = Math.max(1, Number(element.rx || element.r || element.radius || element.width || 0));
1142
+ const ry = Math.max(1, Number(element.ry || element.r || element.radius || element.height || rx));
1143
+ image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (px, py, idx) {
1144
+ const dx = (px - cx) / rx;
1145
+ const dy = (py - cy) / ry;
1146
+ if ((dx * dx) + (dy * dy) <= 1) {
1147
+ this.bitmap.data.writeUInt32BE(color >>> 0, idx);
1148
+ }
1149
+ });
1150
+ continue;
1151
+ }
1152
+ if (type === 'image') {
1153
+ const src = element.src || element.url || element.path;
1154
+ if (!src) continue;
1155
+ const loaded = await Jimp.read(src.startsWith('http') || src.startsWith('data:') ? src : resolveInputPath(src, state.cwd));
1156
+ const x = Number(element.x || 0);
1157
+ const y = Number(element.y || 0);
1158
+ const w = Math.max(1, Number(element.w || element.width || loaded.bitmap.width));
1159
+ const h = Math.max(1, Number(element.h || element.height || loaded.bitmap.height));
1160
+ const clone = loaded.clone().resize({ w, h });
1161
+ image.composite(clone, x, y);
1162
+ continue;
1163
+ }
1164
+
1165
+ const text = String(element.text || '');
1166
+ if (!text) continue;
1167
+ const size = Math.max(8, Math.min(64, Number(element.fontSize || 32)));
1168
+ const font = await loadFont(
1169
+ size <= 8 ? fonts.SANS_8_BLACK :
1170
+ size <= 16 ? fonts.SANS_16_BLACK :
1171
+ size <= 32 ? fonts.SANS_32_BLACK :
1172
+ fonts.SANS_64_BLACK,
1173
+ );
1174
+ image.print({ font, x: Number(element.x || 0), y: Number(element.y || 0), maxWidth: element.maxWidth ? Number(element.maxWidth) : undefined }, text);
1175
+ }
1176
+
1177
+ await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
1178
+ await image.write(outputPath);
1179
+ return [`Imagen creada: ${outputPath}`, `Formato: ${safeFormat}`, `Tamano: ${width}x${height}`, `Elementos: ${elements.length}`].join('\\n');
1180
+ }
1181
+
1182
+
1183
+ async function gitSecretSetTool(args) {
1184
+ const provider = normalizeProfileName(args.provider || '');
1185
+ if (!provider) throw new Error('git_secret_set requiere provider');
1186
+ if (!args.token || typeof args.token !== 'string') throw new Error('git_secret_set requiere token');
1187
+ const saved = upsertGitSecret(provider, args);
1188
+ return `Credencial guardada: ${getGitSecretLabel(provider, saved?.name || args.name || '')}`;
1189
+ }
1190
+
1191
+ async function gitSecretListTool(args) {
1192
+ const provider = normalizeProfileName(args.provider || '');
1193
+ const name = String(args.name || '').trim();
1194
+ const secrets = listGitSecrets();
1195
+ const filtered = secrets.filter(secret => {
1196
+ if (!provider) return true;
1197
+ if (provider === 'custom') return !name || secret.key === `custom:${name}`;
1198
+ return secret.key === provider;
1199
+ });
1200
+ if (!filtered.length) return 'No hay credenciales Git guardadas.';
1201
+ return filtered.map(secret => [
1202
+ `${secret.key}`,
1203
+ ` username: ${secret.username || '-'}`,
1204
+ ` apiBaseUrl: ${secret.apiBaseUrl || '-'}`,
1205
+ ` cloneBaseUrl: ${secret.cloneBaseUrl || '-'}`,
1206
+ ` authHeader: ${secret.authHeader || '-'}`,
1207
+ ` token: ${secret.token}`,
1208
+ ].join('\n')).join('\n\n');
1209
+ }
1210
+
1211
+ async function gitSecretRemoveTool(args) {
1212
+ const provider = normalizeProfileName(args.provider || '');
1213
+ if (!provider) throw new Error('git_secret_remove requiere provider');
1214
+ const name = String(args.name || '').trim();
1215
+ const removed = removeGitSecret(provider, name);
1216
+ return removed ? `Credencial eliminada: ${getGitSecretLabel(provider, name)}` : 'No encontre esa credencial.';
1217
+ }
1218
+
1219
+ async function gitCloneRepoTool(args, state, paint) {
1220
+ const repoUrl = String(args.repoUrl || '').trim();
1221
+ if (!repoUrl) throw new Error('git_clone_repo requiere repoUrl');
1222
+ const provider = normalizeProfileName(args.provider || '');
1223
+ const name = String(args.name || '').trim();
1224
+ const profile = provider ? resolveGitProfile(provider, name) : null;
1225
+ const finalUrl = buildCloneUrl(repoUrl, profile || {});
1226
+ const destination = args.destination ? resolveInputPath(args.destination, state.cwd) : '';
1227
+ const timeoutMs = Math.max(1000, Number.isFinite(Number(args.timeoutMs)) ? Number(args.timeoutMs) : 10 * 60 * 1000);
1228
+
1229
+ const allowed = await askConfirmation(state.rl, 'Clonar repositorio', [
1230
+ repoUrl,
1231
+ profile ? `Provider: ${provider}${name ? ` (${name})` : ''}` : 'Provider: direct',
1232
+ destination ? `Destino: ${destination}` : null,
1233
+ `Timeout: ${timeoutMs}ms`,
1234
+ ].filter(Boolean).join('\n'), paint, state);
1235
+ if (!allowed) return 'Clonado cancelado por el usuario.';
1236
+
1237
+ const result = await runProcess('git', ['clone', ...(args.branch ? ['--branch', String(args.branch)] : []), finalUrl, ...(destination ? [destination] : [])], { cwd: state.cwd, timeoutMs });
1238
+ const lines = [`Exit code: ${result.code}`];
1239
+ if (result.timedOut) lines.push('Timeout: el clon fue detenido por tiempo.');
1240
+ if (result.stdout.trim()) lines.push(`STDOUT:\n${result.stdout.trim()}`);
1241
+ if (result.stderr.trim()) lines.push(`STDERR:\n${result.stderr.trim()}`);
1242
+ return lines.join('\n\n');
1243
+ }
1244
+
1245
+ async function gitApiRequestTool(args, state, paint) {
1246
+ const provider = normalizeProfileName(args.provider || '');
1247
+ if (!provider) throw new Error('git_api_request requiere provider');
1248
+ const pathValue = String(args.path || '').trim();
1249
+ if (!pathValue) throw new Error('git_api_request requiere path');
1250
+ const name = String(args.name || '').trim();
1251
+ const profile = resolveGitProfile(provider, name);
1252
+ if (!profile) throw new Error(`No hay credenciales para ${provider}${provider === 'custom' && name ? `:${name}` : ''}`);
1253
+ const baseUrl = getApiBaseUrl(provider, profile);
1254
+ if (!baseUrl) throw new Error(`No hay apiBaseUrl para ${provider}`);
1255
+ const url = `${baseUrl.replace(/\/+$/, '')}/${pathValue.replace(/^\/+/, '')}`;
1256
+ const timeoutMs = Math.max(1000, Number.isFinite(Number(args.timeoutMs)) ? Number(args.timeoutMs) : 15000);
1257
+ const method = String(args.method || 'GET').toUpperCase();
1258
+ const headers = buildApiHeaders(provider, profile, args.headers && typeof args.headers === 'object' ? args.headers : {});
1259
+
1260
+ const allowed = await askConfirmation(state.rl, 'Git API request', `${method} ${url}\nTimeout: ${timeoutMs}ms`, paint, state);
1261
+ if (!allowed) return 'Request cancelado por el usuario.';
1262
+
1263
+ const axios = require('axios');
1264
+ const response = await axios({
1265
+ url,
1266
+ method,
1267
+ headers: {
1268
+ 'User-Agent': 'Zyn/1.0',
1269
+ Accept: 'application/json, text/plain, */*',
1270
+ ...headers,
1271
+ },
1272
+ data: args.body && typeof args.body === 'object' ? args.body : args.body,
1273
+ timeout: timeoutMs,
1274
+ responseType: 'text',
1275
+ validateStatus: () => true,
1276
+ });
1277
+
1278
+ const text = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
1279
+ return `Status: ${response.status}\n\n${text}`;
1280
+ }
package/src/tui/app.mjs CHANGED
@@ -17,9 +17,17 @@ const {
17
17
  DEFAULT_MODEL_KEY,
18
18
  MODELS,
19
19
  } = require('../config');
20
+ const { normalizeLanguage } = require('../i18n');
20
21
 
21
22
  const h = React.createElement;
23
+ function getTuiLang() {
24
+ return normalizeLanguage(global.__zynCurrentLanguage || 'en');
25
+ }
26
+ function uiText(en, es) {
27
+ return getTuiLang() === 'es' ? es : en;
28
+ }
22
29
  const MAX_THINKING_LINES = 20;
30
+ const MAX_PASTE_PREVIEW = 200;
23
31
  const SPIN_MS = 80;
24
32
 
25
33
  const SPIN_FRAMES = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
@@ -372,7 +380,7 @@ function Banner({ model, resumed, width, cwd }) {
372
380
  return s + ' '.repeat(Math.max(0, inner - s.length));
373
381
  };
374
382
 
375
- const sessionLabel = resumed ? 'sesion reanudada' : 'sesion nueva';
383
+ const sessionLabel = resumed ? uiText('session resumed', 'sesion reanudada') : uiText('new session', 'sesion nueva');
376
384
  const cwdShort = cwd && cwd.length > inner - 6 ? '...' + cwd.slice(-(inner - 9)) : (cwd || '.');
377
385
 
378
386
  return h(Box, { flexDirection: 'column', paddingTop: 1, paddingBottom: 0 },
@@ -429,10 +437,15 @@ function EventLine({ kind, title, detail }) {
429
437
  };
430
438
  const { sym, color } = cfg[kind] || cfg.info;
431
439
 
432
- return h(Box, { paddingLeft: 5, gap: 1 },
433
- h(Text, { color }, sym),
434
- h(Text, { color: kind === 'tool' ? T.textDim : T.textMuted }, title),
435
- detail ? h(Text, { color: T.textGhost }, detail) : null,
440
+ const compactTitle = String(title || '').replace(/\s{2,}/g, ' ').trim();
441
+ const compactDetail = String(detail || '').replace(/\s{2,}/g, ' ').trim();
442
+
443
+ return h(Box, { paddingLeft: 3, flexDirection: 'column' },
444
+ h(Box, { gap: 1 },
445
+ h(Text, { color }, sym),
446
+ h(Text, { color: kind === 'tool' ? T.textDim : T.textMuted, wrap: 'wrap' }, compactTitle),
447
+ ),
448
+ compactDetail ? h(Box, { paddingLeft: 2 }, h(Text, { color: T.textGhost, wrap: 'wrap' }, compactDetail)) : null,
436
449
  );
437
450
  }
438
451
 
@@ -441,7 +454,7 @@ function UserMessage({ text }) {
441
454
  h(Box, { flexDirection: 'column' },
442
455
  h(Box, { gap: 1, marginBottom: 0 },
443
456
  h(Text, { color: T.accent, bold: true }, '\u29bf'),
444
- h(Text, { color: T.textDim, bold: true }, 'You'),
457
+ h(Text, { color: T.textDim, bold: true }, uiText('You', 'Tú')),
445
458
  ),
446
459
  h(Box, { paddingLeft: 2 },
447
460
  h(Text, { color: T.text, wrap: 'wrap' }, text),
@@ -465,17 +478,17 @@ function ThinkingBlock({ text, elapsed, live, width }) {
465
478
  const pulseChar = live ? SPIN_FRAMES[Math.floor(Date.now() / SPIN_MS) % SPIN_FRAMES.length] : '\u25d0';
466
479
 
467
480
  const label = live
468
- ? pulseChar + ' Pensando...'
469
- : '\u25d0 Penso ' + elapsed + 's';
481
+ ? pulseChar + ' ' + uiText('Thinking...', 'Pensando...')
482
+ : '\u25d0 ' + uiText('Thought for ', 'Pensó durante ') + elapsed + 's';
470
483
 
471
- return h(Box, { flexDirection: 'column', paddingLeft: 5, marginTop: 1 },
484
+ return h(Box, { flexDirection: 'column', paddingLeft: 3, marginTop: 1 },
472
485
  h(Text, { color: T.textGhost }, label),
473
486
  lines.length > 0
474
- ? h(Box, { flexDirection: 'column', paddingLeft: 2 },
487
+ ? h(Box, { flexDirection: 'column', paddingLeft: 1 },
475
488
  ...lines.map((line, i) =>
476
489
  h(Text, { key: String(i), color: T.textInvis, wrap: 'wrap' }, line),
477
490
  ),
478
- more > 0 ? h(Text, { color: T.textGhost }, '\u00b7\u00b7\u00b7 ' + more + ' lineas mas') : null,
491
+ more > 0 ? h(Text, { color: T.textGhost }, '\u00b7\u00b7\u00b7 ' + more + ' ' + uiText('more lines', 'líneas más')) : null,
479
492
  )
480
493
  : null,
481
494
  );
@@ -534,10 +547,10 @@ function ConfirmBar({ title, detail }) {
534
547
  : null,
535
548
  h(Box, { marginTop: 0, paddingLeft: 2, gap: 2 },
536
549
  h(Text, { color: T.green, bold: true }, '[y]'),
537
- h(Text, { color: T.textMuted }, 'permitir'),
550
+ h(Text, { color: T.textMuted }, uiText('allow', 'permitir')),
538
551
  h(Text, { color: T.textInvis }, '\u00b7'),
539
552
  h(Text, { color: T.red, bold: true }, '[n]'),
540
- h(Text, { color: T.textMuted }, 'denegar'),
553
+ h(Text, { color: T.textMuted }, uiText('deny', 'denegar')),
541
554
  ),
542
555
  );
543
556
  }
@@ -566,13 +579,13 @@ function StatusBar({ model, processing, width, turnCount }) {
566
579
  ? h(Text, { color: T.accentSoft }, SPIN_FRAMES[frame])
567
580
  : null,
568
581
  turnCount > 0
569
- ? h(Text, { color: T.textInvis }, '\u00b7 ' + turnCount + (turnCount === 1 ? ' turno' : ' turnos'))
582
+ ? h(Text, { color: T.textInvis }, '\u00b7 ' + turnCount + ' ' + uiText(turnCount === 1 ? 'turn' : 'turns', turnCount === 1 ? 'turno' : 'turnos'))
570
583
  : null,
571
584
  ),
572
585
  h(Box, { gap: 1 },
573
586
  h(Text, { color: T.textInvis }, '/help'),
574
587
  h(Text, { color: T.textInvis }, '\u00b7'),
575
- h(Text, { color: T.textInvis }, 'esc salir'),
588
+ h(Text, { color: T.textInvis }, uiText('esc exit', 'esc salir')),
576
589
  ),
577
590
  ),
578
591
  );
@@ -709,8 +722,10 @@ function InputBar({ onSubmit, processing }) {
709
722
  }
710
723
 
711
724
  if (input && !key.ctrl && !key.meta) {
712
- setValue(v => v.slice(0, cursor) + input + v.slice(cursor));
713
- setCursor(c => c + input.length);
725
+ const normalizedInput = input.replace(/\r\n/g, '\n');
726
+ const safeInput = normalizedInput.includes('\n') ? normalizedInput.replace(/\n/g, ' ') : normalizedInput;
727
+ setValue(v => v.slice(0, cursor) + safeInput + v.slice(cursor));
728
+ setCursor(c => c + safeInput.length);
714
729
  setSuggestIdx(0);
715
730
  }
716
731
  });
@@ -721,7 +736,7 @@ function InputBar({ onSubmit, processing }) {
721
736
  const after = value.slice(cursor + 1);
722
737
 
723
738
  const promptColor = processing ? T.amber : T.accent;
724
- const placeholder = processing ? ' En cola — escribe y se procesará después...' : ' Escribe un mensaje...';
739
+ const placeholder = processing ? uiText(' Queued — type and it will run later...', ' En cola — escribe y se procesará después...') : uiText(' Type a message...', ' Escribe un mensaje...');
725
740
 
726
741
  const inputLine = h(Box, { paddingLeft: 3, paddingRight: 3, paddingTop: 0, paddingBottom: 0, marginTop: 1 },
727
742
  h(Text, { color: promptColor }, processing ? '\u{1F4E9} ' : '\u276f '),
@@ -749,7 +764,7 @@ function InputBar({ onSubmit, processing }) {
749
764
  inputLine,
750
765
  h(Box, { flexDirection: 'column', paddingLeft: 5, marginTop: 0 },
751
766
  hasMore && windowStart > 0
752
- ? h(Text, { color: T.textInvis }, ' \u2191 mas')
767
+ ? h(Text, { color: T.textInvis }, ' \u2191 ' + uiText('more', 'más'))
753
768
  : null,
754
769
  ...visible.map((cmd, i) => {
755
770
  const realIdx = windowStart + i;
@@ -766,10 +781,10 @@ function InputBar({ onSubmit, processing }) {
766
781
  );
767
782
  }),
768
783
  hasMore && windowStart + maxVisible < suggestions.length
769
- ? h(Text, { color: T.textInvis }, ' \u2193 mas')
784
+ ? h(Text, { color: T.textInvis }, ' \u2193 ' + uiText('more', 'más'))
770
785
  : null,
771
786
  h(Box, { paddingTop: 0 },
772
- h(Text, { color: T.textInvis }, 'Tab completar \u00b7 \u2191\u2193 navegar'),
787
+ h(Text, { color: T.textInvis }, uiText('Tab complete · ↑↓ navigate', 'Tab completar · ↑↓ navegar')),
773
788
  ),
774
789
  ),
775
790
  );
@@ -777,19 +792,20 @@ function InputBar({ onSubmit, processing }) {
777
792
 
778
793
  function App({ store, state, onSubmit }) {
779
794
  useStore(store);
795
+ global.__zynCurrentLanguage = state?.language || 'en';
780
796
  const { exit } = useApp();
781
797
  const { width } = useDimensions();
782
798
 
783
799
  const modelKey = state?.activeModel || DEFAULT_MODEL_KEY;
784
800
  const modelLabel = state?.concuerdo
785
- ? 'Concuerdo · ' + Object.values(MODELS).map(m => m.label).join(', ')
801
+ ? uiText('Concord · ', 'Concuerdo · ') + Object.values(MODELS).map(m => m.label).join(', ')
786
802
  : (MODELS[modelKey]?.label || modelKey).toLowerCase();
787
803
 
788
804
  const handleInput = useCallback((text) => {
789
805
  if (text === '/exit' || text === '/quit') {
790
806
  if (store.processing) {
791
807
  store.pendingExit = true;
792
- store.addEvent('info', 'saliendo al terminar el turno actual');
808
+ store.addEvent('info', uiText('exiting after current turn', 'saliendo al terminar el turno actual'));
793
809
  return;
794
810
  }
795
811
  exit();
@@ -809,10 +825,10 @@ function App({ store, state, onSubmit }) {
809
825
  state.abortCurrentTurn();
810
826
  }
811
827
  store.pendingExit = false;
812
- store.addEvent('warn', 'agente detenido', 'Interrumpido con ESC x2');
828
+ store.addEvent('warn', uiText('agent stopped', 'agente detenido'), uiText('Interrupted with ESC x2', 'Interrumpido con ESC x2'));
813
829
  } else {
814
830
  store.lastEscapeAt = now;
815
- store.addEvent('info', 'pulsa ESC otra vez', 'para detener el agente');
831
+ store.addEvent('info', uiText('press ESC again', 'pulsa ESC otra vez'), uiText('to stop the agent', 'para detener el agente'));
816
832
  }
817
833
  return;
818
834
  }
@@ -914,7 +930,7 @@ export async function startTUI(options = {}) {
914
930
  const processInput = async (input) => {
915
931
  if (input === '/exit' || input === '/quit') {
916
932
  store.pendingExit = true;
917
- store.addEvent('info', 'hasta luego');
933
+ store.addEvent('info', uiText('bye', 'hasta luego'));
918
934
  return;
919
935
  }
920
936
 
@@ -942,9 +958,9 @@ export async function startTUI(options = {}) {
942
958
  const clean = lines.filter(l => l.trim()).join('\n');
943
959
  if (clean) store.addItem({ type: 'system', text: clean });
944
960
  }
945
- if (!handled) store.addEvent('warn', 'comando no reconocido', input);
961
+ if (!handled) store.addEvent('warn', uiText('command not recognized', 'comando no reconocido'), input);
946
962
  } catch (err) {
947
- store.addEvent('error', 'error', err.message);
963
+ store.addEvent('error', uiText('error', 'error'), err.message);
948
964
  } finally {
949
965
  console.log = origLog;
950
966
  console.error = origError;
@@ -983,6 +999,13 @@ export async function startTUI(options = {}) {
983
999
  let appInstance = null;
984
1000
 
985
1001
  const handleSubmit = async (input) => {
1002
+ if (typeof input === 'string' && input.length > MAX_PASTE_PREVIEW) {
1003
+ store.addEvent(
1004
+ 'info',
1005
+ `[ ${uiText('Pasted Text', 'Texto pegado')} of ${input.length} ${uiText('Characters', 'Caracteres')} ]`,
1006
+ input.slice(0, MAX_PASTE_PREVIEW) + '...'
1007
+ );
1008
+ }
986
1009
  if (input.startsWith('/') && store.processing) {
987
1010
  await processInput(input);
988
1011
  return;