@gaia-ai/addon-gaia-ui 0.6.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -0
  3. package/dist/src/Component/agent-list/index.d.ts +6 -0
  4. package/dist/src/Component/agent-list/index.js +14 -0
  5. package/dist/src/Component/comment-item/index.d.ts +21 -0
  6. package/dist/src/Component/comment-item/index.js +39 -0
  7. package/dist/src/Component/create-form/index.d.ts +3 -0
  8. package/dist/src/Component/create-form/index.js +26 -0
  9. package/dist/src/Component/dashboard-list/index.d.ts +8 -0
  10. package/dist/src/Component/dashboard-list/index.js +7 -0
  11. package/dist/src/Component/run-table/index.d.ts +10 -0
  12. package/dist/src/Component/run-table/index.js +20 -0
  13. package/dist/src/Component/section-list/index.d.ts +9 -0
  14. package/dist/src/Component/section-list/index.js +27 -0
  15. package/dist/src/Component/tab-bar/index.d.ts +9 -0
  16. package/dist/src/Component/tab-bar/index.js +22 -0
  17. package/dist/src/Component/ticket-detail/index.d.ts +30 -0
  18. package/dist/src/Component/ticket-detail/index.js +129 -0
  19. package/dist/src/Component/ticket-table/index.d.ts +5 -0
  20. package/dist/src/Component/ticket-table/index.js +14 -0
  21. package/dist/src/Component/top-tabs/index.d.ts +8 -0
  22. package/dist/src/Component/top-tabs/index.js +13 -0
  23. package/dist/src/Deriver/liveness.d.ts +12 -0
  24. package/dist/src/Deriver/liveness.js +34 -0
  25. package/dist/src/Deriver/run-status.d.ts +12 -0
  26. package/dist/src/Deriver/run-status.js +36 -0
  27. package/dist/src/Kernel/tui-kernel.d.ts +2 -0
  28. package/dist/src/Kernel/tui-kernel.js +983 -0
  29. package/dist/src/Screen/dashboard.d.ts +1 -0
  30. package/dist/src/Screen/dashboard.js +1 -0
  31. package/dist/src/Screen/project.d.ts +1 -0
  32. package/dist/src/Screen/project.js +1 -0
  33. package/dist/src/Screen/ticket.d.ts +1 -0
  34. package/dist/src/Screen/ticket.js +1 -0
  35. package/dist/src/Service/agent.d.ts +17 -0
  36. package/dist/src/Service/agent.js +28 -0
  37. package/dist/src/Service/create-form-data.d.ts +10 -0
  38. package/dist/src/Service/create-form-data.js +52 -0
  39. package/dist/src/Service/create-form.d.ts +52 -0
  40. package/dist/src/Service/create-form.js +98 -0
  41. package/dist/src/Service/project-data.d.ts +48 -0
  42. package/dist/src/Service/project-data.js +92 -0
  43. package/dist/src/Service/projects.d.ts +21 -0
  44. package/dist/src/Service/projects.js +80 -0
  45. package/dist/src/Service/run-data.d.ts +35 -0
  46. package/dist/src/Service/run-data.js +62 -0
  47. package/dist/src/Service/ticket-data.d.ts +6 -0
  48. package/dist/src/Service/ticket-data.js +7 -0
  49. package/dist/src/index.d.ts +6 -0
  50. package/dist/src/index.js +10 -0
  51. package/dist/src/launcher.d.ts +58 -0
  52. package/dist/src/launcher.js +51 -0
  53. package/dist/src/lib/ansi.d.ts +19 -0
  54. package/dist/src/lib/ansi.js +45 -0
  55. package/dist/src/lib/format.d.ts +3 -0
  56. package/dist/src/lib/format.js +10 -0
  57. package/dist/src/lib/scroll.d.ts +1 -0
  58. package/dist/src/lib/scroll.js +16 -0
  59. package/dist/src/lib/ticket-query.d.ts +21 -0
  60. package/dist/src/lib/ticket-query.js +72 -0
  61. package/dist/src/plugin.d.ts +13 -0
  62. package/dist/src/plugin.js +17 -0
  63. package/dist/src/preset.d.ts +2 -0
  64. package/dist/src/preset.js +7 -0
  65. package/dist/src/types.d.ts +98 -0
  66. package/dist/src/types.js +5 -0
  67. package/package.json +28 -0
@@ -0,0 +1,983 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { agentListRows } from '../Component/agent-list/index.js';
5
+ import { createFormLines } from '../Component/create-form/index.js';
6
+ import { dashboardRows } from '../Component/dashboard-list/index.js';
7
+ import { runTableRows } from '../Component/run-table/index.js';
8
+ import { DETAIL_TABS, tabBar } from '../Component/tab-bar/index.js';
9
+ import { tabViewLines } from '../Component/ticket-detail/index.js';
10
+ import { ticketTableRows } from '../Component/ticket-table/index.js';
11
+ import { topTabBar } from '../Component/top-tabs/index.js';
12
+ import { ANSI, fitLine, styleVisibleLines } from '../lib/ansi.js';
13
+ import { scrollTo } from '../lib/scroll.js';
14
+ import { cycleValue, describeCriteria, hasActiveCriteria, STATE_FILTER_VALUES, TYPE_FILTER_VALUES, } from '../lib/ticket-query.js';
15
+ import { buildCreateLaunch } from '../Service/agent.js';
16
+ import { activeField, backspace, cycle, initialForm, moveField, moveLabelCursor, newline, serialize, shortName, toggleLabel, typeChar, } from '../Service/create-form.js';
17
+ import { loadCreateFormOptions } from '../Service/create-form-data.js';
18
+ import { loadFilteredTickets, loadProjectWithTickets, } from '../Service/project-data.js';
19
+ import { loadDashboardProjects, } from '../Service/projects.js';
20
+ import { loadProjectRuns, selectedRunTicketId } from '../Service/run-data.js';
21
+ import { loadTicketDetail } from '../Service/ticket-data.js';
22
+ const nowSeconds = () => Math.floor(Date.now() / 1000);
23
+ // The three read-only screens each project a `document`/state into plain string
24
+ // rows; a Herdr-backed create agent is layered on top as a full-terminal
25
+ // passthrough (no node-pty / @xterm — the core renders raw ANSI to the real TTY).
26
+ export function runTicketTui(_document, _context, services, options = {}) {
27
+ const output = process.stdout;
28
+ const input = process.stdin;
29
+ const agents = services?.agents ?? options?.agents ?? null;
30
+ const defaultProject = options?.project ?? services?.project ?? null;
31
+ const workingDir = options.cwd ?? process.cwd();
32
+ const currentUser = services?.currentUser ?? options?.currentUser;
33
+ const ownConductorMachineId = services?.ownConductorMachineId ?? options?.ownConductorMachineId;
34
+ const createPrompt = services?.createPrompt ?? options?.createPrompt;
35
+ // Non-TTY: emit the dashboard once so `gaia ui </dev/null` stays scriptable.
36
+ if (!input.isTTY || !output.isTTY) {
37
+ return loadDashboardProjects({ client: services.client, nowSeconds })
38
+ .catch(() => [])
39
+ .then((rows) => {
40
+ output.write(`${dashboardRows(rows).join('\n')}\n`);
41
+ });
42
+ }
43
+ let screen = 'dashboard';
44
+ let projects = [];
45
+ let selectedProject = 0;
46
+ let currentProjectSlug = null;
47
+ let currentProjectName = null;
48
+ let ticketRows = [];
49
+ let selectedTicket = 0;
50
+ let currentProjectId = null;
51
+ let criteria = { query: '', state: null, type: null };
52
+ let searchMode = false;
53
+ let searchTimer = null;
54
+ let detail = null;
55
+ let activeTab = 0;
56
+ let selectedComment = 0;
57
+ let expandedComments = new Set();
58
+ let offset = 0;
59
+ let loading = false;
60
+ let error = '';
61
+ let status = '';
62
+ // The current group's live agents (Ticket-Agents screen), refreshed from
63
+ // `agents.list(project)`; the host owns identity + layout.
64
+ let agentRows = [];
65
+ let selectedAgent = 0;
66
+ let runRows = [];
67
+ let selectedRun = 0;
68
+ let closing = false;
69
+ let createSeq = 0;
70
+ let lastCreateAtMs = 0;
71
+ // "New ticket" popup form state (null = not open).
72
+ let createForm = null;
73
+ let createFormOpts = null;
74
+ let formProject = '';
75
+ const history = [];
76
+ const previousRawMode = input.isRaw;
77
+ const viewHeight = () => Math.max(3, output.rows - 9);
78
+ const isCommentsTab = () => DETAIL_TABS[activeTab]?.id === 'comments';
79
+ // The lines rendered for the current screen plus `selRow` (the absolute row to
80
+ // highlight, -1 for none), `selectable` (how many selectable entries), and,
81
+ // for the Comments tab, `headerRows` mapping each comment to its header row.
82
+ const screenView = () => {
83
+ if (screen === 'dashboard') {
84
+ return {
85
+ lines: dashboardRows(projects),
86
+ selectable: projects.length,
87
+ selRow: projects.length ? selectedProject : -1,
88
+ };
89
+ }
90
+ if (screen === 'project') {
91
+ return {
92
+ lines: ticketTableRows(ticketRows, {
93
+ emptyMessage: hasActiveCriteria(criteria)
94
+ ? 'No tasks match the current search/filters.'
95
+ : undefined,
96
+ }),
97
+ selectable: ticketRows.length,
98
+ selRow: ticketRows.length ? 1 + selectedTicket : -1,
99
+ };
100
+ }
101
+ if (screen === 'ticket-agents') {
102
+ return {
103
+ lines: agentListRows(agentRows),
104
+ selectable: agentRows.length,
105
+ selRow: agentRows.length ? 1 + selectedAgent : -1,
106
+ };
107
+ }
108
+ if (screen === 'project-runs') {
109
+ return {
110
+ lines: runTableRows(runRows, nowSeconds()),
111
+ selectable: runRows.length,
112
+ selRow: runRows.length ? 1 + selectedRun : -1,
113
+ };
114
+ }
115
+ const view = tabViewLines(detail ?? {}, activeTab, {
116
+ expanded: expandedComments,
117
+ nowSeconds: nowSeconds(),
118
+ });
119
+ const selRow = isCommentsTab() && view.selectable
120
+ ? (view.headerRows[Math.min(selectedComment, view.selectable - 1)] ??
121
+ -1)
122
+ : -1;
123
+ return {
124
+ lines: view.lines,
125
+ boldRows: view.boldRows,
126
+ headerRows: view.headerRows,
127
+ selectable: view.selectable,
128
+ selRow,
129
+ };
130
+ };
131
+ // The selected entry on the list screens (dashboard/project). The ticket
132
+ // screen highlights via screenView().selRow (comment header rows); its tab
133
+ // key-switching is handled by the `screen === 'ticket'` branch of handleKey.
134
+ const selectedIndex = () => screen === 'dashboard'
135
+ ? selectedProject
136
+ : screen === 'ticket-agents'
137
+ ? selectedAgent
138
+ : screen === 'project-runs'
139
+ ? selectedRun
140
+ : selectedTicket;
141
+ // The "New ticket" popup: a centered floating box drawn ON TOP of the current
142
+ // screen (a real modal overlay, not a full-screen replace) via absolute cursor
143
+ // positioning, so the dashboard/ticket list stays visible around it.
144
+ const drawPopupOverlay = () => {
145
+ if (!createForm || !createFormOpts)
146
+ return;
147
+ const cols = Math.max(40, output.columns);
148
+ const rows = Math.max(10, output.rows);
149
+ const body = [
150
+ `⚡ New ticket · ${formProject}`,
151
+ '',
152
+ ...createFormLines(createForm, createFormOpts),
153
+ '',
154
+ '↑↓ field · ←→ change · Space toggle · Ctrl+S create · Esc cancel',
155
+ ];
156
+ const innerW = Math.min(60, cols - 8);
157
+ const boxW = innerW + 4; // '│ ' + content + ' │'
158
+ const boxH = body.length + 2; // + top/bottom border
159
+ const startRow = Math.max(2, Math.floor((rows - boxH) / 2));
160
+ const startCol = Math.max(1, Math.floor((cols - boxW) / 2) + 1);
161
+ const at = (r) => `\x1b[${r};${startCol}H`;
162
+ let r = startRow;
163
+ output.write(`${at(r++)}${ANSI.brightCyan}${ANSI.bold}╭${'─'.repeat(boxW - 2)}╮${ANSI.reset}`);
164
+ for (const line of body) {
165
+ output.write(`${at(r++)}${ANSI.brightCyan}│${ANSI.reset} ${fitLine(line, innerW)} ${ANSI.brightCyan}│${ANSI.reset}`);
166
+ }
167
+ output.write(`${at(r++)}${ANSI.brightCyan}╰${'─'.repeat(boxW - 2)}╯${ANSI.reset}`);
168
+ };
169
+ const renderScreen = () => {
170
+ const width = Math.max(40, output.columns);
171
+ const contentWidth = Math.max(20, width - 4);
172
+ const height = viewHeight();
173
+ const { lines, boldRows, selRow } = screenView();
174
+ const maxOffset = Math.max(0, lines.length - height);
175
+ offset = Math.min(Math.max(0, offset), maxOffset);
176
+ const visible = styleVisibleLines({
177
+ lines,
178
+ boldRows,
179
+ selRow,
180
+ offset,
181
+ height,
182
+ contentWidth,
183
+ });
184
+ const detailAttr = (key) => {
185
+ const v = detail?.data?.attributes?.[key];
186
+ return typeof v === 'string' ? v : undefined;
187
+ };
188
+ const title = screen === 'dashboard'
189
+ ? 'GAIA projects'
190
+ : screen === 'project'
191
+ ? `${currentProjectName ?? currentProjectSlug} · tickets`
192
+ : screen === 'ticket-agents'
193
+ ? 'Ticket-Agents'
194
+ : screen === 'project-runs'
195
+ ? `${currentProjectName ?? currentProjectSlug} · runs`
196
+ : `${detailAttr('identifier') ?? ''} · ${detailAttr('title') ?? 'ticket'}`;
197
+ const meta = screen === 'dashboard'
198
+ ? `${projects.length} registered project(s) · ${services?.baseUrl ?? ''}`
199
+ : screen === 'project'
200
+ ? searchMode
201
+ ? `search: ${criteria.query}▌`
202
+ : `${ticketRows.length} ticket(s)${hasActiveCriteria(criteria) ? ` · ${describeCriteria(criteria)}` : ''} · n new · a agents · u runs · / search · s status · t type · c clear`
203
+ : screen === 'ticket-agents'
204
+ ? `${agentRows.length} agent(s) · Enter switch · z overview · r retry · x close · Esc back`
205
+ : screen === 'project-runs'
206
+ ? `${runRows.length} run(s) · Enter ticket · Tab cycle · Esc back`
207
+ : tabBar(activeTab);
208
+ const keys = screen === 'ticket'
209
+ ? isCommentsTab()
210
+ ? ' Tab tab · ↑↓ select · Enter/Space expand · Esc back · q quit '
211
+ : ' Tab tab · ↑↓ scroll · Esc back · q quit '
212
+ : screen === 'ticket-agents'
213
+ ? ' ↑↓ select · Enter switch · z overview · r retry · x close · Tab/t tickets · q quit '
214
+ : screen === 'project-runs'
215
+ ? ' ↑↓ select · Enter ticket · Tab cycle · Esc back · q quit '
216
+ : ' ↑↓ select · Enter open · n new · Tab/a agents · u runs · Esc back · q quit ';
217
+ const bar = loading
218
+ ? ' Loading… '
219
+ : error
220
+ ? ` Error: ${error} `
221
+ : status
222
+ ? ` ${status} `
223
+ : keys;
224
+ output.write('\x1b[H\x1b[2J');
225
+ output.write(`${ANSI.brightCyan}${ANSI.bold}╭─ ⚡ GAIA ${'─'.repeat(Math.max(1, width - 11))}╮${ANSI.reset}\n`);
226
+ output.write(`${ANSI.cyan}│${ANSI.reset} ${ANSI.bold}${fitLine(title, width - 4)}${ANSI.cyan} │${ANSI.reset}\n`);
227
+ output.write(`${ANSI.cyan}│${ANSI.reset} ${fitLine(meta, width - 4)}${ANSI.cyan} │${ANSI.reset}\n`);
228
+ output.write(`${ANSI.brightCyan}╰${'─'.repeat(Math.max(1, width - 2))}╯${ANSI.reset}\n`);
229
+ const topActive = screen === 'ticket-agents'
230
+ ? 'ticket-agents'
231
+ : screen === 'project-runs'
232
+ ? 'runs'
233
+ : 'tickets';
234
+ output.write(`${ANSI.bold}${fitLine(` ${topTabBar(topActive)}`, width)}${ANSI.reset}\n\n`);
235
+ output.write(`${visible.join('\n')}\n`);
236
+ output.write(`${ANSI.inverse}${fitLine(bar, width)}${ANSI.reset}`);
237
+ };
238
+ // Compose the frame: the normal screen, then (if open) the popup on top.
239
+ const draw = () => {
240
+ renderScreen();
241
+ if (createForm && createFormOpts)
242
+ drawPopupOverlay();
243
+ };
244
+ const remember = () => ({
245
+ screen,
246
+ projects,
247
+ selectedProject,
248
+ currentProjectSlug,
249
+ currentProjectName,
250
+ ticketRows,
251
+ selectedTicket,
252
+ detail,
253
+ offset,
254
+ selectedAgent,
255
+ runRows,
256
+ selectedRun,
257
+ });
258
+ const restore = (s) => {
259
+ screen = s.screen;
260
+ projects = s.projects;
261
+ selectedProject = s.selectedProject;
262
+ currentProjectSlug = s.currentProjectSlug;
263
+ currentProjectName = s.currentProjectName;
264
+ ticketRows = s.ticketRows;
265
+ selectedTicket = s.selectedTicket;
266
+ detail = s.detail;
267
+ offset = s.offset;
268
+ selectedAgent = s.selectedAgent;
269
+ runRows = s.runRows;
270
+ selectedRun = s.selectedRun;
271
+ error = '';
272
+ status = '';
273
+ };
274
+ const openDashboard = async () => {
275
+ loading = true;
276
+ error = '';
277
+ status = '';
278
+ draw();
279
+ try {
280
+ projects = await loadDashboardProjects({
281
+ client: services.client,
282
+ nowSeconds,
283
+ });
284
+ selectedProject = 0;
285
+ offset = 0;
286
+ screen = 'dashboard';
287
+ }
288
+ catch (caught) {
289
+ error = caught instanceof Error ? caught.message : String(caught);
290
+ }
291
+ finally {
292
+ loading = false;
293
+ draw();
294
+ }
295
+ };
296
+ const openSelectedProject = async () => {
297
+ const p = projects[selectedProject];
298
+ if (!p || loading)
299
+ return;
300
+ history.push(remember());
301
+ loading = true;
302
+ error = '';
303
+ status = '';
304
+ draw();
305
+ try {
306
+ const out = await loadProjectWithTickets(services.client, p.project);
307
+ currentProjectSlug = p.project;
308
+ currentProjectName = out.project.name;
309
+ currentProjectId = out.project.id;
310
+ criteria = { query: '', state: null, type: null };
311
+ searchMode = false;
312
+ ticketRows = out.tickets;
313
+ selectedTicket = 0;
314
+ offset = 0;
315
+ screen = 'project';
316
+ }
317
+ catch (caught) {
318
+ history.pop();
319
+ error = caught instanceof Error ? caught.message : String(caught);
320
+ }
321
+ finally {
322
+ loading = false;
323
+ draw();
324
+ }
325
+ };
326
+ const applyCriteria = async () => {
327
+ if (currentProjectId == null || loading)
328
+ return;
329
+ loading = true;
330
+ error = '';
331
+ status = '';
332
+ draw();
333
+ try {
334
+ ticketRows = await loadFilteredTickets(services.client, currentProjectId, criteria);
335
+ selectedTicket = Math.max(0, Math.min(selectedTicket, ticketRows.length - 1));
336
+ offset = 0;
337
+ }
338
+ catch (caught) {
339
+ error = caught instanceof Error ? caught.message : String(caught);
340
+ }
341
+ finally {
342
+ loading = false;
343
+ draw();
344
+ }
345
+ };
346
+ const scheduleSearch = () => {
347
+ if (searchTimer)
348
+ clearTimeout(searchTimer);
349
+ searchTimer = setTimeout(() => {
350
+ searchTimer = null;
351
+ void applyCriteria();
352
+ }, 200);
353
+ };
354
+ const openSelectedDetail = async () => {
355
+ const t = ticketRows[selectedTicket];
356
+ if (!t?.id || loading)
357
+ return;
358
+ history.push(remember());
359
+ loading = true;
360
+ error = '';
361
+ status = '';
362
+ draw();
363
+ try {
364
+ detail = await loadTicketDetail(services.client, t.id);
365
+ screen = 'ticket';
366
+ activeTab = 0;
367
+ expandedComments = new Set();
368
+ selectedComment = 0;
369
+ offset = 0;
370
+ }
371
+ catch (caught) {
372
+ history.pop();
373
+ error = caught instanceof Error ? caught.message : String(caught);
374
+ }
375
+ finally {
376
+ loading = false;
377
+ draw();
378
+ }
379
+ };
380
+ // Open the project-scoped run overview (GAIA-192). Canonical async-screen
381
+ // pattern: remember the current frame, show loading, load the runs, swap to
382
+ // the screen on success or pop the frame + surface the error on failure.
383
+ const openRunsScreen = async () => {
384
+ if (currentProjectId == null) {
385
+ status = 'open a project first to see its runs';
386
+ draw();
387
+ return;
388
+ }
389
+ if (loading)
390
+ return;
391
+ history.push(remember());
392
+ loading = true;
393
+ error = '';
394
+ status = '';
395
+ draw();
396
+ try {
397
+ runRows = await loadProjectRuns(services.client, currentProjectId);
398
+ selectedRun = 0;
399
+ offset = 0;
400
+ screen = 'project-runs';
401
+ }
402
+ catch (caught) {
403
+ history.pop();
404
+ error = caught instanceof Error ? caught.message : String(caught);
405
+ }
406
+ finally {
407
+ loading = false;
408
+ draw();
409
+ }
410
+ };
411
+ // Enter on a run row → open its ticket detail on the Runs sub-tab (AC-3). A
412
+ // ticket-less run is a no-op; a history frame is pushed so Esc returns here.
413
+ const openSelectedRunTicket = async () => {
414
+ const ticketId = selectedRunTicketId(runRows, selectedRun);
415
+ if (!ticketId || loading)
416
+ return;
417
+ history.push(remember());
418
+ loading = true;
419
+ error = '';
420
+ status = '';
421
+ draw();
422
+ try {
423
+ detail = await loadTicketDetail(services.client, ticketId);
424
+ screen = 'ticket';
425
+ activeTab = Math.max(0, DETAIL_TABS.findIndex((t) => t.id === 'runs'));
426
+ expandedComments = new Set();
427
+ selectedComment = 0;
428
+ offset = 0;
429
+ }
430
+ catch (caught) {
431
+ history.pop();
432
+ error = caught instanceof Error ? caught.message : String(caught);
433
+ }
434
+ finally {
435
+ loading = false;
436
+ draw();
437
+ }
438
+ };
439
+ return new Promise((resolve) => {
440
+ const handleResize = () => {
441
+ draw();
442
+ };
443
+ const finish = () => {
444
+ closing = true;
445
+ if (searchTimer)
446
+ clearTimeout(searchTimer);
447
+ input.off('data', onData);
448
+ output.off('resize', handleResize);
449
+ // Agents keep running in their durable herdr tabs (AC-5 restart) — quitting
450
+ // the TUI just tears down the renderer, never the agents.
451
+ input.setRawMode(previousRawMode ?? false);
452
+ input.pause();
453
+ output.write('\x1b[?25h\x1b[2J\x1b[H');
454
+ resolve();
455
+ };
456
+ // Launch a ticket-create agent for a project via the injected agent host
457
+ // (herdr owns transport + layout), then show the Ticket-Agents screen. The
458
+ // returned handle is appended optimistically; `list` reconciles on refresh.
459
+ const launchCreateFor = async (project, spec) => {
460
+ if (!agents) {
461
+ status = 'create agent unavailable — no agent host injected';
462
+ draw();
463
+ return;
464
+ }
465
+ // Multiple concurrent agents per project are allowed; only guard against a
466
+ // rapid accidental double-submit (debounce).
467
+ const nowMs = Date.now();
468
+ if (nowMs - lastCreateAtMs < 400) {
469
+ draw();
470
+ return;
471
+ }
472
+ lastCreateAtMs = nowMs;
473
+ createSeq += 1;
474
+ try {
475
+ const agent = await agents.launch(buildCreateLaunch({
476
+ project,
477
+ cwd: workingDir,
478
+ specPath: spec?.specPath,
479
+ workflow: spec?.workflow,
480
+ createPrompt,
481
+ label: spec?.shortName ?? `New ${spec?.workflow ?? 'ticket'}`,
482
+ }));
483
+ history.push(remember());
484
+ if (project === currentProjectSlug &&
485
+ !agentRows.some((a) => a.id === agent.id)) {
486
+ agentRows = [...agentRows, agent];
487
+ }
488
+ screen = 'ticket-agents';
489
+ selectedAgent = Math.max(0, agentRows.length - 1);
490
+ offset = 0;
491
+ status = `ticket agent running for ${project}`;
492
+ draw();
493
+ }
494
+ catch (caught) {
495
+ const message = caught instanceof Error ? caught.message : String(caught);
496
+ status = `failed to start ticket agent: ${message}`;
497
+ draw();
498
+ }
499
+ };
500
+ // `n` opens the "New ticket" popup: load the select options, then show the
501
+ // form. On submit the collected fields go to the create agent via a temp
502
+ // spec file (never on the command line — PTY-4096).
503
+ const openCreateAgent = async () => {
504
+ if (createForm)
505
+ return; // already open — ignore repeat presses
506
+ if (!agents) {
507
+ status = 'create agent unavailable — no agent host injected';
508
+ draw();
509
+ return;
510
+ }
511
+ formProject = currentProjectSlug ?? defaultProject ?? 'gaia';
512
+ status = 'loading new-ticket form…';
513
+ draw();
514
+ const options = await loadCreateFormOptions(services.client, {
515
+ currentUser,
516
+ ownConductorMachineId,
517
+ }).catch(() => ({
518
+ users: [],
519
+ conductors: [],
520
+ labels: [],
521
+ workflows: [],
522
+ }));
523
+ createFormOpts = options;
524
+ createForm = initialForm(options);
525
+ status = '';
526
+ draw();
527
+ };
528
+ const closeCreateForm = () => {
529
+ createForm = null;
530
+ createFormOpts = null;
531
+ draw();
532
+ };
533
+ const submitCreateForm = () => {
534
+ if (!createForm || !createFormOpts)
535
+ return;
536
+ const payload = serialize(createForm, createFormOpts);
537
+ const project = formProject;
538
+ let specPath;
539
+ try {
540
+ specPath = join(tmpdir(), `gaia-new-ticket-${nowSeconds()}-${++createSeq}.json`);
541
+ writeFileSync(specPath, JSON.stringify({ project, is_new: true, ...payload }, null, 2), 'utf8');
542
+ }
543
+ catch {
544
+ specPath = undefined; // fall back to a plain create agent
545
+ }
546
+ createForm = null;
547
+ createFormOpts = null;
548
+ void launchCreateFor(project, {
549
+ specPath,
550
+ workflow: payload.workflow?.id,
551
+ shortName: shortName(payload),
552
+ });
553
+ };
554
+ // Route a key into the open form. ↑↓/Tab move fields; ←→ change a select or
555
+ // move the label cursor; Space toggles a label; printable keys edit the
556
+ // description; Ctrl+S submits; Esc cancels.
557
+ const handleFormKey = (key) => {
558
+ if (!createForm || !createFormOpts)
559
+ return;
560
+ const opts = createFormOpts;
561
+ if (key === '\x1b') {
562
+ closeCreateForm();
563
+ return;
564
+ }
565
+ if (key === '\x13') {
566
+ submitCreateForm(); // Ctrl+S
567
+ return;
568
+ }
569
+ if (key === '\x1b[A') {
570
+ createForm = moveField(createForm, -1);
571
+ draw();
572
+ return;
573
+ }
574
+ if (key === '\x1b[B' || key === '\t') {
575
+ createForm = moveField(createForm, 1);
576
+ draw();
577
+ return;
578
+ }
579
+ const field = activeField(createForm);
580
+ if (field.kind === 'text') {
581
+ if (key === '\r' || key === '\n')
582
+ createForm = newline(createForm);
583
+ else if (key === '\x7f')
584
+ createForm = backspace(createForm);
585
+ else if (!key.startsWith('\x1b') && key >= ' ')
586
+ createForm = typeChar(createForm, key);
587
+ else
588
+ return;
589
+ draw();
590
+ return;
591
+ }
592
+ if (field.kind === 'select') {
593
+ if (key === '\x1b[C')
594
+ createForm = cycle(createForm, opts, 1);
595
+ else if (key === '\x1b[D')
596
+ createForm = cycle(createForm, opts, -1);
597
+ else if (key === '\r' || key === '\n')
598
+ createForm = moveField(createForm, 1);
599
+ else
600
+ return;
601
+ draw();
602
+ return;
603
+ }
604
+ if (field.kind === 'multiselect') {
605
+ if (key === '\x1b[C')
606
+ createForm = moveLabelCursor(createForm, opts, 1);
607
+ else if (key === '\x1b[D')
608
+ createForm = moveLabelCursor(createForm, opts, -1);
609
+ else if (key === ' ')
610
+ createForm = toggleLabel(createForm, opts);
611
+ else if (key === '\r' || key === '\n')
612
+ createForm = moveField(createForm, 1);
613
+ else
614
+ return;
615
+ draw();
616
+ return;
617
+ }
618
+ // action field (submit)
619
+ if (key === '\r' || key === '\n')
620
+ submitCreateForm();
621
+ };
622
+ // Switch to an agent: the host brings it to the foreground (herdr zooms).
623
+ const reopenAgent = (a) => {
624
+ if (!agents)
625
+ return;
626
+ void agents.focus(a.id).catch(() => { });
627
+ status = `switched to ${a.group} agent`;
628
+ draw();
629
+ };
630
+ // Overview / home: the host un-zooms the group + returns focus to the caller;
631
+ // every agent keeps running.
632
+ const hideAgents = () => {
633
+ if (agents && currentProjectSlug) {
634
+ void agents.hide(currentProjectSlug).catch(() => { });
635
+ }
636
+ status = 'agents tiled — focus back on the TUI';
637
+ draw();
638
+ };
639
+ // Retry: drop the row locally, then launch a fresh agent for the same group.
640
+ const retryAgent = (a) => {
641
+ agentRows = agentRows.filter((r) => r.id !== a.id);
642
+ selectedAgent = 0;
643
+ void launchCreateFor(a.group);
644
+ };
645
+ // Terminate an agent for good (host `kill`); drop it from the list.
646
+ const terminateAgent = (a) => {
647
+ if (agents)
648
+ void agents.kill(a.id).catch(() => { });
649
+ agentRows = agentRows.filter((r) => r.id !== a.id);
650
+ selectedAgent = 0;
651
+ draw();
652
+ };
653
+ // Reconcile the list with the host's live agents for the current project, so
654
+ // it is exactly the open agents — incl. across a TUI restart. No project
655
+ // (dashboard) → nothing to list.
656
+ const refreshAgents = async () => {
657
+ if (!agents || !currentProjectSlug)
658
+ return;
659
+ try {
660
+ agentRows = await agents.list(currentProjectSlug);
661
+ if (selectedAgent >= agentRows.length) {
662
+ selectedAgent = Math.max(0, agentRows.length - 1);
663
+ }
664
+ }
665
+ catch {
666
+ // best-effort; a lister failure leaves the current list untouched
667
+ }
668
+ };
669
+ const openAgentsScreen = () => {
670
+ if (screen !== 'ticket-agents') {
671
+ history.push(remember());
672
+ screen = 'ticket-agents';
673
+ selectedAgent = 0;
674
+ offset = 0;
675
+ }
676
+ draw();
677
+ // Refresh from the live panes, then redraw if still on the screen.
678
+ void refreshAgents().then(() => {
679
+ if (screen === 'ticket-agents')
680
+ draw();
681
+ });
682
+ };
683
+ // Top-tab: switch back to the Tickets tab, restoring the drill-down screen
684
+ // that was active before Ticket-Agents was opened.
685
+ const switchToTickets = () => {
686
+ if (screen !== 'ticket-agents')
687
+ return;
688
+ const previous = history.pop();
689
+ if (previous)
690
+ restore(previous);
691
+ else {
692
+ screen = 'dashboard';
693
+ selectedProject = 0;
694
+ offset = 0;
695
+ }
696
+ draw();
697
+ };
698
+ const currentTopTab = () => screen === 'ticket-agents'
699
+ ? 'ticket-agents'
700
+ : screen === 'project-runs'
701
+ ? 'runs'
702
+ : 'tickets';
703
+ // Switch to a sibling top tab. `ticket-agents`/`runs` are overlays pushed
704
+ // from the Tickets base, so leaving one first pops back to that base (Esc's
705
+ // frame) before opening the next — the history never stacks two overlays.
706
+ const gotoTopTab = (target) => {
707
+ if (currentTopTab() === target)
708
+ return;
709
+ if (currentTopTab() !== 'tickets') {
710
+ const previous = history.pop();
711
+ if (previous)
712
+ restore(previous);
713
+ else {
714
+ screen = 'dashboard';
715
+ selectedProject = 0;
716
+ offset = 0;
717
+ }
718
+ }
719
+ if (target === 'ticket-agents')
720
+ openAgentsScreen();
721
+ else if (target === 'runs')
722
+ void openRunsScreen();
723
+ else
724
+ draw();
725
+ };
726
+ // Tab / Shift-Tab cycle through the three project top tabs in order.
727
+ const cycleTopTab = (dir) => {
728
+ const order = ['tickets', 'ticket-agents', 'runs'];
729
+ const i = order.indexOf(currentTopTab());
730
+ const next = order[(i + dir + order.length) % order.length];
731
+ if (next)
732
+ gotoTopTab(next);
733
+ };
734
+ const handleKey = async (buffer) => {
735
+ const key = buffer.toString();
736
+ // The New-ticket popup owns all keys while open (so `q`/letters type into
737
+ // the description); Esc cancels, Ctrl+S submits.
738
+ if (createForm) {
739
+ handleFormKey(key);
740
+ return;
741
+ }
742
+ // Search-input mode (project screen): capture all keys as text.
743
+ if (searchMode) {
744
+ if (key === '\r' || key === '\n' || key === '\x1b') {
745
+ searchMode = false; // Enter/Esc: leave typing, keep query
746
+ draw();
747
+ return;
748
+ }
749
+ if (key === '\x7f' || key === '\b') {
750
+ criteria.query = criteria.query.slice(0, -1);
751
+ scheduleSearch();
752
+ draw();
753
+ return;
754
+ }
755
+ if (key >= ' ' && key.length === 1) {
756
+ criteria.query += key;
757
+ scheduleSearch();
758
+ draw();
759
+ return;
760
+ }
761
+ return; // swallow other control keys while typing
762
+ }
763
+ if (key === '\x03' || key === 'q') {
764
+ finish();
765
+ return;
766
+ }
767
+ if (loading)
768
+ return;
769
+ // Esc / Backspace → back.
770
+ if (key === '\x1b' || key === '\x7f') {
771
+ const previous = history.pop();
772
+ if (previous)
773
+ restore(previous);
774
+ draw();
775
+ return;
776
+ }
777
+ // Project screen: search entry + status/type cycle + reset.
778
+ if (screen === 'project') {
779
+ if (key === '/') {
780
+ searchMode = true;
781
+ draw();
782
+ return;
783
+ }
784
+ if (key === 's') {
785
+ criteria.state = cycleValue(STATE_FILTER_VALUES, criteria.state);
786
+ void applyCriteria();
787
+ return;
788
+ }
789
+ if (key === 't') {
790
+ criteria.type = cycleValue(TYPE_FILTER_VALUES, criteria.type);
791
+ void applyCriteria();
792
+ return;
793
+ }
794
+ if (key === 'c') {
795
+ criteria = { query: '', state: null, type: null };
796
+ void applyCriteria();
797
+ return;
798
+ }
799
+ }
800
+ if (key === 'n') {
801
+ openCreateAgent();
802
+ return;
803
+ }
804
+ // Top-tab navigation (global): a → Ticket-Agents, t → Tickets, u → Runs.
805
+ if (key === 'a') {
806
+ openAgentsScreen();
807
+ return;
808
+ }
809
+ if (key === 't' && screen === 'ticket-agents') {
810
+ switchToTickets();
811
+ return;
812
+ }
813
+ if (key === 'u' && screen !== 'ticket') {
814
+ void openRunsScreen();
815
+ return;
816
+ }
817
+ // Tab / Shift-Tab cycles the top-level tabs — except on the ticket screen,
818
+ // where Tab switches the ticket-detail tabs (handled in its own branch).
819
+ if ((key === '\t' || key === '\x1b[Z') && screen !== 'ticket') {
820
+ cycleTopTab(key === '\x1b[Z' ? -1 : 1);
821
+ return;
822
+ }
823
+ if (screen === 'ticket-agents') {
824
+ const sel = agentRows[selectedAgent];
825
+ if (key === '\r' || key === '\n') {
826
+ if (sel)
827
+ reopenAgent(sel);
828
+ return;
829
+ }
830
+ if (key === 'h' || key === 'z') {
831
+ hideAgents();
832
+ return;
833
+ }
834
+ if (key === 'r') {
835
+ if (sel)
836
+ retryAgent(sel);
837
+ return;
838
+ }
839
+ if (key === 'x') {
840
+ if (sel)
841
+ terminateAgent(sel);
842
+ return;
843
+ }
844
+ // other keys (↑↓ selection) fall through to the generic list handler
845
+ }
846
+ if (screen === 'ticket') {
847
+ const tabCount = DETAIL_TABS.length;
848
+ if (key === '\t') {
849
+ activeTab = (activeTab + 1) % tabCount;
850
+ offset = 0;
851
+ selectedComment = 0;
852
+ draw();
853
+ return;
854
+ }
855
+ if (key === '\x1b[Z') {
856
+ activeTab = (activeTab - 1 + tabCount) % tabCount;
857
+ offset = 0;
858
+ selectedComment = 0;
859
+ draw();
860
+ return;
861
+ }
862
+ const view = screenView();
863
+ const height = viewHeight();
864
+ const maxOffset = Math.max(0, view.lines.length - height);
865
+ if (isCommentsTab() && view.selectable) {
866
+ if (key === '\r' || key === '\n' || key === ' ') {
867
+ if (expandedComments.has(selectedComment))
868
+ expandedComments.delete(selectedComment);
869
+ else
870
+ expandedComments.add(selectedComment);
871
+ draw();
872
+ return;
873
+ }
874
+ const moveSel = (delta) => {
875
+ selectedComment = Math.max(0, Math.min(selectedComment + delta, view.selectable - 1));
876
+ offset = scrollTo(view.headerRows?.[selectedComment] ?? 0, offset, height);
877
+ };
878
+ if (key === 'j' || key === '\x1b[B')
879
+ moveSel(1);
880
+ if (key === 'k' || key === '\x1b[A')
881
+ moveSel(-1);
882
+ if (key === 'g')
883
+ moveSel(-view.selectable);
884
+ if (key === 'G')
885
+ moveSel(view.selectable);
886
+ draw();
887
+ return;
888
+ }
889
+ if (key === 'j' || key === '\x1b[B')
890
+ offset += 1;
891
+ if (key === 'k' || key === '\x1b[A')
892
+ offset -= 1;
893
+ if (key === '\x1b[6~')
894
+ offset += height;
895
+ if (key === '\x1b[5~')
896
+ offset -= height;
897
+ if (key === 'g')
898
+ offset = 0;
899
+ if (key === 'G')
900
+ offset = maxOffset;
901
+ offset = Math.max(0, Math.min(offset, maxOffset));
902
+ draw();
903
+ return;
904
+ }
905
+ if (key === '\r' || key === '\n') {
906
+ if (screen === 'dashboard')
907
+ await openSelectedProject();
908
+ else if (screen === 'project')
909
+ await openSelectedDetail();
910
+ else if (screen === 'project-runs')
911
+ await openSelectedRunTicket();
912
+ return;
913
+ }
914
+ const { selectable } = screenView();
915
+ const height = viewHeight();
916
+ // Empty list screen: nothing selectable, fall back to plain scroll.
917
+ if (selectable === 0) {
918
+ const { lines } = screenView();
919
+ const maxOffset = Math.max(0, lines.length - height);
920
+ if (key === 'j' || key === '\x1b[B')
921
+ offset += 1;
922
+ if (key === 'k' || key === '\x1b[A')
923
+ offset -= 1;
924
+ if (key === '\x1b[6~')
925
+ offset += height;
926
+ if (key === '\x1b[5~')
927
+ offset -= height;
928
+ if (key === 'g')
929
+ offset = 0;
930
+ if (key === 'G')
931
+ offset = maxOffset;
932
+ offset = Math.max(0, Math.min(offset, maxOffset));
933
+ draw();
934
+ return;
935
+ }
936
+ // List screens: move the selection, then scroll so the highlight stays
937
+ // visible. Clamp against screenView().selRow — the ABSOLUTE physical row
938
+ // (the project ticket table prepends a header row, so it is 1 + index;
939
+ // the dashboard has none) — not the 0-based index (the AC-2 off-by-one).
940
+ const move = (delta) => {
941
+ const next = Math.max(0, Math.min(selectedIndex() + delta, selectable - 1));
942
+ if (screen === 'dashboard')
943
+ selectedProject = next;
944
+ else if (screen === 'ticket-agents')
945
+ selectedAgent = next;
946
+ else if (screen === 'project-runs')
947
+ selectedRun = next;
948
+ else
949
+ selectedTicket = next;
950
+ offset = scrollTo(screenView().selRow, offset, viewHeight());
951
+ };
952
+ if (key === 'j' || key === '\x1b[B')
953
+ move(1);
954
+ if (key === 'k' || key === '\x1b[A')
955
+ move(-1);
956
+ if (key === 'g')
957
+ move(-selectable);
958
+ if (key === 'G')
959
+ move(selectable);
960
+ draw();
961
+ };
962
+ // Stable listener reference for on/off. A sync throw inside handleKey (a
963
+ // draw/move failure) would otherwise surface as an unhandled rejection;
964
+ // catch it and show it in the status bar instead of crashing the process.
965
+ const onData = (buffer) => {
966
+ Promise.resolve()
967
+ .then(() => handleKey(buffer))
968
+ .catch((caught) => {
969
+ error = caught instanceof Error ? caught.message : String(caught);
970
+ draw();
971
+ });
972
+ };
973
+ input.setRawMode(true);
974
+ input.resume();
975
+ input.on('data', onData);
976
+ output.on('resize', handleResize);
977
+ output.write('\x1b[?25l');
978
+ // Surviving agents rehydrate lazily: opening a project's Ticket-Agents screen
979
+ // reconciles it from that project's live panes (AC-5 restart survival) — there
980
+ // is no project context at start, so nothing to reconcile here.
981
+ void openDashboard();
982
+ });
983
+ }