@danieltmn/tamnora 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/SECURITY.md +31 -0
  5. package/harness/packages/cli/bin/tamnora.mjs +11 -0
  6. package/harness/packages/cli/package.json +10 -0
  7. package/harness/packages/cli/src/cli.js +90 -0
  8. package/harness/packages/cli/src/commands/autostart.js +121 -0
  9. package/harness/packages/cli/src/commands/bridge.js +274 -0
  10. package/harness/packages/cli/src/commands/doctor.js +73 -0
  11. package/harness/packages/cli/src/commands/eval.js +183 -0
  12. package/harness/packages/cli/src/commands/init.js +31 -0
  13. package/harness/packages/cli/src/commands/mcp.js +101 -0
  14. package/harness/packages/cli/src/commands/repl.js +172 -0
  15. package/harness/packages/cli/src/commands/run.js +165 -0
  16. package/harness/packages/cli/src/commands/serve.js +1 -0
  17. package/harness/packages/cli/src/commands/server.js +345 -0
  18. package/harness/packages/cli/src/commands/tui.js +110 -0
  19. package/harness/packages/cli/src/commands/update.js +76 -0
  20. package/harness/packages/cli/src/commands/user.js +135 -0
  21. package/harness/packages/cli/src/lib/core.js +2 -0
  22. package/harness/packages/cli/src/lib/daemon.js +153 -0
  23. package/harness/packages/cli/src/lib/qr.js +203 -0
  24. package/harness/packages/cli/src/lib/runtime.js +110 -0
  25. package/harness/packages/cli/src/lib/tunnel.js +113 -0
  26. package/harness/packages/cli/src/lib/util.js +24 -0
  27. package/harness/packages/cli/src/tui/app.js +302 -0
  28. package/harness/packages/cli/src/tui/keys.js +78 -0
  29. package/harness/packages/cli/src/tui/layout.js +130 -0
  30. package/harness/packages/cli/src/tui/screen.js +13 -0
  31. package/harness/packages/cli/src/ui/color.js +15 -0
  32. package/harness/packages/cli/test/release.test.js +34 -0
  33. package/harness/packages/cli/test/tui.test.js +99 -0
  34. package/harness/packages/cli/test/tunnel.test.js +20 -0
  35. package/harness/packages/core/package.json +14 -0
  36. package/harness/packages/core/src/agent/context.js +66 -0
  37. package/harness/packages/core/src/agent/modes.js +26 -0
  38. package/harness/packages/core/src/agent/runAgent.js +257 -0
  39. package/harness/packages/core/src/config/app.js +49 -0
  40. package/harness/packages/core/src/config/home.js +98 -0
  41. package/harness/packages/core/src/config/identity.js +41 -0
  42. package/harness/packages/core/src/config/index.js +69 -0
  43. package/harness/packages/core/src/config/providers.js +24 -0
  44. package/harness/packages/core/src/env.js +19 -0
  45. package/harness/packages/core/src/eval/dataset.js +47 -0
  46. package/harness/packages/core/src/eval/judge.js +39 -0
  47. package/harness/packages/core/src/eval/report.js +48 -0
  48. package/harness/packages/core/src/eval/runTask.js +71 -0
  49. package/harness/packages/core/src/eval/runner.js +150 -0
  50. package/harness/packages/core/src/eval/scorers.js +217 -0
  51. package/harness/packages/core/src/eval/task.js +59 -0
  52. package/harness/packages/core/src/index.js +72 -0
  53. package/harness/packages/core/src/mcp/common.js +82 -0
  54. package/harness/packages/core/src/mcp/http.js +149 -0
  55. package/harness/packages/core/src/mcp/index.js +167 -0
  56. package/harness/packages/core/src/mcp/stdio.js +218 -0
  57. package/harness/packages/core/src/providers/index.js +119 -0
  58. package/harness/packages/core/src/providers/ollama.js +76 -0
  59. package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
  60. package/harness/packages/core/src/providers/openrouter.js +57 -0
  61. package/harness/packages/core/src/providers/presets.js +19 -0
  62. package/harness/packages/core/src/providers/resilience.js +74 -0
  63. package/harness/packages/core/src/providers/stream.js +200 -0
  64. package/harness/packages/core/src/providers/utils.js +66 -0
  65. package/harness/packages/core/src/storage/sqlite.js +453 -0
  66. package/harness/packages/core/src/tools/diff.js +90 -0
  67. package/harness/packages/core/src/tools/executors.js +277 -0
  68. package/harness/packages/core/src/tools/glob.js +67 -0
  69. package/harness/packages/core/src/tools/index.js +28 -0
  70. package/harness/packages/core/src/tools/paths.js +29 -0
  71. package/harness/packages/core/src/tools/permissions.js +69 -0
  72. package/harness/packages/core/src/tools/registry.js +44 -0
  73. package/harness/packages/core/src/tools/schema.js +177 -0
  74. package/harness/packages/core/test/agent.test.js +237 -0
  75. package/harness/packages/core/test/config.test.js +33 -0
  76. package/harness/packages/core/test/context.test.js +44 -0
  77. package/harness/packages/core/test/diff.test.js +20 -0
  78. package/harness/packages/core/test/eval.test.js +144 -0
  79. package/harness/packages/core/test/git-tools.test.js +44 -0
  80. package/harness/packages/core/test/home.test.js +62 -0
  81. package/harness/packages/core/test/mcp.test.js +158 -0
  82. package/harness/packages/core/test/providers.test.js +80 -0
  83. package/harness/packages/core/test/resilience.test.js +61 -0
  84. package/harness/packages/core/test/storage.test.js +55 -0
  85. package/harness/packages/core/test/stream.test.js +91 -0
  86. package/harness/packages/core/test/tools.test.js +96 -0
  87. package/harness/packages/server/bin/tamnora-server.mjs +11 -0
  88. package/harness/packages/server/package.json +10 -0
  89. package/harness/packages/server/public/app.js +2188 -0
  90. package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
  91. package/harness/packages/server/public/index.html +368 -0
  92. package/harness/packages/server/public/landing/index.html +523 -0
  93. package/harness/packages/server/public/landing/landing.js +89 -0
  94. package/harness/packages/server/public/markdown.js +174 -0
  95. package/harness/packages/server/public/styles.css +923 -0
  96. package/harness/packages/server/src/auth.js +146 -0
  97. package/harness/packages/server/src/cli.js +61 -0
  98. package/harness/packages/server/src/index.js +1 -0
  99. package/harness/packages/server/src/lib/approvals.js +51 -0
  100. package/harness/packages/server/src/lib/core.js +2 -0
  101. package/harness/packages/server/src/lib/http.js +74 -0
  102. package/harness/packages/server/src/lib/modelCache.js +32 -0
  103. package/harness/packages/server/src/lib/prompt.js +48 -0
  104. package/harness/packages/server/src/lib/workspace.js +74 -0
  105. package/harness/packages/server/src/routes/auth.js +56 -0
  106. package/harness/packages/server/src/routes/chat.js +222 -0
  107. package/harness/packages/server/src/routes/eval.js +100 -0
  108. package/harness/packages/server/src/routes/files.js +117 -0
  109. package/harness/packages/server/src/routes/folders.js +80 -0
  110. package/harness/packages/server/src/routes/mcp.js +50 -0
  111. package/harness/packages/server/src/routes/models.js +26 -0
  112. package/harness/packages/server/src/routes/providers.js +135 -0
  113. package/harness/packages/server/src/routes/sessions.js +157 -0
  114. package/harness/packages/server/src/server.js +249 -0
  115. package/harness/packages/server/src/static.js +67 -0
  116. package/harness/packages/server/test/markdown.test.js +36 -0
  117. package/harness/packages/server/test/modelCache.test.js +72 -0
  118. package/harness/packages/server/test/server.test.js +809 -0
  119. package/harness/scripts/example-dataset.jsonl +4 -0
  120. package/harness/scripts/mcp-echo-server.mjs +108 -0
  121. package/harness/scripts/mock-provider.mjs +134 -0
  122. package/harness/tamnora.example.json +18 -0
  123. package/package.json +70 -0
@@ -0,0 +1,110 @@
1
+ import { runAgent, resolveProviderRuntime, composeProviderSettings, resolveApiKey } from './core.js';
2
+
3
+ /**
4
+ * Resolve provider/model/apiKey/baseUrl for CLI commands, merging the active
5
+ * settings and stored providers (SQLite) over `tamnora.json`.
6
+ */
7
+ export function resolveCliProvider(store, config, { provider, model } = {}) {
8
+ const providerId = provider || store.getSetting('activeProvider') || config.provider;
9
+ const activeModel = model || store.getSetting('activeModel') || config.model;
10
+ const customProviders = store
11
+ .listProviders()
12
+ .filter((p) => p.custom)
13
+ .map((p) => ({ id: p.id, label: p.label, baseUrl: p.baseUrl, models: p.models }));
14
+ const settings = composeProviderSettings({
15
+ providerId,
16
+ stored: store.getProvider(providerId) ?? {},
17
+ config,
18
+ customProviders,
19
+ });
20
+ return {
21
+ providerId,
22
+ model: activeModel,
23
+ baseUrl: settings.baseUrl,
24
+ apiKey: resolveApiKey(settings.apiKey, process.env),
25
+ settings,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Build the system prompt from instruction files plus an optional extra block.
31
+ */
32
+ export function buildSystemPrompt(instructions, extra) {
33
+ const parts = [];
34
+ if (instructions?.trim()) parts.push(instructions.trim());
35
+ if (extra?.trim()) parts.push(extra.trim());
36
+ return parts.join('\n\n') || 'Eres un asistente útil. Responde en el idioma del usuario.';
37
+ }
38
+
39
+ /**
40
+ * Run a single agent turn over a history.
41
+ * Returns the agent result plus only the newly produced messages (user + assistant/tool),
42
+ * so callers can persist them without duplicating prior history.
43
+ */
44
+ export async function runTurn({
45
+ providerId,
46
+ model,
47
+ baseUrl,
48
+ apiKey,
49
+ systemPrompt,
50
+ history = [],
51
+ input,
52
+ maxRounds,
53
+ stallWarnMs,
54
+ signal,
55
+ execute,
56
+ tools = [],
57
+ toolContext = {},
58
+ onEvent,
59
+ }) {
60
+ const { provider, config } = resolveProviderRuntime({ providerId, apiKey, baseUrl });
61
+
62
+ const messages = [
63
+ { role: 'system', content: systemPrompt },
64
+ ...history,
65
+ { role: 'user', content: input },
66
+ ];
67
+
68
+ const result = await runAgent({
69
+ provider,
70
+ config,
71
+ model,
72
+ messages,
73
+ tools,
74
+ execute,
75
+ toolContext,
76
+ maxRounds,
77
+ stallWarnMs,
78
+ signal,
79
+ onEvent,
80
+ });
81
+
82
+ const newMessages = result.messages.slice(1 + history.length);
83
+ return { result, newMessages, providerId: provider.id };
84
+ }
85
+
86
+ /**
87
+ * Streaming printer for the CLI. Suppressed when `json` is true so stdout
88
+ * stays machine-readable.
89
+ */
90
+ export function createPrinter({ json = false } = {}) {
91
+ let wroteText = false;
92
+ return {
93
+ onEvent(event) {
94
+ if (json) return;
95
+ if (event.type === 'text') {
96
+ process.stdout.write(event.delta);
97
+ wroteText = true;
98
+ } else if (event.type === 'stall') {
99
+ process.stderr.write(`\n[aviso] ${event.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...\n`);
100
+ } else if (event.type === 'tool_call') {
101
+ process.stderr.write(`\n[tool] ${event.name} ${JSON.stringify(event.args)}\n`);
102
+ } else if (event.type === 'tool_result') {
103
+ process.stderr.write(`[tool] → ${String(event.result).slice(0, 300)}\n`);
104
+ }
105
+ },
106
+ finish() {
107
+ if (!json && wroteText) process.stdout.write('\n');
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,113 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Tuneles publicos. v1 soporta tunnelmole (gratis, sin cuenta; URL aleatoria)
5
+ * via `npx --yes tunnelmole <puerto>`.
6
+ */
7
+
8
+ function killTree(child) {
9
+ if (!child || child.killed) return;
10
+ const pid = child.pid;
11
+ try {
12
+ if (process.platform === 'win32' && pid) {
13
+ spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
14
+ } else {
15
+ if (pid) {
16
+ try {
17
+ process.kill(-pid, 'SIGTERM');
18
+ } catch {
19
+ /* sin grupo */
20
+ }
21
+ }
22
+ try {
23
+ child.kill('SIGTERM');
24
+ } catch {
25
+ /* ya murio */
26
+ }
27
+ }
28
+ } catch {
29
+ /* ya murio */
30
+ }
31
+ }
32
+
33
+ export function parseTunnelmoleUrls(text) {
34
+ const urls = [...new Set(String(text).match(/https?:\/\/[a-z0-9-]+\.tunnelmole\.net/gi) || [])];
35
+ return {
36
+ https: urls.find((u) => u.startsWith('https')) || '',
37
+ http: urls.find((u) => u.startsWith('http:')) || '',
38
+ };
39
+ }
40
+
41
+ function spawnTunnel(bin, args, opts = {}) {
42
+ const log = opts.log || (() => {});
43
+ const parse = opts.parse || (() => ({ https: '', http: '' }));
44
+ const timeoutMs = opts.timeoutMs || 90000;
45
+ const label = opts.label || bin;
46
+ return new Promise((resolve, reject) => {
47
+ let child;
48
+ try {
49
+ child = spawn(bin, args, {
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ windowsHide: true,
52
+ shell: process.platform === 'win32',
53
+ detached: process.platform !== 'win32',
54
+ });
55
+ } catch (err) {
56
+ reject(new Error(`no se pudo iniciar ${label}: ${err.message}`));
57
+ return;
58
+ }
59
+ let buf = '';
60
+ let settled = false;
61
+ const timer = setTimeout(() => {
62
+ if (settled) return;
63
+ settled = true;
64
+ killTree(child);
65
+ reject(new Error(`${label} no devolvio URL (${Math.round(timeoutMs / 1000)} s).`));
66
+ }, timeoutMs);
67
+ const feed = (data) => {
68
+ buf += String(data);
69
+ if (opts.verbose) process.stdout.write(String(data));
70
+ const urls = parse(buf);
71
+ const url = urls.https || urls.http;
72
+ if (url) {
73
+ if (settled) return;
74
+ settled = true;
75
+ clearTimeout(timer);
76
+ log(`${label}: ${url} -> localhost:${opts.port}`);
77
+ resolve({ url, provider: opts.provider, pid: child.pid, stop: () => killTree(child) });
78
+ }
79
+ };
80
+ child.stdout.on('data', feed);
81
+ child.stderr.on('data', feed);
82
+ child.on('error', (err) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+ reject(new Error(`${label}: ${err.message}`));
87
+ });
88
+ child.on('exit', (code) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ clearTimeout(timer);
92
+ reject(new Error(`${label} termino (codigo ${code})${buf ? `: ${buf.slice(0, 200).trim()}` : ''}`));
93
+ });
94
+ });
95
+ }
96
+
97
+ export function startTunnelmole(port, opts = {}) {
98
+ return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
99
+ ...opts,
100
+ port,
101
+ provider: 'tunnelmole',
102
+ label: 'tunnelmole',
103
+ parse: parseTunnelmoleUrls,
104
+ });
105
+ }
106
+
107
+ /** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
108
+ export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
109
+ const p = String(provider || 'tunnelmole').toLowerCase();
110
+ if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
111
+ if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
112
+ throw new Error(`proveedor de tunel no soportado: ${provider}`);
113
+ }
@@ -0,0 +1,24 @@
1
+ import { parseArgs } from 'node:util';
2
+
3
+ /**
4
+ * Parse CLI arguments. Unknown flags are tolerated (strict: false).
5
+ * @param {string[]} argv
6
+ * @param {Record<string, import('node:util').ParseArgsOptionDescriptor>} options
7
+ */
8
+ export function parse(argv, options) {
9
+ const { values, positionals } = parseArgs({
10
+ args: argv,
11
+ options,
12
+ allowPositionals: true,
13
+ strict: false,
14
+ });
15
+ return { values, positionals };
16
+ }
17
+
18
+ /** Re-exportados desde el core: la base es global (casa de Tamnora). */
19
+ export { STATE_DIR, databasePath } from '../../../core/src/index.js';
20
+
21
+ export function num(value, fallback) {
22
+ const n = Number(value);
23
+ return Number.isFinite(n) ? n : fallback;
24
+ }
@@ -0,0 +1,302 @@
1
+ // Full-screen terminal console (opencode/codex-like), zero dependencies.
2
+ // Alt-screen + raw input + incremental rendering; reuses the harness engine.
3
+
4
+ import { createTools } from '../lib/core.js';
5
+ import { runTurn } from '../lib/runtime.js';
6
+ import { parseKeys } from './keys.js';
7
+ import { render } from './layout.js';
8
+ import * as screen from './screen.js';
9
+
10
+ export function runTui({
11
+ providerId,
12
+ model,
13
+ baseUrl,
14
+ apiKey,
15
+ systemPrompt,
16
+ maxRounds,
17
+ toolOptions,
18
+ store,
19
+ sessionId,
20
+ }) {
21
+ const state = {
22
+ header: { provider: providerId, model, cwd: toolOptions.root },
23
+ messages: [],
24
+ input: '',
25
+ inputCursor: 0,
26
+ streaming: false,
27
+ pendingAsk: null,
28
+ scroll: 0,
29
+ follow: true,
30
+ tokens: null,
31
+ };
32
+
33
+ const history = [];
34
+ let historyIndex = -1;
35
+ let conv = [];
36
+ let controller = null;
37
+ let closed = false;
38
+ let resolveExit = () => {};
39
+
40
+ if (sessionId) {
41
+ for (const message of store.getMessages(sessionId)) {
42
+ if (message.role === 'user') {
43
+ state.messages.push({ role: 'user', text: typeof message.content === 'string' ? message.content : '', tools: [] });
44
+ } else if (message.role === 'assistant' && message.content) {
45
+ state.messages.push({ role: 'assistant', text: String(message.content), tools: [] });
46
+ }
47
+ }
48
+ conv = store.getMessages(sessionId);
49
+ }
50
+ state.messages.push({
51
+ role: 'assistant',
52
+ text: `Consola Tamnora · ${providerId}/${model}\nEscribí un mensaje o /salir. Ctrl+C cancela la generación.`,
53
+ tools: [],
54
+ });
55
+
56
+ const onAsk = (name, args) =>
57
+ new Promise((resolve) => {
58
+ state.pendingAsk = { name, detail: args?.command ?? args?.path ?? '', resolve };
59
+ draw();
60
+ });
61
+
62
+ const { definitions, execute } = createTools({ ...toolOptions, onAsk });
63
+
64
+ function draw() {
65
+ if (closed) return;
66
+ const { frame, cursor } = render(state, {
67
+ cols: process.stdout.columns || 80,
68
+ rows: process.stdout.rows || 24,
69
+ });
70
+ process.stdout.write(frame);
71
+ process.stdout.write(screen.moveTo(cursor.row, cursor.col));
72
+ }
73
+
74
+ function addMessage(role, text) {
75
+ const message = { role, text, tools: [] };
76
+ state.messages.push(message);
77
+ return message;
78
+ }
79
+
80
+ function setInput(value, cursor) {
81
+ state.input = value;
82
+ state.inputCursor = cursor == null ? Array.from(value).length : cursor;
83
+ }
84
+
85
+ function insertText(text) {
86
+ const value = String(text).replace(/\r\n?/g, '\n').replace(/\n/g, ' ');
87
+ const chars = Array.from(state.input);
88
+ chars.splice(state.inputCursor, 0, ...Array.from(value));
89
+ setInput(chars.join(''), state.inputCursor + Array.from(value).length);
90
+ }
91
+
92
+ function pageSize() {
93
+ return Math.max(1, (process.stdout.rows || 24) - 5);
94
+ }
95
+
96
+ function resolveAsk(answer) {
97
+ const pending = state.pendingAsk;
98
+ state.pendingAsk = null;
99
+ pending?.resolve(answer);
100
+ }
101
+
102
+ async function submit() {
103
+ if (state.streaming) return;
104
+ const text = state.input.trim();
105
+ if (!text) return;
106
+ if (text === '/salir' || text === '/exit' || text === '/quit') return shutdown(0);
107
+ if (text === '/limpiar' || text === '/clear') {
108
+ state.input = '';
109
+ state.messages = [];
110
+ return;
111
+ }
112
+
113
+ setInput('');
114
+ state.scroll = 0;
115
+ state.follow = true;
116
+ history.push(text);
117
+ historyIndex = -1;
118
+ addMessage('user', text);
119
+ const assistant = addMessage('assistant', '');
120
+ state.streaming = true;
121
+ controller = new AbortController();
122
+ let sawText = false;
123
+ draw();
124
+
125
+ try {
126
+ const { result, newMessages } = await runTurn({
127
+ providerId,
128
+ model,
129
+ baseUrl,
130
+ apiKey,
131
+ systemPrompt,
132
+ history: conv,
133
+ input: text,
134
+ maxRounds,
135
+ signal: controller.signal,
136
+ tools: definitions,
137
+ execute,
138
+ onEvent(event) {
139
+ if (event.type === 'text') {
140
+ sawText = true;
141
+ assistant.text = event.full;
142
+ } else if (event.type === 'reasoning') {
143
+ if (!sawText) assistant.reasoning = event.full;
144
+ } else if (event.type === 'stall') {
145
+ if (!sawText) assistant.text = `⚠ ${event.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`;
146
+ } else if (event.type === 'tool_call') {
147
+ assistant.tools.push({ name: event.name, args: JSON.stringify(event.args) });
148
+ } else if (event.type === 'tool_result') {
149
+ const tool = assistant.tools[assistant.tools.length - 1];
150
+ if (tool) tool.result = String(event.result).slice(0, 2000);
151
+ }
152
+ draw();
153
+ },
154
+ });
155
+ conv = conv.concat(newMessages);
156
+ store.addMessages(sessionId, newMessages);
157
+ store.touchSession(sessionId, { provider: providerId, model });
158
+ if (result?.usage) {
159
+ state.tokens = {
160
+ prompt: result.usage.last_prompt_tokens ?? result.usage.prompt_tokens,
161
+ completion: result.usage.last_completion_tokens ?? result.usage.completion_tokens,
162
+ };
163
+ }
164
+ if (!assistant.text && !sawText) assistant.text = '(sin texto)';
165
+ } catch (error) {
166
+ if (error?.name === 'AbortError') {
167
+ assistant.text = assistant.text ? `${assistant.text}\n[cancelado]` : '[cancelado]';
168
+ } else {
169
+ assistant.text = `Error: ${error?.message ?? error}`;
170
+ }
171
+ } finally {
172
+ state.streaming = false;
173
+ controller = null;
174
+ draw();
175
+ }
176
+ }
177
+
178
+ function handleAction(action) {
179
+ if (state.pendingAsk) {
180
+ if (action.type === 'char' && /[yYsS]/.test(action.value)) resolveAsk(true);
181
+ else if (action.type === 'interrupt' || action.type === 'enter' || (action.type === 'char' && /[nN]/.test(action.value)))
182
+ resolveAsk(false);
183
+ draw();
184
+ return;
185
+ }
186
+
187
+ switch (action.type) {
188
+ case 'char':
189
+ case 'paste':
190
+ insertText(action.value);
191
+ break;
192
+ case 'backspace': {
193
+ const chars = Array.from(state.input);
194
+ if (state.inputCursor > 0) {
195
+ chars.splice(state.inputCursor - 1, 1);
196
+ setInput(chars.join(''), state.inputCursor - 1);
197
+ }
198
+ break;
199
+ }
200
+ case 'delete': {
201
+ const chars = Array.from(state.input);
202
+ if (state.inputCursor < chars.length) {
203
+ chars.splice(state.inputCursor, 1);
204
+ setInput(chars.join(''), state.inputCursor);
205
+ }
206
+ break;
207
+ }
208
+ case 'left':
209
+ setInput(state.input, Math.max(0, state.inputCursor - 1));
210
+ break;
211
+ case 'right':
212
+ setInput(state.input, Math.min(Array.from(state.input).length, state.inputCursor + 1));
213
+ break;
214
+ case 'home':
215
+ setInput(state.input, 0);
216
+ break;
217
+ case 'end':
218
+ setInput(state.input, Array.from(state.input).length);
219
+ break;
220
+ case 'kill-line':
221
+ setInput('');
222
+ break;
223
+ case 'pageup':
224
+ state.follow = false;
225
+ state.scroll = Math.max(0, (state.scroll ?? 0) + pageSize());
226
+ break;
227
+ case 'pagedown':
228
+ state.scroll = Math.max(0, (state.scroll ?? 0) - pageSize());
229
+ if (state.scroll === 0) state.follow = true;
230
+ break;
231
+ case 'up':
232
+ if (history.length) {
233
+ historyIndex = historyIndex <= 0 ? history.length - 1 : historyIndex - 1;
234
+ setInput(history[historyIndex] ?? '');
235
+ }
236
+ break;
237
+ case 'down':
238
+ if (history.length && historyIndex !== -1) {
239
+ historyIndex += 1;
240
+ setInput(historyIndex >= history.length ? ((historyIndex = -1), '') : history[historyIndex]);
241
+ }
242
+ break;
243
+ case 'clear':
244
+ state.messages = [];
245
+ break;
246
+ case 'enter':
247
+ submit();
248
+ return;
249
+ case 'interrupt':
250
+ case 'eof':
251
+ if (state.streaming) controller?.abort();
252
+ else shutdown(0);
253
+ return;
254
+ default:
255
+ break;
256
+ }
257
+ draw();
258
+ }
259
+
260
+ function onData(chunk) {
261
+ for (const action of parseKeys(String(chunk))) handleAction(action);
262
+ }
263
+
264
+ function shutdown(code) {
265
+ if (closed) return;
266
+ closed = true;
267
+ try {
268
+ process.stdin.setRawMode(false);
269
+ } catch {
270
+ // stdin may not be a TTY (tests)
271
+ }
272
+ process.stdin.off('data', onData);
273
+ process.stdout.off('resize', draw);
274
+ process.stdin.pause();
275
+ toolOptions.mcp?.close?.();
276
+ store.close?.();
277
+ process.stdout.write(screen.disableBracketedPaste + screen.showCursor + screen.leaveAltScreen);
278
+ resolveExit(code);
279
+ }
280
+
281
+ // Restaura la terminal aunque el proceso muera por una señal o excepcion.
282
+ process.once('exit', () => {
283
+ try {
284
+ process.stdout.write(screen.disableBracketedPaste + screen.showCursor + screen.leaveAltScreen);
285
+ } catch {
286
+ /* nada */
287
+ }
288
+ });
289
+ process.once('SIGTERM', () => shutdown(0));
290
+
291
+ process.stdout.write(screen.enterAltScreen + screen.hideCursor + screen.enableBracketedPaste);
292
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
293
+ process.stdin.setEncoding('utf8');
294
+ process.stdin.resume();
295
+ process.stdin.on('data', onData);
296
+ process.stdout.on('resize', draw);
297
+ draw();
298
+
299
+ return new Promise((resolve) => {
300
+ resolveExit = resolve;
301
+ });
302
+ }
@@ -0,0 +1,78 @@
1
+ // Parse raw terminal input into high-level actions (pure, testable).
2
+
3
+ const ESCAPE_ACTIONS = {
4
+ '\x1b[A': 'up',
5
+ '\x1bOA': 'up',
6
+ '\x1b[B': 'down',
7
+ '\x1bOB': 'down',
8
+ '\x1b[C': 'right',
9
+ '\x1bOC': 'right',
10
+ '\x1b[D': 'left',
11
+ '\x1bOD': 'left',
12
+ '\x1b[H': 'home',
13
+ '\x1bOH': 'home',
14
+ '\x1b[1~': 'home',
15
+ '\x1b[F': 'end',
16
+ '\x1bOF': 'end',
17
+ '\x1b[4~': 'end',
18
+ '\x1b[3~': 'delete',
19
+ '\x1b[5~': 'pageup',
20
+ '\x1b[6~': 'pagedown',
21
+ };
22
+
23
+ const FINAL_BYTE = /[@-~]/;
24
+
25
+ // Reads one escape sequence starting at `i` (chars is a code-point array).
26
+ function readEscape(chars, i) {
27
+ if (chars[i + 1] === '[') {
28
+ let j = i + 2;
29
+ while (j < chars.length && !FINAL_BYTE.test(chars[j])) j += 1;
30
+ if (j < chars.length) j += 1;
31
+ return { seq: chars.slice(i, j).join(''), len: j - i };
32
+ }
33
+ if (chars[i + 1] === 'O' && i + 2 < chars.length) {
34
+ return { seq: chars.slice(i, i + 3).join(''), len: 3 };
35
+ }
36
+ return { seq: '\x1b', len: 1 };
37
+ }
38
+
39
+ export function parseKeys(input) {
40
+ const chars = Array.from(input);
41
+ const actions = [];
42
+ let i = 0;
43
+
44
+ while (i < chars.length) {
45
+ const ch = chars[i];
46
+
47
+ if (ch === '\x1b') {
48
+ const { seq, len } = readEscape(chars, i);
49
+ if (seq === '\x1b[200~') {
50
+ const remaining = chars.slice(i + len).join('');
51
+ const end = remaining.indexOf('\x1b[201~');
52
+ if (end !== -1) {
53
+ const value = remaining.slice(0, end);
54
+ actions.push({ type: 'paste', value });
55
+ i = i + len + Array.from(value).length + 6;
56
+ continue;
57
+ }
58
+ }
59
+ const mapped = ESCAPE_ACTIONS[seq];
60
+ if (mapped) actions.push({ type: mapped });
61
+ i += len;
62
+ continue;
63
+ }
64
+
65
+ if (ch === '\r' || ch === '\n') actions.push({ type: 'enter' });
66
+ else if (ch === '\x7f' || ch === '\b') actions.push({ type: 'backspace' });
67
+ else if (ch === '\x03') actions.push({ type: 'interrupt' });
68
+ else if (ch === '\x04') actions.push({ type: 'eof' });
69
+ else if (ch === '\x0c') actions.push({ type: 'clear' });
70
+ else if (ch === '\x15') actions.push({ type: 'kill-line' });
71
+ else if (ch === '\x01') actions.push({ type: 'home' });
72
+ else if (ch === '\x05') actions.push({ type: 'end' });
73
+ else if (ch >= ' ') actions.push({ type: 'char', value: ch });
74
+ i += 1;
75
+ }
76
+
77
+ return actions;
78
+ }