@gaia-ai/addon-gaia-ui 0.11.1 → 0.11.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.
Files changed (79) hide show
  1. package/dist/src/Agent/Component/agent-list/index.d.ts +6 -0
  2. package/dist/src/Agent/Component/agent-list/index.js +14 -0
  3. package/dist/src/Component/agent-list/index.d.ts +6 -0
  4. package/dist/src/Component/agent-list/index.js +14 -0
  5. package/dist/src/Component/comment-item/index.d.ts +21 -0
  6. package/dist/src/Component/comment-item/index.js +39 -0
  7. package/dist/src/Component/create-form/index.d.ts +3 -0
  8. package/dist/src/Component/create-form/index.js +26 -0
  9. package/dist/src/Component/dashboard-list/index.d.ts +8 -0
  10. package/dist/src/Component/dashboard-list/index.js +7 -0
  11. package/dist/src/Component/project-picker/index.d.ts +15 -0
  12. package/dist/src/Component/project-picker/index.js +42 -0
  13. package/dist/src/Component/run-table/index.d.ts +10 -0
  14. package/dist/src/Component/run-table/index.js +20 -0
  15. package/dist/src/Component/tab-bar/index.d.ts +20 -0
  16. package/dist/src/Component/tab-bar/index.js +42 -0
  17. package/dist/src/Component/ticket-detail/index.d.ts +38 -0
  18. package/dist/src/Component/ticket-detail/index.js +193 -0
  19. package/dist/src/Component/ticket-table/index.d.ts +5 -0
  20. package/dist/src/Component/ticket-table/index.js +14 -0
  21. package/dist/src/Component/ticket-teaser/index.d.ts +7 -0
  22. package/dist/src/Component/ticket-teaser/index.js +76 -0
  23. package/dist/src/Deriver/liveness.d.ts +12 -0
  24. package/dist/src/Deriver/liveness.js +34 -0
  25. package/dist/src/Deriver/run-status.d.ts +12 -0
  26. package/dist/src/Deriver/run-status.js +36 -0
  27. package/dist/src/Entity/conductor/Component/conductor-list/index.d.ts +25 -0
  28. package/dist/src/Entity/conductor/Component/conductor-list/index.js +60 -0
  29. package/dist/src/Entity/project/Component/dashboard-list/index.d.ts +8 -0
  30. package/dist/src/Entity/project/Component/dashboard-list/index.js +7 -0
  31. package/dist/src/Entity/project/Component/project-list/index.d.ts +8 -0
  32. package/dist/src/Entity/project/Component/project-list/index.js +7 -0
  33. package/dist/src/Entity/project/Component/project-picker/index.d.ts +15 -0
  34. package/dist/src/Entity/project/Component/project-picker/index.js +42 -0
  35. package/dist/src/Entity/run/Component/run-table/index.d.ts +8 -0
  36. package/dist/src/Entity/run/Component/run-table/index.js +11 -0
  37. package/dist/src/Entity/ticket/Component/tab-bar/index.d.ts +20 -0
  38. package/dist/src/Entity/ticket/Component/tab-bar/index.js +71 -0
  39. package/dist/src/Entity/ticket/Component/ticket-table/index.d.ts +7 -0
  40. package/dist/src/Entity/ticket/Component/ticket-table/index.js +10 -0
  41. package/dist/src/Entity/user/Screen/users.d.ts +39 -0
  42. package/dist/src/Entity/user/Screen/users.js +136 -0
  43. package/dist/src/Screen/dashboard.d.ts +1 -0
  44. package/dist/src/Screen/dashboard.js +1 -0
  45. package/dist/src/Screen/project.d.ts +1 -0
  46. package/dist/src/Screen/project.js +1 -0
  47. package/dist/src/Screen/projects.d.ts +1 -0
  48. package/dist/src/Screen/projects.js +1 -0
  49. package/dist/src/Screen/ticket.d.ts +1 -0
  50. package/dist/src/Screen/ticket.js +1 -0
  51. package/dist/src/Service/agent.d.ts +17 -0
  52. package/dist/src/Service/agent.js +28 -0
  53. package/dist/src/Service/create-form-data.d.ts +10 -0
  54. package/dist/src/Service/create-form-data.js +52 -0
  55. package/dist/src/Service/create-form.d.ts +52 -0
  56. package/dist/src/Service/create-form.js +98 -0
  57. package/dist/src/Service/project-data.d.ts +48 -0
  58. package/dist/src/Service/project-data.js +92 -0
  59. package/dist/src/Service/projects.d.ts +21 -0
  60. package/dist/src/Service/projects.js +80 -0
  61. package/dist/src/Service/route.d.ts +11 -0
  62. package/dist/src/Service/route.js +215 -0
  63. package/dist/src/Service/run-data.d.ts +35 -0
  64. package/dist/src/Service/run-data.js +62 -0
  65. package/dist/src/Service/ticket-data.d.ts +6 -0
  66. package/dist/src/Service/ticket-data.js +7 -0
  67. package/dist/src/Tui/controller.d.ts +13 -0
  68. package/dist/src/Tui/controller.js +1794 -0
  69. package/dist/src/lib/ansi.d.ts +34 -0
  70. package/dist/src/lib/ansi.js +97 -0
  71. package/dist/src/lib/hit-test.d.ts +138 -0
  72. package/dist/src/lib/hit-test.js +138 -0
  73. package/dist/src/lib/mouse.d.ts +35 -0
  74. package/dist/src/lib/mouse.js +89 -0
  75. package/dist/src/lib/table.d.ts +37 -0
  76. package/dist/src/lib/table.js +20 -0
  77. package/dist/src/lib/ticket-query.d.ts +21 -0
  78. package/dist/src/lib/ticket-query.js +72 -0
  79. package/package.json +2 -2
@@ -0,0 +1,6 @@
1
+ import type { HostedAgent } from '../../../types.js';
2
+ /** A fixed-column flat table for the Ticket-Agents screen: header + one line per
3
+ * live agent (its label · the project/group it runs for). One physical row per
4
+ * entry, so the Kernel's `1 + index` selection math holds. The agents are the
5
+ * host's live set (from `list(group)`); a finished agent drops out on refresh. */
6
+ export declare function agentListRows(agents: HostedAgent[]): string[];
@@ -0,0 +1,14 @@
1
+ import { pad } from '../../../lib/format.js';
2
+ /** A fixed-column flat table for the Ticket-Agents screen: header + one line per
3
+ * live agent (its label · the project/group it runs for). One physical row per
4
+ * entry, so the Kernel's `1 + index` selection math holds. The agents are the
5
+ * host's live set (from `list(group)`); a finished agent drops out on refresh. */
6
+ export function agentListRows(agents) {
7
+ const header = `${pad('AGENT', 32)} PROJECT`;
8
+ if (agents.length === 0)
9
+ return [header, 'No ticket agents.'];
10
+ return [
11
+ header,
12
+ ...agents.map((a) => `${pad(a.label || a.id, 32)} ${a.group}`),
13
+ ];
14
+ }
@@ -0,0 +1,6 @@
1
+ import type { HostedAgent } from '../../types.js';
2
+ /** A fixed-column flat table for the Ticket-Agents screen: header + one line per
3
+ * live agent (its label · the project/group it runs for). One physical row per
4
+ * entry, so the Kernel's `1 + index` selection math holds. The agents are the
5
+ * host's live set (from `list(group)`); a finished agent drops out on refresh. */
6
+ export declare function agentListRows(agents: HostedAgent[]): string[];
@@ -0,0 +1,14 @@
1
+ import { pad } from '../../lib/format.js';
2
+ /** A fixed-column flat table for the Ticket-Agents screen: header + one line per
3
+ * live agent (its label · the project/group it runs for). One physical row per
4
+ * entry, so the Kernel's `1 + index` selection math holds. The agents are the
5
+ * host's live set (from `list(group)`); a finished agent drops out on refresh. */
6
+ export function agentListRows(agents) {
7
+ const header = `${pad('AGENT', 32)} PROJECT`;
8
+ if (agents.length === 0)
9
+ return [header, 'No ticket agents.'];
10
+ return [
11
+ header,
12
+ ...agents.map((a) => `${pad(a.label || a.id, 32)} ${a.group}`),
13
+ ];
14
+ }
@@ -0,0 +1,21 @@
1
+ /** One collapsible comment entry for the Comments tab. */
2
+ export interface CommentItem {
3
+ type: string;
4
+ author: string;
5
+ created: string | null;
6
+ bodyLines: string[];
7
+ preview: string;
8
+ }
9
+ /** The rendered Comments-tab view. */
10
+ export interface CommentsTabView {
11
+ lines: string[];
12
+ boldRows: Set<number>;
13
+ headerRows: number[];
14
+ selectable: number;
15
+ }
16
+ /** The relative age of a comment's ISO `created`, or '—' when unknown. */
17
+ export declare function relativeCreated(created: string | null | undefined, nowSeconds: number): string;
18
+ export declare function commentsTabView(items: CommentItem[], { expanded, nowSeconds, }?: {
19
+ expanded?: Set<number>;
20
+ nowSeconds?: number;
21
+ }): CommentsTabView;
@@ -0,0 +1,39 @@
1
+ import { humanizeAge } from '../../Deriver/liveness.js';
2
+ /** The relative age of a comment's ISO `created`, or '—' when unknown. */
3
+ export function relativeCreated(created, nowSeconds) {
4
+ const ms = created ? Date.parse(created) : Number.NaN;
5
+ return Number.isNaN(ms)
6
+ ? '—'
7
+ : humanizeAge(Math.floor(ms / 1000), nowSeconds);
8
+ }
9
+ // The Comments tab: one collapsible entry per comment. Collapsed = a single
10
+ // caret header row (`▸ type · author · age · preview`); expanded (`▾`) appends
11
+ // the full body, one physical row per body line. `headerRows[i]` is the row
12
+ // index of comment i's header (for selection highlight + scroll).
13
+ export function commentsTabView(items, { expanded = new Set(), nowSeconds = 0, } = {}) {
14
+ if (!items.length) {
15
+ return {
16
+ lines: ['No comments.'],
17
+ boldRows: new Set(),
18
+ headerRows: [],
19
+ selectable: 0,
20
+ };
21
+ }
22
+ const lines = [];
23
+ const boldRows = new Set();
24
+ const headerRows = [];
25
+ items.forEach((item, i) => {
26
+ headerRows.push(lines.length);
27
+ boldRows.add(lines.length);
28
+ const caret = expanded.has(i) ? '▾' : '▸';
29
+ const age = relativeCreated(item.created, nowSeconds);
30
+ const author = item.author || '—';
31
+ lines.push(`${caret} ${item.type} · ${author} · ${age} · ${item.preview}`);
32
+ if (expanded.has(i)) {
33
+ for (const bodyLine of item.bodyLines)
34
+ lines.push(` ${bodyLine}`);
35
+ lines.push('');
36
+ }
37
+ });
38
+ return { lines, boldRows, headerRows, selectable: items.length };
39
+ }
@@ -0,0 +1,3 @@
1
+ import type { CreateFormOptions, CreateFormState } from '../../Service/create-form.js';
2
+ /** Render the "New ticket" popup form as plain rows (the kernel frames it). */
3
+ export declare function createFormLines(s: CreateFormState, opts: CreateFormOptions): string[];
@@ -0,0 +1,26 @@
1
+ const labelOf = (list, id) => list.find((o) => o.id === id)?.label ?? '—';
2
+ /** Render the "New ticket" popup form as plain rows (the kernel frames it). */
3
+ export function createFormLines(s, opts) {
4
+ const mark = (i) => (s.active === i ? '▸ ' : ' ');
5
+ const lines = [];
6
+ lines.push(`${mark(0)}Description:`);
7
+ const body = s.description.length ? s.description.split('\n') : ['<empty>'];
8
+ for (const line of body)
9
+ lines.push(` ${line}`);
10
+ lines.push(`${mark(1)}Assignee: < ${labelOf(opts.users, s.assignee)} >`);
11
+ lines.push(`${mark(2)}Conductor: < ${labelOf(opts.conductors, s.conductor)} >`);
12
+ lines.push(`${mark(3)}Labels:`);
13
+ if (opts.labels.length === 0) {
14
+ lines.push(' (none available)');
15
+ }
16
+ else {
17
+ opts.labels.forEach((o, i) => {
18
+ const box = s.labels.has(o.id) ? '[x]' : '[ ]';
19
+ const cursor = s.active === 3 && s.labelCursor === i ? '›' : ' ';
20
+ lines.push(` ${cursor}${box} ${o.label}`);
21
+ });
22
+ }
23
+ lines.push(`${mark(4)}Workflow: < ${labelOf(opts.workflows, s.workflow)} >`);
24
+ lines.push(`${mark(5)}[ Create ticket ]`);
25
+ return lines;
26
+ }
@@ -0,0 +1,8 @@
1
+ /** The minimum shape a dashboard row needs to render. */
2
+ export interface DashboardRowInput {
3
+ project: string;
4
+ derivedStatus: string;
5
+ ageLabel: string;
6
+ }
7
+ /** One line per project: name, derived status, relative age. */
8
+ export declare function dashboardRows(projects: DashboardRowInput[]): string[];
@@ -0,0 +1,7 @@
1
+ import { pad } from '../../lib/format.js';
2
+ /** One line per project: name, derived status, relative age. */
3
+ export function dashboardRows(projects) {
4
+ if (projects.length === 0)
5
+ return ['No projects.'];
6
+ return projects.map((p) => `${pad(p.project, 24)} ${pad(p.derivedStatus, 14)} ${p.ageLabel}`);
7
+ }
@@ -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,10 @@
1
+ import type { RunRow } from '../../Service/run-data.js';
2
+ /**
3
+ * A fixed-column flat table of a project's runs: header + one line per run.
4
+ * Columns RUN · TICKET · STATE · PHASE · AGENT · STARTED, mirroring
5
+ * `ticketTableRows` (1 logical entry = 1 physical row). STATE is the derived
6
+ * liveness status and STARTED is the relative start age.
7
+ */
8
+ export declare function runTableRows(runs: RunRow[], nowSeconds: number, { emptyMessage }?: {
9
+ emptyMessage?: string | undefined;
10
+ }): string[];
@@ -0,0 +1,20 @@
1
+ import { deriveRunStatus, runStartedAge } from '../../Deriver/run-status.js';
2
+ import { pad } from '../../lib/format.js';
3
+ /**
4
+ * A fixed-column flat table of a project's runs: header + one line per run.
5
+ * Columns RUN · TICKET · STATE · PHASE · AGENT · STARTED, mirroring
6
+ * `ticketTableRows` (1 logical entry = 1 physical row). STATE is the derived
7
+ * liveness status and STARTED is the relative start age.
8
+ */
9
+ export function runTableRows(runs, nowSeconds, { emptyMessage } = {}) {
10
+ const header = `${pad('RUN', 8)} ${pad('TICKET', 10)} ${pad('STATE', 8)} ${pad('PHASE', 10)} ${pad('AGENT', 8)} STARTED`;
11
+ if (runs.length === 0)
12
+ return [header, emptyMessage ?? 'No runs for this project.'];
13
+ return [
14
+ header,
15
+ ...runs.map((r) => {
16
+ const run = `#${r.drupalId ?? r.id.slice(0, 6)}`;
17
+ return `${pad(run, 8)} ${pad(r.ticketIdentifier, 10)} ${pad(deriveRunStatus(r, nowSeconds), 8)} ${pad(r.phase, 10)} ${pad(r.agent, 8)} ${runStartedAge(r, nowSeconds)}`;
18
+ }),
19
+ ];
20
+ }
@@ -0,0 +1,20 @@
1
+ /** One ticket-detail tab: its id, label, and the section headings it groups. */
2
+ export interface DetailTab {
3
+ id: string;
4
+ label: string;
5
+ headings: string[];
6
+ }
7
+ export declare const DETAIL_TABS: DetailTab[];
8
+ /** The tab bar as a single row; the active tab is bracketed. */
9
+ export declare function tabBar(activeIndex: number): string;
10
+ /** Where one detail tab sits inside the bar: `[start, end)` in display columns
11
+ * from the bar's own first column. */
12
+ export interface DetailTabSpan {
13
+ index: number;
14
+ start: number;
15
+ end: number;
16
+ }
17
+ /** The bar's own hit map, built from the SAME `segment`/`SEPARATOR` the rendered
18
+ * bar is joined from — see `Component/top-tabs/index.ts`'s `topTabSpans` for the
19
+ * full argument. */
20
+ export declare function detailTabSpans(activeIndex: number): DetailTabSpan[];
@@ -0,0 +1,42 @@
1
+ import { displayWidth } from '../../lib/width.js';
2
+ // The ticket-detail screen is split into tabs so only one tab's content draws at
3
+ // a time (keeps the fixed-height window small) — Sub-tickets + Referenced share a
4
+ // Related tab, Comments is its own tab with collapsible entries (owned by the
5
+ // plugin's draw loop, which needs the interactive expand state).
6
+ export const DETAIL_TABS = [
7
+ {
8
+ id: 'overview',
9
+ label: 'Overview',
10
+ headings: ['Description', 'Branch', 'Links', 'Environments'],
11
+ },
12
+ { id: 'comments', label: 'Comments', headings: [] },
13
+ {
14
+ id: 'related',
15
+ label: 'Related',
16
+ headings: ['Sub-tickets', 'Referenced tickets'],
17
+ },
18
+ { id: 'runs', label: 'Runs', headings: ['Runs'] },
19
+ ];
20
+ /** What joins two tabs in the rendered bar — part of neither tab's span. */
21
+ const SEPARATOR = ' ';
22
+ /** One tab's rendered segment: bracketed when active, space-padded otherwise. */
23
+ const segment = (tab, index, activeIndex) => index === activeIndex ? `[${tab.label}]` : ` ${tab.label} `;
24
+ /** The tab bar as a single row; the active tab is bracketed. */
25
+ export function tabBar(activeIndex) {
26
+ return DETAIL_TABS.map((t, i) => segment(t, i, activeIndex)).join(SEPARATOR);
27
+ }
28
+ /** The bar's own hit map, built from the SAME `segment`/`SEPARATOR` the rendered
29
+ * bar is joined from — see `Component/top-tabs/index.ts`'s `topTabSpans` for the
30
+ * full argument. */
31
+ export function detailTabSpans(activeIndex) {
32
+ const spans = [];
33
+ let start = 0;
34
+ DETAIL_TABS.forEach((tab, index) => {
35
+ if (index > 0)
36
+ start += displayWidth(SEPARATOR);
37
+ const width = displayWidth(segment(tab, index, activeIndex));
38
+ spans.push({ index, start, end: start + width });
39
+ start += width;
40
+ });
41
+ return spans;
42
+ }
@@ -0,0 +1,38 @@
1
+ import type { TicketDetailDocument } from '../../types.js';
2
+ import type { CommentItem } from '../comment-item/index.js';
3
+ import type { Section } from '../section-list/index.js';
4
+ /**
5
+ * The `expandedComments` set a freshly opened ticket detail starts with: the
6
+ * newest comment expanded, every older one collapsed.
7
+ *
8
+ * It lives beside the sort on purpose — "index 0 is the newest" is one module's
9
+ * invariant, so the kernel never needs to know which index that is.
10
+ */
11
+ export declare function initialExpandedComments(document: TicketDetailDocument): Set<number>;
12
+ /**
13
+ * The ticket detail sections, each with an explicit empty state:
14
+ * Description, Comments, Sub-tickets, Runs, Referenced tickets, Links,
15
+ * Branch, Environments.
16
+ */
17
+ export declare function detailSections(document: TicketDetailDocument): Section[];
18
+ /** The detailSections that belong to a given (non-comments) tab, in tab order. */
19
+ export declare function sectionsForTab(document: TicketDetailDocument, tabIndex: number): Section[];
20
+ /**
21
+ * One item per comment for the collapsible Comments tab: type, author, created
22
+ * (raw ISO — the plugin formats the relative age), the body split into physical
23
+ * lines, and a first-line preview for the collapsed header.
24
+ */
25
+ export declare function commentItems(document: TicketDetailDocument): CommentItem[];
26
+ export declare function detailLines(detail?: TicketDetailDocument | null): {
27
+ lines: string[];
28
+ boldRows: Set<number>;
29
+ };
30
+ export declare function tabViewLines(detail: TicketDetailDocument | null | undefined, tabIndex: number, { expanded, nowSeconds, }?: {
31
+ expanded?: Set<number>;
32
+ nowSeconds?: number;
33
+ }): {
34
+ lines: string[];
35
+ boldRows: Set<number>;
36
+ headerRows: number[];
37
+ selectable: number;
38
+ };
@@ -0,0 +1,193 @@
1
+ // The ticket-detail screen: composes section-list (flatten + markdown bold),
2
+ // tab-bar (DETAIL_TABS) and comment-item (the collapsible Comments tab) into
3
+ // the two shapes the plugin's draw loop consumes — the whole document
4
+ // flattened (`detailLines`, non-TTY/back-compat) and one tab at a time
5
+ // (`tabViewLines`, the interactive screen).
6
+ import { asString } from '../../lib/format.js';
7
+ import { commentsTabView } from '../comment-item/index.js';
8
+ import { flattenSections } from '../section-list/index.js';
9
+ import { DETAIL_TABS } from '../tab-bar/index.js';
10
+ function fieldValue(value) {
11
+ if (value && typeof value === 'object' && 'value' in value) {
12
+ const inner = value.value;
13
+ if (typeof inner === 'string')
14
+ return inner;
15
+ }
16
+ return typeof value === 'string' ? value : '';
17
+ }
18
+ function refCount(document, name) {
19
+ const data = document.data?.relationships?.[name]?.data;
20
+ return Array.isArray(data) ? data.length : data ? 1 : 0;
21
+ }
22
+ /**
23
+ * Newest-first — the exact reversal of the server's total order
24
+ * (`CommentsItemList`: `created ASC, id ASC`), three keys applied in turn:
25
+ * `created` DESC, then `drupal_internal__id` DESC (the server's own monotonic
26
+ * key, so two comments written in the same second keep the server's relative
27
+ * order, reversed), then the resource `id` DESC.
28
+ *
29
+ * The third key exists to make the comparator TOTAL rather than leaning on
30
+ * `Array.prototype.sort` being stable in modern V8: the order must be
31
+ * *deterministic* — identical for the same comments in any input order, and
32
+ * defined even for a fixture or partial include that carries no
33
+ * `drupal_internal__id`. Stability would only preserve whatever order the
34
+ * include happened to arrive in.
35
+ *
36
+ * A missing or unparseable `created` sorts LAST (treated as the oldest), so a
37
+ * malformed row can never take the top slot and steal the default expansion.
38
+ */
39
+ function compareCommentsNewestFirst(a, b) {
40
+ const at = Date.parse(asString(a.attributes?.created) ?? '');
41
+ const bt = Date.parse(asString(b.attributes?.created) ?? '');
42
+ const aUnknown = Number.isNaN(at);
43
+ const bUnknown = Number.isNaN(bt);
44
+ if (aUnknown !== bUnknown)
45
+ return aUnknown ? 1 : -1;
46
+ if (!aUnknown && at !== bt)
47
+ return bt - at;
48
+ const ai = Number(a.attributes?.drupal_internal__id);
49
+ const bi = Number(b.attributes?.drupal_internal__id);
50
+ if (!Number.isNaN(ai) && !Number.isNaN(bi) && ai !== bi)
51
+ return bi - ai;
52
+ // Code-unit comparison, not localeCompare: a locale-sensitive collation would
53
+ // put the "deterministic" back in the hands of the runtime's locale data.
54
+ const aid = a.id ?? '';
55
+ const bid = b.id ?? '';
56
+ return aid === bid ? 0 : aid < bid ? 1 : -1;
57
+ }
58
+ /**
59
+ * The document's `gaia_comment` includes, NEWEST FIRST.
60
+ *
61
+ * This is the single function both renderings read — `detailSections`' Comments
62
+ * section (→ `detailLines`, the non-TTY render) and `commentItems` (→ the
63
+ * interactive Comments tab) — so newest-first is one line of code and the two
64
+ * orders structurally cannot drift.
65
+ *
66
+ * It sorts the copy `filter` already allocated and NEVER `document.included`
67
+ * itself: that array is mixed (comments *plus* sub-tickets, runs and referenced
68
+ * tickets) and shared with those consumers, so an in-place sort would be an
69
+ * invisible side effect on someone else's input.
70
+ */
71
+ function commentResources(document) {
72
+ return (document.included ?? [])
73
+ .filter((r) => r.type === 'gaia_comment--gaia_comment')
74
+ .sort(compareCommentsNewestFirst);
75
+ }
76
+ /**
77
+ * The `expandedComments` set a freshly opened ticket detail starts with: the
78
+ * newest comment expanded, every older one collapsed.
79
+ *
80
+ * It lives beside the sort on purpose — "index 0 is the newest" is one module's
81
+ * invariant, so the kernel never needs to know which index that is.
82
+ */
83
+ export function initialExpandedComments(document) {
84
+ return commentResources(document).length
85
+ ? new Set([0])
86
+ : new Set();
87
+ }
88
+ /**
89
+ * The ticket detail sections, each with an explicit empty state:
90
+ * Description, Comments, Sub-tickets, Runs, Referenced tickets, Links,
91
+ * Branch, Environments.
92
+ */
93
+ export function detailSections(document) {
94
+ const a = document.data?.attributes ?? {};
95
+ const sections = [];
96
+ const description = fieldValue(a.description);
97
+ sections.push({
98
+ heading: 'Description',
99
+ lines: description ? description.split('\n') : ['No description.'],
100
+ });
101
+ const comments = commentResources(document);
102
+ sections.push({
103
+ heading: 'Comments',
104
+ // One physical line per body line: a `— type ·` marker followed by the body
105
+ // split on '\n' (mirrors the Description section). Keeping a multi-line body
106
+ // in a single entry breaks draw()'s one-entry-one-row invariant (AC-4).
107
+ lines: comments.length
108
+ ? comments.flatMap((c) => {
109
+ const type = asString(c.attributes?.gaia_comment_type) ?? 'comment';
110
+ const body = fieldValue(c.attributes?.body);
111
+ return [`— ${type} ·`, ...(body ? body.split('\n') : [''])];
112
+ })
113
+ : ['No comments.'],
114
+ });
115
+ const relSection = (heading, rel) => {
116
+ const n = refCount(document, rel);
117
+ sections.push({
118
+ heading,
119
+ lines: n
120
+ ? [`${n} ${heading.toLowerCase()}`]
121
+ : [`No ${heading.toLowerCase()}.`],
122
+ });
123
+ };
124
+ relSection('Sub-tickets', 'sub_tickets');
125
+ relSection('Runs', 'runs');
126
+ relSection('Referenced tickets', 'referenced_tickets');
127
+ const links = Array.isArray(a.links) ? a.links : [];
128
+ sections.push({
129
+ heading: 'Links',
130
+ lines: links.length
131
+ ? links.map((l) => {
132
+ const link = l;
133
+ return `${link.title ?? ''} ${link.uri ?? link.url ?? ''}`.trim();
134
+ })
135
+ : ['No links.'],
136
+ });
137
+ sections.push({
138
+ heading: 'Branch',
139
+ lines: [
140
+ `branch: ${asString(a.branch_name) ?? '—'}`,
141
+ `base: ${asString(a.base_branch) ?? '—'}`,
142
+ ],
143
+ });
144
+ relSection('Environments', 'environments');
145
+ return sections;
146
+ }
147
+ /** The detailSections that belong to a given (non-comments) tab, in tab order. */
148
+ export function sectionsForTab(document, tabIndex) {
149
+ const tab = DETAIL_TABS[tabIndex];
150
+ const headings = tab?.headings ?? [];
151
+ if (!headings.length)
152
+ return [];
153
+ const byHeading = new Map(detailSections(document).map((s) => [s.heading, s]));
154
+ return headings
155
+ .map((h) => byHeading.get(h))
156
+ .filter((s) => s !== undefined);
157
+ }
158
+ /**
159
+ * One item per comment for the collapsible Comments tab: type, author, created
160
+ * (raw ISO — the plugin formats the relative age), the body split into physical
161
+ * lines, and a first-line preview for the collapsed header.
162
+ */
163
+ export function commentItems(document) {
164
+ const comments = commentResources(document);
165
+ return comments.map((c) => {
166
+ const type = asString(c.attributes?.gaia_comment_type) ?? 'comment';
167
+ const author = asString(c.attributes?.author_name) ?? '';
168
+ const created = asString(c.attributes?.created) ?? null;
169
+ const body = fieldValue(c.attributes?.body);
170
+ const bodyLines = body ? body.split('\n') : [''];
171
+ return { type, author, created, bodyLines, preview: bodyLines[0] ?? '' };
172
+ });
173
+ }
174
+ // The whole ticket-detail document flattened (all sections) — the NON-TTY ticket
175
+ // render, printed verbatim by the kernel when stdout is not a terminal
176
+ // (`Kernel/tui-kernel.ts:147`), i.e. what `gaia ui GAIA-nnn | cat` shows. The
177
+ // interactive screen renders one tab at a time via tabViewLines instead.
178
+ export function detailLines(detail) {
179
+ return flattenSections(detailSections(detail ?? {}));
180
+ }
181
+ // The rows for one detail tab. The Comments tab is the collapsible list; every
182
+ // other tab flattens just its own sections. Returns `selectable`/`headerRows`
183
+ // so the draw loop can highlight + scroll the selected comment (0 elsewhere).
184
+ export function tabViewLines(detail, tabIndex, { expanded = new Set(), nowSeconds = 0, } = {}) {
185
+ if (DETAIL_TABS[tabIndex]?.id === 'comments') {
186
+ return commentsTabView(commentItems(detail ?? {}), {
187
+ expanded,
188
+ nowSeconds,
189
+ });
190
+ }
191
+ const { lines, boldRows } = flattenSections(sectionsForTab(detail ?? {}, tabIndex));
192
+ return { lines, boldRows, headerRows: [], selectable: 0 };
193
+ }
@@ -0,0 +1,5 @@
1
+ import type { TicketRow } from '../../Service/project-data.js';
2
+ /** A fixed-column flat table: header + one line per ticket. */
3
+ export declare function ticketTableRows(tickets: TicketRow[], { emptyMessage }?: {
4
+ emptyMessage?: string | undefined;
5
+ }): string[];
@@ -0,0 +1,14 @@
1
+ import { pad } from '../../lib/format.js';
2
+ /** A fixed-column flat table: header + one line per ticket. */
3
+ export function ticketTableRows(tickets, { emptyMessage } = {}) {
4
+ const header = `${pad('IDENTIFIER', 10)} ${pad('STATE', 8)} ${pad('PRIO', 5)} ${pad('WHO', 4)} ${pad('RUN', 8)} ${pad('PARENT', 10)} ${pad('COND', 14)} TITLE`;
5
+ if (tickets.length === 0)
6
+ return [header, emptyMessage ?? 'No tickets.'];
7
+ return [
8
+ header,
9
+ ...tickets.map((t) => {
10
+ const labels = t.labels?.length ? ` [${t.labels.join(',')}]` : '';
11
+ return `${pad(t.identifier, 10)} ${pad(t.state, 8)} ${pad(t.priority ?? '—', 5)} ${pad(t.assigneeInitials || '—', 4)} ${pad(t.derivedStatus, 8)} ${pad(t.parent ?? '—', 10)} ${pad(t.conductor ?? '—', 14)} ${t.title}${labels}`;
12
+ }),
13
+ ];
14
+ }
@@ -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
+ }