@gishubperu/ghp 0.0.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.
Files changed (39) hide show
  1. package/.env +13 -0
  2. package/domain/contracts/IAgent.js +3 -0
  3. package/domain/contracts/IMemoryStore.js +3 -0
  4. package/domain/contracts/IModelProvider.js +3 -0
  5. package/domain/contracts/IToolset.js +3 -0
  6. package/domain/dtos/requests/RunAgentRequest.js +8 -0
  7. package/domain/dtos/responses/AgentResponse.js +8 -0
  8. package/domain/dtos/responses/ToolResult.js +8 -0
  9. package/domain/entities/AgentDefinition.js +11 -0
  10. package/domain/entities/Message.js +10 -0
  11. package/domain/entities/ModelInfo.js +11 -0
  12. package/index.js +66 -0
  13. package/infrastructure/agents/agent-factory.js +79 -0
  14. package/infrastructure/agents/agriculture_agent.js +13 -0
  15. package/infrastructure/agents/base_agent.js +19 -0
  16. package/infrastructure/agents/deforestacion_agent.js +13 -0
  17. package/infrastructure/agents/general_agent.js +13 -0
  18. package/infrastructure/agents/minning_agent.js +13 -0
  19. package/infrastructure/constants/agent-config.js +48 -0
  20. package/infrastructure/constants/agent-type.js +20 -0
  21. package/infrastructure/constants/index.js +8 -0
  22. package/infrastructure/constants/model-config.js +37 -0
  23. package/infrastructure/constants/system-prompts.js +88 -0
  24. package/infrastructure/constants/task-type.js +22 -0
  25. package/infrastructure/core/orchestrator.js +155 -0
  26. package/infrastructure/core/queue.js +53 -0
  27. package/infrastructure/core/workers.js +67 -0
  28. package/infrastructure/memory/store.js +115 -0
  29. package/infrastructure/providers/anthropic.js +59 -0
  30. package/infrastructure/providers/gateway.js +140 -0
  31. package/infrastructure/providers/gemini.js +50 -0
  32. package/infrastructure/providers/ollama.js +92 -0
  33. package/infrastructure/providers/openai.js +83 -0
  34. package/infrastructure/router/router.js +115 -0
  35. package/infrastructure/skills/gis.skill.js +105 -0
  36. package/infrastructure/tools/arcgis.js +187 -0
  37. package/infrastructure/tools/tool-formatters.js +110 -0
  38. package/package.json +32 -0
  39. package/presentation/console/procedures/App.js +424 -0
@@ -0,0 +1,110 @@
1
+ // infrastructure/tools/ToolFormatters.js
2
+ // Utility functions for formatting tool results in UI
3
+
4
+ export class ToolFormatters {
5
+
6
+ static buildPreview(toolName, args) {
7
+ if (!args) return '';
8
+
9
+ switch (toolName) {
10
+ case 'getServiceMetadata':
11
+ return 'leyendo estructura del servicio...';
12
+
13
+ case 'createPDFReport':
14
+ return `generando PDF: ${args.title ?? args.filename ?? ''}`;
15
+
16
+ case 'getArcGISData':
17
+ return this.#formatArcGISPreview(args);
18
+
19
+ default:
20
+ return '';
21
+ }
22
+ }
23
+
24
+ static #formatArcGISPreview(args) {
25
+ if (args.returnCountOnly) {
26
+ return 'contando registros...';
27
+ }
28
+ if (args.returnDistinctValues) {
29
+ return `valores únicos de ${args.outFields ?? 'campo'}`;
30
+ }
31
+ if (args.outStatistics) {
32
+ let stat = [];
33
+ try { stat = JSON.parse(args.outStatistics); } catch {}
34
+ const tipos = [...new Set(stat.map(s => s.statisticType))].join('/');
35
+ const grupo = args.groupByFieldsForStatistics ? ` por ${args.groupByFieldsForStatistics}` : '';
36
+ return `calculando ${tipos}${grupo}...`;
37
+ }
38
+ if (args.where && args.where !== '1=1') {
39
+ const w = args.where
40
+ .replace(/TXT_DEPARTAMENTO\s*IN\s*\(([^)]+)\)/i, (_, v) =>
41
+ 'dptos: ' + v.replace(/'/g,'').replace(/,\s*/g,', '))
42
+ .replace(/TXT_DEPARTAMENTO\s*=\s*'([^']+)'/i, 'dpto: $1')
43
+ .replace(/TXT_PROVINCIA\s*=\s*'([^']+)'/i, 'prov: $1')
44
+ .replace(/TXT_CULTIVO\s*LIKE\s*'%([^%']+)%'/i,'cultivo: ~$1')
45
+ .replace(/TXT_CULTIVO\s*=\s*'([^']+)'/i, 'cultivo: $1')
46
+ .replace(/AND/gi, '·').replace(/OR/gi, 'ó')
47
+ .replace(/\s+/g, ' ').trim();
48
+ const lim = args.resultRecordCount ? ` máx ${args.resultRecordCount}` : '';
49
+ return (w + lim).slice(0, 65);
50
+ }
51
+ return args.resultRecordCount ? `primeros ${args.resultRecordCount} registros` : 'consulta general...';
52
+ }
53
+
54
+ static buildSummary(toolName, result) {
55
+ if (!result) return 'listo';
56
+
57
+ if (result.error || result.code === 400) {
58
+ return '⚠ parámetros de consulta inválidos';
59
+ }
60
+
61
+ switch (toolName) {
62
+ case 'getServiceMetadata':
63
+ return `${result.fields?.length ?? 0} campos disponibles`;
64
+
65
+ case 'createPDFReport':
66
+ return result.message ?? result.path ?? 'PDF generado';
67
+
68
+ case 'getArcGISData':
69
+ return this.#formatArcGISSummary(result);
70
+
71
+ default:
72
+ if (result.count != null) {
73
+ return `${Number(result.count).toLocaleString('es-PE')} registros encontrados`;
74
+ }
75
+ if (Array.isArray(result)) {
76
+ return `${result.length} registros`;
77
+ }
78
+ if (result.message) return result.message;
79
+ if (result.path) return result.path;
80
+ if (result.fields) return `${result.fields.length} campos`;
81
+ return 'completado';
82
+ }
83
+ }
84
+
85
+ static #formatArcGISSummary(result) {
86
+ if (result.count != null) {
87
+ return `${Number(result.count).toLocaleString('es-PE')} registros encontrados`;
88
+ }
89
+ if (result.features != null) {
90
+ const n = result.features.length;
91
+ const extra = result.exceededTransferLimit ? ' (hay más, paginando...)' : '';
92
+ return `${n.toLocaleString('es-PE')} registros${extra}`;
93
+ }
94
+ return 'completado';
95
+ }
96
+
97
+ static buildDetail(result) {
98
+ if (!result?.features || result.features.length === 0) return null;
99
+
100
+ return result.features.slice(0, 2).map(r =>
101
+ Object.entries(r)
102
+ .filter(([k]) => !k.match(/^(OBJECTID|FID|IDE_|Shape)/))
103
+ .slice(0, 4)
104
+ .map(([k, v]) => `${k.replace(/^(TXT_|NUM_|NOM_|FEC_)/,'')}: ${v ?? '-'}`)
105
+ .join(' ')
106
+ ).join('\n');
107
+ }
108
+ }
109
+
110
+ export default ToolFormatters;
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@gishubperu/ghp",
3
+ "version": "0.0.1",
4
+ "description": "GHP CLI - Multi-Agent",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "ghp": "./index.js",
9
+ "chy": "./index.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node index.js",
13
+ "dev": "node --watch index.js"
14
+ },
15
+ "dependencies": {
16
+ "@anthropic-ai/sdk": "^0.78.0",
17
+ "@google/generative-ai": "^0.24.1",
18
+ "axios": "^1.13.5",
19
+ "boxen": "^8.0.1",
20
+ "chalk": "^5.6.2",
21
+ "dotenv": "^17.3.1",
22
+ "figlet": "^1.10.0",
23
+ "ink": "^6.8.0",
24
+ "ink-select-input": "^6.2.0",
25
+ "ink-text-input": "^6.0.0",
26
+ "openai": "^6.22.0",
27
+ "ora": "^9.3.0",
28
+ "pdfkit": "^0.17.2",
29
+ "quickchart-js": "^3.1.3",
30
+ "react": "^19.2.4"
31
+ }
32
+ }
@@ -0,0 +1,424 @@
1
+ // ui/App.js — GHP CLI, diseño Kilo Code exacto
2
+
3
+ import React, { useState, useCallback, useEffect, useRef } from 'react';
4
+ import { Box, Text, useApp, useInput, useStdout, Static } from 'ink';
5
+ import SelectInput from 'ink-select-input';
6
+ import TextInput from 'ink-text-input';
7
+
8
+ import { AGENT_LIST, AgentType } from '../../../infrastructure/constants/index.js';
9
+ import { ToolFormatters } from '../../../infrastructure/tools/tool-formatters.js';
10
+
11
+
12
+ const PKG = await import('../../../package.json', { with: { type: 'json' } });
13
+ const APP_NAME = PKG.default.description;
14
+ const APP_VERSION = PKG.default.version;
15
+
16
+ // ── Toast para info de agente ────────────────────────────────────────────────
17
+ function Toast({ message, type = 'info', onClose }) {
18
+ useEffect(() => {
19
+ const timer = setTimeout(() => onClose?.(), 3000);
20
+ return () => clearTimeout(timer);
21
+ }, [onClose]);
22
+
23
+ const colors = {
24
+ info: { bg: 'cyan', text: 'black' },
25
+ success: { bg: 'green', text: 'black' },
26
+ warning: { bg: 'yellow', text: 'black' },
27
+ error: { bg: 'red', text: 'white' },
28
+ };
29
+ const color = colors[type] || colors.info;
30
+
31
+ return React.createElement(Box, {
32
+ borderStyle: 'round',
33
+ borderColor: color.bg,
34
+ paddingX: 1,
35
+ marginY: 0,
36
+ },
37
+ React.createElement(Text, { bold: true, color: color.text, backgroundColor: color.bg }, ' '),
38
+ React.createElement(Text, { color: 'white', marginLeft: 1 }, message)
39
+ );
40
+ }
41
+
42
+ // ── Spinner ───────────────────────────────────────────────────────────────────
43
+ const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
44
+ function Spinner({ label }) {
45
+ const [frame, setFrame] = useState(0);
46
+ useEffect(() => {
47
+ const t = setInterval(() => setFrame(p => (p + 1) % SPINNER_FRAMES.length), 80);
48
+ return () => clearInterval(t);
49
+ }, []);
50
+ return React.createElement(Box, null,
51
+ React.createElement(Text, { color: 'cyan' }, SPINNER_FRAMES[frame] + ' '),
52
+ React.createElement(Text, { color: 'white' }, label)
53
+ );
54
+ }
55
+
56
+
57
+ // ── Logo ASCII ────────────────────────────────────────────────────────────────
58
+ const LOGO = [
59
+ ' ██████╗ ██╗ ██╗██████╗ ██████╗██╗ ██╗',
60
+ '██╔════╝ ██║ ██║██╔══██╗ ██╔════╝██║ ██║',
61
+ '██║ ███╗███████║██████╔╝ ██║ ██║ ██║',
62
+ '██║ ██║██╔══██║██╔═══╝ ██║ ██║ ██║',
63
+ '╚██████╔╝██║ ██║██║ ╚██████╗███████╗██║',
64
+ ' ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═════╝╚══════╝╚═╝',
65
+ ];
66
+
67
+ // ── Mensaje ───────────────────────────────────────────────────────────────────
68
+ function Msg({ msg }) {
69
+ switch (msg.role) {
70
+ case 'user':
71
+ return React.createElement(Box, { flexDirection: 'column', marginBottom: 1 },
72
+ React.createElement(Box, null,
73
+ React.createElement(Text, { backgroundColor: 'yellow', color: 'black', bold: true }, ' Ask '),
74
+ React.createElement(Text, { color: 'white' }, ' ' + msg.text)
75
+ )
76
+ );
77
+
78
+ case 'thinking':
79
+ return React.createElement(Box, { marginBottom: 0, marginLeft: 2 },
80
+ React.createElement(Text, { color: 'cyan', italic: true }, 'Thinking: '),
81
+ React.createElement(Text, { color: 'gray', wrap: 'wrap' }, msg.text)
82
+ );
83
+
84
+ case 'assistant':
85
+ return React.createElement(Box, { flexDirection: 'column', marginBottom: 1 },
86
+ React.createElement(Text, { color: 'gray', dimColor: true },
87
+ ' ' + (msg.model ?? '') + ' · ' + (msg.elapsed ?? '')
88
+ ),
89
+ React.createElement(Box, null,
90
+ React.createElement(Text, { color: 'white', wrap: 'wrap' }, msg.text)
91
+ )
92
+ );
93
+
94
+ case 'tool_start':
95
+ return React.createElement(Box, { marginBottom: 0, marginLeft: 2 },
96
+ React.createElement(Text, { color: 'gray' }, '$ '),
97
+ React.createElement(Text, { color: 'cyan', bold: true }, msg.toolName),
98
+ React.createElement(Text, { color: 'gray' }, msg.preview ? ' ' + msg.preview : '')
99
+ );
100
+
101
+ case 'tool_done':
102
+ return React.createElement(Box, { flexDirection: 'column', marginBottom: 1, marginLeft: 2 },
103
+ React.createElement(Box, null,
104
+ React.createElement(Text, { color: 'green' }, '✓ '),
105
+ React.createElement(Text, { color: 'cyan' }, msg.toolName),
106
+ React.createElement(Text, { color: 'gray' }, ' ' + msg.summary)
107
+ ),
108
+ msg.detail && React.createElement(Text, {
109
+ color: 'gray', dimColor: true, wrap: 'wrap'
110
+ }, ' ' + msg.detail)
111
+ );
112
+
113
+ case 'error':
114
+ return React.createElement(Box, { marginBottom: 1 },
115
+ React.createElement(Text, { color: 'red' }, ' ✗ ' + msg.text)
116
+ );
117
+
118
+ case 'system':
119
+ return React.createElement(Box, { marginBottom: 1 },
120
+ React.createElement(Text, { color: 'gray' }, ' ' + msg.text)
121
+ );
122
+
123
+ default: return null;
124
+ }
125
+ }
126
+
127
+ // ── Selector de modelo ────────────────────────────────────────────────────────
128
+ function ModelSelector({ router, onClose }) {
129
+ const models = router.listAvailable();
130
+ const freeModels = models.filter(m => m.free);
131
+ const paidModels = models.filter(m => !m.free);
132
+ const selectItems = [
133
+ ...freeModels.map((m, i) => ({ label: m.name ?? m.id, value: m, hint: 'free', key: 'f' + i })),
134
+ ...paidModels.map((m, i) => ({ label: m.name ?? m.id, value: m, hint: '', key: 'p' + i })),
135
+ ];
136
+
137
+ useInput((_c, k) => { if (k.escape) onClose(null); });
138
+
139
+ return React.createElement(Box, {
140
+ flexDirection: 'column',
141
+ borderStyle: 'single', borderColor: 'yellow',
142
+ paddingX: 1, marginX: 0,
143
+ },
144
+ React.createElement(Box, { marginBottom: 1, justifyContent: 'space-between' },
145
+ React.createElement(Text, { bold: true, color: 'white' }, 'Select model'),
146
+ React.createElement(Text, { color: 'gray' }, '↑↓ navegar Enter seleccionar Esc cancelar')
147
+ ),
148
+ React.createElement(Box, { marginBottom: 1 },
149
+ React.createElement(Text, { color: 'green', bold: true }, `${freeModels.length} free `),
150
+ React.createElement(Text, { color: 'gray' }, `${paidModels.length} paid`)
151
+ ),
152
+ React.createElement(SelectInput, {
153
+ items: selectItems,
154
+ onSelect: item => onClose(item.value),
155
+ limit: 14,
156
+ itemComponent: ({ isSelected, label, hint }) =>
157
+ React.createElement(Box, null,
158
+ React.createElement(Text, {
159
+ backgroundColor: isSelected ? 'yellow' : undefined,
160
+ color: isSelected ? 'black' : 'white',
161
+ bold: isSelected,
162
+ }, (isSelected ? ' ● ' : ' ') + label),
163
+ hint === 'free' && React.createElement(Text, {
164
+ backgroundColor: isSelected ? 'yellow' : undefined,
165
+ color: isSelected ? 'black' : 'green',
166
+ }, ' (free)')
167
+ ),
168
+ indicatorComponent: () => null,
169
+ })
170
+ );
171
+ }
172
+
173
+ // ── App ───────────────────────────────────────────────────────────────────────
174
+ export function App({ orchestrator, router, memory }) {
175
+ const { exit } = useApp();
176
+ const { stdout } = useStdout();
177
+
178
+ const [mode, setMode] = useState('banner');
179
+ const [input, setInput] = useState('');
180
+ const [messages, setMessages] = useState([]);
181
+ const [status, setStatus] = useState('');
182
+ const [agentIndex, setAgentIndex] = useState(0);
183
+ const [toast, setToast] = useState(null);
184
+ const startRef = useRef(null);
185
+ const abortRef = useRef(null);
186
+
187
+ const currentAgent = AGENT_LIST[agentIndex];
188
+
189
+ const availableModels = router.listAvailable();
190
+ const freeModelCount = availableModels.filter(m => m.free).length;
191
+ const activeModel = router.getActive();
192
+ const modelLabel = activeModel
193
+ ? (activeModel.model.includes('/') ? activeModel.model.split('/').slice(1).join('/') : activeModel.model)
194
+ : 'Auto';
195
+
196
+ useInput((ch, key) => {
197
+ if (key.escape) {
198
+ if (mode === 'model-select') {
199
+ // Cerrar selector
200
+ setMode(messages.length === 0 ? 'banner' : 'chat');
201
+ return;
202
+ }
203
+ if (mode === 'thinking') {
204
+ // CANCELAR proceso en curso
205
+ abortRef.current?.abort();
206
+ return;
207
+ }
208
+ if (mode === 'chat') {
209
+ setInput(''); // limpiar input
210
+ return;
211
+ }
212
+ }
213
+ if (mode === 'model-select') return;
214
+ if (key.ctrl && key.name === 'k') { setMode('model-select'); return; }
215
+ if (key.tab) {
216
+ const newIndex = (agentIndex + 1) % AGENT_LIST.length;
217
+ setAgentIndex(newIndex);
218
+ const agent = AGENT_LIST[newIndex];
219
+ setToast({ message: `${agent.label}: ${agent.description}`, type: 'info', key: Date.now() });
220
+ return;
221
+ }
222
+ });
223
+
224
+ const showAgentToast = useCallback(() => {
225
+ const agent = AGENT_LIST[agentIndex];
226
+ setToast({ message: `${agent.label}: ${agent.description}`, type: 'info', key: Date.now() });
227
+ }, [agentIndex]);
228
+
229
+ const addMsg = useCallback(msg =>
230
+ setMessages(prev => [...prev, { ...msg, id: Date.now() + Math.random() }])
231
+ , []);
232
+
233
+ const onModelSelected = useCallback(selected => {
234
+ if (selected) {
235
+ try {
236
+ router.setModelFromMenu(selected);
237
+ addMsg({ role: 'system', text: `✓ Modelo → ${selected.name ?? selected.id}` });
238
+ } catch (e) { addMsg({ role: 'error', text: e.message }); }
239
+ }
240
+ setMode(messages.length === 0 ? 'banner' : 'chat');
241
+ }, [router, addMsg, messages.length]);
242
+
243
+ const onSubmit = useCallback(async value => {
244
+ const text = value.trim();
245
+ if (!text) return;
246
+ setInput('');
247
+
248
+ if (mode !== 'chat') {
249
+ setMode('chat');
250
+ const agent = AGENT_LIST[agentIndex];
251
+ setToast({ message: `${agent.label}: ${agent.description}`, type: 'info', key: Date.now() });
252
+ }
253
+
254
+ if (text === '/exit') { exit(); return; }
255
+ if (text === '/model') { setMode('model-select'); return; }
256
+ if (text === '/auto') { router.setAuto(); addMsg({ role: 'system', text: '✓ Auto activado' }); return; }
257
+ if (text === '/clear') { await memory.clear(); addMsg({ role: 'system', text: '✓ Memoria borrada' }); return; }
258
+ if (text === '/history') {
259
+ const es = await memory.recent(5);
260
+ if (!es.length) { addMsg({ role: 'system', text: 'Sin historial.' }); return; }
261
+ es.forEach(e => addMsg({ role: 'system', text: `[${e.timestamp.slice(0,16).replace('T',' ')}] ${e.input.slice(0,70)}` }));
262
+ return;
263
+ }
264
+ if (text === '/help') {
265
+ addMsg({ role: 'system', text:
266
+ '/model → selector de modelo\n' +
267
+ '/auto → modo automático\n' +
268
+ '/clear → borrar memoria\n' +
269
+ '/history → últimas conversaciones\n' +
270
+ '/exit → salir'
271
+ });
272
+ return;
273
+ }
274
+
275
+ addMsg({ role: 'user', text });
276
+ setMode('thinking');
277
+ setStatus('Procesando...');
278
+ startRef.current = Date.now();
279
+ abortRef.current = new AbortController();
280
+
281
+ try {
282
+ const currentAgent = AGENT_LIST[agentIndex];
283
+ const result = await orchestrator.run(text, {
284
+ agentId: currentAgent.id,
285
+ signal: abortRef.current.signal,
286
+ onProgress: ({ type, toolName, toolArgs, toolResult }) => {
287
+ if (type === 'planning') setStatus('Planificando...');
288
+ if (type === 'synthesizing') setStatus('Sintetizando...');
289
+ if (type === 'tool_start') {
290
+ setStatus(`Ejecutando ${toolName}...`);
291
+ addMsg({ role: 'tool_start', toolName, preview: ToolFormatters.buildPreview(toolName, toolArgs) });
292
+ }
293
+ if (type === 'tool_done') {
294
+ setStatus('Procesando...');
295
+ addMsg({ role: 'tool_done', toolName,
296
+ summary: ToolFormatters.buildSummary(toolName, toolResult),
297
+ detail: ToolFormatters.buildDetail(toolResult),
298
+ });
299
+ }
300
+ },
301
+ });
302
+
303
+ const elapsed = ((Date.now() - startRef.current) / 1000).toFixed(1) + 's';
304
+ addMsg({ role: 'assistant', text: result.text, model: result.model, elapsed });
305
+ } catch (err) {
306
+ if (err.name === 'AbortError' || err.message?.includes('aborted')) {
307
+ addMsg({ role: 'system', text: '⊘ Proceso cancelado' });
308
+ } else {
309
+ addMsg({ role: 'error', text: err.message });
310
+ }
311
+ }
312
+
313
+ setStatus('');
314
+ setMode('chat');
315
+ }, [orchestrator, router, memory, exit, addMsg]);
316
+
317
+ // ── Barra inferior (siempre fija) ─────────────────────────────────────────
318
+ const BottomBar = () => React.createElement(Box, {
319
+ flexDirection: 'column',
320
+ borderStyle: 'single', borderColor: 'gray',
321
+ paddingLeft: 1, paddingRight: 1,
322
+ },
323
+ // Línea 1: input
324
+ React.createElement(Box, null,
325
+ React.createElement(Text, {
326
+ backgroundColor: 'yellow', color: 'black', bold: true
327
+ }, ' Ask '),
328
+ React.createElement(Text, { color: 'gray' }, ' '),
329
+ (mode === 'chat' || mode === 'banner')
330
+ ? React.createElement(TextInput, {
331
+ value: input, onChange: setInput, onSubmit: onSubmit,
332
+ placeholder: 'Ask anything... ""',
333
+ })
334
+ : React.createElement(Text, { color: 'gray', dimColor: true },
335
+ 'Ask anything... ""')
336
+ ),
337
+ // Línea 2: agente activo + modelo + shortcuts
338
+ React.createElement(Box, { justifyContent: 'space-between' },
339
+ React.createElement(Box, null,
340
+ React.createElement(Text, {
341
+ backgroundColor: 'yellow', color: 'black', bold: true
342
+ }, ' Ask '),
343
+ React.createElement(Text, { color: 'gray' }, ' '),
344
+ React.createElement(Text, {
345
+ color: AGENT_LIST[agentIndex].color, bold: true
346
+ }, AGENT_LIST[agentIndex].label + ' '),
347
+ React.createElement(Text, { color: 'cyan' }, modelLabel + ' '),
348
+ React.createElement(Text, { color: 'gray' }, 'GHP Gateway')
349
+ ),
350
+ React.createElement(Text, { color: 'gray' },
351
+ 'tab agente ctrl+k modelo esc cancelar ctrl+c salir')
352
+ )
353
+ );
354
+
355
+ // ── Layout principal ──────────────────────────────────────────────────────
356
+ const terminalRows = stdout.terminalRows || 24;
357
+ // Banner top padding: center vertically, minus logo(8) + info(2) + bottombar(3)
358
+ const bannerTopPad = Math.max(1, Math.floor((terminalRows - 13) / 2));
359
+
360
+ return React.createElement(Box, { flexDirection: 'column' },
361
+ // Zona de contenido
362
+ React.createElement(Box, { flexDirection: 'column' },
363
+
364
+ // Banner (solo al inicio)
365
+ mode === 'banner' && React.createElement(Box, {
366
+ flexDirection: 'column', alignItems: 'center',
367
+ paddingTop: bannerTopPad, paddingBottom: 2,
368
+ },
369
+ ...LOGO.map((l, i) =>
370
+ React.createElement(Text, { key: i, color: 'yellow', bold: true }, l)
371
+ ),
372
+ React.createElement(Box, { marginTop: 1 },
373
+ React.createElement(Text, { color: 'green', bold: true }, `${freeModelCount} free models `),
374
+ React.createElement(Text, { color: 'gray' }, `${availableModels.length} total GHP Gateway ${APP_NAME} v${APP_VERSION}`)
375
+ ),
376
+ // Agent pills — Tab para cambiar
377
+ React.createElement(Box, { marginTop: 2, gap: 1 },
378
+ ...AGENT_LIST.map((a, i) =>
379
+ React.createElement(Text, {
380
+ key: a.id,
381
+ backgroundColor: i === agentIndex ? 'yellow' : undefined,
382
+ color: i === agentIndex ? 'black' : a.color,
383
+ bold: i === agentIndex,
384
+ }, ` ${a.label} `)
385
+ )
386
+ ),
387
+ // Info fija del agente
388
+ mode === 'banner' && React.createElement(Box, { marginTop: 1 },
389
+ React.createElement(Text, { color: 'gray', italic: true },
390
+ AGENT_LIST[agentIndex].description)
391
+ ),
392
+ React.createElement(Box, { marginTop: 0 },
393
+ React.createElement(Text, { color: 'gray', dimColor: true },
394
+ 'tab para cambiar agente')
395
+ )
396
+ ),
397
+
398
+ // Mensajes
399
+ React.createElement(Static, { items: messages },
400
+ msg => React.createElement(Msg, { key: msg.id, msg })
401
+ ),
402
+
403
+ // Toast de agente
404
+ toast && React.createElement(Toast, {
405
+ key: toast.key ?? Date.now(),
406
+ message: toast.message,
407
+ type: toast.type,
408
+ onClose: () => setToast(null)
409
+ }),
410
+
411
+ // Spinner
412
+ mode === 'thinking' && React.createElement(Box, { marginLeft: 2, marginBottom: 1 },
413
+ React.createElement(Spinner, { label: status })
414
+ ),
415
+
416
+ // Selector de modelo
417
+ mode === 'model-select' &&
418
+ React.createElement(ModelSelector, { router, onClose: onModelSelected }),
419
+ ),
420
+
421
+ // Barra inferior
422
+ React.createElement(BottomBar)
423
+ );
424
+ }