@gaia-ai/addon-gaia-ui 0.9.2 → 0.10.0

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 (119) hide show
  1. package/dist/src/Component/footer-actions/index.d.ts +58 -0
  2. package/dist/src/Component/footer-actions/index.js +111 -0
  3. package/dist/src/Component/list-chrome/index.d.ts +19 -0
  4. package/dist/src/Component/list-chrome/index.js +61 -0
  5. package/dist/src/Component/markdown/index.d.ts +68 -0
  6. package/dist/src/Component/markdown/index.js +133 -0
  7. package/dist/src/Component/section-list/index.d.ts +115 -23
  8. package/dist/src/Component/section-list/index.js +120 -34
  9. package/dist/src/Component/top-tabs/index.d.ts +65 -6
  10. package/dist/src/Component/top-tabs/index.js +29 -6
  11. package/dist/src/Entity/collection.js +1 -1
  12. package/dist/src/Entity/conductor/Screen/conductors.d.ts +26 -41
  13. package/dist/src/Entity/conductor/Screen/conductors.js +65 -123
  14. package/dist/src/Entity/project/Screen/project-picker.d.ts +20 -31
  15. package/dist/src/Entity/project/Screen/project-picker.js +48 -58
  16. package/dist/src/Entity/project/Screen/projects-screen.d.ts +4 -2
  17. package/dist/src/Entity/project/Screen/projects-screen.js +11 -8
  18. package/dist/src/Entity/run/Component/run-detail/index.js +15 -7
  19. package/dist/src/Entity/run/Data/query.d.ts +10 -0
  20. package/dist/src/Entity/run/Data/query.js +16 -1
  21. package/dist/src/Entity/run/Data/runs.d.ts +2 -0
  22. package/dist/src/Entity/run/Data/runs.js +19 -1
  23. package/dist/src/Entity/run/Screen/run-detail.d.ts +16 -23
  24. package/dist/src/Entity/run/Screen/run-detail.js +27 -34
  25. package/dist/src/Entity/run/Screen/runs.d.ts +7 -3
  26. package/dist/src/Entity/run/Screen/runs.js +31 -48
  27. package/dist/src/Entity/run/Screen/user-runs.d.ts +79 -0
  28. package/dist/src/Entity/run/Screen/user-runs.js +98 -0
  29. package/dist/src/Entity/statistics/Data/statistics.d.ts +31 -6
  30. package/dist/src/Entity/statistics/Data/statistics.js +39 -6
  31. package/dist/src/Entity/statistics/Screen/dashboard.d.ts +10 -2
  32. package/dist/src/Entity/statistics/Screen/dashboard.js +111 -34
  33. package/dist/src/Entity/ticket/Component/comment-item/index.d.ts +14 -1
  34. package/dist/src/Entity/ticket/Component/comment-item/index.js +15 -1
  35. package/dist/src/Entity/ticket/Component/sections/index.js +16 -1
  36. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +102 -13
  37. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +440 -72
  38. package/dist/src/Entity/ticket/Data/detail.d.ts +14 -0
  39. package/dist/src/Entity/ticket/Data/detail.js +15 -1
  40. package/dist/src/Entity/ticket/Data/query.d.ts +7 -0
  41. package/dist/src/Entity/ticket/Data/query.js +18 -2
  42. package/dist/src/Entity/ticket/Data/tickets.d.ts +24 -0
  43. package/dist/src/Entity/ticket/Data/tickets.js +41 -1
  44. package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +10 -4
  45. package/dist/src/Entity/ticket/Screen/create-ticket-form.js +37 -16
  46. package/dist/src/Entity/ticket/Screen/edit-assignment-form.d.ts +6 -4
  47. package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +16 -15
  48. package/dist/src/Entity/ticket/Screen/parent-filter-form.d.ts +6 -4
  49. package/dist/src/Entity/ticket/Screen/parent-filter-form.js +14 -11
  50. package/dist/src/Entity/ticket/Screen/ticket-agents.d.ts +5 -2
  51. package/dist/src/Entity/ticket/Screen/ticket-agents.js +14 -14
  52. package/dist/src/Entity/ticket/Screen/ticket-detail.d.ts +89 -4
  53. package/dist/src/Entity/ticket/Screen/ticket-detail.js +295 -74
  54. package/dist/src/Entity/ticket/Screen/tickets.d.ts +66 -3
  55. package/dist/src/Entity/ticket/Screen/tickets.js +140 -76
  56. package/dist/src/Entity/ticket/Screen/user-tickets.d.ts +91 -0
  57. package/dist/src/Entity/ticket/Screen/user-tickets.js +94 -0
  58. package/dist/src/Entity/ticket/Screen/who-filter-form.d.ts +6 -4
  59. package/dist/src/Entity/ticket/Screen/who-filter-form.js +14 -11
  60. package/dist/src/Entity/user/Data/users.d.ts +49 -0
  61. package/dist/src/Entity/user/Data/users.js +46 -0
  62. package/dist/src/Entity/user/Screen/user-overview.d.ts +33 -0
  63. package/dist/src/Entity/user/Screen/user-overview.js +159 -0
  64. package/dist/src/Entity/user/Screen/users.d.ts +39 -0
  65. package/dist/src/Entity/user/Screen/users.js +136 -0
  66. package/dist/src/Extension/contract.d.ts +58 -0
  67. package/dist/src/Extension/contract.js +1 -0
  68. package/dist/src/Extension/load.d.ts +10 -0
  69. package/dist/src/Extension/load.js +21 -0
  70. package/dist/src/Extension/store.d.ts +11 -0
  71. package/dist/src/Extension/store.js +44 -0
  72. package/dist/src/Kernel/chrome.d.ts +49 -12
  73. package/dist/src/Kernel/chrome.js +150 -57
  74. package/dist/src/Kernel/key-input.js +1 -1
  75. package/dist/src/Kernel/mount.d.ts +1 -1
  76. package/dist/src/Kernel/mount.js +1 -1
  77. package/dist/src/Kernel/regions.d.ts +64 -0
  78. package/dist/src/Kernel/regions.js +328 -0
  79. package/dist/src/Kernel/renderer.d.ts +25 -0
  80. package/dist/src/Kernel/renderer.js +142 -0
  81. package/dist/src/Kernel/splash.d.ts +27 -0
  82. package/dist/src/Kernel/splash.js +72 -0
  83. package/dist/src/Kernel/tui-kernel.js +139 -434
  84. package/dist/src/Routing/context.d.ts +5 -0
  85. package/dist/src/Routing/context.js +6 -0
  86. package/dist/src/Routing/registry.js +4 -2
  87. package/dist/src/Routing/router.js +5 -1
  88. package/dist/src/Routing/routes.js +8 -0
  89. package/dist/src/Screen/async-modal-screen.d.ts +26 -0
  90. package/dist/src/Screen/async-modal-screen.js +44 -0
  91. package/dist/src/Screen/async-screen.d.ts +32 -40
  92. package/dist/src/Screen/async-screen.js +45 -41
  93. package/dist/src/Screen/full-screen.d.ts +1 -0
  94. package/dist/src/Screen/full-screen.js +3 -0
  95. package/dist/src/Screen/list-screen.d.ts +7 -1
  96. package/dist/src/Screen/list-screen.js +17 -6
  97. package/dist/src/Screen/modal-screen.d.ts +6 -0
  98. package/dist/src/Screen/modal-screen.js +2 -0
  99. package/dist/src/Screen/screen.d.ts +42 -6
  100. package/dist/src/Screen/screen.js +2 -2
  101. package/dist/src/Widget/textarea.js +10 -14
  102. package/dist/src/Widget/types.d.ts +1 -1
  103. package/dist/src/launcher.d.ts +6 -0
  104. package/dist/src/launcher.js +2 -0
  105. package/dist/src/lib/format.js +1 -1
  106. package/dist/src/lib/markdown.d.ts +53 -0
  107. package/dist/src/lib/markdown.js +522 -0
  108. package/dist/src/lib/refresh-signal.d.ts +56 -0
  109. package/dist/src/lib/refresh-signal.js +85 -0
  110. package/dist/src/lib/theme.d.ts +36 -0
  111. package/dist/src/lib/theme.js +40 -0
  112. package/dist/src/lib/version.d.ts +5 -0
  113. package/dist/src/lib/version.js +46 -0
  114. package/dist/src/lib/vnode.d.ts +86 -63
  115. package/dist/src/lib/vnode.js +112 -85
  116. package/dist/src/types.d.ts +32 -0
  117. package/dist/src/ui.d.ts +2 -0
  118. package/dist/src/ui.js +1 -0
  119. package/package.json +4 -3
@@ -1,11 +1,11 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { actionItem } from '../../../Component/footer-actions/index.js';
2
3
  import { columnNode, textNode } from '../../../lib/vnode.js';
3
- import { BaseScreen, presentation, } from '../../../Screen/screen.js';
4
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
4
5
  import { listRowIds, listTableTree } from '../../collection.js';
5
6
  import { CONDUCTOR_TABLE, listConductorRows, listProjectNames, } from '../Data/conductors.js';
6
7
  import { tailConductorLog } from '../Data/log-tail.js';
7
8
  import { createConductorProcessHost } from '../Data/process.js';
8
- const REFRESH_MS = 5_000;
9
9
  /**
10
10
  * The open row's log block: where it is, and its tail.
11
11
  *
@@ -24,7 +24,14 @@ const logFor = (row) => row.isLocal
24
24
  heading: `log — ${row.label} runs on another machine; no log here`,
25
25
  lines: [],
26
26
  };
27
- export class ConductorsScreen extends BaseScreen {
27
+ /**
28
+ * Conductors list — a normal {@link AsyncScreen}.
29
+ *
30
+ * Refresh is the shared mechanism (GAIA-376 `ui-refresh` file + `R`), not a
31
+ * screen-private timer. Enter loads; navigation away cancels in-flight reads
32
+ * via the async lifecycle.
33
+ */
34
+ export class ConductorsScreen extends AsyncScreen {
28
35
  static route = {
29
36
  id: 'conductors',
30
37
  path: '/conductors',
@@ -32,70 +39,41 @@ export class ConductorsScreen extends BaseScreen {
32
39
  };
33
40
  id = ConductorsScreen.route.id;
34
41
  frameId = 'conductors';
35
- rows = [];
36
- projectNames = new Map();
37
42
  selectedIndex = 0;
38
43
  logFor = null;
39
- status = '';
40
- reading = false;
41
- timer = null;
42
44
  busyIds = new Set();
43
- activation = 0;
44
45
  topTab() {
45
46
  return { level: 'root', tab: 'conductors' };
46
47
  }
47
- // GAIA-285 spec D5/D6 — the shared `R` lands here; the 5 s tick passes
48
- // `quiet` so only an EXPLICIT reload states its loading in the footer.
49
- quietRead = false;
50
- get busy() {
51
- return this.reading && !this.quietRead;
52
- }
53
48
  reload(context) {
54
- void this.refresh(context);
49
+ this.retry(context);
55
50
  }
56
- enter(context) {
57
- const activation = ++this.activation;
58
- void this.refresh(context, true, activation);
59
- this.stopTimer();
60
- this.timer = setInterval(() => void this.refresh(context, false, undefined, true), REFRESH_MS);
61
- this.timer.unref?.();
51
+ get rows() {
52
+ return this.readyValue?.rows ?? [];
53
+ }
54
+ async load(context) {
55
+ if (!context.services)
56
+ throw new Error('Conductors requires services');
57
+ const client = context.services.client;
58
+ const [projectNames, rows] = await Promise.all([
59
+ listProjectNames(client),
60
+ listConductorRows(client, {
61
+ now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
62
+ exists: existsSync,
63
+ }),
64
+ ]);
65
+ return { rows, projectNames };
62
66
  }
63
- leave() {
64
- this.activation++;
65
- this.stopTimer();
67
+ renderLoading() {
68
+ return ['Loading conductors…'];
66
69
  }
67
- /**
68
- * GAIA-340 D1 — the list as the renderable tree the screen hands over, and
69
- * since GAIA-345 that tree is the SHARED table's (AC 1/2).
70
- *
71
- * There is no `conductor-list` component any more: the eight columns, the
72
- * noun and the empty message are `CONDUCTOR_TABLE`, declared beside the row
73
- * mapper that produces the rows. What this screen still owns is the CONTEXT
74
- * the columns read — the frame's clock, which conductors have an action in
75
- * flight, and the project names its own small read resolved — because none of
76
- * those is a property of a `gaia_conductor` record.
77
- *
78
- * THE OPEN LOG IS NOT A ROW OF THE LIST, so it is not in the table: it is
79
- * neither keyed on a conductor nor laid out in the columns, and a log line
80
- * keyed under the list's ids could collide with a row's. It stays its own
81
- * block below the table, whose lines are keyed by position — honest for a
82
- * block replaced wholesale on every read that indexes nothing outside itself.
83
- * The wrapper is stated on EVERY frame, log or no log, so the body's root
84
- * node keeps one identity across the keypress that opens one.
85
- *
86
- * MOUNTED WHOLE, so the bound on its nodes is the data's own: one box of 8
87
- * cells per conductor, over the conductors of one user on one control plane,
88
- * plus at most 40 log lines. `@opentui/core@0.5.1` hands out 16 382 before it
89
- * throws, so this screen windows nothing of its own — deliberately, not by
90
- * omission.
91
- */
92
- surface(context) {
93
- const open = this.rows.find((row) => row.id === this.logFor);
70
+ renderReady(data, context) {
71
+ const open = data.rows.find((row) => row.id === this.logFor);
94
72
  const log = open ? logFor(open) : undefined;
95
- return columnNode('conductor-list', listTableTree(CONDUCTOR_TABLE, this.rows, {
73
+ return columnNode('conductor-list', listTableTree(CONDUCTOR_TABLE, data.rows, {
96
74
  nowSeconds: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
97
75
  busyIds: this.busyIds,
98
- projectNames: this.projectNames,
76
+ projectNames: data.projectNames,
99
77
  }, { selectedIndex: this.selectedIndex }), ...(log
100
78
  ? [
101
79
  textNode({ id: 'conductor-log-gap', content: '' }),
@@ -104,19 +82,26 @@ export class ConductorsScreen extends BaseScreen {
104
82
  ]
105
83
  : []));
106
84
  }
107
- present(context) {
108
- return presentation(this.surface(context), {
109
- selectable: this.rows.length,
85
+ didLoad(data) {
86
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, data.rows.length - 1));
87
+ }
88
+ chrome(_context) {
89
+ const rows = this.rows;
90
+ return {
91
+ selectable: rows.length,
110
92
  selectedIndex: this.selectedIndex,
111
- // The node a click on each row lands on (GAIA-340 Task 11), read off the
112
- // SAME configuration that keys the rows — so the click target and the row
113
- // node cannot be spelled two ways (GAIA-345 D2). The log's lines are not
114
- // in here, which is the other half of "a log is not a list row".
115
- rowIds: listRowIds(CONDUCTOR_TABLE, this.rows),
93
+ rowIds: listRowIds(CONDUCTOR_TABLE, rows),
116
94
  title: 'GAIA conductors',
117
- meta: `${this.rows.length} conductor(s) · s start · x stop${this.status ? ` · ${this.status}` : ''}`,
118
- status: ' ↑↓ select · Enter log · s start · x stop · Tab tabs · q quit ',
119
- });
95
+ meta: `${rows.length} conductor(s)`,
96
+ };
97
+ }
98
+ actions() {
99
+ return [
100
+ actionItem('conductors-start', 's', 'start'),
101
+ actionItem('conductors-stop', 'x', 'stop'),
102
+ actionItem('conductors-enter', 'Enter', 'log'),
103
+ actionItem('conductors-tab', 'Tab', 'tabs'),
104
+ ];
120
105
  }
121
106
  select(index, context) {
122
107
  if (index < 0 || index >= this.rows.length)
@@ -134,6 +119,8 @@ export class ConductorsScreen extends BaseScreen {
134
119
  return true;
135
120
  }
136
121
  handleInput(input, context) {
122
+ if (super.handleInput(input, context))
123
+ return true;
137
124
  if (input === 's' || input === 'x') {
138
125
  void this.act(input === 's' ? 'start' : 'stop', context);
139
126
  return true;
@@ -147,87 +134,42 @@ export class ConductorsScreen extends BaseScreen {
147
134
  return false;
148
135
  }
149
136
  reset() {
150
- this.leave();
151
- this.rows = [];
152
- this.projectNames.clear();
153
137
  this.selectedIndex = 0;
154
138
  this.logFor = null;
155
- this.status = '';
156
139
  this.busyIds.clear();
157
- }
158
- async refresh(context, includeProjects = false, activation = this.activation, quiet = false) {
159
- if (this.reading || !context.services)
160
- return;
161
- this.reading = true;
162
- this.quietRead = quiet;
163
- try {
164
- if (includeProjects && this.projectNames.size === 0) {
165
- const names = await listProjectNames(context.services.client);
166
- if (activation !== this.activation)
167
- return;
168
- this.projectNames = names;
169
- }
170
- const rows = await listConductorRows(context.services.client, {
171
- now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
172
- exists: existsSync,
173
- });
174
- if (activation !== this.activation)
175
- return;
176
- this.rows = rows;
177
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
178
- }
179
- finally {
180
- this.reading = false;
181
- this.quietRead = false;
182
- }
183
- // After the finally, so the frame this triggers already reads busy=false —
184
- // drawn from inside the try it would restate `Loading…` one last time.
185
- if (context.isActive())
186
- context.requestDraw();
140
+ super.reset();
187
141
  }
188
142
  async act(action, context) {
189
143
  const row = this.rows[this.selectedIndex];
190
144
  if (!row || this.busyIds.has(row.id))
191
145
  return;
192
- if (!row.isLocal) {
193
- this.status = `${row.label} runs on another machine — no action from here`;
194
- context.requestDraw();
146
+ if (!row.isLocal)
195
147
  return;
196
- }
197
- if (action === 'start' && row.derivedStatus === 'running') {
198
- this.status = `${row.label} is already running`;
199
- context.requestDraw();
148
+ if (action === 'start' && row.derivedStatus === 'running')
200
149
  return;
201
- }
202
150
  this.busyIds.add(row.id);
203
- this.status = `${action}…`;
204
151
  context.requestDraw();
205
152
  const host = context.services?.conductorProcess ?? createConductorProcessHost();
206
153
  try {
207
- const result = action === 'start'
208
- ? await host.start({
154
+ if (action === 'start') {
155
+ await host.start({
209
156
  workspaceRoot: row.workspaceRoot,
210
157
  conductorName: row.conductorName,
211
- })
212
- : await host.stop({
158
+ });
159
+ }
160
+ else {
161
+ await host.stop({
213
162
  pid: row.lastPid,
214
163
  workspaceRoot: row.workspaceRoot,
215
164
  });
216
- this.status = result.message;
165
+ }
217
166
  }
218
- catch (caught) {
219
- this.status = caught instanceof Error ? caught.message : String(caught);
167
+ catch {
168
+ // Outcome lives in the reloaded row state, not a footer notice.
220
169
  }
221
170
  finally {
222
171
  this.busyIds.delete(row.id);
223
- await this.refresh(context);
224
- if (context.isActive())
225
- context.requestDraw();
172
+ this.retry(context);
226
173
  }
227
174
  }
228
- stopTimer() {
229
- if (this.timer)
230
- clearInterval(this.timer);
231
- this.timer = null;
232
- }
233
175
  }
@@ -1,4 +1,7 @@
1
- import { BaseScreen, type ScreenContext } from '../../../Screen/screen.js';
1
+ import type { VNode } from '@opentui/core';
2
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
3
+ import type { FullScreenChrome } from '../../../Screen/full-screen.js';
4
+ import type { ScreenContext } from '../../../Screen/screen.js';
2
5
  /**
3
6
  * A project that claims the repo.
4
7
  *
@@ -15,7 +18,11 @@ export interface ProjectPickerInput {
15
18
  readonly repo: string;
16
19
  }
17
20
  export declare const projectPickerInputContext: import("../../../Routing/context.js").ContextKey<ProjectPickerInput>;
18
- export declare class ProjectPickerScreen extends BaseScreen {
21
+ /**
22
+ * Project picker — normal {@link AsyncScreen}. Candidates arrive via navigation
23
+ * context; refresh is the shared R / ui-refresh path.
24
+ */
25
+ export declare class ProjectPickerScreen extends AsyncScreen<ProjectPickerInput> {
19
26
  readonly frameId = "project-picker";
20
27
  topTab(): {
21
28
  readonly level: "root";
@@ -31,48 +38,30 @@ export declare class ProjectPickerScreen extends BaseScreen {
31
38
  context: (_params: Readonly<Record<string, string>>, state: unknown, base: import("../../../Routing/context.js").ContextBag) => import("../../../Routing/context.js").ContextBag;
32
39
  };
33
40
  readonly id: string;
34
- private candidates;
35
- private repo;
36
41
  selectedIndex: number;
37
- enter(context: ScreenContext): void;
42
+ reload(context: ScreenContext): void;
43
+ get candidates(): readonly PickerCandidate[];
44
+ get repo(): string;
38
45
  get rows(): readonly PickerCandidate[];
46
+ protected load(context: ScreenContext): Promise<ProjectPickerInput>;
47
+ protected renderLoading(): string[];
39
48
  /**
40
49
  * The question this screen asks, and the one place it is written.
41
50
  *
42
- * Both surfaces open with it — the tree as a keyed row above the table, the
43
- * pipe as its first line — so composing it twice is how the two would come to
44
- * disagree about the repo they name.
51
+ * Both surfaces open with it — the tree via meta, the pipe as its first line —
52
+ * so composing it twice is how the two would come to disagree about the repo.
45
53
  */
46
54
  private headingText;
47
- /** The candidate the cursor stands on, clamped: an out-of-range index must
48
- * move the marker, never lose it. */
49
55
  private selected;
56
+ protected renderReady(data: ProjectPickerInput): VNode;
50
57
  /**
51
58
  * F5 — the pipe's own output, and it stays `string[]`.
52
59
  *
53
- * GAIA-345 D7/D8 — built from the SAME configuration the tree is, through
54
- * `listTableRows` and `lineText`, plus the two heading lines and the trailing
55
- * hint, which are the screen's and not rows of the list. The bytes are
56
- * unchanged by construction rather than by a comparison somebody has to
57
- * remember to make, and `tests/Entity/project/Screen/project-picker.test.ts`
58
- * pins them line for line against the pre-conversion capture.
60
+ * GAIA-345 D7/D8 — built from the SAME configuration the tree is.
59
61
  */
60
62
  renderNonInteractive(): string[];
61
- /**
62
- * The picker's body: the question, a blank, then the list (GAIA-345 D8).
63
- *
64
- * The two heading rows are composed AROUND the table's node because they are
65
- * not rows of it — they name no candidate, they are not selectable, and they
66
- * carry no entry in `rowIds`. That is also what the old `selRow` offset of 2
67
- * was compensating for before the kernel started finding the cursor by node.
68
- *
69
- * MOUNTED WHOLE — no window of its own, and the bound is named rather than
70
- * assumed: a row is one `gaia_project` that claims the cwd's remote, which is
71
- * the multisite topology GAIA-126/137 designs for. That is a handful of sites
72
- * per repo, against `@opentui/core@0.5.1`'s ceiling of 16 382 renderables.
73
- */
74
- private surface;
75
- present(_context: ScreenContext): import("../../../Screen/screen.js").ScreenPresentation;
63
+ protected chrome(_context: ScreenContext): FullScreenChrome;
64
+ protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
76
65
  handleInput(input: string, context: ScreenContext): boolean;
77
66
  activate(index: number, context: ScreenContext): boolean;
78
67
  select(index: number, context: ScreenContext): boolean;
@@ -11,10 +11,10 @@
11
11
  // GAIA-345 D8 — `Component/project-picker/` is GONE and its two survivors live
12
12
  // here, in their only consumer: the candidate type and the one-column table the
13
13
  // two surfaces are both made of.
14
+ import { actionItem } from '../../../Component/footer-actions/index.js';
14
15
  import { lineText, } from '../../../Component/section-list/index.js';
15
- import { columnNode, textNode } from '../../../lib/vnode.js';
16
16
  import { defineContext } from '../../../Routing/context.js';
17
- import { BaseScreen, presentation, } from '../../../Screen/screen.js';
17
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
18
18
  import { listRowIds, listTableRows, listTableTree, } from '../../collection.js';
19
19
  /**
20
20
  * The picker as a configuration (GAIA-345 D8) — one column, and NO HEADER.
@@ -54,7 +54,11 @@ const clamp = (selected, length) => {
54
54
  return 0;
55
55
  return Math.max(0, Math.min(Math.trunc(selected), length - 1));
56
56
  };
57
- export class ProjectPickerScreen extends BaseScreen {
57
+ /**
58
+ * Project picker — normal {@link AsyncScreen}. Candidates arrive via navigation
59
+ * context; refresh is the shared R / ui-refresh path.
60
+ */
61
+ export class ProjectPickerScreen extends AsyncScreen {
58
62
  frameId = 'project-picker';
59
63
  topTab() {
60
64
  return { level: 'root', tab: 'projects' };
@@ -69,47 +73,53 @@ export class ProjectPickerScreen extends BaseScreen {
69
73
  : base,
70
74
  };
71
75
  id = ProjectPickerScreen.route.id;
72
- candidates = [];
73
- repo = '';
74
76
  selectedIndex = 0;
75
- enter(context) {
76
- const input = context.contexts?.has(projectPickerInputContext)
77
- ? context.contexts.get(projectPickerInputContext)
78
- : undefined;
79
- if (input) {
80
- this.candidates = [...input.candidates];
81
- this.repo = input.repo;
82
- this.selectedIndex = 0;
83
- }
84
- context.requestDraw();
77
+ reload(context) {
78
+ this.retry(context);
79
+ }
80
+ get candidates() {
81
+ return this.readyValue?.candidates ?? [];
82
+ }
83
+ get repo() {
84
+ return this.readyValue?.repo ?? '';
85
85
  }
86
86
  get rows() {
87
87
  return this.candidates;
88
88
  }
89
+ load(context) {
90
+ if (context.contexts?.has(projectPickerInputContext)) {
91
+ const input = context.contexts.get(projectPickerInputContext);
92
+ return Promise.resolve({
93
+ candidates: [...input.candidates],
94
+ repo: input.repo,
95
+ });
96
+ }
97
+ return Promise.resolve({ candidates: [], repo: '' });
98
+ }
99
+ renderLoading() {
100
+ return ['Loading projects…'];
101
+ }
89
102
  /**
90
103
  * The question this screen asks, and the one place it is written.
91
104
  *
92
- * Both surfaces open with it — the tree as a keyed row above the table, the
93
- * pipe as its first line — so composing it twice is how the two would come to
94
- * disagree about the repo they name.
105
+ * Both surfaces open with it — the tree via meta, the pipe as its first line —
106
+ * so composing it twice is how the two would come to disagree about the repo.
95
107
  */
96
108
  headingText() {
97
109
  return `Several gaia projects claim ${this.repo} — which one?`;
98
110
  }
99
- /** The candidate the cursor stands on, clamped: an out-of-range index must
100
- * move the marker, never lose it. */
101
111
  selected() {
102
112
  return this.candidates[clamp(this.selectedIndex, this.candidates.length)];
103
113
  }
114
+ renderReady(data) {
115
+ return listTableTree(PICKER_TABLE, data.candidates, this.selected(), {
116
+ selectedIndex: clamp(this.selectedIndex, data.candidates.length),
117
+ });
118
+ }
104
119
  /**
105
120
  * F5 — the pipe's own output, and it stays `string[]`.
106
121
  *
107
- * GAIA-345 D7/D8 — built from the SAME configuration the tree is, through
108
- * `listTableRows` and `lineText`, plus the two heading lines and the trailing
109
- * hint, which are the screen's and not rows of the list. The bytes are
110
- * unchanged by construction rather than by a comparison somebody has to
111
- * remember to make, and `tests/Entity/project/Screen/project-picker.test.ts`
112
- * pins them line for line against the pre-conversion capture.
122
+ * GAIA-345 D7/D8 — built from the SAME configuration the tree is.
113
123
  */
114
124
  renderNonInteractive() {
115
125
  return [
@@ -120,46 +130,27 @@ export class ProjectPickerScreen extends BaseScreen {
120
130
  'Run gaia ui project:<name> to choose directly.',
121
131
  ];
122
132
  }
123
- /**
124
- * The picker's body: the question, a blank, then the list (GAIA-345 D8).
125
- *
126
- * The two heading rows are composed AROUND the table's node because they are
127
- * not rows of it — they name no candidate, they are not selectable, and they
128
- * carry no entry in `rowIds`. That is also what the old `selRow` offset of 2
129
- * was compensating for before the kernel started finding the cursor by node.
130
- *
131
- * MOUNTED WHOLE — no window of its own, and the bound is named rather than
132
- * assumed: a row is one `gaia_project` that claims the cwd's remote, which is
133
- * the multisite topology GAIA-126/137 designs for. That is a handful of sites
134
- * per repo, against `@opentui/core@0.5.1`'s ceiling of 16 382 renderables.
135
- */
136
- surface() {
137
- return columnNode('project-picker', textNode({
138
- id: 'project-picker-heading',
139
- content: this.headingText(),
140
- }), textNode({ id: 'project-picker-heading-gap', content: '' }), listTableTree(PICKER_TABLE, this.candidates, this.selected(), {
141
- selectedIndex: clamp(this.selectedIndex, this.candidates.length),
142
- }));
143
- }
144
- present(_context) {
145
- return presentation(this.surface(), {
133
+ chrome(_context) {
134
+ return {
146
135
  selectable: this.candidates.length,
147
136
  selectedIndex: this.selectedIndex,
148
- // The node a click on each candidate lands on (GAIA-340 Task 11), read
149
- // off the SAME configuration that keys the rows — which retires the
150
- // hand-spelled `project-picker-candidate-<id>` the screen used to publish
151
- // beside a row keyed the same way by a second expression (GAIA-345 D2).
152
137
  rowIds: listRowIds(PICKER_TABLE, this.candidates),
153
138
  title: 'Which project?',
154
- meta: `${this.candidates.length} project(s) claim ${this.repo} · Enter open · Esc dashboard`,
155
- status: ' ↑↓ select · Enter open · Esc dashboard · q quit ',
156
- });
139
+ meta: `${this.candidates.length} project(s) claim ${this.repo} · ${this.headingText()}`,
140
+ };
141
+ }
142
+ actions() {
143
+ return [actionItem('picker-enter', 'Enter', 'open')];
157
144
  }
158
145
  handleInput(input, context) {
146
+ if (super.handleInput(input, context))
147
+ return true;
159
148
  if (input === 'j' || input === 'down')
160
149
  this.selectedIndex = Math.min(this.selectedIndex + 1, this.candidates.length - 1);
161
150
  else if (input === 'k' || input === 'up')
162
151
  this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
152
+ else if (input === 'enter')
153
+ return this.activate(this.selectedIndex, context);
163
154
  else
164
155
  return false;
165
156
  context.requestDraw();
@@ -184,8 +175,7 @@ export class ProjectPickerScreen extends BaseScreen {
184
175
  return true;
185
176
  }
186
177
  reset() {
187
- this.candidates = [];
188
- this.repo = '';
189
178
  this.selectedIndex = 0;
179
+ super.reset();
190
180
  }
191
181
  }
@@ -1,6 +1,7 @@
1
1
  import type { VNode } from '@opentui/core';
2
2
  import { AsyncScreen } from '../../../Screen/async-screen.js';
3
- import { type ScreenContext } from '../../../Screen/screen.js';
3
+ import type { FullScreenChrome } from '../../../Screen/full-screen.js';
4
+ import type { ScreenContext } from '../../../Screen/screen.js';
4
5
  import { type ProjectRow } from '../Data/projects.js';
5
6
  /**
6
7
  * GAIA-340 D1 — the ready page is a renderable TREE; `loading` and `error` are
@@ -44,7 +45,8 @@ export declare class ProjectsScreen extends AsyncScreen<ProjectRow[]> {
44
45
  protected load(context: ScreenContext): Promise<ProjectRow[]>;
45
46
  protected renderLoading(): string[];
46
47
  protected renderReady(rows: ProjectRow[]): VNode;
47
- present(context: ScreenContext): import("../../../Screen/screen.js").ScreenPresentation;
48
+ protected chrome(context: ScreenContext): FullScreenChrome;
49
+ protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
48
50
  activate(index: number, context: ScreenContext): boolean;
49
51
  select(index: number, context: ScreenContext): boolean;
50
52
  handleInput(input: string, context: ScreenContext): boolean;
@@ -1,6 +1,6 @@
1
+ import { actionItem } from '../../../Component/footer-actions/index.js';
1
2
  import { ListCursor } from '../../../lib/cursor.js';
2
3
  import { AsyncScreen } from '../../../Screen/async-screen.js';
3
- import { presentation } from '../../../Screen/screen.js';
4
4
  import { listRowIds, listTableTree } from '../../collection.js';
5
5
  import { loadProjectRows, PROJECT_TABLE, } from '../Data/projects.js';
6
6
  /**
@@ -69,19 +69,22 @@ export class ProjectsScreen extends AsyncScreen {
69
69
  selectedIndex: this.selectedIndex,
70
70
  });
71
71
  }
72
- present(context) {
72
+ chrome(context) {
73
73
  const rows = this.rows;
74
- return presentation(this.surface(context), {
74
+ return {
75
75
  selectable: rows.length,
76
76
  selectedIndex: this.selectedIndex,
77
- // The node a click on each row lands on (GAIA-340 Task 11), read off the
78
- // SAME configuration that keys the rows — so the click target and the row
79
- // node cannot be spelled two ways (GAIA-345 D2).
77
+ // Click targets match listTableTree row ids (GAIA-345 D2).
80
78
  rowIds: listRowIds(PROJECT_TABLE, rows),
81
79
  title: 'GAIA projects',
82
80
  meta: `${rows.length} registered project(s) · ${context.services?.baseUrl ?? ''}`,
83
- status: ' ↑↓ select · Enter open · n new · Tab tabs · q quit ',
84
- });
81
+ };
82
+ }
83
+ actions() {
84
+ return [
85
+ actionItem('projects-new', 'n', 'new'),
86
+ actionItem('projects-tab', 'Tab', 'tabs'),
87
+ ];
85
88
  }
86
89
  activate(index, context) {
87
90
  const row = this.readyValue?.[index];
@@ -8,12 +8,20 @@
8
8
  //
9
9
  // GAIA-340 D1 — the FIRST block to hand its content over as renderables rather
10
10
  // than as rows of text: the sections are built once, keyed, and turned into a
11
- // VNode tree. It shares the minimal-markdown rule with the blocks that still
12
- // compose rows (`markdownRow`), so a body line renders the same either way.
11
+ // VNode tree.
12
+ //
13
+ // GAIA-372 D9 — IT IS `markdownRow`'S ONLY CALLER NOW, and the ATX-only rule is
14
+ // THIS SCREEN'S rule rather than a rule shared with the ticket detail. A run log
15
+ // entry is MACHINE OUTPUT, not authored Markdown: an agent line full of `*`, `_`
16
+ // and backticks would be re-styled and have characters deleted by a renderer
17
+ // meant for prose. So the log keeps the narrow rule while the ticket detail's
18
+ // `gaia_rich` bodies go through `Component/markdown/`. Nothing about this screen's
19
+ // behaviour changed; routing the log through the full subset is a separate
20
+ // ticket, not a silent side effect of that one.
13
21
  //
14
22
  // Read-only by design (the ticket's own out-of-scope list): no write, no edit, no
15
23
  // navigation out of it except Esc.
16
- import { markdownRow } from '../../../../Component/section-list/index.js';
24
+ import { markdownRow } from '../../../../Component/markdown/index.js';
17
25
  import { asString } from '../../../../lib/format.js';
18
26
  import { columnNode, textNode } from '../../../../lib/vnode.js';
19
27
  /** The sideloaded `gaia_run` with this id, or undefined. */
@@ -208,10 +216,10 @@ function flatRows(document, runId) {
208
216
  { key: `section-${section.key}`, text: section.heading, bold: true },
209
217
  ...section.rows.map((entry) => ({
210
218
  key: entry.key,
211
- // The same minimal-markdown rule the row surface applies, from the same
212
- // function — an ATX heading inside a body renders bold with its markers
213
- // stripped, and a log line starting with `#` must not render one way on
214
- // screen and another way in a pipe.
219
+ // The log's OWN minimal-markdown rule (GAIA-372 D9): an ATX heading inside
220
+ // a log entry renders bold with its markers stripped, and a log line
221
+ // starting with `#` must not render one way on screen and another way in a
222
+ // pipe. Nothing else in an entry is parsed, deliberately — see the header.
215
223
  ...markdownRow(entry.text),
216
224
  })),
217
225
  // The blank row between two sections, which the flattened surface pushed
@@ -20,3 +20,13 @@ export declare function buildRunQueryParams(projectId: string, criteria: RunCrit
20
20
  size: number;
21
21
  index: number;
22
22
  }): DrupalJsonApiParams;
23
+ /**
24
+ * Build the JSON:API query for one assignee's runs (GAIA-377 D2).
25
+ *
26
+ * Runs carry no user of their own — attribution is
27
+ * `ticket_id.assignee_user_id.id`, matching StatisticsCounter::SOURCES.
28
+ */
29
+ export declare function buildUserRunQueryParams(userId: string, criteria: RunCriteria, page?: {
30
+ size: number;
31
+ index: number;
32
+ }): DrupalJsonApiParams;
@@ -25,6 +25,22 @@ export function describeCriteria(criteria) {
25
25
  export function buildRunQueryParams(projectId, criteria, page = { size: LIST_PAGE_SIZE, index: 0 }) {
26
26
  const params = new DrupalJsonApiParams();
27
27
  params.addFilter('ticket_id.project_id.id', projectId);
28
+ applyRunCriteria(params, criteria, page);
29
+ return params;
30
+ }
31
+ /**
32
+ * Build the JSON:API query for one assignee's runs (GAIA-377 D2).
33
+ *
34
+ * Runs carry no user of their own — attribution is
35
+ * `ticket_id.assignee_user_id.id`, matching StatisticsCounter::SOURCES.
36
+ */
37
+ export function buildUserRunQueryParams(userId, criteria, page = { size: LIST_PAGE_SIZE, index: 0 }) {
38
+ const params = new DrupalJsonApiParams();
39
+ params.addFilter('ticket_id.assignee_user_id.id', userId);
40
+ applyRunCriteria(params, criteria, page);
41
+ return params;
42
+ }
43
+ function applyRunCriteria(params, criteria, page) {
28
44
  if (criteria.closed !== null)
29
45
  params.addFilter('closed', criteria.closed ? '1' : '0');
30
46
  params.addInclude(['ticket_id']);
@@ -37,5 +53,4 @@ export function buildRunQueryParams(projectId, criteria, page = { size: LIST_PAG
37
53
  // Page one carries no offset, so its query is the shortest it can be.
38
54
  if (page.index > 0)
39
55
  params.addPageOffset(page.index * page.size);
40
- return params;
41
56
  }