@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.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/Component/agent-list/index.d.ts +6 -0
- package/dist/src/Component/agent-list/index.js +14 -0
- package/dist/src/Component/comment-item/index.d.ts +21 -0
- package/dist/src/Component/comment-item/index.js +39 -0
- package/dist/src/Component/create-form/index.d.ts +3 -0
- package/dist/src/Component/create-form/index.js +26 -0
- package/dist/src/Component/dashboard-list/index.d.ts +8 -0
- package/dist/src/Component/dashboard-list/index.js +7 -0
- package/dist/src/Component/run-table/index.d.ts +10 -0
- package/dist/src/Component/run-table/index.js +20 -0
- package/dist/src/Component/section-list/index.d.ts +9 -0
- package/dist/src/Component/section-list/index.js +27 -0
- package/dist/src/Component/tab-bar/index.d.ts +9 -0
- package/dist/src/Component/tab-bar/index.js +22 -0
- package/dist/src/Component/ticket-detail/index.d.ts +30 -0
- package/dist/src/Component/ticket-detail/index.js +129 -0
- package/dist/src/Component/ticket-table/index.d.ts +5 -0
- package/dist/src/Component/ticket-table/index.js +14 -0
- package/dist/src/Component/top-tabs/index.d.ts +8 -0
- package/dist/src/Component/top-tabs/index.js +13 -0
- package/dist/src/Deriver/liveness.d.ts +12 -0
- package/dist/src/Deriver/liveness.js +34 -0
- package/dist/src/Deriver/run-status.d.ts +12 -0
- package/dist/src/Deriver/run-status.js +36 -0
- package/dist/src/Kernel/tui-kernel.d.ts +2 -0
- package/dist/src/Kernel/tui-kernel.js +983 -0
- package/dist/src/Screen/dashboard.d.ts +1 -0
- package/dist/src/Screen/dashboard.js +1 -0
- package/dist/src/Screen/project.d.ts +1 -0
- package/dist/src/Screen/project.js +1 -0
- package/dist/src/Screen/ticket.d.ts +1 -0
- package/dist/src/Screen/ticket.js +1 -0
- package/dist/src/Service/agent.d.ts +17 -0
- package/dist/src/Service/agent.js +28 -0
- package/dist/src/Service/create-form-data.d.ts +10 -0
- package/dist/src/Service/create-form-data.js +52 -0
- package/dist/src/Service/create-form.d.ts +52 -0
- package/dist/src/Service/create-form.js +98 -0
- package/dist/src/Service/project-data.d.ts +48 -0
- package/dist/src/Service/project-data.js +92 -0
- package/dist/src/Service/projects.d.ts +21 -0
- package/dist/src/Service/projects.js +80 -0
- package/dist/src/Service/run-data.d.ts +35 -0
- package/dist/src/Service/run-data.js +62 -0
- package/dist/src/Service/ticket-data.d.ts +6 -0
- package/dist/src/Service/ticket-data.js +7 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +10 -0
- package/dist/src/launcher.d.ts +58 -0
- package/dist/src/launcher.js +51 -0
- package/dist/src/lib/ansi.d.ts +19 -0
- package/dist/src/lib/ansi.js +45 -0
- package/dist/src/lib/format.d.ts +3 -0
- package/dist/src/lib/format.js +10 -0
- package/dist/src/lib/scroll.d.ts +1 -0
- package/dist/src/lib/scroll.js +16 -0
- package/dist/src/lib/ticket-query.d.ts +21 -0
- package/dist/src/lib/ticket-query.js +72 -0
- package/dist/src/plugin.d.ts +13 -0
- package/dist/src/plugin.js +17 -0
- package/dist/src/preset.d.ts +2 -0
- package/dist/src/preset.js +7 -0
- package/dist/src/types.d.ts +98 -0
- package/dist/src/types.js +5 -0
- 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
|
+
}
|