@rse/ase 1.0.7 → 2.0.1
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/dst/ase-agent-definition-api.d.ts +184 -0
- package/dst/ase-agent-definition-api.js +6 -0
- package/dst/ase-agent-definition-builtin/condenser.js +42 -0
- package/dst/ase-agent-definition-builtin/editor.js +45 -0
- package/dst/ase-agent-definition-builtin/estimator.js +98 -0
- package/dst/ase-agent-definition-builtin/implementer.js +121 -0
- package/dst/ase-agent-definition-builtin/integrator.js +56 -0
- package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
- package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
- package/dst/ase-agent-definition-builtin/researcher.js +77 -0
- package/dst/ase-agent-definition-core.js +827 -0
- package/dst/ase-agent-fleet.js +772 -0
- package/dst/ase-agent-process.js +232 -0
- package/dst/ase-agent-relay-client.js +509 -0
- package/dst/ase-agent-relay-core.js +213 -0
- package/dst/ase-agent-relay-server-bind.js +242 -0
- package/dst/ase-agent-relay-server-cli.js +50 -0
- package/dst/ase-agent-runtime-claim.js +189 -0
- package/dst/ase-agent-runtime-engine.js +1100 -0
- package/dst/ase-agent-runtime-exec.js +275 -0
- package/dst/ase-agent-runtime-phase.js +581 -0
- package/dst/ase-agent-runtime-request.js +272 -0
- package/dst/ase-agent-runtime-tool.js +207 -0
- package/dst/ase-agent-runtime.js +1316 -0
- package/dst/ase-agent.js +65 -0
- package/dst/ase-artifact.js +6 -5
- package/dst/ase-config-core.js +465 -0
- package/dst/ase-config-schema.js +357 -0
- package/dst/ase-config-scope.js +172 -0
- package/dst/ase-config.js +25 -585
- package/dst/ase-hook.js +228 -111
- package/dst/ase-lib-daemon-cli.js +130 -0
- package/dst/ase-lib-hash.js +20 -0
- package/dst/{ase-log.js → ase-lib-log.js} +40 -7
- package/dst/ase-lib-server-bind.js +115 -0
- package/dst/ase-lib-server-cli.js +474 -0
- package/dst/{ase-table.js → ase-lib-table.js} +1 -1
- package/dst/{ase-version.js → ase-lib-version.js} +8 -1
- package/dst/ase-man.js +96 -0
- package/dst/ase-mcp.js +166 -13
- package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
- package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
- package/dst/ase-service-otel.js +1246 -0
- package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
- package/dst/ase-service-state.js +101 -0
- package/dst/ase-service.js +269 -81
- package/dst/ase-setup-common.js +141 -0
- package/dst/ase-setup-mcp.js +359 -0
- package/dst/ase-setup-settings.js +837 -0
- package/dst/ase-setup.js +113 -869
- package/dst/ase-spec.js +31 -19
- package/dst/ase-statusline.js +108 -43
- package/dst/ase-task-board-core.js +739 -0
- package/dst/ase-task-board-filter.js +127 -0
- package/dst/ase-task-board-flow.js +237 -0
- package/dst/ase-task-board-graph.js +497 -0
- package/dst/ase-task-board-stats.js +263 -0
- package/dst/ase-task-board-tui-control.js +775 -0
- package/dst/ase-task-board-tui-model.js +1008 -0
- package/dst/ase-task-board-tui-popup.js +626 -0
- package/dst/ase-task-board-tui-style.js +106 -0
- package/dst/ase-task-board-tui-view.js +847 -0
- package/dst/ase-task-board-tui.js +189 -0
- package/dst/ase-task-board-web-client.css +1 -0
- package/dst/ase-task-board-web-client.html +20 -0
- package/dst/ase-task-board-web-client.js +81 -0
- package/dst/ase-task-board-web-server.js +823 -0
- package/dst/ase-task-board.js +134 -0
- package/dst/ase-task-format.js +757 -0
- package/dst/ase-task-request.js +208 -0
- package/dst/ase-task-store-client.js +495 -0
- package/dst/ase-task-store-core.js +804 -0
- package/dst/ase-task-store-issues.js +237 -0
- package/dst/ase-task-store-plugin-api.js +6 -0
- package/dst/ase-task-store-plugin-ase.js +484 -0
- package/dst/ase-task-store-plugin-delegate.js +137 -0
- package/dst/ase-task-store-plugin-gitea.js +666 -0
- package/dst/ase-task-store-plugin-github.js +696 -0
- package/dst/ase-task-store-plugin-gitlab.js +668 -0
- package/dst/ase-task-store-server-bind.js +538 -0
- package/dst/ase-task-store-server-cli.js +154 -0
- package/dst/ase-task.js +1335 -357
- package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
- package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
- package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
- package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
- package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
- package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
- package/dst/ase-util.js +20 -0
- package/dst/ase.js +108 -32
- package/dst/man/ase-agent-adhoc.1 +515 -0
- package/dst/man/ase-agent-condenser.1 +193 -0
- package/dst/man/ase-agent-editor.1 +212 -0
- package/dst/man/ase-agent-estimator.1 +214 -0
- package/dst/man/ase-agent-fleet.1 +185 -0
- package/dst/man/ase-agent-implementer.1 +233 -0
- package/dst/man/ase-agent-integrator.1 +213 -0
- package/dst/man/ase-agent-qualifier.1 +214 -0
- package/dst/man/ase-agent-rebooter.1 +193 -0
- package/dst/man/ase-agent-relay.1 +128 -0
- package/dst/man/ase-agent-researcher.1 +217 -0
- package/dst/man/ase-agent.1 +264 -0
- package/dst/man/ase-artifact-list.1 +38 -0
- package/dst/man/ase-artifact-name.1 +39 -0
- package/dst/man/ase-artifact.1 +56 -0
- package/dst/man/ase-config-delete.1 +45 -0
- package/dst/man/ase-config-edit.1 +48 -0
- package/dst/man/ase-config-get.1 +46 -0
- package/dst/man/ase-config-init.1 +60 -0
- package/dst/man/ase-config-list.1 +42 -0
- package/dst/man/ase-config-set.1 +52 -0
- package/dst/man/ase-config.1 +231 -0
- package/dst/man/ase-hook-permission-request.1 +36 -0
- package/dst/man/ase-hook-pre-tool-use.1 +59 -0
- package/dst/man/ase-hook-session-end.1 +47 -0
- package/dst/man/ase-hook-session-persona.1 +37 -0
- package/dst/man/ase-hook-session-start.1 +81 -0
- package/dst/man/ase-hook-stop.1 +34 -0
- package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
- package/dst/man/ase-hook.1 +51 -0
- package/dst/man/ase-man.1 +67 -0
- package/dst/man/ase-mcp.1 +61 -0
- package/dst/man/ase-service-log.1 +43 -0
- package/dst/man/ase-service-send.1 +47 -0
- package/dst/man/ase-service-start.1 +54 -0
- package/dst/man/ase-service-status.1 +46 -0
- package/dst/man/ase-service-stop.1 +37 -0
- package/dst/man/ase-service.1 +85 -0
- package/dst/man/ase-setup-disable.1 +44 -0
- package/dst/man/ase-setup-enable.1 +45 -0
- package/dst/man/ase-setup-install.1 +73 -0
- package/dst/man/ase-setup-mcp.1 +124 -0
- package/dst/man/ase-setup-status.1 +60 -0
- package/dst/man/ase-setup-statusline.1 +92 -0
- package/dst/man/ase-setup-telemetry.1 +92 -0
- package/dst/man/ase-setup-uninstall.1 +59 -0
- package/dst/man/ase-setup-update.1 +64 -0
- package/dst/man/ase-setup.1 +93 -0
- package/dst/man/ase-spec-export.1 +62 -0
- package/dst/man/ase-spec-lint.1 +40 -0
- package/dst/man/ase-spec-preview.1 +40 -0
- package/dst/man/ase-spec.1 +46 -0
- package/dst/man/ase-statusline.1 +189 -0
- package/dst/man/ase-task-assign.1 +71 -0
- package/dst/man/ase-task-board.1 +384 -0
- package/dst/man/ase-task-delete.1 +35 -0
- package/dst/man/ase-task-edit.1 +46 -0
- package/dst/man/ase-task-idscheme.1 +65 -0
- package/dst/man/ase-task-lifecycle.1 +57 -0
- package/dst/man/ase-task-list.1 +47 -0
- package/dst/man/ase-task-load.1 +36 -0
- package/dst/man/ase-task-newid.1 +61 -0
- package/dst/man/ase-task-purge.1 +37 -0
- package/dst/man/ase-task-rename.1 +44 -0
- package/dst/man/ase-task-request.1 +61 -0
- package/dst/man/ase-task-save.1 +48 -0
- package/dst/man/ase-task-status.1 +55 -0
- package/dst/man/ase-task-store.1 +194 -0
- package/dst/man/ase-task-view.1 +40 -0
- package/dst/man/ase-task.1 +160 -0
- package/dst/man/ase-util-compat.1 +25 -0
- package/dst/man/ase-util-diagram.1 +76 -0
- package/dst/man/ase-util-meta.1 +45 -0
- package/dst/man/ase-util-metric.1 +54 -0
- package/dst/man/ase-util-mint.1 +59 -0
- package/dst/man/ase-util-worktree.1 +72 -0
- package/dst/man/ase-util.1 +50 -0
- package/dst/man/ase.1 +124 -0
- package/package.json +81 -21
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-meta-diagram.md +12 -8
- package/plugin/agents/ase-repo-review.md +322 -0
- package/plugin/hooks/hooks-codex.json +5 -0
- package/plugin/hooks/hooks-copilot.json +5 -0
- package/plugin/hooks/hooks.json +8 -0
- package/plugin/meta/ase-common-approach.md +117 -0
- package/plugin/meta/ase-common-changeset.md +256 -0
- package/plugin/meta/ase-common-code.md +10 -173
- package/plugin/meta/ase-common-grill.md +317 -0
- package/plugin/meta/ase-common-resolve.md +305 -0
- package/plugin/meta/ase-common-task.md +83 -4
- package/plugin/meta/ase-constitution.md +0 -2
- package/plugin/meta/ase-format-specbook.md +208 -25
- package/plugin/meta/ase-format-specbook.yaml +242 -851
- package/plugin/meta/ase-format-task.md +669 -125
- package/plugin/meta/ase-persona.md +3 -4
- package/plugin/meta/ase-skill.md +9 -0
- package/plugin/mods/ase-mods.ts +409 -0
- package/plugin/mods/claude-code.d.ts +13417 -0
- package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
- package/plugin/skills/ase-arch-analyze/help.md +2 -2
- package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
- package/plugin/skills/ase-code-analyze/help.md +29 -4
- package/plugin/skills/ase-code-edit/SKILL.md +107 -181
- package/plugin/skills/ase-code-edit/help.md +118 -31
- package/plugin/skills/ase-code-lint/SKILL.md +64 -13
- package/plugin/skills/ase-code-lint/help.md +32 -2
- package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
- package/plugin/skills/ase-docs-proofread/help.md +1 -1
- package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
- package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
- package/plugin/skills/ase-help-intent/data.md +2164 -1087
- package/plugin/skills/ase-help-intent/help.md +1 -1
- package/plugin/skills/ase-help-skill/catalog.md +12 -9
- package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
- package/plugin/skills/ase-meta-config/SKILL.md +3 -4
- package/plugin/skills/ase-meta-config/help.md +31 -10
- package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
- package/plugin/skills/ase-meta-mint/help.md +10 -5
- package/plugin/skills/ase-meta-why/help.md +1 -1
- package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
- package/plugin/skills/ase-meta-workflow/help.md +4 -4
- package/plugin/skills/ase-meta-workflow/sample.md +3 -3
- package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
- package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
- package/plugin/skills/ase-repo-merge/help.md +107 -0
- package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
- package/plugin/skills/ase-repo-resolve/help.md +102 -0
- package/plugin/skills/ase-repo-review/SKILL.md +406 -0
- package/plugin/skills/ase-repo-review/help.md +181 -0
- package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
- package/plugin/skills/ase-spec-edit/help.md +107 -26
- package/plugin/skills/ase-sync-reconcile/help.md +1 -1
- package/plugin/skills/ase-task-assign/SKILL.md +111 -0
- package/plugin/skills/ase-task-assign/help.md +119 -0
- package/plugin/skills/ase-task-condense/SKILL.md +21 -21
- package/plugin/skills/ase-task-condense/help.md +12 -10
- package/plugin/skills/ase-task-delete/SKILL.md +1 -1
- package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
- package/plugin/skills/ase-task-dissect/help.md +10 -9
- package/plugin/skills/ase-task-edit/SKILL.md +379 -94
- package/plugin/skills/ase-task-edit/help.md +95 -22
- package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
- package/plugin/skills/ase-task-estimate/help.md +158 -0
- package/plugin/skills/ase-task-grill/SKILL.md +246 -138
- package/plugin/skills/ase-task-grill/help.md +129 -26
- package/plugin/skills/ase-task-id/SKILL.md +8 -1
- package/plugin/skills/ase-task-id/help.md +6 -3
- package/plugin/skills/ase-task-implement/SKILL.md +535 -145
- package/plugin/skills/ase-task-implement/help.md +138 -47
- package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
- package/plugin/skills/ase-task-integrate/help.md +187 -0
- package/plugin/skills/ase-task-list/SKILL.md +55 -23
- package/plugin/skills/ase-task-list/help.md +45 -21
- package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
- package/plugin/skills/ase-task-reboot/help.md +28 -8
- package/plugin/skills/ase-task-rename/SKILL.md +10 -2
- package/plugin/skills/ase-task-research/SKILL.md +386 -0
- package/plugin/skills/ase-task-research/help.md +102 -0
- package/plugin/skills/ase-task-status/SKILL.md +137 -0
- package/plugin/skills/ase-task-status/help.md +80 -0
- package/plugin/skills/ase-task-view/SKILL.md +49 -24
- package/plugin/skills/ase-task-view/help.md +19 -9
- package/plugin/agents/ase-meta-review.md +0 -187
- package/plugin/skills/ase-code-craft/SKILL.md +0 -264
- package/plugin/skills/ase-code-craft/help.md +0 -122
- package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
- package/plugin/skills/ase-code-refactor/help.md +0 -109
- package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
- package/plugin/skills/ase-code-resolve/help.md +0 -116
- package/plugin/skills/ase-meta-review/SKILL.md +0 -210
- package/plugin/skills/ase-meta-review/help.md +0 -96
- package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
- package/plugin/skills/ase-task-preflight/help.md +0 -82
- /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
- /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
- /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
- /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
- /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Box, Text } from "ink";
|
|
8
|
+
import { splitHeight, rowLanes, toneOf, cardLabel, groupLabel, editMarker, effortMarker, clampLines, glueId, glueTitle, glueGroup, agentLabel, agentUser, agentUptime, agentUsage, agentDuration, badgesOf, viewCycleHint } from "./ase-task-board-core.js";
|
|
9
|
+
import { drawGraphText } from "./ase-task-board-graph.js";
|
|
10
|
+
import { drawFlowText, isFlowCard, flowRelations } from "./ase-task-board-flow.js";
|
|
11
|
+
import { statsRanges, statsRangeLabels, statsRangeLabel } from "./ase-task-board-stats.js";
|
|
12
|
+
import { palette, dashed, dashedBold, cx, isClass, pulseFrames, lowerEighths } from "./ase-task-board-tui-style.js";
|
|
13
|
+
/* shorthand for creating React elements without JSX */
|
|
14
|
+
export const h = React.createElement;
|
|
15
|
+
/* layout constants: minimum group width, collapsed group width, gap */
|
|
16
|
+
const GROUP_MIN = 26;
|
|
17
|
+
const GROUP_COLL = 5;
|
|
18
|
+
const GROUP_GAP = 1;
|
|
19
|
+
/* layout constants of a grown lane: minimum card width, gap between cards */
|
|
20
|
+
const GRID_MIN = 30;
|
|
21
|
+
const GRID_GAP = 1;
|
|
22
|
+
/* make a text of the agent relay safe for the terminal (strip control characters) */
|
|
23
|
+
const clean = (text) => text.replace(/\p{Cc}/gu, "");
|
|
24
|
+
/* compute the visible group range starting at a first group, with the
|
|
25
|
+
width of each visible group (collapsed groups stay narrow, expanded
|
|
26
|
+
groups share the remaining width but never drop below the minimum) */
|
|
27
|
+
export const fitGroups = (groups, collapsed, first, width) => {
|
|
28
|
+
const widths = [];
|
|
29
|
+
let used = 0;
|
|
30
|
+
for (let g = first; g < groups.length; g++) {
|
|
31
|
+
const w = collapsed.includes(groups[g].title) ? GROUP_COLL : GROUP_MIN;
|
|
32
|
+
const gap = widths.length > 0 ? GROUP_GAP : 0;
|
|
33
|
+
if (widths.length > 0 && used + gap + w > width)
|
|
34
|
+
break;
|
|
35
|
+
widths.push(w);
|
|
36
|
+
used += gap + w;
|
|
37
|
+
}
|
|
38
|
+
const expanded = widths.map((w, i) => w === GROUP_COLL && collapsed.includes(groups[first + i].title) ? -1 : i)
|
|
39
|
+
.filter((i) => i >= 0);
|
|
40
|
+
if (expanded.length > 0) {
|
|
41
|
+
const extra = Math.max(0, width - used);
|
|
42
|
+
const each = Math.floor(extra / expanded.length);
|
|
43
|
+
expanded.forEach((i) => { widths[i] += each; });
|
|
44
|
+
widths[expanded[0]] += extra - each * expanded.length;
|
|
45
|
+
}
|
|
46
|
+
return { first, last: first + widths.length - 1, widths };
|
|
47
|
+
};
|
|
48
|
+
/* the scroll arrows of a lane box, attached outside to the vertical middle
|
|
49
|
+
of its side borders (in the side padding of the board), placed either
|
|
50
|
+
within the bordered box itself (whose offsets start inside the border)
|
|
51
|
+
or within an unbordered wrapper around it */
|
|
52
|
+
const scrollArrows = ({ scroll, dim }, g, height, wrapper = false) => {
|
|
53
|
+
const off = wrapper ? -1 : -2;
|
|
54
|
+
const top = Math.max(0, Math.floor((height - 2) / 2)) + (wrapper ? 1 : 0);
|
|
55
|
+
return [
|
|
56
|
+
...(scroll.left === g ? [h(Box, { key: "scroll-left", position: "absolute", left: off, top }, h(Text, cx("dim", "bold", dim && "dimmed"), "◁"))] : []),
|
|
57
|
+
...(scroll.right === g ? [h(Box, { key: "scroll-right", position: "absolute", right: off, top }, h(Text, cx("dim", "bold", dim && "dimmed"), "▷"))] : [])
|
|
58
|
+
];
|
|
59
|
+
};
|
|
60
|
+
/* the label lines of a card of a width: its group (if any, and not for an epic,
|
|
61
|
+
between "♛" and "▶"), the task id (behind "≡", and of an epic also behind "♛"), its markers
|
|
62
|
+
(of the edit requests and of the effort size), and title,
|
|
63
|
+
cut onto a single line, or if titles are shown, wrapped onto at most three lines
|
|
64
|
+
(with an ellipsis if cut), padded by one column on each side, with the group
|
|
65
|
+
glued to the task id by five placeholder columns (the "♛", the "▶", their
|
|
66
|
+
spacing, and the gap), a placeholder column glued
|
|
67
|
+
in front of the task id to reserve the extra column of its inverse
|
|
68
|
+
rendering (plus two for the "≡", and two more for the "♛" of an epic), the arrow in front of the
|
|
69
|
+
title dropped, and a placeholder column glued in front of the first marker
|
|
70
|
+
(resp. the title) for its spacing to the inverse task id, where, if titles are
|
|
71
|
+
shown and the group and the task id together do not fit onto the first line,
|
|
72
|
+
the task id starts on the second line */
|
|
73
|
+
const cardLines = ({ titles }, c, width) => {
|
|
74
|
+
const textW = Math.max(3, width - 2);
|
|
75
|
+
const edit = editMarker(c);
|
|
76
|
+
const size = effortMarker(c);
|
|
77
|
+
const marks = (edit !== "" ? ` ${edit}` : "") + (size !== "" ? ` ${size}` : "");
|
|
78
|
+
const group = groupLabel(c);
|
|
79
|
+
const epic = group === c.id;
|
|
80
|
+
const glued = group !== "" && !epic ? glueGroup.repeat(3) + group.replace(/ /g, glueGroup) + glueGroup.repeat(2) : "";
|
|
81
|
+
const head = glueId + glueGroup.repeat(epic ? 4 : 2) + c.id;
|
|
82
|
+
const text = glued + (titles && glued !== "" && glued.length + head.length > textW - 2 ? " " : "") + head +
|
|
83
|
+
(marks + cardLabel(c).slice(c.id.length).replace(/^ ▶ /, " ")).replace(/^ /, ` ${glueTitle}`);
|
|
84
|
+
return clampLines(text, textW - 2, titles ? 3 : 1).map((line) => ` ${line}`);
|
|
85
|
+
};
|
|
86
|
+
/* render one card of a width and height (in rows) of a lane, either regularly,
|
|
87
|
+
as the carried task on top of the selected target lane (moving), or as the
|
|
88
|
+
dim ghost of the carried task at its original position (phantom) */
|
|
89
|
+
const renderCard = (ctx, lane, c, lines, width, rows, cut, moving, phantom) => {
|
|
90
|
+
const { board, sel, dim, carry } = ctx;
|
|
91
|
+
const held = carry?.id === c.id && !phantom;
|
|
92
|
+
const on = (c.id === sel.id || moving) && !dim && !phantom;
|
|
93
|
+
const tone = toneOf(board, c);
|
|
94
|
+
const tint = phantom ? palette.dim : on ? palette.signal : tone === "active" ? palette.accent : tone === "done" ? palette.dim : palette.normal;
|
|
95
|
+
/* the group (if any, and not for an epic) at the start of the first line
|
|
96
|
+
(behind the margin) between "♛" and "▶" in bold box color, followed by the task id
|
|
97
|
+
(behind the placeholder), which is always bold and inverse, with one column
|
|
98
|
+
of spacing on each side (behind "≡", and for an epic also behind "♛"), and then the
|
|
99
|
+
rest of the label, where a partially visible card loses its cut border and text lines,
|
|
100
|
+
and where the task id is either on the first line behind the group or on the second line */
|
|
101
|
+
const group = groupLabel(c);
|
|
102
|
+
const epic = group === c.id;
|
|
103
|
+
const lead = group !== "" && !epic ? group.length + 5 : 0;
|
|
104
|
+
const at = Math.max(0, lines.findIndex((line) => line.includes(glueId)));
|
|
105
|
+
const from = (at > 0 ? lines[at].indexOf(glueId) : lead + 1) + (epic ? 5 : 3);
|
|
106
|
+
const to = from + c.id.length;
|
|
107
|
+
const badge = lead > 0 ? [h(Text, { key: "group", ...cx("bold") }, `♛ ${lines[0].slice(4, lead - 1).replace(new RegExp(glueGroup, "g"), " ")} ▶`), " "] : [];
|
|
108
|
+
const box = {
|
|
109
|
+
key: moving ? "moving" : c.id, ...(moving ? {} : { ref: ctx.cardRef(c.id) }),
|
|
110
|
+
borderStyle: phantom ? dashed : held ? "double" : "single", borderColor: tint, borderDimColor: dim,
|
|
111
|
+
borderTop: cut !== "top", borderBottom: cut !== "bottom", width, height: rows, flexDirection: "column"
|
|
112
|
+
};
|
|
113
|
+
/* the markers of the edit requests and of the effort size (in this order, each on
|
|
114
|
+
whatever line it was wrapped onto) in bold (except for their enclosing brackets),
|
|
115
|
+
and in the regular color of the card text */
|
|
116
|
+
const marks = [{ key: "edit", text: editMarker(c) }, { key: "effort", text: effortMarker(c) }]
|
|
117
|
+
.filter((mark) => mark.text !== "");
|
|
118
|
+
let marked = 0;
|
|
119
|
+
const marker = (text) => {
|
|
120
|
+
const out = [];
|
|
121
|
+
let rest = text;
|
|
122
|
+
while (marked < marks.length) {
|
|
123
|
+
const mark = marks[marked];
|
|
124
|
+
const at = rest.indexOf(mark.text);
|
|
125
|
+
if (at < 0)
|
|
126
|
+
break;
|
|
127
|
+
marked++;
|
|
128
|
+
const inner = mark.text.replace(/^\[(.*)\]$/, "$1");
|
|
129
|
+
const frame = inner !== mark.text;
|
|
130
|
+
out.push(rest.slice(0, at), h(Text, { key: mark.key }, frame ? "[" : "", h(Text, { bold: true }, inner), frame ? "]" : ""));
|
|
131
|
+
rest = rest.slice(at + mark.text.length);
|
|
132
|
+
}
|
|
133
|
+
return [...out, rest];
|
|
134
|
+
};
|
|
135
|
+
/* the part of a task id too long for its line, which is hard-wrapped onto the
|
|
136
|
+
following lines (behind the margin), is also rendered inverse, with the trailing
|
|
137
|
+
column of spacing behind its last part */
|
|
138
|
+
let spill = Math.max(0, to - lines[at].length);
|
|
139
|
+
const spills = lines.map((line, k) => {
|
|
140
|
+
const n = k <= at ? 0 : Math.min(spill, line.length - 1);
|
|
141
|
+
spill -= n;
|
|
142
|
+
return { n, end: n > 0 && spill === 0 };
|
|
143
|
+
});
|
|
144
|
+
const text = lines.map((line, k) => h(Text, { key: k, color: tint, dimColor: dim, wrap: "truncate" }, ...(k < at ? [line.slice(0, 1), ...badge] :
|
|
145
|
+
k === at ? [line.slice(0, 1), ...(k === 0 ? badge : []), h(Text, { key: "id", ...cx("badge") }, ` ${epic ? "♛ " : ""}≡ ${line.slice(from, to)} `),
|
|
146
|
+
...marker(line.slice(to + 1).replace(new RegExp(`^${glueTitle}`), " ").replace(glueTitle, ""))] :
|
|
147
|
+
spills[k].n > 0 ? [line.slice(0, 1), h(Text, { key: "id", ...cx("badge") }, line.slice(1, 1 + spills[k].n) + (spills[k].end ? " " : "")),
|
|
148
|
+
...marker(line.slice(2 + spills[k].n).replace(new RegExp(`^${glueTitle}`), " ").replace(glueTitle, ""))] :
|
|
149
|
+
marker(line.replace(glueTitle, "")))))
|
|
150
|
+
.slice(cut === "top" ? lines.length + 1 - rows : 0, cut === "bottom" ? rows - 1 : lines.length);
|
|
151
|
+
/* the badges of the task (the agents busy with it, else its assignee), each as an inverse
|
|
152
|
+
" <icon> <name> " box (separated by a border character) in signal color when selected,
|
|
153
|
+
in accent color when active, and else in normal color, on the left part of its top border (placed
|
|
154
|
+
on the border row, i.e. above the content of a bordered box, and behind one border character) */
|
|
155
|
+
const badges = !moving && !phantom && cut !== "top" ? badgesOf(c, ctx.agents, ctx.working.get(c.id)) : [];
|
|
156
|
+
const shade = on ? palette.signal : tone === "active" ? palette.accent : palette.normal;
|
|
157
|
+
const labels = (top) => badges.length > 0 && width > 10 ? [
|
|
158
|
+
h(Box, { key: "badges", position: "absolute", top, left: top < 0 ? 1 : 2, width: width - 7 }, h(Text, { color: tint, dimColor: dim, wrap: "truncate" }, ...badges.flatMap((badge, k) => [k > 0 ? (held ? "═" : "─") : "",
|
|
159
|
+
h(Text, { key: k, ...cx("badge"), color: shade, dimColor: dim }, ` ${badge.icon} ${badge.name} `)])))
|
|
160
|
+
] : [];
|
|
161
|
+
if (!lane.active || ctx.pulse < 0 || moving || phantom || cut === "top")
|
|
162
|
+
return h(Box, box, ...text, ...labels(-1));
|
|
163
|
+
/* the tasks of active lanes pulse in their top border, which hence
|
|
164
|
+
is drawn on its own above the box without its regular top border */
|
|
165
|
+
const [cl, cm, cr] = held ? ["╔", "═", "╗"] : ["┌", "─", "┐"];
|
|
166
|
+
const top = cl + cm.repeat(Math.max(0, width - 4)) + pulseFrames[ctx.pulse] + cm + cr;
|
|
167
|
+
return h(Box, { key: box.key, ref: ctx.cardRef(c.id), width, height: rows, flexDirection: "column" }, h(Text, { color: tint, dimColor: dim, wrap: "truncate" }, top), ...(rows > 1 ? [h(Box, { ...box, key: "box", ref: undefined, borderTop: false, height: rows - 1 }, ...text)] : []), ...labels(0));
|
|
168
|
+
};
|
|
169
|
+
/* render one lane of the lane view */
|
|
170
|
+
const renderLane = (ctx, lane, g, l, width, height) => {
|
|
171
|
+
const { board, surface, sel, dim, carry } = ctx;
|
|
172
|
+
const cards = board.lanes.get(lane.status) ?? [];
|
|
173
|
+
const color = lane.active ? palette.accent : lane.kind === "terminal" ? palette.dim : palette.normal;
|
|
174
|
+
const target = carry?.targets.has(lane.status) ?? false;
|
|
175
|
+
const min = surface.minimized.includes(lane.status);
|
|
176
|
+
/* a signal-colored border marks the selected lane, and a heavy border marks
|
|
177
|
+
the selected lane or, while moving a task, the target lanes instead */
|
|
178
|
+
const picked = sel.g === g && sel.l === l;
|
|
179
|
+
const heavy = carry !== null ? target : picked;
|
|
180
|
+
const style = lane.dashed ? (heavy ? dashedBold : dashed) : (heavy ? "bold" : "round");
|
|
181
|
+
const frame = { key: lane.status, borderStyle: style, borderColor: picked ? palette.signal : color, borderDimColor: dim };
|
|
182
|
+
const head = h(Box, { key: "head", justifyContent: "space-between", paddingX: 1 }, h(Text, { color, bold: true, dimColor: dim, wrap: "truncate" }, `${min ? "▶" : "▼"} ${lane.status}`), h(Text, { color, dimColor: dim }, String(cards.length)));
|
|
183
|
+
if (min)
|
|
184
|
+
return h(Box, { ...frame, ref: ctx.laneRef(g, l), width, height: 3, flexDirection: "column" }, head, ...scrollArrows(ctx, g, 3));
|
|
185
|
+
/* while moving a task onto a reachable target lane, the carried task is
|
|
186
|
+
shown on top of the selected target lane, while a dim ghost of it
|
|
187
|
+
stays at its original position until the drop happens */
|
|
188
|
+
const moved = carry !== null && carry.targets.has(board.groups[sel.g]?.lanes[sel.l]?.status ?? "");
|
|
189
|
+
const carried = moved && picked ? board.cards.get(carry.id) : undefined;
|
|
190
|
+
const list = carried !== undefined ? [carried, ...cards] : cards;
|
|
191
|
+
/* determine the visible cards by their actual heights, keeping the
|
|
192
|
+
selected card (or the carried task instead) visible and, if not all
|
|
193
|
+
cards fit, reserving one line for the indicator of the hidden cards */
|
|
194
|
+
const labels = list.map((c) => cardLines(ctx, c, width - 2));
|
|
195
|
+
const heights = labels.map((lines) => lines.length + 2);
|
|
196
|
+
const sum = (a, b) => heights.slice(a, b).reduce((n, x) => n + x, 0);
|
|
197
|
+
const room = height - 3;
|
|
198
|
+
const idx = carried !== undefined ? 0 : list.findIndex((c) => c.id === sel.id);
|
|
199
|
+
let start = 0;
|
|
200
|
+
let end = list.length;
|
|
201
|
+
if (sum(0, list.length) > room) {
|
|
202
|
+
end = 0;
|
|
203
|
+
while (end < list.length && sum(0, end + 1) <= room - 1)
|
|
204
|
+
end++;
|
|
205
|
+
if (idx >= end) {
|
|
206
|
+
start = idx;
|
|
207
|
+
end = idx + 1;
|
|
208
|
+
while (start > 0 && sum(start - 1, end) <= room - 1)
|
|
209
|
+
start--;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const rest = list.length - end;
|
|
213
|
+
/* the space left besides the fully visible cards is filled by a partially
|
|
214
|
+
visible card, cut at its top above them if scrolled, else cut at its bottom below them */
|
|
215
|
+
const part = start > 0 || rest > 0 ? room - 1 - sum(start, end) : 0;
|
|
216
|
+
const cuts = [
|
|
217
|
+
...(part > 0 && start > 0 ? [{ n: start - 1, cut: "top", rows: part }] : []),
|
|
218
|
+
...list.slice(start, end).map((_c, i) => ({ n: start + i, cut: "none", rows: heights[start + i] })),
|
|
219
|
+
...(part > 0 && start === 0 ? [{ n: end, cut: "bottom", rows: part }] : [])
|
|
220
|
+
];
|
|
221
|
+
const items = cuts.map(({ n, cut, rows }) => {
|
|
222
|
+
const moving = carried !== undefined && n === 0;
|
|
223
|
+
const phantom = moved && !moving && list[n].id === carry?.id;
|
|
224
|
+
return renderCard(ctx, lane, list[n], labels[n], width - 2, rows, cut, moving, phantom);
|
|
225
|
+
});
|
|
226
|
+
/* the centered indicator of the hidden cards, pushed to the bottom of the lane by a growing spacer */
|
|
227
|
+
if (start > 0 || rest > 0)
|
|
228
|
+
items.push(h(Box, { key: "spacer", flexGrow: 1 }), h(Box, { key: "more", justifyContent: "center" }, h(Text, cx("dim", dim && "dimmed"), [...(start > 0 ? [`△ ${start}`] : []), ...(rest > 0 ? [`▽ ${rest}`] : [])].join(" "))));
|
|
229
|
+
return h(Box, { ...frame, ref: ctx.laneRef(g, l), width, height, flexDirection: "column" }, head, ...items, ...scrollArrows(ctx, g, height));
|
|
230
|
+
};
|
|
231
|
+
/* the number of grid columns of the cards of a grown lane of a width:
|
|
232
|
+
at least four, but more if the cards then still keep their minimum width */
|
|
233
|
+
export const gridColumns = (width) => Math.max(4, Math.floor((width - 4 + GRID_GAP) / (GRID_MIN + GRID_GAP)));
|
|
234
|
+
/* render the grown lane of the lane view: all its cards in a grid of rows,
|
|
235
|
+
all cards of a row having the height of its highest card, where only
|
|
236
|
+
the rows fitting into the lane are shown, keeping the selected card visible */
|
|
237
|
+
const renderGrown = (ctx, lane, g, l, width, height) => {
|
|
238
|
+
const { board, sel, dim } = ctx;
|
|
239
|
+
const cards = board.lanes.get(lane.status) ?? [];
|
|
240
|
+
const color = lane.active ? palette.accent : lane.kind === "terminal" ? palette.dim : palette.normal;
|
|
241
|
+
const style = lane.dashed ? dashedBold : "bold";
|
|
242
|
+
const head = h(Box, { key: "head", justifyContent: "space-between", paddingX: 1 }, h(Text, { color, bold: true, dimColor: dim, wrap: "truncate" }, `◆ ${lane.status}`), h(Text, { color, dimColor: dim }, String(cards.length)));
|
|
243
|
+
/* the column widths, distributing the remaining width onto the first columns */
|
|
244
|
+
const cols = gridColumns(width);
|
|
245
|
+
const free = width - 4 - (cols - 1) * GRID_GAP;
|
|
246
|
+
const widths = Array.from({ length: cols }, (_x, i) => Math.floor(free / cols) + (i < free % cols ? 1 : 0));
|
|
247
|
+
/* determine the visible rows by their actual heights, keeping the row of
|
|
248
|
+
the selected card visible and, if not all rows fit, reserving one line
|
|
249
|
+
for the indicator of the hidden cards */
|
|
250
|
+
const labels = cards.map((c, i) => cardLines(ctx, c, widths[i % cols]));
|
|
251
|
+
const total = Math.ceil(cards.length / cols);
|
|
252
|
+
const heights = Array.from({ length: total }, (_x, r) => Math.max(...labels.slice(r * cols, (r + 1) * cols).map((lines) => lines.length + 2)));
|
|
253
|
+
const sum = (a, b) => heights.slice(a, b).reduce((n, x) => n + x, 0);
|
|
254
|
+
const room = height - 3;
|
|
255
|
+
const idx = Math.floor(Math.max(0, cards.findIndex((c) => c.id === sel.id)) / cols);
|
|
256
|
+
let start = 0;
|
|
257
|
+
let end = total;
|
|
258
|
+
if (sum(0, total) > room) {
|
|
259
|
+
end = 0;
|
|
260
|
+
while (end < total && sum(0, end + 1) <= room - 1)
|
|
261
|
+
end++;
|
|
262
|
+
if (idx >= end) {
|
|
263
|
+
start = idx;
|
|
264
|
+
end = idx + 1;
|
|
265
|
+
while (start > 0 && sum(start - 1, end) <= room - 1)
|
|
266
|
+
start--;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const above = start * cols;
|
|
270
|
+
const below = Math.max(0, cards.length - end * cols);
|
|
271
|
+
const items = heights.slice(start, end).map((rows, i) => {
|
|
272
|
+
const r = start + i;
|
|
273
|
+
return h(Box, { key: `row${r}`, height: rows, columnGap: GRID_GAP, paddingX: 1 }, ...cards.slice(r * cols, (r + 1) * cols).map((c, k) => renderCard(ctx, lane, c, labels[r * cols + k], widths[k], rows, "none", false, false)));
|
|
274
|
+
});
|
|
275
|
+
/* the centered indicator of the hidden cards, pushed to the bottom of the lane by a growing spacer */
|
|
276
|
+
if (above > 0 || below > 0)
|
|
277
|
+
items.push(h(Box, { key: "spacer", flexGrow: 1 }), h(Box, { key: "more", justifyContent: "center" }, h(Text, cx("dim", dim && "dimmed"), [...(above > 0 ? [`△ ${above}`] : []), ...(below > 0 ? [`▽ ${below}`] : [])].join(" "))));
|
|
278
|
+
return h(Box, {
|
|
279
|
+
key: lane.status, ref: ctx.laneRef(g, l), borderStyle: style, borderColor: palette.signal, borderDimColor: dim,
|
|
280
|
+
width, height, flexDirection: "column"
|
|
281
|
+
}, head, ...items);
|
|
282
|
+
};
|
|
283
|
+
/* render one group column of the lane view */
|
|
284
|
+
const renderGroup = (ctx, group, g, width, height, last) => {
|
|
285
|
+
const { board, surface, sel, dim } = ctx;
|
|
286
|
+
const gap = last ? 0 : GROUP_GAP;
|
|
287
|
+
if (surface.collapsed.includes(group.title)) {
|
|
288
|
+
/* render each lane of the collapsed phase as a narrow column with
|
|
289
|
+
its vertically written status and number of cards */
|
|
290
|
+
const heights = splitHeight(height - 1, group.lanes.map((l) => l.weight), 3);
|
|
291
|
+
const label = "▶";
|
|
292
|
+
const rest = Math.max(0, width - label.length);
|
|
293
|
+
const left = Math.floor(rest / 2);
|
|
294
|
+
return h(Box, { key: group.title, ref: ctx.groupRef(g), flexDirection: "column", width, marginRight: gap }, h(Text, cx("group-head", sel.g === g && "group-head-selected", dim && "dimmed"), " ".repeat(left) + label + " ".repeat(rest - left)), ...group.lanes.map((lane, l) => {
|
|
295
|
+
const count = board.lanes.get(lane.status)?.length ?? 0;
|
|
296
|
+
const picked = sel.g === g && sel.l === l;
|
|
297
|
+
const color = lane.active ? palette.accent : lane.kind === "terminal" ? palette.dim : palette.normal;
|
|
298
|
+
const digits = [...String(count)];
|
|
299
|
+
/* the title fits between the arrow and the count, each separated
|
|
300
|
+
by a blank line (inside the two border lines), and is cut with an ellipsis */
|
|
301
|
+
const room = Math.max(0, heights[l] - 2 - 1 - 1 - 1 - digits.length);
|
|
302
|
+
let title = [...lane.status.toUpperCase()];
|
|
303
|
+
if (title.length > room)
|
|
304
|
+
title = room > 0 ? [...title.slice(0, room - 1), "…"] : [];
|
|
305
|
+
/* the selection marks only the border (signal-colored and bold), not the text */
|
|
306
|
+
const frame = {
|
|
307
|
+
borderStyle: lane.dashed ? (picked ? dashedBold : dashed) : (picked ? "bold" : "round"),
|
|
308
|
+
borderColor: picked ? palette.signal : color, borderDimColor: dim,
|
|
309
|
+
height: heights[l], flexDirection: "column", alignItems: "center", overflow: "hidden"
|
|
310
|
+
};
|
|
311
|
+
/* the characters must not shrink, so a too long label is cut */
|
|
312
|
+
const char = (key, ch, bold) => h(Box, { key, flexShrink: 0 }, h(Text, { color, bold, dimColor: dim }, ch));
|
|
313
|
+
/* the unbordered wrapper allows the scroll arrows to escape the clipping,
|
|
314
|
+
the growing spacer pushes the count to the bottom */
|
|
315
|
+
return h(Box, { key: lane.status, ref: ctx.laneRef(g, l), height: heights[l], flexDirection: "column" }, h(Box, frame, char("arrow", "▶", false), h(Box, { height: 1, flexShrink: 0 }), ...title.map((ch, i) => char(`t${i}`, ch, ch !== "…")), h(Box, { flexGrow: 1, minHeight: 1 }), h(Box, { flexDirection: "column", alignItems: "center", flexShrink: 0 }, ...digits.map((ch, i) => char(`c${i}`, ch, false)))), ...scrollArrows(ctx, g, heights[l], true));
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
const min = group.lanes.map((l) => surface.minimized.includes(l.status));
|
|
319
|
+
const free = height - 1 - min.filter((m) => m).length * 3;
|
|
320
|
+
const heights = splitHeight(free, group.lanes.map((l, i) => min[i] ? 0 : l.weight), 3);
|
|
321
|
+
const label = ` ▼ ${group.title} `;
|
|
322
|
+
const rest = Math.max(0, width - label.length);
|
|
323
|
+
const left = Math.floor(rest / 2);
|
|
324
|
+
return h(Box, { key: group.title, ref: ctx.groupRef(g), flexDirection: "column", width, marginRight: gap }, h(Text, cx("group-head", sel.g === g && "group-head-selected", dim && "dimmed"), " ".repeat(left) + label + " ".repeat(rest - left)), ...group.lanes.map((lane, l) => renderLane(ctx, lane, g, l, width, heights[l])));
|
|
325
|
+
};
|
|
326
|
+
/* render the lane view */
|
|
327
|
+
export const renderLanes = (ctx) => {
|
|
328
|
+
const { board, surface, sel, dim, carry, fit, columns, boardH, mouse, pulse, grown, cardRef, laneRef, groupRef, agents, working } = ctx;
|
|
329
|
+
const total = board.groups.length;
|
|
330
|
+
const shown = fit.last - fit.first + 1;
|
|
331
|
+
const arrows = shown < total && !grown;
|
|
332
|
+
const group = board.groups[sel.g];
|
|
333
|
+
const info = grown ? `lane ${group.lanes[sel.l].status} of group ${group.title} grown · g shrinks` :
|
|
334
|
+
`groups ${fit.first + 1}–${fit.last + 1} of ${total} · ${arrows ? "←/→ scrolls" : "all visible"}`;
|
|
335
|
+
const view = { board, surface, sel, dim, carry, titles: surface.titles, scroll: { left: -1, right: -1 }, pulse, cardRef, laneRef, groupRef, agents, working };
|
|
336
|
+
/* the scroll bar consumes the entire width remaining besides the info */
|
|
337
|
+
const track = Math.max(1, columns - 2 - info.length - 3);
|
|
338
|
+
const on = Math.max(1, Math.round(track * shown / total));
|
|
339
|
+
const off = Math.round(track * fit.first / total);
|
|
340
|
+
const minned = surface.minimized.includes(board.groups[sel.g]?.lanes[sel.l]?.status ?? "");
|
|
341
|
+
const folded = surface.collapsed.includes(board.groups[sel.g]?.title ?? "");
|
|
342
|
+
const rowMin = rowLanes(board.groups, surface.collapsed, sel.l).every((s) => surface.minimized.includes(s));
|
|
343
|
+
const edges = { left: fit.first > 0 ? fit.first : -1, right: fit.last < total - 1 ? fit.last : -1 };
|
|
344
|
+
return [
|
|
345
|
+
h(Box, { key: "board", height: boardH, paddingX: 1 }, ...(grown ? [renderGrown(view, group.lanes[sel.l], sel.g, sel.l, columns - 2, boardH)] :
|
|
346
|
+
board.groups.slice(fit.first, fit.last + 1).map((g, i) => renderGroup({ ...view, scroll: edges }, g, fit.first + i, fit.widths[i], boardH, i === fit.widths.length - 1)))),
|
|
347
|
+
h(Box, { key: "bar", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), (arrows ? "░".repeat(off) + "█".repeat(on) + "░".repeat(Math.max(0, track - off - on)) + " · " : "") + info)),
|
|
348
|
+
h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), grown ?
|
|
349
|
+
"↑/↓/←/→: select task · ⇈/⇊/⇤/⇥: page/first/last task · ⏎: view task · e: edit task · SPACE: start transition task · T: transition task" :
|
|
350
|
+
"↑/↓/←/→: select task · ⇈/⇊/⇤/⇥: select lane · ⏎: view task · e: edit task · SPACE: start/stop transition task · T: transition task")),
|
|
351
|
+
h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), "D: delete task · A: assign task · E: edit by prompt · R: request by prompt · N: new task · " +
|
|
352
|
+
(grown ? "g/ESC: shrink lane" : `m: ${minned ? "maximize/expand" : "minimize/collapse"} lane · g: grow lane`))),
|
|
353
|
+
h(Box, { key: "keys3", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), (grown ? "" :
|
|
354
|
+
`c: ${folded ? "expand" : "collapse"} phase · C: ${rowMin ? "expand" : "collapse"} lane row · `) +
|
|
355
|
+
`t: ${surface.titles ? "collapse" : "expand"} titles · ` +
|
|
356
|
+
`/: filter tasks · v/V: ${viewCycleHint("lanes", agents !== null)}`)),
|
|
357
|
+
h(Box, { key: "keys4", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `Left-Click: select task/lane/phase · Double-Click: ${grown ? "view task / shrink lane" : "view task, collapse/expand lane/phase"} · ` +
|
|
358
|
+
`M: ${mouse ? "disable" : "enable"} mouse · ?: hide key hints · q: quit`))
|
|
359
|
+
];
|
|
360
|
+
};
|
|
361
|
+
/* render the graph view */
|
|
362
|
+
export const renderGraph = (ctx) => {
|
|
363
|
+
const { board, sel, dim, layout, boardH, x, y, viewH, viewW, graphView, graphTitles, surface, mouse, pulse, agents, working } = ctx;
|
|
364
|
+
const card = sel.id !== "" ? board.cards.get(sel.id) : undefined;
|
|
365
|
+
if (layout === null)
|
|
366
|
+
return [h(Box, { key: "graph", height: boardH, marginX: 1, paddingX: 1, ...cx("frame", "border-dim") }, h(Text, cx("dim"), board.cards.size === 0 ? "(no tasks)" : "laying out …")),
|
|
367
|
+
h(Text, { key: "info" }, " "), h(Text, { key: "keys1" }, " "), h(Text, { key: "keys2" }, " "), h(Text, { key: "keys3" }, " ")];
|
|
368
|
+
/* draw the ELK layout, with the active nodes pulsing and the badges of the
|
|
369
|
+
nodes (by their current cards, as the layout might lag behind the board) */
|
|
370
|
+
const badges = new Map([...layout.board.cards.values()].map((c) => [c.id, badgesOf(board.cards.get(c.id) ?? c, agents, working.get(c.id))]));
|
|
371
|
+
const { lines, tones } = drawGraphText(layout.board, layout.graph, sel.id, layout.titles, pulse >= 0 ? pulseFrames[pulse] : "", badges);
|
|
372
|
+
const visible = lines.slice(y, y + viewH).map((l, i) => {
|
|
373
|
+
const segs = [];
|
|
374
|
+
for (let k = x; k < Math.min(l.length, x + viewW); k++) {
|
|
375
|
+
const tone = tones[y + i][k];
|
|
376
|
+
if (segs.length > 0 && segs[segs.length - 1].tone === tone)
|
|
377
|
+
segs[segs.length - 1].text += l[k];
|
|
378
|
+
else
|
|
379
|
+
segs.push({ text: l[k], tone });
|
|
380
|
+
}
|
|
381
|
+
return segs;
|
|
382
|
+
});
|
|
383
|
+
const deps = [...board.pred.values()].reduce((n, ps) => n + ps.length, 0);
|
|
384
|
+
return [
|
|
385
|
+
h(Box, { key: "graph", height: boardH, marginX: 1 },
|
|
386
|
+
/* a graph smaller than the viewport is centered in it */
|
|
387
|
+
h(Box, { paddingX: 1, justifyContent: "center", alignItems: "center", ...cx("grow", "column", "frame", "border-dim", dim && "border-dimmed") }, h(Box, { ref: graphView, ...cx("column") }, ...visible.map((segs, i) => h(Box, { key: i }, ...(segs.length === 0 ? [h(Text, { key: 0 }, " ")] : segs.map((seg, k) => {
|
|
388
|
+
const tone = `tone-${seg.tone}`;
|
|
389
|
+
return h(Text, { key: k, ...cx(dim && "dimmed", isClass(tone) && tone) }, seg.text);
|
|
390
|
+
})))))),
|
|
391
|
+
/* overlay the graph statistics centered onto the top border */
|
|
392
|
+
h(Box, { position: "absolute", top: 0, left: 0, width: "100%", justifyContent: "center" }, h(Text, { ...cx("dim", dim && "dimmed"), wrap: "truncate" }, `[ ${board.cards.size} tasks, ${deps} dependencies ]`))),
|
|
393
|
+
h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), card === undefined ? " " :
|
|
394
|
+
["task: ", h(Text, { key: "id", ...cx("bold") }, card.id), " · status: ", h(Text, { key: "status", ...cx("bold") }, card.status)])),
|
|
395
|
+
h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), "↑/↓/←/→/Left-Click: select task · ⏎/Double-Click: view task · e: edit task · T: transition task")),
|
|
396
|
+
h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `D: delete task · A: assign task · E: edit by prompt · R: request by prompt · N: new task · t: ${graphTitles ? "collapse" : "expand"} titles · ` +
|
|
397
|
+
`s: ${surface.standalone ? "hide" : "show"} standalone tasks`)),
|
|
398
|
+
h(Box, { key: "keys3", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `/: filter tasks · v/V: ${viewCycleHint("graph", agents !== null)} · M: ${mouse ? "disable" : "enable"} mouse · ?: hide key hints · q: quit`))
|
|
399
|
+
];
|
|
400
|
+
};
|
|
401
|
+
/* render the agent graph view (like the graph view): the agent cards with the lifecycle states
|
|
402
|
+
they observe, operate in, and write, with the selected agent card (with its states), resp.
|
|
403
|
+
the selected state (with the agent cards observing, writing, and gating on it), in the
|
|
404
|
+
information line */
|
|
405
|
+
export const renderFlow = (ctx) => {
|
|
406
|
+
const { agents, flow, flowLayout, flowSel, dim, boardH, x, y, viewH, viewW, graphView, mouse, pulse, beatOf } = ctx;
|
|
407
|
+
const card = flow.cards.get(flowSel);
|
|
408
|
+
const rel = card === undefined && flowSel !== "" && !isFlowCard(flowSel) ? flowRelations(flow, flowSel) : null;
|
|
409
|
+
const names = (cards) => cards.map((c) => clean(agentLabel(c.agents[0])) + (c.agents.length > 1 ? ` ×${c.agents.length}` : "")).join(", ") || "none";
|
|
410
|
+
const empty = agents === null || !agents.connected ? "agent relay unreachable — reconnecting …" : "no agents running";
|
|
411
|
+
if (flowLayout === null || flow.cards.size === 0)
|
|
412
|
+
return [h(Box, { key: "flow", height: boardH, marginX: 1, paddingX: 1, ...cx("frame", "border-dim") }, h(Text, cx("dim"), flow.cards.size === 0 ? empty : "laying out …")),
|
|
413
|
+
h(Text, { key: "info" }, " "), h(Text, { key: "keys1" }, " "), h(Text, { key: "keys2" }, " ")];
|
|
414
|
+
/* draw the ELK layout, with the busy agent cards pulsing */
|
|
415
|
+
const { lines, tones } = drawFlowText(flow, flowLayout.graph, flowSel, pulse >= 0 ? pulseFrames[pulse] : "", beatOf);
|
|
416
|
+
const visible = lines.slice(y, y + viewH).map((l, i) => {
|
|
417
|
+
const segs = [];
|
|
418
|
+
for (let k = x; k < Math.min(l.length, x + viewW); k++) {
|
|
419
|
+
const tone = tones[y + i][k];
|
|
420
|
+
if (segs.length > 0 && segs[segs.length - 1].tone === tone)
|
|
421
|
+
segs[segs.length - 1].text += l[k];
|
|
422
|
+
else
|
|
423
|
+
segs.push({ text: l[k], tone });
|
|
424
|
+
}
|
|
425
|
+
return segs;
|
|
426
|
+
});
|
|
427
|
+
const states = card?.states;
|
|
428
|
+
return [
|
|
429
|
+
h(Box, { key: "flow", height: boardH, marginX: 1 },
|
|
430
|
+
/* a graph smaller than the viewport is centered in it */
|
|
431
|
+
h(Box, { paddingX: 1, justifyContent: "center", alignItems: "center", ...cx("grow", "column", "frame", "border-dim", dim && "border-dimmed") }, h(Box, { ref: graphView, ...cx("column") }, ...visible.map((segs, i) => h(Box, { key: i }, ...(segs.length === 0 ? [h(Text, { key: 0 }, " ")] : segs.map((seg, k) => {
|
|
432
|
+
const tone = `tone-${seg.tone}`;
|
|
433
|
+
return h(Text, { key: k, ...cx(dim && "dimmed", isClass(tone) && tone) }, seg.text);
|
|
434
|
+
})))))),
|
|
435
|
+
/* overlay the graph statistics centered onto the top border */
|
|
436
|
+
h(Box, { position: "absolute", top: 0, left: 0, width: "100%", justifyContent: "center" }, h(Text, { ...cx("dim", dim && "dimmed"), wrap: "truncate" }, `[ ${agents?.agents.length ?? 0} agents, ${flow.states.length} states ]`))),
|
|
437
|
+
h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), rel !== null ? [
|
|
438
|
+
"state: ", h(Text, { key: "state", ...cx("bold") }, rel.state),
|
|
439
|
+
" · observed by: ", h(Text, { key: "in", ...cx("bold") }, names(rel.input)),
|
|
440
|
+
" · written by: ", h(Text, { key: "out", ...cx("bold") }, names(rel.output)),
|
|
441
|
+
...(rel.gate.length > 0 ? [" · gate of: ", h(Text, { key: "gate", ...cx("bold") }, names(rel.gate))] : [])
|
|
442
|
+
] : card === undefined ? " " : [
|
|
443
|
+
"agent: ", h(Text, { key: "agent", ...cx("bold") }, clean(agentLabel(card.agents[0])) +
|
|
444
|
+
(card.agents.length > 1 ? ` ×${card.agents.length}` : "")),
|
|
445
|
+
...(states === undefined ? [" · requests: ", h(Text, { key: "req", ...cx("bold") }, clean(card.agents[0].request ?? "none"))] : [
|
|
446
|
+
" · input: ", h(Text, { key: "in", ...cx("bold") }, states.input.join(", ") || "none"),
|
|
447
|
+
...(states.activity !== undefined ? [" · activity: ", h(Text, { key: "act", ...cx("bold") }, states.activity)] : []),
|
|
448
|
+
" · output: ", h(Text, { key: "out", ...cx("bold") }, states.output.join(", ") || "none"),
|
|
449
|
+
...(states.gate !== undefined ? [" · gate: ", h(Text, { key: "gate", ...cx("bold") }, states.gate)] : [])
|
|
450
|
+
])
|
|
451
|
+
])),
|
|
452
|
+
h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), "↑/↓/←/→/Left-Click: select agent/state · ⏎/Double-Click: view agent · " +
|
|
453
|
+
"⇈/⇊/⇤/⇥/Wheel: scroll canvas")),
|
|
454
|
+
h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `v/V: ${viewCycleHint("flow", agents !== null)} · M: ${mouse ? "disable" : "enable"} mouse · ?: hide key hints · q: quit`))
|
|
455
|
+
];
|
|
456
|
+
};
|
|
457
|
+
/* the width of the master list (incl. its frame) and the height of an agent card of the agents view,
|
|
458
|
+
and the height of its panes with the process and accounting of the selected agent (incl. their frames) */
|
|
459
|
+
const AGENT_W = 36;
|
|
460
|
+
const AGENT_H = 6;
|
|
461
|
+
export const AGENT_INFO_H = 6;
|
|
462
|
+
/* the inner width of the detail view of the agents view on a screen width (without the side paddings
|
|
463
|
+
of the screen, the frame and its left padding, the master list, the gap between the master list
|
|
464
|
+
and the detail view, and the always reserved scroll bar) */
|
|
465
|
+
export const agentLogWidth = (columns) => Math.max(10, columns - 2 - AGENT_W - 1 - 3 - 2);
|
|
466
|
+
export const agentOpLines = (agent, columns) => ({
|
|
467
|
+
name: clean(agent?.name ?? ""),
|
|
468
|
+
title: clean(agent?.title ?? ""),
|
|
469
|
+
desc: clampLines(clean(agent?.description ?? ""), agentLogWidth(columns) + 1, 6)
|
|
470
|
+
});
|
|
471
|
+
export const agentOpHeight = (op) => 2 + 1 + op.desc.length;
|
|
472
|
+
/* render the agents view: on the left the framed master list "agents" of the agents as vertically
|
|
473
|
+
aligned cards (name and state, kind and mode of operation, and the ids of the tasks it is
|
|
474
|
+
busy with, inversely with the task icon, else "none (waiting)"), scrolled to keep the selected agent visible, and on
|
|
475
|
+
the right (taking the remaining width) the framed pane of the operation of the selected agent (its
|
|
476
|
+
name inversely with the agent icon, its title in bold, and its description), below it two equally wide framed panes of the selected agent
|
|
477
|
+
(in accent color, like the efficiency of the statistics view): its process (host, process id,
|
|
478
|
+
user name and id, and working directory, cut at its start) and its accounting (uptime, age of its
|
|
479
|
+
heartbeat, and total and last usage of the agent tool), and below them the framed detail view "agent log" with the recent processing log of
|
|
480
|
+
the selected agent, scrolled to the bottom unless scrolled up, and with the scroll position
|
|
481
|
+
overlaid onto its frame */
|
|
482
|
+
export const renderAgents = (ctx) => {
|
|
483
|
+
const { agents, agentList, agentIdx, agent, agentOp, logLines, logH, logFirst, logMax, boardH, dim, mouse, pulse, now, beatOf, agentRef } = ctx;
|
|
484
|
+
/* the visible agent cards, where a line is reserved for the indicator of the hidden ones (like in the lanes) */
|
|
485
|
+
const room = boardH - 2;
|
|
486
|
+
const all = Math.max(1, Math.floor(room / AGENT_H));
|
|
487
|
+
const fit = agentList.length > all ? Math.max(1, Math.floor((room - 1) / AGENT_H)) : all;
|
|
488
|
+
const first = Math.max(0, Math.min(agentIdx - fit + 1, agentList.length - fit));
|
|
489
|
+
const above = first;
|
|
490
|
+
const below = Math.max(0, agentList.length - first - fit);
|
|
491
|
+
const empty = agents === null || !agents.connected ? "agent relay unreachable — reconnecting …" : "no agents running";
|
|
492
|
+
const title = (text, accent = false) => h(Box, { key: "title", position: "absolute", top: 0, left: 0, width: "100%", justifyContent: "center" }, h(Text, { ...cx(accent ? "accent" : "dim", dim && "dimmed"), wrap: "truncate" }, "[ ", h(Text, cx("bold"), text), " ]"));
|
|
493
|
+
/* the master list, where the top border of a card carries on its right side (like the
|
|
494
|
+
task cards of the active lanes) the pulse of a busy agent, resp. the circle of a waiting one,
|
|
495
|
+
and the top right corner inside the card carries the heart of the agent (filled for a
|
|
496
|
+
second after each received heartbeat, else empty) */
|
|
497
|
+
const cardW = AGENT_W - 4;
|
|
498
|
+
const card = (a, n) => {
|
|
499
|
+
const on = n === agentIdx && !dim;
|
|
500
|
+
const tint = on ? palette.signal : a.state === "busy" ? palette.accent : palette.normal;
|
|
501
|
+
const label = (text) => text.padEnd(6);
|
|
502
|
+
const val = (text) => h(Text, { bold: true }, text);
|
|
503
|
+
const mode = typeof a.mode === "string" && a.mode !== "" ? clean(a.mode) : "none";
|
|
504
|
+
const [cl, cm, cr] = on ? ["┏", "━", "┓"] : ["┌", "─", "┐"];
|
|
505
|
+
const mark = a.state !== "busy" ? "○" : pulse >= 0 ? pulseFrames[pulse] : pulseFrames[0];
|
|
506
|
+
return h(Box, { key: a.id, ref: agentRef(a.id), width: cardW, height: AGENT_H, flexShrink: 0, flexDirection: "column" }, h(Text, { color: tint, dimColor: dim, wrap: "truncate" }, cl + cm.repeat(Math.max(0, cardW - 4)) + mark + cm + cr), h(Box, {
|
|
507
|
+
height: AGENT_H - 1, flexDirection: "column", paddingX: 1,
|
|
508
|
+
borderStyle: on ? "bold" : "single", borderTop: false, borderColor: tint, borderDimColor: dim
|
|
509
|
+
}, h(Box, { flexDirection: "row" }, h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { wrap: "truncate" }, h(Text, { ...cx("badge"), color: tint, dimColor: dim }, ` ⚉ ${clean(a.name)} `))), h(Box, { flexShrink: 0, marginLeft: 1 }, h(Text, { color: tint, dimColor: dim }, beatOf(a.id) ? "♥" : "♡"))), h(Text, { color: tint, dimColor: dim, wrap: "truncate" }, label("kind:"), val(clean(a.kind))), h(Text, { color: tint, dimColor: dim, wrap: "truncate" }, label("mode:"), val(mode)), h(Text, { color: tint, dimColor: dim, wrap: "truncate" }, label("task:"), ...(a.tasks.length === 0 ? [val("none (waiting)")] :
|
|
510
|
+
a.tasks.flatMap((id, k) => [k > 0 ? " " : "", h(Text, { key: id, ...cx("badge"), color: tint, dimColor: dim }, ` ≡ ${clean(id)} `)])))));
|
|
511
|
+
};
|
|
512
|
+
/* the space left besides the fully visible cards is filled by a partially visible
|
|
513
|
+
card (like in the lanes), cut at its top above them if scrolled, else cut at its bottom below them */
|
|
514
|
+
const part = above > 0 || below > 0 ? room - 1 - fit * AGENT_H : 0;
|
|
515
|
+
const clip = (n, cut) => h(Box, {
|
|
516
|
+
key: `part-${cut}`, height: part, flexShrink: 0, overflow: "hidden", flexDirection: "column",
|
|
517
|
+
justifyContent: cut === "top" ? "flex-end" : "flex-start"
|
|
518
|
+
}, card(agentList[n], n));
|
|
519
|
+
const cards = [
|
|
520
|
+
...(part > 0 && above > 0 ? [clip(first - 1, "top")] : []),
|
|
521
|
+
...agentList.slice(first, first + fit).map((a, i) => card(a, first + i)),
|
|
522
|
+
...(part > 0 && above === 0 && below > 0 ? [clip(first + fit, "bottom")] : [])
|
|
523
|
+
];
|
|
524
|
+
const master = h(Box, { key: "master", width: AGENT_W, height: boardH, flexShrink: 0 }, h(Box, { paddingX: 1, ...cx("grow", "column", "frame", "border-dim", dim && "border-dimmed") }, ...(cards.length > 0 ? cards : [h(Text, { key: "empty", ...cx("dim", dim && "dimmed") }, empty)]),
|
|
525
|
+
/* the centered indicator of the hidden agent cards, pushed to the bottom by a growing spacer */
|
|
526
|
+
...(above > 0 || below > 0 ? [h(Box, { key: "spacer", flexGrow: 1 }),
|
|
527
|
+
h(Box, { key: "more", justifyContent: "center" }, h(Text, cx("dim", dim && "dimmed"), [...(above > 0 ? [`△ ${above}`] : []), ...(below > 0 ? [`▽ ${below}`] : [])].join(" ")))] : [])), title("Agents"));
|
|
528
|
+
/* the detail view, with a vertical scroll bar directly at its right border (like the read dialog) */
|
|
529
|
+
const lines = logLines.slice(logFirst, logFirst + logH);
|
|
530
|
+
const thumbH = Math.max(1, Math.round(logH * Math.min(1, logH / Math.max(1, logLines.length))));
|
|
531
|
+
const thumbY = logMax === 0 ? 0 : Math.round((logH - thumbH) * logFirst / logMax);
|
|
532
|
+
const field = (name, value, width, wrap = "truncate") => h(Box, { key: name }, h(Box, { flexShrink: 0 }, h(Text, cx("accent", dim && "dimmed"), name.padEnd(width))), h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, { ...cx("accent", "bold", dim && "dimmed"), wrap }, value)));
|
|
533
|
+
const pane = (key, text, fields, first = false, accent = false) => h(Box, { key, flexGrow: 1, flexBasis: 0, marginLeft: first ? 0 : 1 }, h(Box, { paddingX: 1, ...cx("grow", "column", "frame", accent ? "border-accent" : "border-dim", dim && "border-dimmed") }, ...(agent === undefined ? [h(Text, { key: "none", ...cx("dim", "truncate", dim && "dimmed") }, empty)] : fields())), title(text, accent));
|
|
534
|
+
const op = h(Box, { key: "op", height: agentOpHeight(agentOp), flexShrink: 0 }, pane("operation", "Operation", () => [
|
|
535
|
+
h(Text, { key: "title", ...cx("normal", "truncate", dim && "dimmed") }, h(Text, cx("normal", "badge", dim && "dimmed"), ` ⚉ ${agentOp.name} `), " ", agentOp.title !== "" ? h(Text, cx("bold"), agentOp.title) :
|
|
536
|
+
agentOp.desc.length === 0 ? h(Text, cx("dim"), "(no operation title and description)") : ""),
|
|
537
|
+
...agentOp.desc.map((line, i) => h(Text, { key: `desc-${i}`, ...cx("normal", "truncate", dim && "dimmed") }, line))
|
|
538
|
+
], true));
|
|
539
|
+
const info = h(Box, { key: "info", height: AGENT_INFO_H, flexShrink: 0 }, pane("process", "Process", () => [
|
|
540
|
+
field("host:", clean(agent.host), 6),
|
|
541
|
+
field("pid:", String(agent.pid), 6),
|
|
542
|
+
field("uid:", clean(agentUser(agent)), 6),
|
|
543
|
+
field("cwd:", clean(agent.cwd ?? "n/a"), 6, "truncate-start")
|
|
544
|
+
], true, true), pane("accounting", "Accounting", () => [
|
|
545
|
+
field("uptime:", agentUptime(agent), 18),
|
|
546
|
+
field("heartbeat:", `${agentDuration(now - agent.beat)} ago`, 18),
|
|
547
|
+
field("usage (total):", agentUsage(agent.usage?.total), 18),
|
|
548
|
+
field("usage (last job):", agentUsage(agent.usage?.last), 18)
|
|
549
|
+
], false, true));
|
|
550
|
+
const log = h(Box, { key: "log", flexGrow: 1 }, h(Box, { paddingLeft: 1, ...cx("grow", "column", "frame", "border-dim", dim && "border-dimmed") }, ...(agent === undefined ? [h(Text, { key: "none", ...cx("dim", dim && "dimmed") }, empty)] :
|
|
551
|
+
lines.length === 0 ? [h(Text, { key: "none", ...cx("dim", dim && "dimmed") }, "(no processing log lines yet)")] :
|
|
552
|
+
lines.map((line, i) => h(Box, { key: logFirst + i }, h(Box, { flexGrow: 1, flexShrink: 1 }, h(Text, cx("truncate", dim && "dimmed"), h(Text, cx("normal", dim && "dimmed"), line.prefix), ...(line.task !== "" ? [h(Text, cx(line.tone, "badge", dim && "dimmed"), ` ≡ ${line.task} `), " "] : []), h(Text, cx(line.tone, dim && "dimmed"), line.level), h(Text, cx(line.tone, dim && "dimmed"), line.text))), h(Text, cx("signal", dim && "dimmed"), logLines.length <= logH ? " " : i >= thumbY && i < thumbY + thumbH ? " █" : " ░"))))), title("Recent Log Entries"), ...(agent !== undefined ? [h(Box, { key: "pos", position: "absolute", bottom: 0, right: 2 }, h(Text, cx("dim", dim && "dimmed"), logLines.length <= logH ? `[ ${logLines.length} lines ]` :
|
|
553
|
+
`[ lines ${logFirst + 1}–${logFirst + lines.length} of ${logLines.length} · ${logFirst >= logMax ? "following" : "scrolled"} ]`))] : []));
|
|
554
|
+
const detail = h(Box, { key: "detail", flexGrow: 1, height: boardH, marginLeft: 1, flexDirection: "column" }, op, info, log);
|
|
555
|
+
return [
|
|
556
|
+
h(Box, { key: "agents", height: boardH, paddingX: 1 }, master, detail),
|
|
557
|
+
h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", "truncate", dim && "dimmed"), agent === undefined ? " " : [
|
|
558
|
+
"agent: ", h(Text, { key: "agent", ...cx("bold") }, clean(agentLabel(agent))),
|
|
559
|
+
...(agentOp.title !== "" ? [
|
|
560
|
+
" · operation: ", h(Text, { key: "op", ...cx("bold") }, agentOp.title)
|
|
561
|
+
] : [])
|
|
562
|
+
])),
|
|
563
|
+
h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), "↑/↓/Left-Click: select agent · ⇈/⇊/⇤/⇥: page/top/bottom agent log · Mouse-Wheel: scroll agent log")),
|
|
564
|
+
h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `v/V: ${viewCycleHint("agents", agents !== null)} · M: ${mouse ? "disable" : "enable"} mouse · ?: hide key hints · q: quit`))
|
|
565
|
+
];
|
|
566
|
+
};
|
|
567
|
+
/* the heights of the buttons of the time ranges (with a blank line below them), and of the
|
|
568
|
+
frames "Task Progress" (and "Open Tasks" besides it), "Usage" (and "Effort" besides
|
|
569
|
+
it), and "Top-5 Tasks" (and "Finished Tasks" and "Efficiency" besides it), each
|
|
570
|
+
with an inner blank line at its top and bottom (except for the column charts), of the
|
|
571
|
+
statistics view */
|
|
572
|
+
const RANGES_H = 2;
|
|
573
|
+
const PROGRESS_H = 6;
|
|
574
|
+
const USAGE_H = 8;
|
|
575
|
+
const TOP_H = 10;
|
|
576
|
+
/* the compact amounts of the statistics view: a time (in minutes) in minutes below one
|
|
577
|
+
hour, else in hours (for the narrow columns of a column chart in whole hours from ten
|
|
578
|
+
hours on), a number of tokens with a metric suffix, and costs in USD */
|
|
579
|
+
const statsHours = (minutes) => minutes < 60 ? `${Math.round(minutes)}m` : `${(minutes / 60).toFixed(1)}h`;
|
|
580
|
+
const statsHoursN = (minutes) => minutes < 10 * 60 ? statsHours(minutes) : `${Math.round(minutes / 60)}h`;
|
|
581
|
+
const statsTokens = (n) => n < 1000 ? String(n) : n < 1000 * 1000 ? `${(n / 1000).toFixed(1)}k` :
|
|
582
|
+
n < 1000 * 1000 * 1000 ? `${(n / (1000 * 1000)).toFixed(1)}M` : `${(n / (1000 * 1000 * 1000)).toFixed(1)}G`;
|
|
583
|
+
const statsCost = (usd) => `$${usd.toFixed(2)}`;
|
|
584
|
+
/* the compact duration (in minutes) of the statistics view: in minutes below one hour,
|
|
585
|
+
in hours below two days, else in days, resp. "n/a" if unknown */
|
|
586
|
+
const statsDuration = (minutes) => minutes === null ? "n/a" : minutes < 60 ? `${Math.round(minutes)}m` :
|
|
587
|
+
minutes < 48 * 60 ? `${(minutes / 60).toFixed(1)}h` : `${(minutes / (24 * 60)).toFixed(1)}d`;
|
|
588
|
+
/* the forecast of the backlog of the statistics view: the number of unfinished tasks
|
|
589
|
+
and the number of weeks to finish them (resp. "n/a" without any throughput) */
|
|
590
|
+
const statsForecast = (backlog, weeks) => backlog === 0 ? "forecast: no unfinished tasks" :
|
|
591
|
+
`forecast: ${backlog} unfinished ≈ ${weeks !== null ? `${weeks.toFixed(1)} week${weeks === 1 ? "" : "s"}` : "n/a"}`;
|
|
592
|
+
/* center a text within a width (cut with an ellipsis if too long), padded by a fill character */
|
|
593
|
+
const center = (text, width, fill = " ") => {
|
|
594
|
+
const cut = text.length <= width ? text : width > 1 ? text.slice(0, width - 1) + "…" : text.slice(0, width);
|
|
595
|
+
const left = Math.floor((width - cut.length) / 2);
|
|
596
|
+
return fill.repeat(left) + cut + fill.repeat(width - cut.length - left);
|
|
597
|
+
};
|
|
598
|
+
/* split a number of cells onto parts by their amounts (by the largest remainder method) */
|
|
599
|
+
const splitCells = (cells, amounts) => {
|
|
600
|
+
const sum = amounts.reduce((n, a) => n + a, 0);
|
|
601
|
+
if (sum <= 0)
|
|
602
|
+
return amounts.map(() => 0);
|
|
603
|
+
const exact = amounts.map((a) => cells * a / sum);
|
|
604
|
+
const parts = exact.map((e) => Math.floor(e));
|
|
605
|
+
const order = exact.map((_e, i) => i).sort((a, b) => (exact[b] - parts[b]) - (exact[a] - parts[a]));
|
|
606
|
+
const rest = cells - parts.reduce((n, p) => n + p, 0);
|
|
607
|
+
for (let k = 0; k < rest; k++)
|
|
608
|
+
parts[order[k]]++;
|
|
609
|
+
return parts;
|
|
610
|
+
};
|
|
611
|
+
/* a horizontal bar of a width for a value of a maximum, in whole cells (as a partially filled
|
|
612
|
+
cell would leave a gap before the track), on a light shaded track (in the color of the bar) */
|
|
613
|
+
const statsBar = (value, max, width) => {
|
|
614
|
+
const n = max > 0 ? Math.round(value / max * width) : 0;
|
|
615
|
+
return "█".repeat(n).padEnd(width, "░");
|
|
616
|
+
};
|
|
617
|
+
/* render the statistics view: the framed column chart "Tasks per State" of the numbers of tasks per
|
|
618
|
+
lifecycle state (labeled by state, its effort, and phase, in the colors of their lanes, with the framed column
|
|
619
|
+
chart "Throughput" of the finished tasks of the last weeks besides it), the framed stacked bar
|
|
620
|
+
"Task Progress" of the cancelled, finished, work-in-progress, and open tasks (with the framed
|
|
621
|
+
stacked bar "Open Tasks" of the ready and blocked open tasks besides it), the framed bars "Usage"
|
|
622
|
+
of the total times, tokens, and costs of the tasks (with the framed column chart "Effort"
|
|
623
|
+
of the numbers of tasks per effort size besides it), and the framed table "Top-5 Tasks" of the
|
|
624
|
+
tasks of the highest usage (with the framed "Finished Tasks" of the usage per finished task and
|
|
625
|
+
the framed "Efficiency" of the AI usage besides it), below the buttons of the time ranges */
|
|
626
|
+
export const renderStats = (ctx) => {
|
|
627
|
+
const { stats, usage, range, rangeRef, columns, boardH, dim, mouse, filter, agents } = ctx;
|
|
628
|
+
const innerW = Math.max(20, columns - 6);
|
|
629
|
+
/* the widths of the frames of the rows with two frames (the left one taking two thirds,
|
|
630
|
+
less the columns the right one is grown by), and the inner widths of these frames
|
|
631
|
+
(without their borders and paddings) */
|
|
632
|
+
const rightW = Math.max(16, Math.floor((innerW + 4) / 3));
|
|
633
|
+
const leftW = Math.max(16, innerW + 4 - rightW - 1);
|
|
634
|
+
const pair = (key, height, left, right, grow = 0) => h(Box, { key, height, flexShrink: 0 }, h(Box, { width: leftW - grow, flexShrink: 0, flexDirection: "column" }, left), h(Box, { width: 1, flexShrink: 0 }), h(Box, { width: rightW + grow, flexShrink: 0, flexDirection: "column" }, right));
|
|
635
|
+
/* the number of columns the frames "Finished Tasks" and "Efficiency" are each grown by
|
|
636
|
+
(at the expense of the frame "Top-5 Tasks") */
|
|
637
|
+
const avgGrow = 4;
|
|
638
|
+
const tint = (tone) => tone === "active" ? palette.accent : tone === "done" ? palette.dim : palette.normal;
|
|
639
|
+
const frameOf = (accent) => (key, height, title, padY, ...content) => h(Box, { key, height, flexShrink: 0 }, h(Box, { paddingX: 1, paddingY: padY, ...cx("grow", "column", "frame", accent ? "border-accent" : "border-dim", dim && "border-dimmed") }, ...content), h(Box, { position: "absolute", top: 0, left: 0, width: "100%", justifyContent: "center" }, h(Text, { ...cx(accent ? "accent" : "dim", dim && "dimmed"), wrap: "truncate" }, "[ ", h(Text, cx("bold"), title), " ]")));
|
|
640
|
+
const frame = frameOf(false);
|
|
641
|
+
const accentFrame = frameOf(true);
|
|
642
|
+
if (stats === null)
|
|
643
|
+
return [h(Box, { key: "stats", height: boardH }), h(Text, { key: "info" }, " "), h(Text, { key: "keys1" }, " "), h(Text, { key: "keys2" }, " ")];
|
|
644
|
+
/* a framed column chart of an inner width, where the highest column leaves the top row
|
|
645
|
+
for its number, each number sits on top of its column (being at least a thin line,
|
|
646
|
+
even without any number, like in the web board), the columns stand on a blank line above
|
|
647
|
+
their labels (each cut to its first five characters, without an ellipsis, for
|
|
648
|
+
readability) and their sub-labels below them (like the effort of a state, cut with
|
|
649
|
+
an ellipsis, and blank if empty, to keep the columns of all charts aligned), and each
|
|
650
|
+
group of columns is labeled below its line spanning its columns
|
|
651
|
+
(like in the web board), followed by a footer line (blank if empty, to keep the columns
|
|
652
|
+
of all charts aligned), where the columns start directly at the left padding of the
|
|
653
|
+
frame, are separated by a gap of one column, and leave the remaining width on the right,
|
|
654
|
+
and where a compact chart of a height omits the blank line, the sub-labels, and the groups */
|
|
655
|
+
const chartH = Math.max(9, boardH - RANGES_H - PROGRESS_H - USAGE_H - TOP_H);
|
|
656
|
+
const colChart = (key, title, width, cols, groups, footer = "", height = chartH, compact = false) => {
|
|
657
|
+
const areaH = Math.max(1, height - (compact ? 4 : 8));
|
|
658
|
+
const colW = Math.max(2, Math.floor((width + 1) / cols.length));
|
|
659
|
+
const lineW = colW - 1;
|
|
660
|
+
const fitW = Math.max(1, Math.min(14, lineW));
|
|
661
|
+
const barW = fitW > 1 && (lineW - fitW) % 2 !== 0 ? fitW - 1 : fitW;
|
|
662
|
+
const barL = Math.floor((lineW - barW) / 2);
|
|
663
|
+
const max = Math.max(1, ...cols.map((c) => c.count));
|
|
664
|
+
const gapOf = (i, n) => i < n - 1 ? " " : "";
|
|
665
|
+
const column = (k, tone, text) => h(Text, { key: k, color: tint(tone), dimColor: dim }, " ".repeat(barL) + text + " ".repeat(Math.max(0, lineW - barW - barL)) + gapOf(k, cols.length));
|
|
666
|
+
const rows = Array.from({ length: areaH }, (_x, r) => {
|
|
667
|
+
const level = areaH - 1 - r;
|
|
668
|
+
return h(Box, { key: `row${r}` }, h(Text, { wrap: "truncate" }, ...cols.map((c, i) => {
|
|
669
|
+
const fill = Math.max(1, Math.round(c.count / max * (areaH - 1) * 8));
|
|
670
|
+
const cell = Math.max(0, Math.min(8, fill - level * 8));
|
|
671
|
+
const text = String(c.count);
|
|
672
|
+
if (cell === 0 && level === Math.ceil(fill / 8) && text.length > barW)
|
|
673
|
+
return h(Text, { key: i, color: tint(c.tone), dimColor: dim }, center(text, lineW) + gapOf(i, cols.length));
|
|
674
|
+
return column(i, c.tone, cell > 0 ? lowerEighths[cell].repeat(barW) :
|
|
675
|
+
level === Math.ceil(fill / 8) ? center(text, barW) : " ".repeat(barW));
|
|
676
|
+
})));
|
|
677
|
+
});
|
|
678
|
+
const labels = h(Box, { key: "labels" }, h(Text, { wrap: "truncate" }, ...cols.map((c, i) => h(Text, { key: i, color: tint(c.tone), dimColor: dim, bold: true }, center(c.label.slice(0, Math.min(5, lineW)), lineW) + gapOf(i, cols.length)))));
|
|
679
|
+
const foot = h(Box, { key: "footer" }, h(Text, { ...cx("dim", dim && "dimmed"), wrap: "truncate" }, center(footer, Math.max(0, cols.length * colW - 1))));
|
|
680
|
+
if (compact)
|
|
681
|
+
return frame(key, height, title, 0, ...rows, labels, foot);
|
|
682
|
+
return frame(key, height, title, 0, ...rows, h(Box, { key: "base" }, h(Text, { wrap: "truncate" }, " ")), labels, h(Box, { key: "subs" }, h(Text, { wrap: "truncate" }, ...cols.map((c, i) => h(Text, { key: i, color: tint(c.tone), dimColor: dim }, center(c.sub ?? "", lineW) + gapOf(i, cols.length))))), h(Box, { key: "groups" }, h(Text, { ...cx("dim", dim && "dimmed"), wrap: "truncate" }, groups.map((g, i) => "─".repeat(Math.max(0, g.n * colW - 1)) + gapOf(i, groups.length)).join(""))), h(Box, { key: "group-labels" }, h(Text, { ...cx("dim", dim && "dimmed"), wrap: "truncate" }, groups.map((g, i) => center(g.label, g.n * colW - 1) + gapOf(i, groups.length)).join(""))), foot);
|
|
683
|
+
};
|
|
684
|
+
/* the column chart of the number of tasks per state (labeled by the states and below by the
|
|
685
|
+
effort of their estimated tasks, grouped by their phases, and with the total number of tasks
|
|
686
|
+
as its footer), with the column chart of the throughput of the last weeks besides it (labeled by
|
|
687
|
+
their week numbers, prefixed with "W" if fitting, and grouped by all weeks with the total
|
|
688
|
+
and average number of finished tasks, and with the forecast of the backlog as its footer),
|
|
689
|
+
grown like the frames "Finished Tasks" and "Efficiency" together, to align with them */
|
|
690
|
+
const phases = stats.states.reduce((list, s) => {
|
|
691
|
+
const last = list.at(-1);
|
|
692
|
+
if (last !== undefined && last.label === s.phase)
|
|
693
|
+
last.n++;
|
|
694
|
+
else
|
|
695
|
+
list.push({ label: s.phase, n: 1 });
|
|
696
|
+
return list;
|
|
697
|
+
}, []);
|
|
698
|
+
const weeksW = rightW + 2 * avgGrow - 4;
|
|
699
|
+
const weekW = Math.max(2, Math.floor(weeksW / Math.max(1, stats.throughput.length))) - 1;
|
|
700
|
+
const weekSum = stats.throughput.reduce((n, w) => n + w.count, 0);
|
|
701
|
+
const weekAvg = stats.throughput.length > 0 ? weekSum / stats.throughput.length : 0;
|
|
702
|
+
const chart = pair("charts", chartH, colChart("chart", "Tasks per State", leftW - 2 * avgGrow - 4, stats.states.map((s) => ({
|
|
703
|
+
label: s.status, count: s.count, tone: s.tone,
|
|
704
|
+
sub: s.effort.estimated > 0 ? statsHoursN(s.effort.minutes) : ""
|
|
705
|
+
})), phases, `total tasks: ${stats.states.reduce((n, s) => n + s.count, 0)}`), colChart("throughput", "Throughput", weeksW, stats.throughput.map((w) => ({ label: weekW >= 3 ? `W${w.week}` : String(w.week), count: w.count, tone: "idle" })), [{ label: `${weekSum} finished · ⌀ ${weekAvg.toFixed(1)}/week`, n: stats.throughput.length }], statsForecast(stats.forecast.backlog, stats.forecast.weeks)), 2 * avgGrow);
|
|
706
|
+
/* a framed stacked bar of the numbers of tasks of parts, with its legend */
|
|
707
|
+
const stacked = (key, title, width, parts) => {
|
|
708
|
+
const total = parts.reduce((n, p) => n + p.count, 0);
|
|
709
|
+
const cells = splitCells(width, parts.map((p) => p.count));
|
|
710
|
+
const pct = (count) => `${total > 0 ? Math.round(count / total * 100) : 0}%`;
|
|
711
|
+
return frame(key, PROGRESS_H, title, 1, h(Text, { key: "bar", wrap: "truncate" }, ...(total === 0 ? [h(Text, { key: "none", ...cx("dim", dim && "dimmed") }, "░".repeat(width))] :
|
|
712
|
+
parts.map((p, i) => h(Text, { key: i, color: p.color, dimColor: dim }, p.glyph.repeat(cells[i]))))), h(Box, { key: "legend", justifyContent: "center" }, h(Text, cx("normal", "truncate", dim && "dimmed"), ...parts.flatMap((p, i) => [
|
|
713
|
+
i > 0 ? " · " : "", h(Text, { key: `g${i}`, color: p.color, dimColor: dim }, p.glyph), ` ${p.label}: `,
|
|
714
|
+
h(Text, { key: `n${i}`, ...cx("bold") }, String(p.count)), ` (${pct(p.count)})`
|
|
715
|
+
]))));
|
|
716
|
+
};
|
|
717
|
+
/* the stacked bars of the progress of the tasks and of the readiness of the open tasks
|
|
718
|
+
(grown like the frames "Finished Tasks" and "Efficiency" together, to align with them) */
|
|
719
|
+
const { finished, cancelled, wip, open } = stats.progress;
|
|
720
|
+
const progress = pair("progress", PROGRESS_H, stacked("progress", "Task Progress", leftW - 2 * avgGrow - 4, [
|
|
721
|
+
{ label: "cancelled", count: cancelled, glyph: "▒", color: palette.dim },
|
|
722
|
+
{ label: "finished", count: finished, glyph: "█", color: palette.normal },
|
|
723
|
+
{ label: "wip", count: wip, glyph: "█", color: palette.accent },
|
|
724
|
+
{ label: "open", count: open, glyph: "░", color: palette.normal }
|
|
725
|
+
]), stacked("readiness", "Open Tasks", rightW + 2 * avgGrow - 4, [
|
|
726
|
+
{ label: "ready", count: stats.readiness.ready, glyph: "░", color: palette.normal },
|
|
727
|
+
{ label: "blocked", count: stats.readiness.blocked, glyph: "▒", color: palette.signal }
|
|
728
|
+
]), 2 * avgGrow);
|
|
729
|
+
/* the bars of the total usage by times, tokens (stacked by their kinds),
|
|
730
|
+
and costs (columns), with the bars of a column scaled to its maximum, where each
|
|
731
|
+
column ends with a gap (which the last column lets run out at the frame), within
|
|
732
|
+
the frame on the left (grown like the frames "Finished Tasks" and "Efficiency"
|
|
733
|
+
together, to align with them), where the token column is widened (at the expense of
|
|
734
|
+
the time and cost columns) as far as needed to fit the two-column legend of the token
|
|
735
|
+
kinds below its bar, which is indented like the bar as far as the column keeps its gap,
|
|
736
|
+
and where the values of a column are as wide as its widest one, so the bars take all
|
|
737
|
+
the remaining width of the column and the columns are separated by a gap of two spaces */
|
|
738
|
+
const u = stats.usage;
|
|
739
|
+
const nameW = 5;
|
|
740
|
+
const gapW = 2;
|
|
741
|
+
const tokens = u.cread + u.cwrite + u.input + u.output;
|
|
742
|
+
const share = (n) => `${tokens > 0 ? Math.round(n / tokens * 100) : 0}%`;
|
|
743
|
+
const kind = (name, n) => ` ${name} (${share(n)})`;
|
|
744
|
+
const leftL = Math.max(kind("cread", u.cread).length, kind("cwrite", u.cwrite).length) + 1;
|
|
745
|
+
const rightL = Math.max(kind("input", u.input).length, kind("output", u.output).length);
|
|
746
|
+
const timeV = Math.max(...[u.timeAI, u.timeHR, u.timeReal].map((m) => statsHours(m).length));
|
|
747
|
+
const tokenV = statsTokens(tokens).length;
|
|
748
|
+
const costV = Math.max(...[u.costAI, u.costHR].map((usd) => statsCost(usd).length));
|
|
749
|
+
const usageW = leftW - 2 * avgGrow - 4 + gapW;
|
|
750
|
+
const tokenW = Math.max(Math.floor(usageW / 3), 1 + leftL + 1 + rightL + gapW, nameW + 2 + tokenV + gapW);
|
|
751
|
+
const cellW = Math.max(nameW + 2 + Math.max(timeV, costV) + gapW, Math.floor((usageW - tokenW) / 2));
|
|
752
|
+
const timeM = cellW - nameW - 1 - timeV - gapW;
|
|
753
|
+
const costM = cellW - nameW - 1 - costV - gapW;
|
|
754
|
+
const stackW = tokenW - nameW - 1 - tokenV - gapW;
|
|
755
|
+
const maxTime = Math.max(u.timeAI, u.timeHR, u.timeReal);
|
|
756
|
+
const maxCost = Math.max(u.costAI, u.costHR);
|
|
757
|
+
const cell = (key, name, color, meter, value, valW, last = false) => [
|
|
758
|
+
name.padEnd(nameW), h(Text, { key, color, dimColor: dim }, meter), " ", last ? value : value.padEnd(valW + gapW)
|
|
759
|
+
];
|
|
760
|
+
const split = splitCells(tokens > 0 ? stackW : 0, [u.cread, u.cwrite, u.input, u.output]);
|
|
761
|
+
const stack = ["░", "▒", "▓", "█"].map((glyph, i) => glyph.repeat(split[i])).join("");
|
|
762
|
+
const time = (name, color, minutes) => cell(`time-${name}`, name, color, statsBar(minutes, maxTime, timeM), statsHours(minutes), timeV);
|
|
763
|
+
const cost = (name, color, usd) => cell(`cost-${name}`, name, color, statsBar(usd, maxCost, costM), statsCost(usd), costV, true);
|
|
764
|
+
const indent = Math.max(0, Math.min(nameW, tokenW - gapW - (1 + leftL + 1 + rightL)));
|
|
765
|
+
const legend = (key, left, right) => [
|
|
766
|
+
" ".repeat(indent), h(Text, { key: `${key}-l`, color: palette.accent, dimColor: dim }, left[0]),
|
|
767
|
+
kind(left[1], left[2]).padEnd(leftL),
|
|
768
|
+
h(Text, { key: `${key}-r`, color: palette.accent, dimColor: dim }, right[0]),
|
|
769
|
+
kind(right[1], right[2]).padEnd(Math.max(0, tokenW - indent - 1 - leftL - 1))
|
|
770
|
+
];
|
|
771
|
+
const lines = [
|
|
772
|
+
[...time("AI", palette.accent, u.timeAI), ...cell("token", "AI", palette.accent, stack.padEnd(stackW), statsTokens(tokens), tokenV),
|
|
773
|
+
...cost("AI", palette.accent, u.costAI)],
|
|
774
|
+
[...time("HR", palette.accent, u.timeHR), ...legend("legend1", ["░", "cread", u.cread], ["▓", "input", u.input]),
|
|
775
|
+
...cost("HR", palette.accent, u.costHR)],
|
|
776
|
+
[...time("real", palette.dim, u.timeReal), ...legend("legend2", ["▒", "cwrite", u.cwrite], ["█", "output", u.output])]
|
|
777
|
+
].map((segs, i) => h(Text, { key: `line${i}`, ...cx("normal", "truncate", dim && "dimmed") }, ...segs));
|
|
778
|
+
const head = h(Text, { key: "head", ...cx("normal", "bold", "truncate", dim && "dimmed") }, "Time:".padEnd(cellW) + "Token:".padEnd(tokenW) + "Cost:");
|
|
779
|
+
const matrix = frame("usage", USAGE_H, "Usage", 1, ...(usage === null ?
|
|
780
|
+
[h(Text, { key: "loading", ...cx("dim", dim && "dimmed") }, "loading …")] : [head, ...lines]));
|
|
781
|
+
/* the compact column chart of the distribution of the effort sizes (the numbers of the
|
|
782
|
+
estimated tasks per size, from the smallest to the largest size), with the numbers of
|
|
783
|
+
the estimated and of all tasks as its footer, besides the bars of the total usage */
|
|
784
|
+
const effort = stats.states.reduce((sum, s) => ({ estimated: sum.estimated + s.effort.estimated, tasks: sum.tasks + s.effort.tasks }), { estimated: 0, tasks: 0 });
|
|
785
|
+
const usages = pair("usage", USAGE_H, matrix, colChart("sizes", "Effort", rightW + 2 * avgGrow - 4, stats.sizes.map((s) => ({ label: s.size, count: s.count, tone: "idle" })), [], `estimated: ${effort.estimated} of ${effort.tasks} tasks`, USAGE_H, true), 2 * avgGrow);
|
|
786
|
+
/* the table of the tasks of the highest usage: the id (inverse behind "≡", in the color of
|
|
787
|
+
its lane), the title (cut with an ellipsis), a bar of its share of the total costs (resp.
|
|
788
|
+
tokens), the costs, and the input and output tokens (as "<input> ▷ <output>", with
|
|
789
|
+
the "▷" vertically aligned), with two columns of spacing between the columns */
|
|
790
|
+
const topW = leftW - 2 * avgGrow - 4;
|
|
791
|
+
const fit = (text, width) => text.length <= width ? text.padEnd(width) : text.slice(0, Math.max(0, width - 1)) + "…";
|
|
792
|
+
const idW = Math.min(28, Math.max(7, ...stats.top.map((t) => t.id.length + 4)));
|
|
793
|
+
const pctW = Math.max(11, Math.min(16, Math.floor(topW / 8)));
|
|
794
|
+
const costW = 7;
|
|
795
|
+
const inW = Math.max(0, ...stats.top.map((t) => statsTokens(t.input).length));
|
|
796
|
+
const outW = Math.max(0, ...stats.top.map((t) => statsTokens(t.output).length));
|
|
797
|
+
const tokW = Math.max(5, inW + 3 + outW);
|
|
798
|
+
const gap = " ";
|
|
799
|
+
const titleW = Math.max(0, topW - idW - pctW - costW - tokW - 4 * gap.length);
|
|
800
|
+
const topHead = h(Text, { key: "head", ...cx("normal", "bold", "truncate", dim && "dimmed") }, ["Task Id".padEnd(idW), "Task Title".padEnd(titleW), "Total Share".padEnd(pctW),
|
|
801
|
+
"Cost".padStart(costW), "Token".padEnd(tokW)].join(gap));
|
|
802
|
+
const topList = usage === null ? [h(Text, { key: "loading", ...cx("dim", dim && "dimmed") }, "loading …")] :
|
|
803
|
+
stats.top.length === 0 ? [h(Text, { key: "none", ...cx("dim", dim && "dimmed") }, "no usage")] :
|
|
804
|
+
[topHead, ...stats.top.map((t, i) => {
|
|
805
|
+
const id = fit(t.id, idW - 4).trimEnd();
|
|
806
|
+
return h(Text, { key: `top${i}`, ...cx("normal", "truncate", dim && "dimmed") }, h(Text, { key: "id", color: tint(t.tone), dimColor: dim, ...cx("badge") }, ` ≡ ${id} `), " ".repeat(idW - id.length - 4) + gap + fit(clean(t.title), titleW) + gap, h(Text, { key: "bar", color: palette.accent, dimColor: dim }, statsBar(t.share, 1, pctW)), gap + statsCost(t.cost).padStart(costW) + gap +
|
|
807
|
+
`${statsTokens(t.input).padStart(inW)} ▷ ${statsTokens(t.output).padStart(outW)}`.padStart(tokW));
|
|
808
|
+
})];
|
|
809
|
+
/* the averages and lead times of the finished tasks and the efficiency of the AI usage (the
|
|
810
|
+
AI/HR time and cost ratios and the AI cache hit ratio), side by side, in accent color (like the
|
|
811
|
+
active lanes), each as lines of labels with their values right-aligned (where a too long
|
|
812
|
+
label is cut with an ellipsis) */
|
|
813
|
+
const avgW = Math.floor((rightW - 1) / 2) + avgGrow;
|
|
814
|
+
const effW = rightW + 2 * avgGrow - 1 - avgW;
|
|
815
|
+
const eff = stats.efficiency;
|
|
816
|
+
const ratio = (r) => r !== null ? `${r.toFixed(1)}×` : "n/a";
|
|
817
|
+
const percent = (r) => r !== null ? `${Math.round(r * 100)}%` : "n/a";
|
|
818
|
+
const valLine = (key, width, label, value) => h(Text, { key, ...cx("accent", "truncate", dim && "dimmed") }, fit(label, Math.max(0, width - 4 - value.length)), h(Text, { key: "v", ...cx("bold") }, value));
|
|
819
|
+
const loading = [h(Text, { key: "loading", ...cx("dim", dim && "dimmed") }, "loading …")];
|
|
820
|
+
const avgList = usage === null ? loading : [
|
|
821
|
+
valLine("cost-ai", avgW, "AI cost (⌀)", statsCost(eff.costAI)),
|
|
822
|
+
valLine("cost-hr", avgW, "HR cost (⌀)", statsCost(eff.costHR)),
|
|
823
|
+
valLine("tokens", avgW, "AI token (⌀)", `${statsTokens(eff.input)} ▷ ${statsTokens(eff.output)}`),
|
|
824
|
+
valLine("lead-median", avgW, "lead (median)", statsDuration(eff.leadMedian)),
|
|
825
|
+
valLine("lead-p85", avgW, "lead (p85)", statsDuration(eff.leadP85))
|
|
826
|
+
];
|
|
827
|
+
const effList = usage === null ? loading : [
|
|
828
|
+
valLine("time-ratio", effW, "AI/HR time ratio", ratio(eff.timeRatio)),
|
|
829
|
+
valLine("cost-ratio", effW, "AI/HR cost ratio", ratio(eff.costRatio)),
|
|
830
|
+
valLine("cache-ratio", effW, "AI cache hit ratio", percent(eff.cacheRatio))
|
|
831
|
+
];
|
|
832
|
+
const tops = pair("top", TOP_H, frame("top", TOP_H, "Top-5 Tasks", 1, ...topList), h(Box, { height: TOP_H }, h(Box, { width: avgW, flexShrink: 0, flexDirection: "column" }, accentFrame("averages", TOP_H, "Finished Tasks", 1, ...avgList)), h(Box, { width: 1, flexShrink: 0 }), h(Box, { width: effW, flexShrink: 0, flexDirection: "column" }, accentFrame("efficiency", TOP_H, "Efficiency", 1, ...effList))), 2 * avgGrow);
|
|
833
|
+
/* the buttons of the time ranges, separated by dots, with the one of the shown time range
|
|
834
|
+
highlighted, and the one of the time range "total" with its number of days */
|
|
835
|
+
const ranges = h(Box, { key: "ranges", height: RANGES_H, flexShrink: 0, justifyContent: "center" }, ...statsRanges.flatMap((r, i) => [
|
|
836
|
+
...(i > 0 ? [h(Text, { key: `sep${i}`, ...cx("dim", dim && "dimmed") }, " · ")] : []),
|
|
837
|
+
h(Box, { key: r, ref: rangeRef(r) }, h(Text, cx("button", r === range && "button-active", dim && "dimmed"), ` ${statsRangeLabel(r, stats.days)} `))
|
|
838
|
+
]));
|
|
839
|
+
const shown = stats.states.reduce((n, s) => n + s.count, 0);
|
|
840
|
+
return [
|
|
841
|
+
h(Box, { key: "stats", height: boardH, paddingX: 1, flexDirection: "column", overflow: "hidden" }, ranges, chart, progress, usages, tops),
|
|
842
|
+
h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), ["statistics of ", h(Text, { key: "n", ...cx("bold") }, String(shown)),
|
|
843
|
+
` ${filter !== "" ? "filtered " : ""}tasks`, ...(range !== "total" ? [" ", h(Text, { key: "r", ...cx("bold") }, statsRangeLabels[range])] : [])])),
|
|
844
|
+
h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `←/→: select time range · /: filter tasks · v/V: ${viewCycleHint("stats", agents !== null)}`)),
|
|
845
|
+
h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `M: ${mouse ? "disable" : "enable"} mouse · ?: hide key hints · q: quit`))
|
|
846
|
+
];
|
|
847
|
+
};
|