@gaia-ai/addon-gaia-ui 0.6.4 → 0.7.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.
- package/README.md +1 -1
- package/dist/src/Agent/Component/agent-list/index.d.ts +6 -0
- package/dist/src/Agent/Component/agent-list/index.js +14 -0
- package/dist/src/Agent/launch.d.ts +25 -0
- package/dist/src/Agent/launch.js +49 -0
- package/dist/src/Component/section-list/index.d.ts +37 -0
- package/dist/src/Component/section-list/index.js +60 -13
- package/dist/src/Component/stats-panel/index.d.ts +49 -0
- package/dist/src/Component/stats-panel/index.js +114 -0
- package/dist/src/Component/tab-bar/index.d.ts +11 -0
- package/dist/src/Component/tab-bar/index.js +21 -1
- package/dist/src/Component/ticket-detail/index.d.ts +8 -0
- package/dist/src/Component/ticket-detail/index.js +68 -4
- package/dist/src/Component/top-tabs/index.d.ts +118 -3
- package/dist/src/Component/top-tabs/index.js +123 -8
- package/dist/src/Entity/conductor/Data/conductors.d.ts +28 -0
- package/dist/src/Entity/conductor/Data/conductors.js +23 -0
- package/dist/src/Entity/conductor/Data/liveness.d.ts +10 -0
- package/dist/src/Entity/conductor/Data/liveness.js +25 -0
- package/dist/src/Entity/project/Component/dashboard-list/index.d.ts +8 -0
- package/dist/src/Entity/project/Component/dashboard-list/index.js +7 -0
- package/dist/src/Entity/project/Component/project-list/index.d.ts +8 -0
- package/dist/src/Entity/project/Component/project-list/index.js +7 -0
- package/dist/src/Entity/project/Component/project-picker/index.d.ts +15 -0
- package/dist/src/Entity/project/Component/project-picker/index.js +42 -0
- package/dist/src/Entity/project/Data/projects.d.ts +21 -0
- package/dist/src/Entity/project/Data/projects.js +81 -0
- package/dist/src/Entity/project/Data/with-tickets.d.ts +20 -0
- package/dist/src/Entity/project/Data/with-tickets.js +36 -0
- package/dist/src/Entity/run/Component/run-detail/index.d.ts +17 -0
- package/dist/src/Entity/run/Component/run-detail/index.js +145 -0
- package/dist/src/Entity/run/Component/run-table/index.d.ts +10 -0
- package/dist/src/Entity/run/Component/run-table/index.js +20 -0
- package/dist/src/Entity/run/Data/active-run.d.ts +15 -0
- package/dist/src/Entity/run/Data/active-run.js +23 -0
- package/dist/src/Entity/run/Data/runs.d.ts +72 -0
- package/dist/src/Entity/run/Data/runs.js +125 -0
- package/dist/src/Entity/run/Data/status.d.ts +12 -0
- package/dist/src/Entity/run/Data/status.js +40 -0
- package/dist/src/Entity/statistics/Data/statistics.d.ts +87 -0
- package/dist/src/Entity/statistics/Data/statistics.js +144 -0
- package/dist/src/Entity/term/Data/terms.d.ts +16 -0
- package/dist/src/Entity/term/Data/terms.js +14 -0
- package/dist/src/Entity/ticket/Component/comment-item/index.d.ts +21 -0
- package/dist/src/Entity/ticket/Component/comment-item/index.js +31 -0
- package/dist/src/Entity/ticket/Component/create-form/index.d.ts +15 -0
- package/dist/src/Entity/ticket/Component/create-form/index.js +60 -0
- package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +28 -0
- package/dist/src/Entity/ticket/Component/edit-form/index.js +76 -0
- package/dist/src/Entity/ticket/Component/tab-bar/index.d.ts +20 -0
- package/dist/src/Entity/ticket/Component/tab-bar/index.js +65 -0
- package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +106 -0
- package/dist/src/Entity/ticket/Component/ticket-detail/index.js +461 -0
- package/dist/src/Entity/ticket/Component/ticket-table/index.d.ts +5 -0
- package/dist/src/Entity/ticket/Component/ticket-table/index.js +14 -0
- package/dist/src/Entity/ticket/Component/ticket-teaser/index.d.ts +7 -0
- package/dist/src/Entity/ticket/Component/ticket-teaser/index.js +76 -0
- package/dist/src/Entity/ticket/Data/detail.d.ts +28 -0
- package/dist/src/Entity/ticket/Data/detail.js +29 -0
- package/dist/src/Entity/ticket/Data/parents.d.ts +27 -0
- package/dist/src/Entity/ticket/Data/parents.js +67 -0
- package/dist/src/Entity/ticket/Data/query.d.ts +20 -0
- package/dist/src/Entity/ticket/Data/query.js +66 -0
- package/dist/src/Entity/ticket/Data/tickets.d.ts +45 -0
- package/dist/src/Entity/ticket/Data/tickets.js +80 -0
- package/dist/src/Entity/ticket/Data/workflow.d.ts +13 -0
- package/dist/src/Entity/ticket/Data/workflow.js +26 -0
- package/dist/src/Entity/ticket/Data/write.d.ts +12 -0
- package/dist/src/Entity/ticket/Data/write.js +18 -0
- package/dist/src/Entity/ticket/Form/create-data.d.ts +64 -0
- package/dist/src/Entity/ticket/Form/create-data.js +178 -0
- package/dist/src/Entity/ticket/Form/create.d.ts +125 -0
- package/dist/src/Entity/ticket/Form/create.js +112 -0
- package/dist/src/Entity/ticket/Form/edit-data.d.ts +9 -0
- package/dist/src/Entity/ticket/Form/edit-data.js +38 -0
- package/dist/src/Entity/ticket/Form/edit.d.ts +65 -0
- package/dist/src/Entity/ticket/Form/edit.js +61 -0
- package/dist/src/Entity/transport.d.ts +50 -0
- package/dist/src/Entity/transport.js +49 -0
- package/dist/src/Entity/user/Data/users.d.ts +29 -0
- package/dist/src/Entity/user/Data/users.js +28 -0
- package/dist/src/Form/field.d.ts +99 -0
- package/dist/src/Form/field.js +27 -0
- package/dist/src/Form/form.d.ts +53 -0
- package/dist/src/Form/form.js +142 -0
- package/dist/src/Form/index.d.ts +3 -0
- package/dist/src/Form/index.js +5 -0
- package/dist/src/Form/keys.d.ts +3 -0
- package/dist/src/Form/keys.js +57 -0
- package/dist/src/Kernel/tui-kernel.d.ts +11 -0
- package/dist/src/Kernel/tui-kernel.js +1566 -320
- package/dist/src/Screen/projects.d.ts +1 -0
- package/dist/src/Screen/projects.js +1 -0
- package/dist/src/Widget/action.d.ts +3 -0
- package/dist/src/Widget/action.js +9 -0
- package/dist/src/Widget/autocomplete.d.ts +3 -0
- package/dist/src/Widget/autocomplete.js +80 -0
- package/dist/src/Widget/chrome.d.ts +38 -0
- package/dist/src/Widget/chrome.js +60 -0
- package/dist/src/Widget/index.d.ts +4 -0
- package/dist/src/Widget/index.js +12 -0
- package/dist/src/Widget/multiselect.d.ts +3 -0
- package/dist/src/Widget/multiselect.js +36 -0
- package/dist/src/Widget/option-source.d.ts +58 -0
- package/dist/src/Widget/option-source.js +98 -0
- package/dist/src/Widget/render.d.ts +42 -0
- package/dist/src/Widget/render.js +82 -0
- package/dist/src/Widget/select.d.ts +3 -0
- package/dist/src/Widget/select.js +26 -0
- package/dist/src/Widget/text.d.ts +3 -0
- package/dist/src/Widget/text.js +23 -0
- package/dist/src/Widget/types.d.ts +59 -0
- package/dist/src/Widget/types.js +21 -0
- package/dist/src/launcher.d.ts +33 -8
- package/dist/src/launcher.js +12 -4
- package/dist/src/lib/ansi.d.ts +15 -0
- package/dist/src/lib/ansi.js +53 -9
- package/dist/src/lib/format.d.ts +21 -0
- package/dist/src/lib/format.js +46 -4
- package/dist/src/lib/hit-test.d.ts +138 -0
- package/dist/src/lib/hit-test.js +138 -0
- package/dist/src/lib/jsonapi-error.d.ts +7 -0
- package/dist/src/lib/jsonapi-error.js +43 -0
- package/dist/src/lib/mouse.d.ts +35 -0
- package/dist/src/lib/mouse.js +89 -0
- package/dist/src/lib/width-table.d.ts +4 -0
- package/dist/src/lib/width-table.js +147 -0
- package/dist/src/lib/width.d.ts +61 -0
- package/dist/src/lib/width.js +277 -0
- package/dist/src/plugin.js +3 -3
- package/dist/src/route.d.ts +11 -0
- package/dist/src/route.js +215 -0
- package/dist/src/types.d.ts +53 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# @gaia-ai/addon-gaia-ui
|
|
2
2
|
|
|
3
|
-
GAIA TUI: interactive terminal UI (
|
|
3
|
+
GAIA TUI: interactive terminal UI (dashboard, projects, tickets) as a dropsh `tui` renderer plugin.
|
|
4
4
|
|
|
5
5
|
Part of the GAIA CLI. Install the meta package `@gaia-ai/gaia` to get the `gaia` CLI with all addons. Source: https://git.key-tec.de/keytec/gaia (gaia-cli/).
|
|
@@ -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,25 @@
|
|
|
1
|
+
import type { CreateFormPayload } from '../Entity/ticket/Form/create.js';
|
|
2
|
+
import type { TuiAgentService } from '../types.js';
|
|
3
|
+
/** The launch intent passed to {@link TuiAgentService.launch}. */
|
|
4
|
+
export type CreateLaunch = Parameters<TuiAgentService['launch']>[0];
|
|
5
|
+
/**
|
|
6
|
+
* Build the `ticket:create` launch intent for a project. The prompt is a pure
|
|
7
|
+
* pointer to WORKFLOW.md (the pre-filled form is passed via
|
|
8
|
+
* `$GAIA_NEW_TICKET_INPUT`); `GAIA_ID` is never inherited (a create must not be
|
|
9
|
+
* bound to a claimed ticket).
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildCreateLaunch(opts: {
|
|
12
|
+
project: string;
|
|
13
|
+
cwd: string;
|
|
14
|
+
specPath?: string | undefined;
|
|
15
|
+
workflow?: string | undefined;
|
|
16
|
+
createPrompt?: string | undefined;
|
|
17
|
+
label?: string | undefined;
|
|
18
|
+
}): CreateLaunch;
|
|
19
|
+
/**
|
|
20
|
+
* A short human label for the agent list: the description's first line (capped to 40
|
|
21
|
+
* incl. the ellipsis), else "New <workflow>". GAIA-236 AC-8: it builds the `label`
|
|
22
|
+
* `buildCreateLaunch` takes, so it belongs to the module that builds the launch
|
|
23
|
+
* intent — moved from `Service/create-form.ts` unchanged.
|
|
24
|
+
*/
|
|
25
|
+
export declare function shortName(payload: CreateFormPayload): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Ticket-create launch request (AC-3). Pressing `n` starts an embedded agent
|
|
2
|
+
// that runs the GAIA `ticket:create` workflow. This module is pure: it builds
|
|
3
|
+
// the launch intent (prompt + env) the TUI hands to the injected agent host
|
|
4
|
+
// (`services.agents.launch`); the host (herdr) owns transport + layout. No
|
|
5
|
+
// node-pty / @xterm, no session object.
|
|
6
|
+
const CREATE_PROMPT = 'Read the repository\'s WORKFLOW.md and follow its "Create ticket" section to ' +
|
|
7
|
+
'create a new GAIA ticket. Do not start work on the new ticket.';
|
|
8
|
+
/**
|
|
9
|
+
* Build the `ticket:create` launch intent for a project. The prompt is a pure
|
|
10
|
+
* pointer to WORKFLOW.md (the pre-filled form is passed via
|
|
11
|
+
* `$GAIA_NEW_TICKET_INPUT`); `GAIA_ID` is never inherited (a create must not be
|
|
12
|
+
* bound to a claimed ticket).
|
|
13
|
+
*/
|
|
14
|
+
export function buildCreateLaunch(opts) {
|
|
15
|
+
const env = {
|
|
16
|
+
...process.env,
|
|
17
|
+
TERM: 'xterm-256color',
|
|
18
|
+
GAIA_PROJECT: opts.project,
|
|
19
|
+
};
|
|
20
|
+
delete env.GAIA_ID;
|
|
21
|
+
if (opts.specPath)
|
|
22
|
+
env.GAIA_NEW_TICKET_INPUT = opts.specPath;
|
|
23
|
+
if (opts.workflow)
|
|
24
|
+
env.GAIA_WORKFLOW = opts.workflow;
|
|
25
|
+
return {
|
|
26
|
+
group: opts.project,
|
|
27
|
+
cwd: opts.cwd,
|
|
28
|
+
prompt: opts.createPrompt?.trim() || CREATE_PROMPT,
|
|
29
|
+
env,
|
|
30
|
+
isNew: true,
|
|
31
|
+
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A short human label for the agent list: the description's first line (capped to 40
|
|
36
|
+
* incl. the ellipsis), else "New <workflow>". GAIA-236 AC-8: it builds the `label`
|
|
37
|
+
* `buildCreateLaunch` takes, so it belongs to the module that builds the launch
|
|
38
|
+
* intent — moved from `Service/create-form.ts` unchanged.
|
|
39
|
+
*/
|
|
40
|
+
export function shortName(payload) {
|
|
41
|
+
const firstLine = payload.description
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((l) => l.trim())
|
|
44
|
+
.find((l) => l.length > 0);
|
|
45
|
+
if (firstLine) {
|
|
46
|
+
return firstLine.length > 40 ? `${firstLine.slice(0, 39)}…` : firstLine;
|
|
47
|
+
}
|
|
48
|
+
return `New ${payload.workflow?.label ?? 'ticket'}`;
|
|
49
|
+
}
|
|
@@ -7,3 +7,40 @@ export declare function flattenSections(sections: Section[]): {
|
|
|
7
7
|
lines: string[];
|
|
8
8
|
boldRows: Set<number>;
|
|
9
9
|
};
|
|
10
|
+
/**
|
|
11
|
+
* One collapsible entry: a caret header row plus the rows shown in each state.
|
|
12
|
+
*
|
|
13
|
+
* GAIA-255 D2: the Comments tab's collapse/expand interaction and the Overview
|
|
14
|
+
* tab's Description are ONE primitive rather than two renderers that happen to
|
|
15
|
+
* agree — AC-2 asks for "the same expand interaction the Comments tab offers",
|
|
16
|
+
* and structural sharing is the only form of that claim a test can hold.
|
|
17
|
+
*/
|
|
18
|
+
export interface ExpandableEntry {
|
|
19
|
+
/** The header row's text, after the caret. */
|
|
20
|
+
header: string;
|
|
21
|
+
/** Rows shown while collapsed (indented like the expanded body). */
|
|
22
|
+
collapsed: string[];
|
|
23
|
+
/** Rows shown while expanded. */
|
|
24
|
+
expanded: string[];
|
|
25
|
+
}
|
|
26
|
+
/** The rendered rows of a list of expandable entries. */
|
|
27
|
+
export interface ExpandableView {
|
|
28
|
+
lines: string[];
|
|
29
|
+
boldRows: Set<number>;
|
|
30
|
+
/** `headerRows[i]` is the row index of entry i's header — the selection map. */
|
|
31
|
+
headerRows: number[];
|
|
32
|
+
selectable: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Flatten expandable entries: one caret header row per entry (`▸` collapsed,
|
|
36
|
+
* `▾` expanded) followed by that state's rows. `headerRows[i]` is entry i's
|
|
37
|
+
* header row, which is both the highlight target and the click map (the rows are
|
|
38
|
+
* NOT uniformly spaced — an expanded entry appends its body).
|
|
39
|
+
*
|
|
40
|
+
* Zero entries render `emptyMessage` and nothing selectable, so an empty list is
|
|
41
|
+
* an explicit statement rather than a blank screen.
|
|
42
|
+
*/
|
|
43
|
+
export declare function flattenExpandable(entries: ExpandableEntry[], { expanded, emptyMessage, }: {
|
|
44
|
+
expanded?: ReadonlySet<number>;
|
|
45
|
+
emptyMessage: string;
|
|
46
|
+
}): ExpandableView;
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append one body row, applying the MINIMAL MARKDOWN rule: an ATX heading
|
|
3
|
+
* (`#`…`######`) renders as bold with the markers stripped — the same rule a
|
|
4
|
+
* section heading follows, styled after fit(). Bullets and code fences stay raw
|
|
5
|
+
* by design (explicit v1 decision).
|
|
6
|
+
*
|
|
7
|
+
* Extracted so `flattenSections` and `flattenExpandable` cannot answer "what does
|
|
8
|
+
* a `## Context` line render as" differently: the Description block is rendered
|
|
9
|
+
* by BOTH (flat on the piped render, expandable on the Overview tab), and a rule
|
|
10
|
+
* living in only one of them would make the same body look different depending on
|
|
11
|
+
* which renderer reached it.
|
|
12
|
+
*/
|
|
13
|
+
function pushRow(lines, boldRows, line) {
|
|
14
|
+
const md = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
15
|
+
if (md) {
|
|
16
|
+
boldRows.add(lines.length);
|
|
17
|
+
lines.push(md[2] ?? '');
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
lines.push(line);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
1
23
|
// Flatten `{heading, lines}` sections into plain rows plus the indices that are
|
|
2
24
|
// bold (section headings + markdown ATX headings). Headings carry NO ANSI here —
|
|
3
25
|
// the draw step applies bold after fit(), so sanitizeDisplay never sees (and
|
|
@@ -8,20 +30,45 @@ export function flattenSections(sections) {
|
|
|
8
30
|
for (const section of sections) {
|
|
9
31
|
boldRows.add(lines.length);
|
|
10
32
|
lines.push(section.heading);
|
|
11
|
-
for (const line of section.lines)
|
|
12
|
-
|
|
13
|
-
// markers stripped — same rule as a section heading, styled after fit().
|
|
14
|
-
// Bullets and code fences stay raw by design (explicit v1 decision).
|
|
15
|
-
const md = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
16
|
-
if (md) {
|
|
17
|
-
boldRows.add(lines.length);
|
|
18
|
-
lines.push(md[2] ?? '');
|
|
19
|
-
}
|
|
20
|
-
else {
|
|
21
|
-
lines.push(line);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
33
|
+
for (const line of section.lines)
|
|
34
|
+
pushRow(lines, boldRows, line);
|
|
24
35
|
lines.push('');
|
|
25
36
|
}
|
|
26
37
|
return { lines, boldRows };
|
|
27
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Flatten expandable entries: one caret header row per entry (`▸` collapsed,
|
|
41
|
+
* `▾` expanded) followed by that state's rows. `headerRows[i]` is entry i's
|
|
42
|
+
* header row, which is both the highlight target and the click map (the rows are
|
|
43
|
+
* NOT uniformly spaced — an expanded entry appends its body).
|
|
44
|
+
*
|
|
45
|
+
* Zero entries render `emptyMessage` and nothing selectable, so an empty list is
|
|
46
|
+
* an explicit statement rather than a blank screen.
|
|
47
|
+
*/
|
|
48
|
+
export function flattenExpandable(entries, { expanded = new Set(), emptyMessage,
|
|
49
|
+
// GAIA-255 AC-11 review: `ReadonlySet` because the render path is handed ONE
|
|
50
|
+
// shared empty set for every not-yet-expanded tab (`expandedFor`) — growing
|
|
51
|
+
// it here would leak one tab's expansion into all the others. The renderer
|
|
52
|
+
// only ever calls `.has()`, so the narrowing costs it nothing.
|
|
53
|
+
}) {
|
|
54
|
+
if (!entries.length) {
|
|
55
|
+
return {
|
|
56
|
+
lines: [emptyMessage],
|
|
57
|
+
boldRows: new Set(),
|
|
58
|
+
headerRows: [],
|
|
59
|
+
selectable: 0,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const lines = [];
|
|
63
|
+
const boldRows = new Set();
|
|
64
|
+
const headerRows = [];
|
|
65
|
+
entries.forEach((entry, i) => {
|
|
66
|
+
headerRows.push(lines.length);
|
|
67
|
+
boldRows.add(lines.length);
|
|
68
|
+
lines.push(`${expanded.has(i) ? '▾' : '▸'} ${entry.header}`);
|
|
69
|
+
for (const line of expanded.has(i) ? entry.expanded : entry.collapsed) {
|
|
70
|
+
pushRow(lines, boldRows, line);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return { lines, boldRows, headerRows, selectable: entries.length };
|
|
74
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** A filled bar cell. U+2588 FULL BLOCK. */
|
|
2
|
+
export declare const BAR_FULL = "\u2588";
|
|
3
|
+
/** An empty bar cell. U+2591 LIGHT SHADE. */
|
|
4
|
+
export declare const BAR_EMPTY = "\u2591";
|
|
5
|
+
/**
|
|
6
|
+
* The bar's width in display columns.
|
|
7
|
+
*
|
|
8
|
+
* `█`/`░` are narrow per `lib/width-table.ts` — neither is in `WIDE_RANGES` — so
|
|
9
|
+
* the row geometry GAIA-241's hit testing depends on is unaffected. They are NOT
|
|
10
|
+
* ASCII, though, and both are East-Asian-AMBIGUOUS: a terminal configured
|
|
11
|
+
* ambiguous-as-wide (common in CJK setups) renders them two columns and shifts
|
|
12
|
+
* every number column at runtime while every test here stays green, because the
|
|
13
|
+
* width table deliberately treats Ambiguous as narrow (the frame's box-drawing
|
|
14
|
+
* characters depend on that). Accepted rather than mitigated — spec R9. If it
|
|
15
|
+
* ever bites, the fix is an ASCII bar glyph, never a width-table change.
|
|
16
|
+
*/
|
|
17
|
+
export declare const BAR_WIDTH = 20;
|
|
18
|
+
/** One row of a block: a name and its counts, most-significant number last. */
|
|
19
|
+
export interface StatsPanelRow {
|
|
20
|
+
label: string;
|
|
21
|
+
/**
|
|
22
|
+
* The counts to print, in column order. The LAST one is what the bar
|
|
23
|
+
* measures — for a breakdown row that is the month, so a row whose day count
|
|
24
|
+
* is zero still shows the activity it actually had.
|
|
25
|
+
*/
|
|
26
|
+
numbers: number[];
|
|
27
|
+
}
|
|
28
|
+
export interface StatsPanelOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Column titles, printed as a header row above the numbers. Omitted for a
|
|
31
|
+
* single-number block, where the label already says which window it is.
|
|
32
|
+
*
|
|
33
|
+
* The mock in spec D11 hangs these titles off the block's HEADING line; they
|
|
34
|
+
* are their own row here because the heading belongs to `flattenSections`,
|
|
35
|
+
* which owns the bold-row set. Same information, one row lower, and the two
|
|
36
|
+
* components keep their boundary.
|
|
37
|
+
*/
|
|
38
|
+
columns?: readonly string[] | undefined;
|
|
39
|
+
/** What to render instead of rows when there are none (spec R7). */
|
|
40
|
+
emptyMessage?: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One block's body rows: an optional column header, then one row per entry.
|
|
44
|
+
*
|
|
45
|
+
* Every column is measured with `displayWidth` (through `pad`/`padNumber`), so a
|
|
46
|
+
* wide-character label cannot shift the numbers out of alignment — the failure
|
|
47
|
+
* that makes a table of counts unreadable exactly when the data is interesting.
|
|
48
|
+
*/
|
|
49
|
+
export declare function statsPanelRows(rows: readonly StatsPanelRow[], { columns, emptyMessage }?: StatsPanelOptions): string[];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { pad } from '../../lib/format.js';
|
|
2
|
+
import { displayWidth } from '../../lib/width.js';
|
|
3
|
+
// GAIA-256 spec D11 — the Dashboard's `/usage`-style bar rows.
|
|
4
|
+
//
|
|
5
|
+
// A SHARED component (the third in `src/Component/`), and it earns that place by
|
|
6
|
+
// carrying no entity vocabulary at all: it takes `{ label, numbers }` and draws
|
|
7
|
+
// bars. Nothing here knows a ticket from a run from a project — which is exactly
|
|
8
|
+
// what G4 asserts, and why the Dashboard can render four different blocks from
|
|
9
|
+
// one function.
|
|
10
|
+
//
|
|
11
|
+
// It produces the ROWS OF ONE BLOCK. Stacking blocks under bold headings is
|
|
12
|
+
// `flattenSections`' job (`Component/section-list`), unchanged: this component
|
|
13
|
+
// only fills a block's body.
|
|
14
|
+
/** A filled bar cell. U+2588 FULL BLOCK. */
|
|
15
|
+
export const BAR_FULL = '█';
|
|
16
|
+
/** An empty bar cell. U+2591 LIGHT SHADE. */
|
|
17
|
+
export const BAR_EMPTY = '░';
|
|
18
|
+
/**
|
|
19
|
+
* The bar's width in display columns.
|
|
20
|
+
*
|
|
21
|
+
* `█`/`░` are narrow per `lib/width-table.ts` — neither is in `WIDE_RANGES` — so
|
|
22
|
+
* the row geometry GAIA-241's hit testing depends on is unaffected. They are NOT
|
|
23
|
+
* ASCII, though, and both are East-Asian-AMBIGUOUS: a terminal configured
|
|
24
|
+
* ambiguous-as-wide (common in CJK setups) renders them two columns and shifts
|
|
25
|
+
* every number column at runtime while every test here stays green, because the
|
|
26
|
+
* width table deliberately treats Ambiguous as narrow (the frame's box-drawing
|
|
27
|
+
* characters depend on that). Accepted rather than mitigated — spec R9. If it
|
|
28
|
+
* ever bites, the fix is an ASCII bar glyph, never a width-table change.
|
|
29
|
+
*/
|
|
30
|
+
export const BAR_WIDTH = 20;
|
|
31
|
+
/**
|
|
32
|
+
* The label column, in display columns: 26 for the text plus {@link LABEL_GAP}.
|
|
33
|
+
*
|
|
34
|
+
* The text budget is wide enough for the longest label the Dashboard actually
|
|
35
|
+
* builds: a breakdown row suffixes its entity type (`gaia · tickets`), the
|
|
36
|
+
* unattributed bucket's `— unattributed · tickets` is 24 columns and a real
|
|
37
|
+
* project name reaches 26 (`gaia-e2e-project · tickets`). At 18 that suffix was
|
|
38
|
+
* cut to `— unattributed · t…`, which silently made the two entity types
|
|
39
|
+
* indistinguishable in exactly the row where attribution is already unclear.
|
|
40
|
+
*/
|
|
41
|
+
const LABEL_WIDTH = 27;
|
|
42
|
+
/**
|
|
43
|
+
* Columns of the label field reserved as the gap before the bar.
|
|
44
|
+
*
|
|
45
|
+
* Load-bearing, not decoration. `pad` fills a label to exactly `LABEL_WIDTH`, so
|
|
46
|
+
* a label of precisely that width left NO separator and the row rendered
|
|
47
|
+
* `gaia-e2e-project · tickets████████` — a real project name, at exactly 26
|
|
48
|
+
* columns. A truncated label collided the same way, ending in `…` against the
|
|
49
|
+
* bar. Every fixture in the tests was shorter, which is why the suite was green
|
|
50
|
+
* while the screen was wrong; the gap is part of the geometry now so the label
|
|
51
|
+
* length cannot decide whether it exists.
|
|
52
|
+
*
|
|
53
|
+
* It is ADDED to the field rather than taken out of it: the 26 columns above are
|
|
54
|
+
* a content budget with a stated reason, and spending one of them on the gap
|
|
55
|
+
* would cut `gaia-e2e-project · tickets` to `gaia-e2e-project · ticke…` — losing
|
|
56
|
+
* exactly the entity-type suffix that tells the two rows of a project apart.
|
|
57
|
+
*/
|
|
58
|
+
const LABEL_GAP = 1;
|
|
59
|
+
/** One number column, in display columns. */
|
|
60
|
+
const NUMBER_WIDTH = 8;
|
|
61
|
+
/** The indent every row of a block carries, matching the other body renderers. */
|
|
62
|
+
const INDENT = ' ';
|
|
63
|
+
/** `value` right-aligned to `width` DISPLAY columns — the number columns' rule. */
|
|
64
|
+
function padNumber(value, width) {
|
|
65
|
+
const gap = Math.max(0, width - displayWidth(value));
|
|
66
|
+
return ' '.repeat(gap) + value;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The bar for `value` within a group whose largest bar value is `max`.
|
|
70
|
+
*
|
|
71
|
+
* Scaled RELATIVE TO THE LARGEST VALUE IN ITS OWN GROUP, not to a limit. This is
|
|
72
|
+
* the one place the `/usage` analogy does not carry: `/usage` measures against a
|
|
73
|
+
* quota, and there is no quota here — so a full bar means "the biggest of these",
|
|
74
|
+
* never "100 %". An all-zero group has no maximum to scale by and renders empty
|
|
75
|
+
* rather than dividing by zero.
|
|
76
|
+
*/
|
|
77
|
+
function bar(value, max) {
|
|
78
|
+
const filled = max > 0
|
|
79
|
+
? Math.max(0, Math.min(BAR_WIDTH, Math.round((value / max) * BAR_WIDTH)))
|
|
80
|
+
: 0;
|
|
81
|
+
return BAR_FULL.repeat(filled) + BAR_EMPTY.repeat(BAR_WIDTH - filled);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* One block's body rows: an optional column header, then one row per entry.
|
|
85
|
+
*
|
|
86
|
+
* Every column is measured with `displayWidth` (through `pad`/`padNumber`), so a
|
|
87
|
+
* wide-character label cannot shift the numbers out of alignment — the failure
|
|
88
|
+
* that makes a table of counts unreadable exactly when the data is interesting.
|
|
89
|
+
*/
|
|
90
|
+
export function statsPanelRows(rows, { columns, emptyMessage } = {}) {
|
|
91
|
+
if (rows.length === 0) {
|
|
92
|
+
return emptyMessage === undefined ? [] : [`${INDENT}${emptyMessage}`];
|
|
93
|
+
}
|
|
94
|
+
const barValue = (row) => row.numbers.at(-1) ?? 0;
|
|
95
|
+
const max = Math.max(...rows.map(barValue));
|
|
96
|
+
const out = [];
|
|
97
|
+
if (columns && columns.length > 0) {
|
|
98
|
+
out.push(INDENT +
|
|
99
|
+
pad('', LABEL_WIDTH) +
|
|
100
|
+
' '.repeat(BAR_WIDTH) +
|
|
101
|
+
columns.map((c) => padNumber(c, NUMBER_WIDTH)).join(''));
|
|
102
|
+
}
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
out.push(INDENT +
|
|
105
|
+
// Padded to the field width MINUS the gap, then the gap: the label
|
|
106
|
+
// column stays `LABEL_WIDTH` wide either way, so the header row above
|
|
107
|
+
// and every bar below still line up.
|
|
108
|
+
pad(row.label, LABEL_WIDTH - LABEL_GAP) +
|
|
109
|
+
' '.repeat(LABEL_GAP) +
|
|
110
|
+
bar(barValue(row), max) +
|
|
111
|
+
row.numbers.map((n) => padNumber(String(n), NUMBER_WIDTH)).join(''));
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
@@ -7,3 +7,14 @@ export interface DetailTab {
|
|
|
7
7
|
export declare const DETAIL_TABS: DetailTab[];
|
|
8
8
|
/** The tab bar as a single row; the active tab is bracketed. */
|
|
9
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[];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { displayWidth } from '../../lib/width.js';
|
|
1
2
|
// The ticket-detail screen is split into tabs so only one tab's content draws at
|
|
2
3
|
// a time (keeps the fixed-height window small) — Sub-tickets + Referenced share a
|
|
3
4
|
// Related tab, Comments is its own tab with collapsible entries (owned by the
|
|
@@ -16,7 +17,26 @@ export const DETAIL_TABS = [
|
|
|
16
17
|
},
|
|
17
18
|
{ id: 'runs', label: 'Runs', headings: ['Runs'] },
|
|
18
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} `;
|
|
19
24
|
/** The tab bar as a single row; the active tab is bracketed. */
|
|
20
25
|
export function tabBar(activeIndex) {
|
|
21
|
-
return DETAIL_TABS.map((t, i) => i
|
|
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;
|
|
22
42
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { TicketDetailDocument } from '../../types.js';
|
|
2
2
|
import type { CommentItem } from '../comment-item/index.js';
|
|
3
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>;
|
|
4
12
|
/**
|
|
5
13
|
* The ticket detail sections, each with an explicit empty state:
|
|
6
14
|
* Description, Comments, Sub-tickets, Runs, Referenced tickets, Links,
|
|
@@ -19,8 +19,71 @@ function refCount(document, name) {
|
|
|
19
19
|
const data = document.data?.relationships?.[name]?.data;
|
|
20
20
|
return Array.isArray(data) ? data.length : data ? 1 : 0;
|
|
21
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
|
+
*/
|
|
22
71
|
function commentResources(document) {
|
|
23
|
-
return (document.included ?? [])
|
|
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();
|
|
24
87
|
}
|
|
25
88
|
/**
|
|
26
89
|
* The ticket detail sections, each with an explicit empty state:
|
|
@@ -108,9 +171,10 @@ export function commentItems(document) {
|
|
|
108
171
|
return { type, author, created, bodyLines, preview: bodyLines[0] ?? '' };
|
|
109
172
|
});
|
|
110
173
|
}
|
|
111
|
-
// The whole ticket-detail document flattened (all sections)
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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.
|
|
114
178
|
export function detailLines(detail) {
|
|
115
179
|
return flattenSections(detailSections(detail ?? {}));
|
|
116
180
|
}
|