@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.
@@ -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
- // Non-TTY: emit the dashboard once so `gaia ui </dev/null` stays scriptable.
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
- : selectedTicket;
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
- : `${detailAttr('identifier') ?? ''} · ${detailAttr('title') ?? 'ticket'}`;
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
- : tabBar(activeTab);
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
- ? isCommentsTab()
210
- ? ' Tab tab · ↑↓ select · Enter/Space expand · Esc back · q quit '
211
- : ' Tab tab · ↑↓ scroll · Esc back · q quit '
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
- : ' ↑↓ select · Enter open · n new · Tab/a agents · u runs · Esc back · q quit ';
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
- if (key === '\t') {
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
- void openDashboard();
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
+ }
@@ -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
@@ -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
+ }
@@ -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) => runTicketTui(document, context, services, options),
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
  };
@@ -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.1",
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.1"
26
+ "@gaia-ai/core": "^0.6.2"
27
27
  }
28
28
  }