trackops 1.0.0 → 1.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 (72) hide show
  1. package/README.md +341 -232
  2. package/bin/trackops.js +102 -70
  3. package/lib/config.js +260 -35
  4. package/lib/control.js +518 -475
  5. package/lib/env.js +227 -0
  6. package/lib/i18n.js +61 -53
  7. package/lib/init.js +146 -55
  8. package/lib/locale.js +63 -0
  9. package/lib/opera-bootstrap.js +523 -0
  10. package/lib/opera.js +319 -170
  11. package/lib/registry.js +27 -13
  12. package/lib/release.js +56 -0
  13. package/lib/resources.js +42 -0
  14. package/lib/server.js +912 -418
  15. package/lib/skills.js +148 -124
  16. package/lib/workspace.js +260 -0
  17. package/locales/en.json +331 -139
  18. package/locales/es.json +331 -139
  19. package/package.json +14 -3
  20. package/scripts/skills-marketplace-smoke.js +124 -0
  21. package/scripts/smoke-tests.js +445 -0
  22. package/scripts/sync-skill-version.js +21 -0
  23. package/scripts/validate-skill.js +88 -0
  24. package/skills/trackops/SKILL.md +64 -0
  25. package/skills/trackops/agents/openai.yaml +3 -0
  26. package/skills/trackops/references/activation.md +39 -0
  27. package/skills/trackops/references/troubleshooting.md +34 -0
  28. package/skills/trackops/references/workflow.md +20 -0
  29. package/skills/trackops/scripts/bootstrap-trackops.js +201 -0
  30. package/skills/trackops/skill.json +29 -0
  31. package/templates/etapa/agent.md +2 -2
  32. package/templates/etapa/references/etapa-cycle.md +1 -1
  33. package/templates/opera/agent.md +1 -1
  34. package/templates/opera/en/agent.md +26 -0
  35. package/templates/opera/en/genesis.md +79 -0
  36. package/templates/opera/en/references/autonomy-and-recovery.md +23 -0
  37. package/templates/opera/en/references/opera-cycle.md +62 -0
  38. package/templates/opera/en/registry.md +28 -0
  39. package/templates/opera/en/router.md +39 -0
  40. package/templates/opera/genesis.md +79 -94
  41. package/templates/skills/changelog-updater/locales/en/SKILL.md +11 -0
  42. package/templates/skills/commiter/locales/en/SKILL.md +11 -0
  43. package/templates/skills/project-starter-skill/SKILL.md +5 -3
  44. package/templates/skills/project-starter-skill/locales/en/SKILL.md +24 -0
  45. package/ui/css/base.css +266 -0
  46. package/ui/css/charts.css +327 -0
  47. package/ui/css/components.css +570 -0
  48. package/ui/css/panels.css +956 -0
  49. package/ui/css/tokens.css +227 -0
  50. package/ui/favicon.svg +5 -0
  51. package/ui/index.html +91 -351
  52. package/ui/js/api.js +220 -0
  53. package/ui/js/app.js +200 -0
  54. package/ui/js/console-logger.js +172 -0
  55. package/ui/js/i18n.js +14 -0
  56. package/ui/js/icons.js +104 -0
  57. package/ui/js/onboarding.js +439 -0
  58. package/ui/js/router.js +125 -0
  59. package/ui/js/state.js +130 -0
  60. package/ui/js/theme.js +100 -0
  61. package/ui/js/time-tracker.js +248 -0
  62. package/ui/js/utils.js +175 -0
  63. package/ui/js/views/board.js +255 -0
  64. package/ui/js/views/execution.js +256 -0
  65. package/ui/js/views/flash.js +47 -0
  66. package/ui/js/views/insights.js +340 -0
  67. package/ui/js/views/overview.js +365 -0
  68. package/ui/js/views/settings.js +381 -0
  69. package/ui/js/views/sidebar.js +131 -0
  70. package/ui/js/views/skills.js +163 -0
  71. package/ui/js/views/tasks.js +406 -0
  72. package/ui/js/views/topbar.js +239 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * board.js — Tablero Kanban con drag & drop
3
+ */
4
+
5
+ import { icon } from '../icons.js';
6
+ import * as state from '../state.js';
7
+ import * as api from '../api.js';
8
+ import * as router from '../router.js';
9
+ import { flash } from './flash.js';
10
+ import { esc, debounce } from '../utils.js';
11
+ import { t } from '../i18n.js';
12
+
13
+ const COLUMNS = [
14
+ { id: 'pending', label: 'Pending' },
15
+ { id: 'in_progress', label: 'In Progress' },
16
+ { id: 'in_review', label: 'In Review' },
17
+ { id: 'blocked', label: 'Blocked' },
18
+ { id: 'completed', label: 'Completed' },
19
+ ];
20
+
21
+ let _dragTaskId = null;
22
+
23
+ export async function render() {
24
+ const payload = state.getPayload();
25
+ if (!payload) return `<div class="empty-state" style="margin:3rem">${t('ui.board.noData', {}, 'No project data.')}</div>`;
26
+
27
+ const tasks = _filterTasks(payload.derived.tasks);
28
+ const hasCancelled = tasks.some(t => t.status === 'cancelled');
29
+ const columns = hasCancelled
30
+ ? [...COLUMNS, { id: 'cancelled', label: t('status.cancelled', {}, 'Cancelled') }]
31
+ : COLUMNS.map(col => ({ ...col, label: t(`status.${col.id}`, {}, col.label) }));
32
+
33
+ const html = `
34
+ <div class="view-enter">
35
+ <div class="section-header">
36
+ <div class="section-header-left">
37
+ <p class="eyebrow">${t('ui.board.eyebrow', {}, 'Board')}</p>
38
+ <h2>${t('ui.board.title', {}, 'Operational board')}</h2>
39
+ </div>
40
+ <div style="display:flex;gap:var(--space-2)">
41
+ <button class="btn btn-ghost btn-sm" id="board-filter-done" type="button">
42
+ ${icon('check', 14)} ${_showCompleted() ? t('ui.board.hideCompleted', {}, 'Hide completed') : t('ui.board.showCompleted', {}, 'Show completed')}
43
+ </button>
44
+ <button class="btn btn-primary btn-sm" id="new-task-btn" type="button" aria-label="${t('ui.board.newTask', {}, 'Create new task')}">
45
+ ${icon('plus', 14)} ${t('ui.tasks.new', {}, 'New task')}
46
+ </button>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="board-grid" id="board" aria-label="${t('ui.board.aria', {}, 'Task board by status')}" role="region">
51
+ ${columns.map(col => {
52
+ const colTasks = tasks.filter(t => t.status === col.id);
53
+ return _renderColumn(col, colTasks);
54
+ }).join('')}
55
+ </div>
56
+ </div>
57
+ `;
58
+
59
+ setTimeout(() => _bindEvents(), 0);
60
+ return html;
61
+ }
62
+
63
+ function _filterTasks(tasks) {
64
+ let list = tasks;
65
+ const query = state.get('searchQuery')?.toLowerCase();
66
+ if (query) {
67
+ list = list.filter(t =>
68
+ t.title.toLowerCase().includes(query) ||
69
+ t.id.toLowerCase().includes(query) ||
70
+ (t.summary || '').toLowerCase().includes(query)
71
+ );
72
+ }
73
+ if (!_showCompleted()) {
74
+ list = list.filter(t => t.status !== 'completed' && t.status !== 'cancelled');
75
+ }
76
+ return list;
77
+ }
78
+
79
+ function _showCompleted() {
80
+ return sessionStorage.getItem('board-show-completed') === 'true';
81
+ }
82
+
83
+ function _renderColumn(col, tasks) {
84
+ return `
85
+ <section class="board-column col-${col.id}" data-status="${col.id}" aria-label="${t('ui.board.column', { label: col.label }, `Column ${col.label}`)}">
86
+ <div class="board-column-header">
87
+ <h3 class="board-column-title" id="col-${col.id}">
88
+ <span class="board-column-dot" aria-hidden="true"></span>
89
+ ${esc(col.label)}
90
+ </h3>
91
+ <span class="board-column-count" aria-label="${t('ui.board.tasksCount', { count: tasks.length }, `${tasks.length} tasks`)}">${tasks.length}</span>
92
+ </div>
93
+ <div class="board-column-body" aria-labelledby="col-${col.id}" role="list">
94
+ ${tasks.map(t => _renderCard(t)).join('')}
95
+ ${tasks.length === 0
96
+ ? `<div class="empty-state" style="padding:var(--space-5);min-height:80px;border-style:dashed">${t('ui.board.noTasks', {}, 'No tasks')}</div>`
97
+ : ''}
98
+ </div>
99
+ </section>
100
+ `;
101
+ }
102
+
103
+ function _renderCard(task) {
104
+ const isSelected = task.id === state.get('selectedTaskId');
105
+ const statusLabels = state.getStatusLabels();
106
+ const phases = state.getPhases();
107
+ const phaseInfo = phases.find(p => p.id === task.phase);
108
+
109
+ const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
110
+
111
+ return `
112
+ <article
113
+ class="task-card ${isSelected ? 'is-selected' : ''}"
114
+ data-task-id="${esc(task.id)}"
115
+ data-status="${esc(task.status)}"
116
+ draggable="true"
117
+ role="listitem"
118
+ tabindex="0"
119
+ aria-label="${esc(task.title)}, ${statusLabels[task.status] || task.status}, ${t('ui.board.priority', { priority: task.priority }, `priority ${task.priority}`)}"
120
+ aria-selected="${isSelected}"
121
+ >
122
+ <strong class="task-card-title">${esc(task.title)}</strong>
123
+ <span class="task-card-id">${esc(task.id)}</span>
124
+ <p class="task-card-summary">${esc(task.summary || t('ui.board.noDescription', {}, 'No description.'))}</p>
125
+ <div class="task-card-meta">
126
+ <span class="badge badge-${priorityVariant[task.priority] || 'muted'}">${esc(task.priority)}</span>
127
+ <span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
128
+ ${task.stream ? `<span class="badge badge-muted">${esc(task.stream)}</span>` : ''}
129
+ ${task.blocker ? `<span class="badge badge-danger" title="${esc(task.blocker)}">${icon('alertTriangle', 10)} ${t('status.blocked', {}, 'Blocked')}</span>` : ''}
130
+ </div>
131
+ </article>
132
+ `;
133
+ }
134
+
135
+ function _bindEvents() {
136
+ const board = document.getElementById('board');
137
+ if (!board) return;
138
+
139
+ // Clic en task card → seleccionar y navegar a tasks
140
+ board.addEventListener('click', e => {
141
+ const card = e.target.closest('.task-card');
142
+ if (!card) return;
143
+
144
+ const id = card.dataset.taskId;
145
+ state.update('selectedTaskId', id);
146
+
147
+ // Navegar al editor si hubo doble clic; si un clic simple, solo marcar
148
+ if (e.detail === 2) {
149
+ router.navigate('tasks');
150
+ } else {
151
+ // Resaltar la card seleccionada sin re-render completo
152
+ board.querySelectorAll('.task-card').forEach(c => {
153
+ c.classList.toggle('is-selected', c.dataset.taskId === id);
154
+ c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
155
+ });
156
+ }
157
+ });
158
+
159
+ // Teclado en cards (Enter = seleccionar, Space = toggle)
160
+ board.addEventListener('keydown', e => {
161
+ const card = e.target.closest('.task-card');
162
+ if (!card) return;
163
+ if (e.key === 'Enter') {
164
+ state.update('selectedTaskId', card.dataset.taskId);
165
+ router.navigate('tasks');
166
+ }
167
+ });
168
+
169
+ // Toggle mostrar completadas
170
+ document.getElementById('board-filter-done')?.addEventListener('click', () => {
171
+ const current = _showCompleted();
172
+ sessionStorage.setItem('board-show-completed', current ? 'false' : 'true');
173
+ router.refresh();
174
+ });
175
+
176
+ // Nueva tarea
177
+ document.getElementById('new-task-btn')?.addEventListener('click', () => {
178
+ state.update('selectedTaskId', null);
179
+ router.navigate('tasks');
180
+ });
181
+
182
+ // Drag & drop
183
+ _bindDragDrop(board);
184
+ }
185
+
186
+ function _bindDragDrop(board) {
187
+ // Dragstart
188
+ board.addEventListener('dragstart', e => {
189
+ const card = e.target.closest('.task-card');
190
+ if (!card) return;
191
+ _dragTaskId = card.dataset.taskId;
192
+ card.classList.add('is-dragging');
193
+ e.dataTransfer.setData('text/plain', _dragTaskId);
194
+ e.dataTransfer.effectAllowed = 'move';
195
+ });
196
+
197
+ board.addEventListener('dragend', e => {
198
+ const card = e.target.closest('.task-card');
199
+ card?.classList.remove('is-dragging');
200
+ _dragTaskId = null;
201
+ board.querySelectorAll('.board-column').forEach(col => col.classList.remove('is-drop-target'));
202
+ });
203
+
204
+ board.addEventListener('dragover', e => {
205
+ e.preventDefault();
206
+ const col = e.target.closest('.board-column');
207
+ if (!col) return;
208
+ e.dataTransfer.dropEffect = 'move';
209
+ board.querySelectorAll('.board-column').forEach(c => c.classList.remove('is-drop-target'));
210
+ col.classList.add('is-drop-target');
211
+ });
212
+
213
+ board.addEventListener('dragleave', e => {
214
+ const col = e.target.closest('.board-column');
215
+ if (!col) return;
216
+ // Solo eliminar si salimos de la columna, no de un hijo
217
+ if (!col.contains(e.relatedTarget)) {
218
+ col.classList.remove('is-drop-target');
219
+ }
220
+ });
221
+
222
+ board.addEventListener('drop', async e => {
223
+ e.preventDefault();
224
+ const col = e.target.closest('.board-column');
225
+ if (!col) return;
226
+ col.classList.remove('is-drop-target');
227
+
228
+ const taskId = e.dataTransfer.getData('text/plain') || _dragTaskId;
229
+ if (!taskId) return;
230
+
231
+ const newStatus = col.dataset.status;
232
+ const task = state.getPayload()?.derived?.tasks?.find(t => t.id === taskId);
233
+ if (!task || task.status === newStatus) return;
234
+
235
+ const statusToAction = {
236
+ pending: 'pending',
237
+ in_progress: 'start',
238
+ in_review: 'review',
239
+ blocked: 'block',
240
+ completed: 'complete',
241
+ cancelled: 'cancel',
242
+ };
243
+
244
+ const action = statusToAction[newStatus];
245
+ if (!action) return;
246
+
247
+ try {
248
+ await api.taskAction(taskId, action, t('ui.board.movedFromBoard', { status: newStatus }, `Moved to ${newStatus} from the board.`));
249
+ flash(t('ui.board.movedSuccess', { status: newStatus }, `Task moved to ${newStatus}.`), 'success');
250
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
251
+ } catch (err) {
252
+ flash(err.message, 'error');
253
+ }
254
+ });
255
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * execution.js — Consola integrada + sesiones de comandos
3
+ */
4
+
5
+ import { icon } from '../icons.js';
6
+ import * as state from '../state.js';
7
+ import * as api from '../api.js';
8
+ import { flash } from './flash.js';
9
+ import { esc, formatDate } from '../utils.js';
10
+ import { t } from '../i18n.js';
11
+
12
+ export async function render() {
13
+ const sessions = state.get('sessions');
14
+ const selected = sessions.find(s => s.id === state.get('selectedSessionId'));
15
+ const quickCommands = [
16
+ { label: t('ui.execution.quick.status', {}, 'status'), cmd: 'npx trackops status' },
17
+ { label: t('ui.execution.quick.sync', {}, 'sync docs'), cmd: 'npx trackops sync' },
18
+ { label: t('ui.execution.quick.next', {}, 'next tasks'), cmd: 'npx trackops next' },
19
+ { label: t('ui.execution.quick.repo', {}, 'refresh repo'), cmd: 'npx trackops refresh-repo' },
20
+ { label: 'git status', cmd: 'git status --short' },
21
+ { label: 'git log', cmd: 'git log --oneline -10' },
22
+ ];
23
+
24
+ return `
25
+ <div class="view-enter">
26
+ <div class="section-header">
27
+ <div class="section-header-left">
28
+ <p class="eyebrow">${t('ui.execution.eyebrow', {}, 'Execution')}</p>
29
+ <h2>${t('ui.execution.title', {}, 'Command Console')}</h2>
30
+ </div>
31
+ </div>
32
+
33
+ <div style="display:grid;grid-template-columns:300px 1fr;gap:var(--space-4);align-items:start">
34
+
35
+ <!-- Panel izquierdo: input + sesiones -->
36
+ <div style="display:flex;flex-direction:column;gap:var(--space-4)">
37
+
38
+ <!-- Command input -->
39
+ <div class="panel">
40
+ <div class="panel-header">
41
+ <p class="panel-title">${t('ui.execution.run', {}, 'Run')}</p>
42
+ </div>
43
+ <div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
44
+ <div class="field">
45
+ <label for="cmd-input">${t('ui.execution.command', {}, 'Command')}</label>
46
+ <textarea id="cmd-input" rows="3"
47
+ placeholder="npx trackops status"
48
+ aria-label="${t('ui.execution.commandAria', {}, 'Enter the command to run')}"
49
+ style="font-family:var(--font-mono);font-size:var(--text-sm)"></textarea>
50
+ </div>
51
+ <button class="btn btn-primary" type="button" id="run-cmd-btn" aria-label="${t('ui.execution.runButtonAria', {}, 'Run command')}">
52
+ ${icon('execution', 15)} ${t('ui.execution.run', {}, 'Run')}
53
+ </button>
54
+ </div>
55
+ <div class="panel-footer">
56
+ <p class="label-sm" style="margin-bottom:var(--space-2)">${t('ui.execution.quickTitle', {}, 'Quick commands')}</p>
57
+ <div class="preset-strip">
58
+ ${quickCommands.map(c => `
59
+ <button class="chip" type="button" data-quick="${esc(c.cmd)}"
60
+ aria-label="${t('ui.execution.runQuick', { label: c.label }, `Run ${c.label}`)}">
61
+ ${esc(c.label)}
62
+ </button>
63
+ `).join('')}
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Lista de sesiones -->
69
+ <div class="panel">
70
+ <div class="panel-header">
71
+ <p class="panel-title">${t('ui.execution.sessions', {}, 'Sessions')}</p>
72
+ <span class="badge badge-muted">${sessions.length}</span>
73
+ </div>
74
+ <div class="panel-body" style="max-height:360px;overflow-y:auto">
75
+ ${sessions.length === 0
76
+ ? `<div class="empty-state">${t('ui.execution.noSessions', {}, 'No active sessions.')}</div>`
77
+ : `<div class="stack stack-sm">
78
+ ${sessions.slice().reverse().map(s => `
79
+ <div class="session-pill ${s.id === state.get('selectedSessionId') ? 'is-selected' : ''}"
80
+ data-session-id="${esc(s.id)}"
81
+ role="button" tabindex="0"
82
+ aria-selected="${s.id === state.get('selectedSessionId')}"
83
+ aria-label="${esc(s.command)}, ${esc(s.status)}">
84
+ <span class="session-pill-cmd">${esc(s.command)}</span>
85
+ <span class="badge session-pill-status ${_sessionBadgeClass(s.status)}">${esc(s.status)}</span>
86
+ </div>
87
+ `).join('')}
88
+ </div>`
89
+ }
90
+ </div>
91
+ </div>
92
+
93
+ </div>
94
+
95
+ <!-- Terminal output -->
96
+ <div class="terminal-surface" style="min-height:600px;display:flex;flex-direction:column">
97
+ <div class="terminal-header">
98
+ <div class="terminal-dots" aria-hidden="true">
99
+ <span></span><span></span><span></span>
100
+ </div>
101
+ <p class="terminal-title">${selected ? esc(selected.command) : t('ui.execution.noSessionTitle', {}, 'ops@terminal — no active session')}</p>
102
+ <div style="display:flex;gap:var(--space-2)">
103
+ ${selected && selected.status === 'running' ? `
104
+ <button class="btn btn-ghost btn-sm" id="kill-session-btn" type="button" aria-label="${t('ui.execution.stopAria', {}, 'Stop process')}">
105
+ ${icon('stop', 13)} ${t('ui.execution.stop', {}, 'Stop')}
106
+ </button>
107
+ ` : ''}
108
+ </div>
109
+ </div>
110
+ <pre class="terminal-output" id="terminal-output" aria-label="${t('ui.execution.output', {}, 'Command output')}" aria-live="polite">${selected ? esc(selected.output) : `${t('ui.execution.outputPlaceholder', {}, '# Run a command to see its output here…')}\n`}</pre>
111
+ ${selected ? `<div class="panel-footer" style="display:flex;justify-content:space-between;align-items:center;font-family:var(--font-mono);font-size:var(--text-xs)">
112
+ <span class="text-muted">${t('ui.execution.started', {}, 'Started')}: ${formatDate(selected.startedAt)}</span>
113
+ <span class="badge ${_sessionBadgeClass(selected.status)}">${esc(selected.status)}${selected.exitCode != null ? ` (${selected.exitCode})` : ''}</span>
114
+ </div>` : ''}
115
+ </div>
116
+
117
+ </div>
118
+ </div>
119
+ `;
120
+ }
121
+
122
+ function _sessionBadgeClass(status) {
123
+ const map = {
124
+ running: 'badge-info',
125
+ completed: 'badge-success',
126
+ failed: 'badge-danger',
127
+ terminated:'badge-warning',
128
+ };
129
+ return map[status] || 'badge-muted';
130
+ }
131
+
132
+ export function bindEvents() {
133
+ // Run command button
134
+ document.getElementById('run-cmd-btn')?.addEventListener('click', _runCommand);
135
+
136
+ // Ctrl+Enter en el textarea
137
+ document.getElementById('cmd-input')?.addEventListener('keydown', e => {
138
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
139
+ e.preventDefault();
140
+ _runCommand();
141
+ }
142
+ });
143
+
144
+ // Quick commands
145
+ document.querySelectorAll('[data-quick]').forEach(btn => {
146
+ btn.addEventListener('click', () => {
147
+ const input = document.getElementById('cmd-input');
148
+ if (input) {
149
+ input.value = btn.dataset.quick;
150
+ input.focus();
151
+ }
152
+ });
153
+ });
154
+
155
+ // Seleccionar sesión
156
+ document.querySelectorAll('[data-session-id]').forEach(pill => {
157
+ pill.addEventListener('click', () => {
158
+ const id = pill.dataset.sessionId;
159
+ state.update('selectedSessionId', id);
160
+ import('../router.js').then(r => r.refresh());
161
+ });
162
+ pill.addEventListener('keydown', e => {
163
+ if (e.key === 'Enter' || e.key === ' ') {
164
+ e.preventDefault();
165
+ pill.click();
166
+ }
167
+ });
168
+ });
169
+ }
170
+
171
+ async function _runCommand() {
172
+ const input = document.getElementById('cmd-input');
173
+ const cmd = input?.value.trim();
174
+ if (!cmd) return;
175
+
176
+ const btn = document.getElementById('run-cmd-btn');
177
+ if (btn) { btn.disabled = true; btn.innerHTML = `${icon('spinner', 15)} ${t('ui.execution.running', {}, 'Running…')}`; }
178
+
179
+ try {
180
+ const result = await api.runCommand(cmd);
181
+ const sessionId = result.session?.id || result.sessionId;
182
+
183
+ // Añadir sesión al estado
184
+ const sessions = state.get('sessions');
185
+ const newSession = {
186
+ id: sessionId,
187
+ command: cmd,
188
+ status: 'running',
189
+ exitCode: null,
190
+ output: '',
191
+ startedAt: new Date().toISOString(),
192
+ };
193
+ sessions.push(newSession);
194
+ state.update('sessions', sessions);
195
+ state.update('selectedSessionId', sessionId);
196
+
197
+ // Refresh UI
198
+ import('../router.js').then(r => r.refresh()).then(() => {
199
+ _subscribeToStream(sessionId, cmd);
200
+ });
201
+
202
+ if (input) input.value = '';
203
+ } catch (err) {
204
+ flash(err.message, 'error');
205
+ } finally {
206
+ if (btn) { btn.disabled = false; btn.innerHTML = `${icon('execution', 15)} ${t('ui.execution.run', {}, 'Run')}`; }
207
+ }
208
+ }
209
+
210
+ function _subscribeToStream(sessionId, _cmd) {
211
+ const es = api.streamSession(sessionId);
212
+ const terminal = document.getElementById('terminal-output');
213
+
214
+ es.addEventListener('message', e => {
215
+ try {
216
+ const data = JSON.parse(e.data);
217
+ const sessions = state.get('sessions');
218
+ const session = sessions.find(s => s.id === sessionId);
219
+
220
+ if (data.type === 'stdout' || data.type === 'stderr') {
221
+ if (session) session.output += data.chunk;
222
+ if (terminal && state.get('selectedSessionId') === sessionId) {
223
+ terminal.textContent = session?.output || '';
224
+ terminal.scrollTop = terminal.scrollHeight;
225
+ }
226
+ } else if (data.type === 'done') {
227
+ if (session) {
228
+ session.status = data.status;
229
+ session.exitCode = data.exitCode;
230
+ session.output = data.output;
231
+ }
232
+ state.update('sessions', sessions);
233
+ if (state.get('selectedSessionId') === sessionId) {
234
+ import('../router.js').then(r => r.refresh());
235
+ }
236
+ es.close();
237
+ window.dispatchEvent(new CustomEvent('ops:refresh'));
238
+ }
239
+ } catch (err) {
240
+ console.error('[execution] Error parseando evento SSE:', err);
241
+ }
242
+ });
243
+
244
+ es.onerror = () => {
245
+ const sessions = state.get('sessions');
246
+ const session = sessions.find(s => s.id === sessionId);
247
+ if (session && session.status === 'running') {
248
+ session.status = 'failed';
249
+ state.update('sessions', sessions);
250
+ }
251
+ es.close();
252
+ };
253
+ }
254
+
255
+ // Vinculación de eventos se llama desde la vista
256
+ export { bindEvents as bind };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * flash.js — Sistema de toasts / notificaciones
3
+ */
4
+
5
+ const DURATION = 3500;
6
+
7
+ /**
8
+ * Muestra una notificación tipo toast
9
+ * @param {string} message
10
+ * @param {'success'|'error'|'warning'|'info'} [type='info']
11
+ * @param {number} [duration] ms antes de desaparecer (0 = permanente)
12
+ */
13
+ export function flash(message, type = 'info', duration = DURATION) {
14
+ const container = document.getElementById('flash-container');
15
+ if (!container) return;
16
+
17
+ const icons = {
18
+ success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
19
+ error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
20
+ warning: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
21
+ info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
22
+ };
23
+
24
+ const colorMap = {
25
+ success: 'var(--success)',
26
+ error: 'var(--danger)',
27
+ warning: 'var(--warning)',
28
+ info: 'var(--accent)',
29
+ };
30
+
31
+ const toastEl = document.createElement('div');
32
+ toastEl.className = `flash flash-${type}`;
33
+ toastEl.setAttribute('role', 'alert');
34
+ toastEl.innerHTML = `
35
+ <span style="color:${colorMap[type] || 'var(--accent)'}; flex-shrink:0">${icons[type] || icons.info}</span>
36
+ <span>${message}</span>
37
+ `;
38
+
39
+ container.appendChild(toastEl);
40
+
41
+ if (duration > 0) {
42
+ setTimeout(() => {
43
+ toastEl.classList.add('flash-exit');
44
+ setTimeout(() => toastEl.remove(), 300);
45
+ }, duration);
46
+ }
47
+ }