@gaia-ai/addon-gaia-ui 0.6.1 → 0.6.2
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/dist/src/Component/project-picker/index.d.ts +15 -0
- package/dist/src/Component/project-picker/index.js +42 -0
- package/dist/src/Component/ticket-teaser/index.d.ts +7 -0
- package/dist/src/Component/ticket-teaser/index.js +76 -0
- package/dist/src/Kernel/tui-kernel.js +225 -11
- package/dist/src/Service/route.d.ts +11 -0
- package/dist/src/Service/route.js +215 -0
- package/dist/src/launcher.d.ts +6 -1
- package/dist/src/launcher.js +4 -0
- package/dist/src/lib/repo-url.d.ts +12 -0
- package/dist/src/lib/repo-url.js +64 -0
- package/dist/src/plugin.js +22 -1
- package/dist/src/types.d.ts +65 -0
- package/package.json +2 -2
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A project that claims the repo. */
|
|
2
|
+
export interface PickerCandidate {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The picker screen's rows: a heading naming the repo, then one row per
|
|
8
|
+
* candidate with `▸` on the selected one.
|
|
9
|
+
*/
|
|
10
|
+
export declare function projectPickerRows(candidates: PickerCandidate[], repo: string, selected: number): string[];
|
|
11
|
+
/**
|
|
12
|
+
* The non-TTY form (spec Decision 11): the candidate list plus the direct
|
|
13
|
+
* command, printed once so `gaia ui --here | cat` stays scriptable — no prompt.
|
|
14
|
+
*/
|
|
15
|
+
export declare function projectPickerHint(candidates: PickerCandidate[], repo: string): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// GAIA-223 AC-8 (spec Decision 14) — the picker `gaia ui --here` shows when the
|
|
2
|
+
// cwd's repo is claimed by MORE THAN ONE gaia project.
|
|
3
|
+
//
|
|
4
|
+
// One repo mapping to several projects is designed for, not an edge case:
|
|
5
|
+
// GAIA-126/137 let `.gaia/` hold one conductor config per project, which is how
|
|
6
|
+
// a Drupal multisite repo maps each site to its own GAIA project. Erroring on
|
|
7
|
+
// N>1 would fail permanently, by design, in exactly the repos the feature is
|
|
8
|
+
// most useful in — so the ambiguity is shown rather than resolved behind the
|
|
9
|
+
// user's back.
|
|
10
|
+
//
|
|
11
|
+
// Pure, like every other Component: candidates in, rows out. The kernel owns
|
|
12
|
+
// selection state, Enter and Esc.
|
|
13
|
+
/** Clamp a selection index into the candidate range (0 when there are none). */
|
|
14
|
+
function clamp(selected, length) {
|
|
15
|
+
if (length <= 0)
|
|
16
|
+
return 0;
|
|
17
|
+
return Math.max(0, Math.min(Math.trunc(selected), length - 1));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The picker screen's rows: a heading naming the repo, then one row per
|
|
21
|
+
* candidate with `▸` on the selected one.
|
|
22
|
+
*/
|
|
23
|
+
export function projectPickerRows(candidates, repo, selected) {
|
|
24
|
+
const heading = [`Several gaia projects claim ${repo} — which one?`, ''];
|
|
25
|
+
if (candidates.length === 0) {
|
|
26
|
+
return [...heading, 'No projects.'];
|
|
27
|
+
}
|
|
28
|
+
const index = clamp(selected, candidates.length);
|
|
29
|
+
return [
|
|
30
|
+
...heading,
|
|
31
|
+
...candidates.map((c, i) => `${i === index ? '▸' : ' '} ${c.name}`),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The non-TTY form (spec Decision 11): the candidate list plus the direct
|
|
36
|
+
* command, printed once so `gaia ui --here | cat` stays scriptable — no prompt.
|
|
37
|
+
*/
|
|
38
|
+
export function projectPickerHint(candidates, repo) {
|
|
39
|
+
const names = candidates.map((c) => ` ${c.name}`).join('\n');
|
|
40
|
+
return (`Several gaia projects claim ${repo}:\n${names}\n` +
|
|
41
|
+
"Open one directly with 'gaia ui project:<name>'.");
|
|
42
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TicketDetailDocument, UiViewMode } from '../../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* A ticket rendered compactly, with the field set of its Drupal view-mode
|
|
4
|
+
* display. `full` never reaches here — the kernel renders the tabbed detail for
|
|
5
|
+
* it — but it is accepted and treated as `teaser` so the function is total.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ticketTeaserLines(document: TicketDetailDocument, mode: UiViewMode): string[];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// GAIA-223 AC-7 — the compact ticket block behind `gaia ui <ticket> --view
|
|
2
|
+
// teaser|board_card` (spec Decision 13, variant A).
|
|
3
|
+
//
|
|
4
|
+
// THE FIELD SETS BELOW ARE A DELIBERATE DUPLICATE of two Drupal displays:
|
|
5
|
+
// config/sync/core.entity_view_display.gaia_ticket.gaia_ticket.teaser.yml
|
|
6
|
+
// → gaia_theme:ticket-row — identifier, title, state, derived_status,
|
|
7
|
+
// since_label, claim_machine, assignee_{name,initials,color}
|
|
8
|
+
// config/sync/core.entity_view_display.gaia_ticket.gaia_ticket.board_card.yml
|
|
9
|
+
// → gaia_theme:board-card — identifier, title, state, workflow(type),
|
|
10
|
+
// derived_status, priority, url; footer slot: claim_machine
|
|
11
|
+
//
|
|
12
|
+
// Why duplicated: those displays are display_builder→SDC bindings producing
|
|
13
|
+
// HTML, JSON:API never serves rendered output, and NEITHER the `session` nor the
|
|
14
|
+
// `pm` scope may read `entity_view_display` (both come back empty with
|
|
15
|
+
// "insufficient authorization"). Reading the mapping at runtime — variant B — is
|
|
16
|
+
// the named follow-up in the spec's *Out of scope*; it needs a `gaia_session`
|
|
17
|
+
// read permission in `config/sync` AND the recipe copy, plus proof that
|
|
18
|
+
// `third_party_settings` survives JSON:API serialization. When that lands, this
|
|
19
|
+
// module is the ONE place that changes; the `--view` CLI surface is identical
|
|
20
|
+
// either way. Until then, this file drifting from those YAMLs is the accepted
|
|
21
|
+
// cost, named in Decision 13.
|
|
22
|
+
import { asString } from '../../lib/format.js';
|
|
23
|
+
/** The same em-dash placeholder ticket-table/run-table use for a missing value. */
|
|
24
|
+
const MISSING = '—';
|
|
25
|
+
/** Left column width, so the labels line up like the other components' columns. */
|
|
26
|
+
const LABEL_WIDTH = 10;
|
|
27
|
+
function row(label, value) {
|
|
28
|
+
return `${label.padEnd(LABEL_WIDTH)} ${value}`;
|
|
29
|
+
}
|
|
30
|
+
/** An attribute as a display string, or the shared placeholder. */
|
|
31
|
+
function attr(attributes, key) {
|
|
32
|
+
const raw = attributes[key];
|
|
33
|
+
if (typeof raw === 'string' && raw !== '')
|
|
34
|
+
return raw;
|
|
35
|
+
if (typeof raw === 'number')
|
|
36
|
+
return String(raw);
|
|
37
|
+
return MISSING;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A ticket rendered compactly, with the field set of its Drupal view-mode
|
|
41
|
+
* display. `full` never reaches here — the kernel renders the tabbed detail for
|
|
42
|
+
* it — but it is accepted and treated as `teaser` so the function is total.
|
|
43
|
+
*/
|
|
44
|
+
export function ticketTeaserLines(document, mode) {
|
|
45
|
+
const a = document.data?.attributes ?? {};
|
|
46
|
+
const heading = `${attr(a, 'identifier')} · ${attr(a, 'title')}`;
|
|
47
|
+
if (mode === 'board_card') {
|
|
48
|
+
return [
|
|
49
|
+
heading,
|
|
50
|
+
'',
|
|
51
|
+
row('state', attr(a, 'state')),
|
|
52
|
+
row('type', attr(a, 'workflow')),
|
|
53
|
+
row('status', attr(a, 'derived_status')),
|
|
54
|
+
row('priority', attr(a, 'priority')),
|
|
55
|
+
row('claim', attr(a, 'claim_machine')),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
// `teaser`. The display binds assignee_name + assignee_initials + a colour;
|
|
59
|
+
// the terminal block shows the name with the initials as its short handle.
|
|
60
|
+
const name = attr(a, 'assignee_name');
|
|
61
|
+
const initials = asString(a.assignee_initials);
|
|
62
|
+
const assignee = name === MISSING
|
|
63
|
+
? (initials ?? MISSING)
|
|
64
|
+
: initials
|
|
65
|
+
? `${name} (${initials})`
|
|
66
|
+
: name;
|
|
67
|
+
return [
|
|
68
|
+
heading,
|
|
69
|
+
'',
|
|
70
|
+
row('state', attr(a, 'state')),
|
|
71
|
+
row('status', attr(a, 'derived_status')),
|
|
72
|
+
row('since', attr(a, 'since_label')),
|
|
73
|
+
row('assignee', assignee),
|
|
74
|
+
row('claim', attr(a, 'claim_machine')),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
@@ -4,10 +4,12 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { agentListRows } from '../Component/agent-list/index.js';
|
|
5
5
|
import { createFormLines } from '../Component/create-form/index.js';
|
|
6
6
|
import { dashboardRows } from '../Component/dashboard-list/index.js';
|
|
7
|
+
import { projectPickerHint, projectPickerRows, } from '../Component/project-picker/index.js';
|
|
7
8
|
import { runTableRows } from '../Component/run-table/index.js';
|
|
8
9
|
import { DETAIL_TABS, tabBar } from '../Component/tab-bar/index.js';
|
|
9
|
-
import { tabViewLines } from '../Component/ticket-detail/index.js';
|
|
10
|
+
import { detailLines, tabViewLines } from '../Component/ticket-detail/index.js';
|
|
10
11
|
import { ticketTableRows } from '../Component/ticket-table/index.js';
|
|
12
|
+
import { ticketTeaserLines } from '../Component/ticket-teaser/index.js';
|
|
11
13
|
import { topTabBar } from '../Component/top-tabs/index.js';
|
|
12
14
|
import { ANSI, fitLine, styleVisibleLines } from '../lib/ansi.js';
|
|
13
15
|
import { scrollTo } from '../lib/scroll.js';
|
|
@@ -20,6 +22,13 @@ import { loadDashboardProjects, } from '../Service/projects.js';
|
|
|
20
22
|
import { loadProjectRuns, selectedRunTicketId } from '../Service/run-data.js';
|
|
21
23
|
import { loadTicketDetail } from '../Service/ticket-data.js';
|
|
22
24
|
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
25
|
+
/** `<identifier> · <title>` for a ticket document — the TTY title bar's text,
|
|
26
|
+
* reused as the heading of the non-TTY ticket output (spec Decision 11). */
|
|
27
|
+
function ticketHeading(document) {
|
|
28
|
+
const a = document.data?.attributes ?? {};
|
|
29
|
+
const str = (key) => typeof a[key] === 'string' ? a[key] : '';
|
|
30
|
+
return `${str('identifier')} · ${str('title') || 'ticket'}`.trim();
|
|
31
|
+
}
|
|
23
32
|
// The three read-only screens each project a `document`/state into plain string
|
|
24
33
|
// rows; a Herdr-backed create agent is layered on top as a full-terminal
|
|
25
34
|
// passthrough (no node-pty / @xterm — the core renders raw ANSI to the real TTY).
|
|
@@ -32,8 +41,40 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
32
41
|
const currentUser = services?.currentUser ?? options?.currentUser;
|
|
33
42
|
const ownConductorMachineId = services?.ownConductorMachineId ?? options?.ownConductorMachineId;
|
|
34
43
|
const createPrompt = services?.createPrompt ?? options?.createPrompt;
|
|
35
|
-
//
|
|
44
|
+
// GAIA-223: the RESOLVED deep-link target (`Service/route.ts` produced it in
|
|
45
|
+
// the plugin's `run()`, before we got here). Absent ⇒ the dashboard (AC-4).
|
|
46
|
+
const bootRoute = options.route;
|
|
47
|
+
// Non-TTY: emit ONE screen so `gaia ui </dev/null` stays scriptable. With a
|
|
48
|
+
// route it emits THAT route's content (spec Decision 11) — otherwise
|
|
49
|
+
// `gaia ui GAIA-223 | cat` would silently print the wrong thing.
|
|
36
50
|
if (!input.isTTY || !output.isTTY) {
|
|
51
|
+
if (bootRoute?.screen === 'ticket') {
|
|
52
|
+
return loadTicketDetail(services.client, bootRoute.ticketId).then((doc) => {
|
|
53
|
+
const lines = bootRoute.view === 'full'
|
|
54
|
+
? // The same `<identifier> · <title>` heading the TTY draws in its
|
|
55
|
+
// title bar (and the teaser block emits itself) — without it a
|
|
56
|
+
// piped `gaia ui GAIA-223` or `gaia ui run:<uuid>` would print a
|
|
57
|
+
// wall of sections that never says WHICH ticket it is.
|
|
58
|
+
[ticketHeading(doc), '', ...detailLines(doc).lines]
|
|
59
|
+
: ticketTeaserLines(doc, bootRoute.view);
|
|
60
|
+
output.write(`${lines.join('\n')}\n`);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// AC-8: no terminal ⇒ no prompt. Print the candidates plus the direct
|
|
64
|
+
// command and exit, so `gaia ui --here | cat` stays scriptable (Decision 11).
|
|
65
|
+
if (bootRoute?.screen === 'project-picker') {
|
|
66
|
+
output.write(`${projectPickerHint(bootRoute.candidates, bootRoute.repo)}\n`);
|
|
67
|
+
return Promise.resolve();
|
|
68
|
+
}
|
|
69
|
+
if (bootRoute?.screen === 'project') {
|
|
70
|
+
return loadFilteredTickets(services.client, bootRoute.projectId, {
|
|
71
|
+
query: '',
|
|
72
|
+
state: null,
|
|
73
|
+
type: null,
|
|
74
|
+
}).then((rows) => {
|
|
75
|
+
output.write(`${bootRoute.projectName} · tickets\n${ticketTableRows(rows).join('\n')}\n`);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
37
78
|
return loadDashboardProjects({ client: services.client, nowSeconds })
|
|
38
79
|
.catch(() => [])
|
|
39
80
|
.then((rows) => {
|
|
@@ -52,6 +93,10 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
52
93
|
let searchMode = false;
|
|
53
94
|
let searchTimer = null;
|
|
54
95
|
let detail = null;
|
|
96
|
+
// GAIA-223 AC-7: how the ticket screen renders. 'full' is the tabbed detail
|
|
97
|
+
// (every path except an explicit `--view`); the other two are the compact
|
|
98
|
+
// block, which has no tabs (spec Decision 13).
|
|
99
|
+
const ticketView = bootRoute?.screen === 'ticket' ? bootRoute.view : 'full';
|
|
55
100
|
let activeTab = 0;
|
|
56
101
|
let selectedComment = 0;
|
|
57
102
|
let expandedComments = new Set();
|
|
@@ -65,6 +110,11 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
65
110
|
let selectedAgent = 0;
|
|
66
111
|
let runRows = [];
|
|
67
112
|
let selectedRun = 0;
|
|
113
|
+
// GAIA-223 AC-8: the `--here` picker's state (spec Decision 14). Empty unless
|
|
114
|
+
// a `project-picker` route booted us.
|
|
115
|
+
let pickerCandidates = bootRoute?.screen === 'project-picker' ? bootRoute.candidates : [];
|
|
116
|
+
let pickerRepo = bootRoute?.screen === 'project-picker' ? bootRoute.repo : '';
|
|
117
|
+
let selectedCandidate = 0;
|
|
68
118
|
let closing = false;
|
|
69
119
|
let createSeq = 0;
|
|
70
120
|
let lastCreateAtMs = 0;
|
|
@@ -112,6 +162,23 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
112
162
|
selRow: runRows.length ? 1 + selectedRun : -1,
|
|
113
163
|
};
|
|
114
164
|
}
|
|
165
|
+
if (screen === 'project-picker') {
|
|
166
|
+
// Two heading rows precede the candidates, so the physical row is 2 + i.
|
|
167
|
+
return {
|
|
168
|
+
lines: projectPickerRows(pickerCandidates, pickerRepo, selectedCandidate),
|
|
169
|
+
selectable: pickerCandidates.length,
|
|
170
|
+
selRow: pickerCandidates.length ? 2 + selectedCandidate : -1,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// AC-7: a non-`full` view replaces the tabbed detail with the compact block
|
|
174
|
+
// — nothing selectable, no tabs.
|
|
175
|
+
if (ticketView !== 'full') {
|
|
176
|
+
return {
|
|
177
|
+
lines: ticketTeaserLines(detail ?? {}, ticketView),
|
|
178
|
+
selectable: 0,
|
|
179
|
+
selRow: -1,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
115
182
|
const view = tabViewLines(detail ?? {}, activeTab, {
|
|
116
183
|
expanded: expandedComments,
|
|
117
184
|
nowSeconds: nowSeconds(),
|
|
@@ -137,7 +204,9 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
137
204
|
? selectedAgent
|
|
138
205
|
: screen === 'project-runs'
|
|
139
206
|
? selectedRun
|
|
140
|
-
:
|
|
207
|
+
: screen === 'project-picker'
|
|
208
|
+
? selectedCandidate
|
|
209
|
+
: selectedTicket;
|
|
141
210
|
// The "New ticket" popup: a centered floating box drawn ON TOP of the current
|
|
142
211
|
// screen (a real modal overlay, not a full-screen replace) via absolute cursor
|
|
143
212
|
// positioning, so the dashboard/ticket list stays visible around it.
|
|
@@ -193,7 +262,9 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
193
262
|
? 'Ticket-Agents'
|
|
194
263
|
: screen === 'project-runs'
|
|
195
264
|
? `${currentProjectName ?? currentProjectSlug} · runs`
|
|
196
|
-
:
|
|
265
|
+
: screen === 'project-picker'
|
|
266
|
+
? 'Which project?'
|
|
267
|
+
: `${detailAttr('identifier') ?? ''} · ${detailAttr('title') ?? 'ticket'}`;
|
|
197
268
|
const meta = screen === 'dashboard'
|
|
198
269
|
? `${projects.length} registered project(s) · ${services?.baseUrl ?? ''}`
|
|
199
270
|
: screen === 'project'
|
|
@@ -204,16 +275,24 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
204
275
|
? `${agentRows.length} agent(s) · Enter switch · z overview · r retry · x close · Esc back`
|
|
205
276
|
: screen === 'project-runs'
|
|
206
277
|
? `${runRows.length} run(s) · Enter ticket · Tab cycle · Esc back`
|
|
207
|
-
:
|
|
278
|
+
: screen === 'project-picker'
|
|
279
|
+
? `${pickerCandidates.length} project(s) claim ${pickerRepo} · Enter open · Esc dashboard`
|
|
280
|
+
: ticketView !== 'full'
|
|
281
|
+
? `view: ${ticketView} · Esc back · q quit`
|
|
282
|
+
: tabBar(activeTab);
|
|
208
283
|
const keys = screen === 'ticket'
|
|
209
|
-
?
|
|
210
|
-
? '
|
|
211
|
-
:
|
|
284
|
+
? ticketView !== 'full'
|
|
285
|
+
? ' ↑↓ scroll · Esc back · q quit '
|
|
286
|
+
: isCommentsTab()
|
|
287
|
+
? ' Tab tab · ↑↓ select · Enter/Space expand · Esc back · q quit '
|
|
288
|
+
: ' Tab tab · ↑↓ scroll · Esc back · q quit '
|
|
212
289
|
: screen === 'ticket-agents'
|
|
213
290
|
? ' ↑↓ select · Enter switch · z overview · r retry · x close · Tab/t tickets · q quit '
|
|
214
291
|
: screen === 'project-runs'
|
|
215
292
|
? ' ↑↓ select · Enter ticket · Tab cycle · Esc back · q quit '
|
|
216
|
-
:
|
|
293
|
+
: screen === 'project-picker'
|
|
294
|
+
? ' ↑↓ select · Enter open · Esc dashboard · q quit '
|
|
295
|
+
: ' ↑↓ select · Enter open · n new · Tab/a agents · u runs · Esc back · q quit ';
|
|
217
296
|
const bar = loading
|
|
218
297
|
? ' Loading… '
|
|
219
298
|
: error
|
|
@@ -254,6 +333,9 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
254
333
|
selectedAgent,
|
|
255
334
|
runRows,
|
|
256
335
|
selectedRun,
|
|
336
|
+
pickerCandidates,
|
|
337
|
+
pickerRepo,
|
|
338
|
+
selectedCandidate,
|
|
257
339
|
});
|
|
258
340
|
const restore = (s) => {
|
|
259
341
|
screen = s.screen;
|
|
@@ -268,6 +350,9 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
268
350
|
selectedAgent = s.selectedAgent;
|
|
269
351
|
runRows = s.runRows;
|
|
270
352
|
selectedRun = s.selectedRun;
|
|
353
|
+
pickerCandidates = s.pickerCandidates;
|
|
354
|
+
pickerRepo = s.pickerRepo;
|
|
355
|
+
selectedCandidate = s.selectedCandidate;
|
|
271
356
|
error = '';
|
|
272
357
|
status = '';
|
|
273
358
|
};
|
|
@@ -293,6 +378,112 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
293
378
|
draw();
|
|
294
379
|
}
|
|
295
380
|
};
|
|
381
|
+
/**
|
|
382
|
+
* Open a project screen from an already-resolved id + name — the deep-link
|
|
383
|
+
* path (`project:`/`--here`) and the picker's Enter. `openSelectedProject`
|
|
384
|
+
* cannot be reused: it starts from a dashboard ROW and re-looks-up the project
|
|
385
|
+
* by name, and neither the resolved route nor the picker has such a row.
|
|
386
|
+
*/
|
|
387
|
+
const openProjectById = async (projectId, projectName, { push = false, initial = false, } = {}) => {
|
|
388
|
+
if (loading)
|
|
389
|
+
return;
|
|
390
|
+
if (push)
|
|
391
|
+
history.push(remember());
|
|
392
|
+
// See `openTicketById`: the deep-link loading frame shows the TARGET screen.
|
|
393
|
+
if (initial) {
|
|
394
|
+
screen = 'project';
|
|
395
|
+
ticketRows = [];
|
|
396
|
+
currentProjectName = projectName;
|
|
397
|
+
currentProjectSlug = projectName;
|
|
398
|
+
}
|
|
399
|
+
loading = true;
|
|
400
|
+
error = '';
|
|
401
|
+
status = '';
|
|
402
|
+
draw();
|
|
403
|
+
try {
|
|
404
|
+
criteria = { query: '', state: null, type: null };
|
|
405
|
+
searchMode = false;
|
|
406
|
+
ticketRows = await loadFilteredTickets(services.client, projectId, criteria);
|
|
407
|
+
currentProjectId = projectId;
|
|
408
|
+
currentProjectName = projectName;
|
|
409
|
+
currentProjectSlug = projectName;
|
|
410
|
+
selectedTicket = 0;
|
|
411
|
+
offset = 0;
|
|
412
|
+
screen = 'project';
|
|
413
|
+
}
|
|
414
|
+
catch (caught) {
|
|
415
|
+
if (push)
|
|
416
|
+
history.pop();
|
|
417
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
loading = false;
|
|
421
|
+
draw();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
/**
|
|
425
|
+
* Open a ticket screen from an already-resolved id — the deep-link path.
|
|
426
|
+
* `tab: 'runs'` activates the Runs tab, which is the observable meaning of a
|
|
427
|
+
* `run:` target (spec Decision 2, the same landing `openSelectedRunTicket`
|
|
428
|
+
* produces when you press Enter on a run row).
|
|
429
|
+
*/
|
|
430
|
+
const openTicketById = async (ticketId, tab, { initial = false } = {}) => {
|
|
431
|
+
// Booting a deep link: swap to the target screen BEFORE the first draw, so
|
|
432
|
+
// the loading frame is an empty ticket skeleton and never a dashboard the
|
|
433
|
+
// user did not ask for (AC-3).
|
|
434
|
+
if (initial) {
|
|
435
|
+
screen = 'ticket';
|
|
436
|
+
detail = null;
|
|
437
|
+
activeTab = 0;
|
|
438
|
+
}
|
|
439
|
+
loading = true;
|
|
440
|
+
error = '';
|
|
441
|
+
status = '';
|
|
442
|
+
draw();
|
|
443
|
+
try {
|
|
444
|
+
detail = await loadTicketDetail(services.client, ticketId);
|
|
445
|
+
screen = 'ticket';
|
|
446
|
+
activeTab =
|
|
447
|
+
tab === 'runs'
|
|
448
|
+
? Math.max(0, DETAIL_TABS.findIndex((t) => t.id === 'runs'))
|
|
449
|
+
: 0;
|
|
450
|
+
expandedComments = new Set();
|
|
451
|
+
selectedComment = 0;
|
|
452
|
+
offset = 0;
|
|
453
|
+
}
|
|
454
|
+
catch (caught) {
|
|
455
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
loading = false;
|
|
459
|
+
draw();
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
/**
|
|
463
|
+
* GAIA-223 AC-3/AC-4/AC-8: boot the RESOLVED route's screen instead of the
|
|
464
|
+
* dashboard. No route ⇒ `openDashboard()`, byte-for-byte the previous
|
|
465
|
+
* behaviour. Nothing here pushes a history frame — there is no frame to go
|
|
466
|
+
* back to — which is exactly the case Decision 7's back handler covers.
|
|
467
|
+
*/
|
|
468
|
+
const bootFromRoute = async () => {
|
|
469
|
+
if (bootRoute === undefined)
|
|
470
|
+
return openDashboard();
|
|
471
|
+
if (bootRoute.screen === 'ticket') {
|
|
472
|
+
return openTicketById(bootRoute.ticketId, bootRoute.tab, {
|
|
473
|
+
initial: true,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
if (bootRoute.screen === 'project') {
|
|
477
|
+
return openProjectById(bootRoute.projectId, bootRoute.projectName, {
|
|
478
|
+
initial: true,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
// The picker's candidates are already in hand — no read at all.
|
|
482
|
+
screen = 'project-picker';
|
|
483
|
+
selectedCandidate = 0;
|
|
484
|
+
offset = 0;
|
|
485
|
+
draw();
|
|
486
|
+
};
|
|
296
487
|
const openSelectedProject = async () => {
|
|
297
488
|
const p = projects[selectedProject];
|
|
298
489
|
if (!p || loading)
|
|
@@ -771,6 +962,15 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
771
962
|
const previous = history.pop();
|
|
772
963
|
if (previous)
|
|
773
964
|
restore(previous);
|
|
965
|
+
// GAIA-223 Decision 7: a deep-linked screen is not a dead end. Booting
|
|
966
|
+
// straight into a ticket/project/picker pushes no frame, so Esc used to
|
|
967
|
+
// do NOTHING and only `q` was left. An empty stack anywhere but the
|
|
968
|
+
// dashboard now converges on the normal navigation model — which also
|
|
969
|
+
// fixes the same latent trap for any future screen entered frameless.
|
|
970
|
+
else if (screen !== 'dashboard') {
|
|
971
|
+
void openDashboard();
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
774
974
|
draw();
|
|
775
975
|
return;
|
|
776
976
|
}
|
|
@@ -845,7 +1045,8 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
845
1045
|
}
|
|
846
1046
|
if (screen === 'ticket') {
|
|
847
1047
|
const tabCount = DETAIL_TABS.length;
|
|
848
|
-
|
|
1048
|
+
// AC-7: the compact block has no tabs, so Tab is inert there.
|
|
1049
|
+
if (ticketView === 'full' && key === '\t') {
|
|
849
1050
|
activeTab = (activeTab + 1) % tabCount;
|
|
850
1051
|
offset = 0;
|
|
851
1052
|
selectedComment = 0;
|
|
@@ -909,6 +1110,15 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
909
1110
|
await openSelectedDetail();
|
|
910
1111
|
else if (screen === 'project-runs')
|
|
911
1112
|
await openSelectedRunTicket();
|
|
1113
|
+
else if (screen === 'project-picker') {
|
|
1114
|
+
// AC-8: the picked candidate takes the SAME code path a resolved
|
|
1115
|
+
// `project` route takes — the picker only chose which one.
|
|
1116
|
+
const picked = pickerCandidates[selectedCandidate];
|
|
1117
|
+
if (picked)
|
|
1118
|
+
await openProjectById(picked.id, picked.name, {
|
|
1119
|
+
push: true,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
912
1122
|
return;
|
|
913
1123
|
}
|
|
914
1124
|
const { selectable } = screenView();
|
|
@@ -945,6 +1155,8 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
945
1155
|
selectedAgent = next;
|
|
946
1156
|
else if (screen === 'project-runs')
|
|
947
1157
|
selectedRun = next;
|
|
1158
|
+
else if (screen === 'project-picker')
|
|
1159
|
+
selectedCandidate = next;
|
|
948
1160
|
else
|
|
949
1161
|
selectedTicket = next;
|
|
950
1162
|
offset = scrollTo(screenView().selRow, offset, viewHeight());
|
|
@@ -978,6 +1190,8 @@ export function runTicketTui(_document, _context, services, options = {}) {
|
|
|
978
1190
|
// Surviving agents rehydrate lazily: opening a project's Ticket-Agents screen
|
|
979
1191
|
// reconciles it from that project's live panes (AC-5 restart survival) — there
|
|
980
1192
|
// is no project context at start, so nothing to reconcile here.
|
|
981
|
-
|
|
1193
|
+
// GAIA-223: with a resolved deep link this boots that screen instead of the
|
|
1194
|
+
// dashboard; without one it IS `openDashboard()` (AC-4).
|
|
1195
|
+
void bootFromRoute();
|
|
982
1196
|
});
|
|
983
1197
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DropshClient, ResolvedRoute, UiInitialRoute } from '../types.js';
|
|
2
|
+
/** Context for the error messages — which control plane was asked. */
|
|
3
|
+
export interface ResolveRouteContext {
|
|
4
|
+
baseUrl?: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a CLI deep-link target into the screen the kernel should boot.
|
|
8
|
+
* Throws — always with a message naming the target and the control plane — when
|
|
9
|
+
* the target does not exist or is ambiguous.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveRoute(client: DropshClient, route: UiInitialRoute, ctx?: ResolveRouteContext): Promise<ResolvedRoute>;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// GAIA-223: deep-link resolution — the unresolved CLI route (`UiInitialRoute`)
|
|
2
|
+
// against the control plane into the renderer's `ResolvedRoute`.
|
|
3
|
+
//
|
|
4
|
+
// WHERE this runs matters (spec Decision 5). The `gaia ui` entry has no JSON:API
|
|
5
|
+
// client at all — the client first exists as `services.client` inside the dropsh
|
|
6
|
+
// program, in the renderer's `run()`. That is the earliest point resolution can
|
|
7
|
+
// happen, and it is still strictly BEFORE `runTicketTui` reaches
|
|
8
|
+
// `input.setRawMode(true)`. So every failure here throws out of `run()`, through
|
|
9
|
+
// `parseAsync`, into `cmdUi`'s catch: no alternate screen, no raw mode, no
|
|
10
|
+
// dashboard — a stderr line and exit 1 (AC-3).
|
|
11
|
+
import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
|
|
12
|
+
import { asString } from '../lib/format.js';
|
|
13
|
+
import { normalizeRepoUrl } from '../lib/repo-url.js';
|
|
14
|
+
/** `" on <baseUrl>"`, or '' when the caller did not supply one. */
|
|
15
|
+
function on(ctx) {
|
|
16
|
+
return ctx?.baseUrl ? ` on ${ctx.baseUrl}` : '';
|
|
17
|
+
}
|
|
18
|
+
/** `" (from branch '<b>')"` — so a branch-derived over-match (`release/v2-2026`
|
|
19
|
+
* → `V2-2026`, spec § Risks) explains itself instead of looking like a typo. */
|
|
20
|
+
function via(route) {
|
|
21
|
+
return route.fromBranch ? ` (derived from branch '${route.fromBranch}')` : '';
|
|
22
|
+
}
|
|
23
|
+
/** Read a single resource, mapping any transport failure to `undefined` so the
|
|
24
|
+
* caller always produces the same named, actionable error. */
|
|
25
|
+
async function getOne(client, path) {
|
|
26
|
+
const doc = (await client.get(path).catch(() => undefined));
|
|
27
|
+
return doc?.data ?? undefined;
|
|
28
|
+
}
|
|
29
|
+
/** The id of a single-valued relationship linkage, or null. */
|
|
30
|
+
function relId(resource, name) {
|
|
31
|
+
const data = resource.relationships?.[name]?.data;
|
|
32
|
+
if (!data)
|
|
33
|
+
return null;
|
|
34
|
+
return Array.isArray(data) ? (data[0]?.id ?? null) : (data.id ?? null);
|
|
35
|
+
}
|
|
36
|
+
async function resolveTicketByUuid(client, route, ctx) {
|
|
37
|
+
const found = await getOne(client, `gaia_ticket/gaia_ticket/${route.value}`);
|
|
38
|
+
if (!found?.id) {
|
|
39
|
+
throw new Error(`no ticket ${route.value}${on(ctx)}${via(route)}.`);
|
|
40
|
+
}
|
|
41
|
+
return { screen: 'ticket', ticketId: found.id, view: route.view ?? 'full' };
|
|
42
|
+
}
|
|
43
|
+
async function resolveTicketByIdentifier(client, route, ctx) {
|
|
44
|
+
// Pages to TWO, not one: identifiers are unique per PROJECT, so a
|
|
45
|
+
// cross-project collision is possible in principle. Fetching a second row
|
|
46
|
+
// turns a silent wrong-ticket into a named ambiguity (spec Decision 6).
|
|
47
|
+
const params = new DrupalJsonApiParams();
|
|
48
|
+
params.addFilter('identifier', route.value);
|
|
49
|
+
params.addPageLimit(2);
|
|
50
|
+
const doc = (await client
|
|
51
|
+
.get('gaia_ticket/gaia_ticket', params)
|
|
52
|
+
.catch(() => undefined));
|
|
53
|
+
const rows = doc?.data ?? [];
|
|
54
|
+
if (rows.length === 0) {
|
|
55
|
+
throw new Error(`no ticket ${route.value}${on(ctx)}${via(route)}.`);
|
|
56
|
+
}
|
|
57
|
+
if (rows.length > 1) {
|
|
58
|
+
const candidates = rows
|
|
59
|
+
.map((r) => ` ticket:${r.id} — ${asString(r.attributes?.title) ?? ''}`)
|
|
60
|
+
.join('\n');
|
|
61
|
+
throw new Error(`${route.value} matches ${rows.length} tickets${on(ctx)}${via(route)}. ` +
|
|
62
|
+
`Name one with 'ticket:<uuid>':\n${candidates}`);
|
|
63
|
+
}
|
|
64
|
+
const id = rows[0]?.id;
|
|
65
|
+
if (!id) {
|
|
66
|
+
throw new Error(`no ticket ${route.value}${on(ctx)}${via(route)}.`);
|
|
67
|
+
}
|
|
68
|
+
return { screen: 'ticket', ticketId: id, view: route.view ?? 'full' };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The repo URLs a `gaia_project` row declares, normalised. `repos` is a JSON
|
|
72
|
+
* field: JSON:API serves it as a real array of objects (measured on dev2), but a
|
|
73
|
+
* JSON *string* is accepted too so a differently-serialized deployment does not
|
|
74
|
+
* silently stop matching. Anything unparseable yields no URLs — a malformed row
|
|
75
|
+
* is skipped, never fatal.
|
|
76
|
+
*/
|
|
77
|
+
function projectRepoIdentities(resource) {
|
|
78
|
+
const raw = resource.attributes?.repos;
|
|
79
|
+
let entries;
|
|
80
|
+
if (typeof raw === 'string') {
|
|
81
|
+
try {
|
|
82
|
+
entries = JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
entries = raw;
|
|
90
|
+
}
|
|
91
|
+
if (!Array.isArray(entries))
|
|
92
|
+
return [];
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!entry || typeof entry !== 'object')
|
|
96
|
+
continue;
|
|
97
|
+
const normalised = normalizeRepoUrl(entry.url);
|
|
98
|
+
if (normalised !== undefined)
|
|
99
|
+
out.push(normalised);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The `--here` project rung (spec Decision 3): the project(s) whose `repos[]`
|
|
105
|
+
* claim the cwd's normalised git remote.
|
|
106
|
+
*
|
|
107
|
+
* The match is CLIENT-SIDE by necessity, not by taste — `filter[repos]=…`
|
|
108
|
+
* answers **HTTP 400** (measured): `repos` is a JSON field on the entity, not a
|
|
109
|
+
* relationship JSON:API can filter inside. So this reads the project collection
|
|
110
|
+
* (a handful of rows) and matches in memory: one round trip, the same one a name
|
|
111
|
+
* lookup would have cost.
|
|
112
|
+
*
|
|
113
|
+
* Zero matches is the one hard error. TWO OR MORE is NOT — a repo claimed by
|
|
114
|
+
* several projects is a designed-for topology (Decision 14, AC-8), so it
|
|
115
|
+
* resolves to the picker screen.
|
|
116
|
+
*/
|
|
117
|
+
async function resolveProjectByRepo(client, route, ctx) {
|
|
118
|
+
const params = new DrupalJsonApiParams();
|
|
119
|
+
params.addFields('gaia_project--gaia_project', ['name', 'repos']);
|
|
120
|
+
params.addPageLimit(200);
|
|
121
|
+
const doc = (await client
|
|
122
|
+
.get('gaia_project/gaia_project', params)
|
|
123
|
+
.catch(() => undefined));
|
|
124
|
+
const rows = doc?.data ?? [];
|
|
125
|
+
const candidates = rows
|
|
126
|
+
.filter((r) => projectRepoIdentities(r).includes(route.value))
|
|
127
|
+
.map((r) => ({
|
|
128
|
+
id: r.id ?? '',
|
|
129
|
+
name: asString(r.attributes?.name) ?? '',
|
|
130
|
+
}))
|
|
131
|
+
.filter((c) => c.id !== '');
|
|
132
|
+
if (candidates.length === 0) {
|
|
133
|
+
const known = rows
|
|
134
|
+
.map((r) => asString(r.attributes?.name))
|
|
135
|
+
.filter((n) => Boolean(n));
|
|
136
|
+
const list = known.length ? known.join(', ') : '(none visible)';
|
|
137
|
+
throw new Error(`no gaia project owns the repo ${route.value}${on(ctx)}${via(route)}.\n` +
|
|
138
|
+
` Known projects: ${list}\n` +
|
|
139
|
+
" Run 'gaia ui' for the dashboard, or 'gaia ui project:<name>' to open one directly.");
|
|
140
|
+
}
|
|
141
|
+
if (candidates.length === 1) {
|
|
142
|
+
const only = candidates[0];
|
|
143
|
+
return {
|
|
144
|
+
screen: 'project',
|
|
145
|
+
projectId: only.id,
|
|
146
|
+
projectName: only.name || route.value,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { screen: 'project-picker', repo: route.value, candidates };
|
|
150
|
+
}
|
|
151
|
+
async function resolveProject(client, route, ctx) {
|
|
152
|
+
if (route.by === 'repo')
|
|
153
|
+
return resolveProjectByRepo(client, route, ctx);
|
|
154
|
+
if (route.by === 'uuid') {
|
|
155
|
+
const found = await getOne(client, `gaia_project/gaia_project/${route.value}`);
|
|
156
|
+
if (!found?.id) {
|
|
157
|
+
throw new Error(`no project ${route.value}${on(ctx)}${via(route)}.`);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
screen: 'project',
|
|
161
|
+
projectId: found.id,
|
|
162
|
+
projectName: asString(found.attributes?.name) ?? route.value,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// By NAME — the same `collection('gaia_project').where('name','=',…)` read
|
|
166
|
+
// the project screen already performs, so both agree on what a name means.
|
|
167
|
+
const found = await client
|
|
168
|
+
.collection('gaia_project')
|
|
169
|
+
.where('name', '=', route.value)
|
|
170
|
+
.first()
|
|
171
|
+
.catch(() => null);
|
|
172
|
+
if (!found) {
|
|
173
|
+
throw new Error(`no project named '${route.value}'${on(ctx)}${via(route)}.`);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
screen: 'project',
|
|
177
|
+
projectId: found.id,
|
|
178
|
+
projectName: found.attr('name') ?? route.value,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
async function resolveRun(client, route, ctx) {
|
|
182
|
+
const found = await getOne(client, `gaia_run/gaia_run/${route.value}?include=ticket_id`);
|
|
183
|
+
if (!found?.id) {
|
|
184
|
+
throw new Error(`no run ${route.value}${on(ctx)}${via(route)}.`);
|
|
185
|
+
}
|
|
186
|
+
const ticketId = relId(found, 'ticket_id');
|
|
187
|
+
if (!ticketId) {
|
|
188
|
+
throw new Error(`run ${route.value} has no ticket${on(ctx)}.`);
|
|
189
|
+
}
|
|
190
|
+
// Decision 2: the observable meaning of a `run:` target is the owning
|
|
191
|
+
// ticket's detail screen with the Runs tab active. `runId` rides along even
|
|
192
|
+
// though nothing renders it, so a later dedicated run screen is a purely
|
|
193
|
+
// renderer-internal change.
|
|
194
|
+
return {
|
|
195
|
+
screen: 'ticket',
|
|
196
|
+
ticketId,
|
|
197
|
+
view: 'full',
|
|
198
|
+
tab: 'runs',
|
|
199
|
+
runId: found.id,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Resolve a CLI deep-link target into the screen the kernel should boot.
|
|
204
|
+
* Throws — always with a message naming the target and the control plane — when
|
|
205
|
+
* the target does not exist or is ambiguous.
|
|
206
|
+
*/
|
|
207
|
+
export function resolveRoute(client, route, ctx) {
|
|
208
|
+
if (route.kind === 'run')
|
|
209
|
+
return resolveRun(client, route, ctx);
|
|
210
|
+
if (route.kind === 'project')
|
|
211
|
+
return resolveProject(client, route, ctx);
|
|
212
|
+
return route.by === 'uuid'
|
|
213
|
+
? resolveTicketByUuid(client, route, ctx)
|
|
214
|
+
: resolveTicketByIdentifier(client, route, ctx);
|
|
215
|
+
}
|
package/dist/src/launcher.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type GaiaUiOptions } from './plugin.js';
|
|
2
|
-
import type { TuiAgentService } from './types.js';
|
|
2
|
+
import type { TuiAgentService, UiInitialRoute, UiViewMode } from './types.js';
|
|
3
3
|
/** A dropsh program: only the `parseAsync` we drive is modelled. */
|
|
4
4
|
export interface DropshProgram {
|
|
5
5
|
parseAsync(argv: string[], opts: {
|
|
@@ -48,7 +48,12 @@ export interface GaiaUiLauncherOptions {
|
|
|
48
48
|
/** Injected renderer factory; defaults to this package's own plugin. The
|
|
49
49
|
* `$GAIA_UI_PLUGIN` dev override resolves a different module in the entry. */
|
|
50
50
|
renderer?: ((opts: GaiaUiOptions) => unknown) | undefined;
|
|
51
|
+
/** GAIA-223: the deep-link target the entry parsed, still UNRESOLVED — the
|
|
52
|
+
* entry has no JSON:API client (spec Finding 1), so the renderer resolves it
|
|
53
|
+
* in `run()` before the kernel takes the terminal. Absent ⇒ the dashboard. */
|
|
54
|
+
initialRoute?: UiInitialRoute | undefined;
|
|
51
55
|
}
|
|
56
|
+
export type { UiInitialRoute, UiViewMode };
|
|
52
57
|
/**
|
|
53
58
|
* Build + run the dropsh `--format tui` program with the TUI renderer and the
|
|
54
59
|
* injected agent host. A trivial `search gaia_project` seeds the renderer's
|
package/dist/src/launcher.js
CHANGED
|
@@ -32,6 +32,10 @@ export async function runGaiaUi(opts) {
|
|
|
32
32
|
...(opts.createPrompt !== undefined
|
|
33
33
|
? { createPrompt: opts.createPrompt }
|
|
34
34
|
: {}),
|
|
35
|
+
// Optional at every hop — omitting the key IS the dashboard path (AC-4).
|
|
36
|
+
...(opts.initialRoute !== undefined
|
|
37
|
+
? { initialRoute: opts.initialRoute }
|
|
38
|
+
: {}),
|
|
35
39
|
});
|
|
36
40
|
const program = opts.buildProgram({
|
|
37
41
|
plugins: [...opts.connection.authPlugins, renderer],
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `host/path`, with scheme, userinfo, port, a trailing `.git` and trailing
|
|
3
|
+
* slashes dropped, lowercased. `undefined` when the input carries no host+path.
|
|
4
|
+
*
|
|
5
|
+
* | raw | normalised |
|
|
6
|
+
* |-----------------------------------------|---------------------|
|
|
7
|
+
* | `git@host:keytec/gaia.git` | `host/keytec/gaia` |
|
|
8
|
+
* | `ssh://git@host/keytec/gaia.git` | `host/keytec/gaia` |
|
|
9
|
+
* | `ssh://git@host:2222/keytec/gaia.git` | `host/keytec/gaia` |
|
|
10
|
+
* | `https://host/keytec/gaia.git` | `host/keytec/gaia` |
|
|
11
|
+
*/
|
|
12
|
+
export declare function normalizeRepoUrl(raw: unknown): string | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// GAIA-223 (spec Decision 3) — a git remote URL reduced to a comparable
|
|
2
|
+
// `host/path` identity.
|
|
3
|
+
//
|
|
4
|
+
// DELIBERATE MIRROR of `@gaia-ai/ui`'s `normalizeRepoUrl` (`ui/src/route.ts`),
|
|
5
|
+
// for the same reason `UiInitialRoute` and `HomeConnection` are mirrored: the
|
|
6
|
+
// command package resolves this renderer DYNAMICALLY and must never import it
|
|
7
|
+
// (`ui/tests/no-gaia-ui-import.test.ts`), and the layering forbids an addon from
|
|
8
|
+
// edging `@gaia-ai/ui`. Both sides need the SAME reduction — the CLI normalises
|
|
9
|
+
// the cwd's remote, this side normalises every `gaia_project.repos[].url` — so
|
|
10
|
+
// the function exists twice and is fixture-tested on both sides against the same
|
|
11
|
+
// measured pair.
|
|
12
|
+
/**
|
|
13
|
+
* `host/path`, with scheme, userinfo, port, a trailing `.git` and trailing
|
|
14
|
+
* slashes dropped, lowercased. `undefined` when the input carries no host+path.
|
|
15
|
+
*
|
|
16
|
+
* | raw | normalised |
|
|
17
|
+
* |-----------------------------------------|---------------------|
|
|
18
|
+
* | `git@host:keytec/gaia.git` | `host/keytec/gaia` |
|
|
19
|
+
* | `ssh://git@host/keytec/gaia.git` | `host/keytec/gaia` |
|
|
20
|
+
* | `ssh://git@host:2222/keytec/gaia.git` | `host/keytec/gaia` |
|
|
21
|
+
* | `https://host/keytec/gaia.git` | `host/keytec/gaia` |
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeRepoUrl(raw) {
|
|
24
|
+
if (typeof raw !== 'string')
|
|
25
|
+
return undefined;
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
if (trimmed === '')
|
|
28
|
+
return undefined;
|
|
29
|
+
let host;
|
|
30
|
+
let path;
|
|
31
|
+
// `scheme://[user@]host[:port]/path` — checked FIRST, so an `ssh://…:2222/…`
|
|
32
|
+
// port is never mistaken for the scp form's `host:path` colon.
|
|
33
|
+
const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(.+)$/.exec(trimmed);
|
|
34
|
+
if (withScheme?.[1] !== undefined) {
|
|
35
|
+
const rest = withScheme[1];
|
|
36
|
+
const slash = rest.indexOf('/');
|
|
37
|
+
if (slash < 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
host = rest.slice(0, slash);
|
|
40
|
+
path = rest.slice(slash + 1);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// The scp-like form `[user@]host:path` — what `git remote get-url` emits for
|
|
44
|
+
// an ssh clone. A bare local path has no colon and falls out here.
|
|
45
|
+
const scp = /^(?:[^/@]+@)?([^/:]+):(.+)$/.exec(trimmed);
|
|
46
|
+
if (scp?.[1] === undefined || scp[2] === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
host = scp[1];
|
|
49
|
+
path = scp[2];
|
|
50
|
+
}
|
|
51
|
+
host = host
|
|
52
|
+
.replace(/^[^@]*@/, '')
|
|
53
|
+
.replace(/:\d+$/, '')
|
|
54
|
+
.toLowerCase();
|
|
55
|
+
path = path
|
|
56
|
+
.replace(/^\/+/, '')
|
|
57
|
+
.replace(/\/+$/, '')
|
|
58
|
+
.replace(/\.git$/i, '')
|
|
59
|
+
.replace(/\/+$/, '')
|
|
60
|
+
.toLowerCase();
|
|
61
|
+
if (host === '' || path === '')
|
|
62
|
+
return undefined;
|
|
63
|
+
return `${host}/${path}`;
|
|
64
|
+
}
|
package/dist/src/plugin.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { runTicketTui } from './Kernel/tui-kernel.js';
|
|
2
|
+
import { resolveRoute } from './Service/route.js';
|
|
2
3
|
// The dropsh `tui` renderer plugin: a single interactive renderer that hands off
|
|
3
4
|
// to the TUI kernel. The conductor's plugin resolver auto-picks the default
|
|
4
5
|
// export, so the factory is the module default.
|
|
6
|
+
//
|
|
7
|
+
// GAIA-223 (spec Decision 5): `run()` is ALSO where a deep-link target is
|
|
8
|
+
// resolved. It is the earliest point at which a JSON:API client exists —
|
|
9
|
+
// `services.client` — and it is still strictly before the kernel reaches
|
|
10
|
+
// `input.setRawMode(true)`. A resolution failure therefore rejects out of
|
|
11
|
+
// `run()`, through dropsh's `parseAsync`, into `cmdUi`'s catch: no alternate
|
|
12
|
+
// screen, no raw mode, no dashboard — a stderr line and exit 1 (AC-3).
|
|
5
13
|
export default function gaiaUiPlugin(options = {}) {
|
|
6
14
|
return {
|
|
7
15
|
id: 'gaia-ticket-renderer',
|
|
@@ -10,7 +18,20 @@ export default function gaiaUiPlugin(options = {}) {
|
|
|
10
18
|
{
|
|
11
19
|
id: 'tui',
|
|
12
20
|
interactive: true,
|
|
13
|
-
run: (document, context, services) =>
|
|
21
|
+
run: async (document, context, services) => {
|
|
22
|
+
const route = options.initialRoute === undefined
|
|
23
|
+
? undefined
|
|
24
|
+
: await resolveRoute(services.client, options.initialRoute, {
|
|
25
|
+
...(services.baseUrl !== undefined
|
|
26
|
+
? { baseUrl: services.baseUrl }
|
|
27
|
+
: {}),
|
|
28
|
+
});
|
|
29
|
+
return runTicketTui(document, context, services, {
|
|
30
|
+
...options,
|
|
31
|
+
// Optional at every hop — omitting the key IS the dashboard path.
|
|
32
|
+
...(route !== undefined ? { route } : {}),
|
|
33
|
+
});
|
|
34
|
+
},
|
|
14
35
|
},
|
|
15
36
|
],
|
|
16
37
|
};
|
package/dist/src/types.d.ts
CHANGED
|
@@ -87,6 +87,66 @@ export interface RendererServices {
|
|
|
87
87
|
/** Config override for the ticket-create agent's base prompt. */
|
|
88
88
|
createPrompt?: string;
|
|
89
89
|
}
|
|
90
|
+
/** How a ticket target is rendered. Mirrors the Drupal `gaia_ticket` view modes. */
|
|
91
|
+
export type UiViewMode = 'full' | 'teaser' | 'board_card';
|
|
92
|
+
/**
|
|
93
|
+
* A deep-link target as the CLI parsed it — UNRESOLVED. It says what to look up
|
|
94
|
+
* and how, never what screen results. Mirror of `@gaia-ai/ui`'s `UiInitialRoute`.
|
|
95
|
+
*/
|
|
96
|
+
export interface UiInitialRoute {
|
|
97
|
+
kind: 'ticket' | 'project' | 'run';
|
|
98
|
+
/**
|
|
99
|
+
* How `value` must be looked up. `'repo'` is the `--here` project rung: the
|
|
100
|
+
* value is a NORMALISED remote URL (`host/path`), matched client-side against
|
|
101
|
+
* every `gaia_project.repos[].url` (spec Decision 3).
|
|
102
|
+
*/
|
|
103
|
+
by: 'uuid' | 'identifier' | 'name' | 'repo';
|
|
104
|
+
value: string;
|
|
105
|
+
/** Drupal view mode for a ticket target; default 'full'. */
|
|
106
|
+
view?: UiViewMode | undefined;
|
|
107
|
+
/** Set when the target came from `--here`; error messages only. */
|
|
108
|
+
fromBranch?: string | undefined;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A route AFTER resolution against the control plane — renderer-internal, never
|
|
112
|
+
* crossing a package boundary, so it may speak screen vocabulary.
|
|
113
|
+
*
|
|
114
|
+
* `view` and `tab` are orthogonal and both needed: `view` says HOW the ticket is
|
|
115
|
+
* rendered (the tabbed detail vs. a compact block), `tab` says which detail tab
|
|
116
|
+
* is active WHEN `view` is 'full'. A `run:` target sets `tab: 'runs'`. The
|
|
117
|
+
* project variant carries both id and name — the kernel queries by id and
|
|
118
|
+
* headlines by name, and resolving `project:<uuid>` already fetched both.
|
|
119
|
+
*/
|
|
120
|
+
export type ResolvedRoute = {
|
|
121
|
+
screen: 'ticket';
|
|
122
|
+
ticketId: string;
|
|
123
|
+
view: UiViewMode;
|
|
124
|
+
tab?: 'runs' | undefined;
|
|
125
|
+
/** Carried even though nothing renders it yet, so a future dedicated run
|
|
126
|
+
* screen needs no change to the CLI grammar or the launcher contract. */
|
|
127
|
+
runId?: string | undefined;
|
|
128
|
+
} | {
|
|
129
|
+
screen: 'project';
|
|
130
|
+
projectId: string;
|
|
131
|
+
projectName: string;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Spec Decision 14 (AC-8): several gaia projects claim the same repo — a
|
|
135
|
+
* DESIGNED-FOR topology (GAIA-126/137 map each site of a Drupal multisite repo
|
|
136
|
+
* to its own project), so `--here` shows the ambiguity and lets the user pick
|
|
137
|
+
* instead of failing permanently. This is the one route outcome that is a
|
|
138
|
+
* screen rather than a lookup result, which is why Decision 5's "abort before
|
|
139
|
+
* the TUI" invariant is scoped to FAILURES.
|
|
140
|
+
*/
|
|
141
|
+
| {
|
|
142
|
+
screen: 'project-picker';
|
|
143
|
+
/** The normalised `host/path` every candidate claims. */
|
|
144
|
+
repo: string;
|
|
145
|
+
candidates: {
|
|
146
|
+
id: string;
|
|
147
|
+
name: string;
|
|
148
|
+
}[];
|
|
149
|
+
};
|
|
90
150
|
/** Options passed by the factory into the kernel. */
|
|
91
151
|
export interface TuiOptions {
|
|
92
152
|
project?: string;
|
|
@@ -95,4 +155,9 @@ export interface TuiOptions {
|
|
|
95
155
|
currentUser?: string;
|
|
96
156
|
ownConductorMachineId?: string;
|
|
97
157
|
createPrompt?: string;
|
|
158
|
+
/** GAIA-223: the unresolved deep-link target, as it arrived from the CLI. */
|
|
159
|
+
initialRoute?: UiInitialRoute | undefined;
|
|
160
|
+
/** GAIA-223: the RESOLVED route the plugin's `run()` produced. The kernel
|
|
161
|
+
* boots this screen instead of the dashboard; absent ⇒ dashboard (AC-4). */
|
|
162
|
+
route?: ResolvedRoute | undefined;
|
|
98
163
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaia-ai/addon-gaia-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "GAIA TUI: interactive terminal UI (project dashboard, ticket list, ticket detail) as a dropsh `tui` renderer plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,6 @@
|
|
|
23
23
|
"drupal-jsonapi-params": "^3.0.1"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"@gaia-ai/core": "^0.6.
|
|
26
|
+
"@gaia-ai/core": "^0.6.2"
|
|
27
27
|
}
|
|
28
28
|
}
|