@rse/ase 1.0.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/dst/ase-agent-definition-api.d.ts +184 -0
  2. package/dst/ase-agent-definition-api.js +6 -0
  3. package/dst/ase-agent-definition-builtin/condenser.js +42 -0
  4. package/dst/ase-agent-definition-builtin/editor.js +45 -0
  5. package/dst/ase-agent-definition-builtin/estimator.js +98 -0
  6. package/dst/ase-agent-definition-builtin/implementer.js +121 -0
  7. package/dst/ase-agent-definition-builtin/integrator.js +56 -0
  8. package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
  9. package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
  10. package/dst/ase-agent-definition-builtin/researcher.js +77 -0
  11. package/dst/ase-agent-definition-core.js +827 -0
  12. package/dst/ase-agent-fleet.js +772 -0
  13. package/dst/ase-agent-process.js +232 -0
  14. package/dst/ase-agent-relay-client.js +509 -0
  15. package/dst/ase-agent-relay-core.js +213 -0
  16. package/dst/ase-agent-relay-server-bind.js +242 -0
  17. package/dst/ase-agent-relay-server-cli.js +50 -0
  18. package/dst/ase-agent-runtime-claim.js +189 -0
  19. package/dst/ase-agent-runtime-engine.js +1100 -0
  20. package/dst/ase-agent-runtime-exec.js +275 -0
  21. package/dst/ase-agent-runtime-phase.js +581 -0
  22. package/dst/ase-agent-runtime-request.js +272 -0
  23. package/dst/ase-agent-runtime-tool.js +207 -0
  24. package/dst/ase-agent-runtime.js +1316 -0
  25. package/dst/ase-agent.js +65 -0
  26. package/dst/ase-artifact.js +6 -5
  27. package/dst/ase-config-core.js +465 -0
  28. package/dst/ase-config-schema.js +351 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +228 -111
  32. package/dst/ase-lib-daemon-cli.js +130 -0
  33. package/dst/ase-lib-hash.js +20 -0
  34. package/dst/{ase-log.js → ase-lib-log.js} +40 -7
  35. package/dst/ase-lib-server-bind.js +115 -0
  36. package/dst/ase-lib-server-cli.js +474 -0
  37. package/dst/{ase-table.js → ase-lib-table.js} +1 -1
  38. package/dst/{ase-version.js → ase-lib-version.js} +3 -0
  39. package/dst/ase-man.js +96 -0
  40. package/dst/ase-mcp.js +166 -13
  41. package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
  42. package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
  43. package/dst/ase-service-otel.js +1246 -0
  44. package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
  45. package/dst/ase-service-state.js +101 -0
  46. package/dst/ase-service.js +251 -76
  47. package/dst/ase-setup-common.js +141 -0
  48. package/dst/ase-setup-mcp.js +359 -0
  49. package/dst/ase-setup-settings.js +837 -0
  50. package/dst/ase-setup.js +113 -869
  51. package/dst/ase-spec.js +31 -19
  52. package/dst/ase-statusline.js +108 -43
  53. package/dst/ase-task-board-core.js +739 -0
  54. package/dst/ase-task-board-filter.js +127 -0
  55. package/dst/ase-task-board-flow.js +237 -0
  56. package/dst/ase-task-board-graph.js +497 -0
  57. package/dst/ase-task-board-stats.js +263 -0
  58. package/dst/ase-task-board-tui-control.js +775 -0
  59. package/dst/ase-task-board-tui-model.js +1008 -0
  60. package/dst/ase-task-board-tui-popup.js +626 -0
  61. package/dst/ase-task-board-tui-style.js +106 -0
  62. package/dst/ase-task-board-tui-view.js +847 -0
  63. package/dst/ase-task-board-tui.js +189 -0
  64. package/dst/ase-task-board-web-client.css +1 -0
  65. package/dst/ase-task-board-web-client.html +20 -0
  66. package/dst/ase-task-board-web-client.js +81 -0
  67. package/dst/ase-task-board-web-server.js +823 -0
  68. package/dst/ase-task-board.js +134 -0
  69. package/dst/ase-task-format.js +757 -0
  70. package/dst/ase-task-request.js +208 -0
  71. package/dst/ase-task-store-client.js +495 -0
  72. package/dst/ase-task-store-core.js +804 -0
  73. package/dst/ase-task-store-issues.js +237 -0
  74. package/dst/ase-task-store-plugin-api.js +6 -0
  75. package/dst/ase-task-store-plugin-ase.js +484 -0
  76. package/dst/ase-task-store-plugin-delegate.js +137 -0
  77. package/dst/ase-task-store-plugin-gitea.js +666 -0
  78. package/dst/ase-task-store-plugin-github.js +696 -0
  79. package/dst/ase-task-store-plugin-gitlab.js +668 -0
  80. package/dst/ase-task-store-server-bind.js +538 -0
  81. package/dst/ase-task-store-server-cli.js +154 -0
  82. package/dst/ase-task.js +1335 -357
  83. package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
  84. package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
  85. package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
  86. package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
  87. package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
  88. package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
  89. package/dst/ase-util.js +20 -0
  90. package/dst/ase.js +108 -32
  91. package/dst/man/ase-agent-adhoc.1 +515 -0
  92. package/dst/man/ase-agent-condenser.1 +193 -0
  93. package/dst/man/ase-agent-editor.1 +212 -0
  94. package/dst/man/ase-agent-estimator.1 +214 -0
  95. package/dst/man/ase-agent-fleet.1 +185 -0
  96. package/dst/man/ase-agent-implementer.1 +233 -0
  97. package/dst/man/ase-agent-integrator.1 +213 -0
  98. package/dst/man/ase-agent-qualifier.1 +214 -0
  99. package/dst/man/ase-agent-rebooter.1 +193 -0
  100. package/dst/man/ase-agent-relay.1 +128 -0
  101. package/dst/man/ase-agent-researcher.1 +217 -0
  102. package/dst/man/ase-agent.1 +264 -0
  103. package/dst/man/ase-artifact-list.1 +38 -0
  104. package/dst/man/ase-artifact-name.1 +39 -0
  105. package/dst/man/ase-artifact.1 +56 -0
  106. package/dst/man/ase-config-delete.1 +45 -0
  107. package/dst/man/ase-config-edit.1 +48 -0
  108. package/dst/man/ase-config-get.1 +46 -0
  109. package/dst/man/ase-config-init.1 +60 -0
  110. package/dst/man/ase-config-list.1 +42 -0
  111. package/dst/man/ase-config-set.1 +52 -0
  112. package/dst/man/ase-config.1 +225 -0
  113. package/dst/man/ase-hook-permission-request.1 +36 -0
  114. package/dst/man/ase-hook-pre-tool-use.1 +59 -0
  115. package/dst/man/ase-hook-session-end.1 +47 -0
  116. package/dst/man/ase-hook-session-persona.1 +37 -0
  117. package/dst/man/ase-hook-session-start.1 +81 -0
  118. package/dst/man/ase-hook-stop.1 +34 -0
  119. package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
  120. package/dst/man/ase-hook.1 +51 -0
  121. package/dst/man/ase-man.1 +67 -0
  122. package/dst/man/ase-mcp.1 +61 -0
  123. package/dst/man/ase-service-log.1 +43 -0
  124. package/dst/man/ase-service-send.1 +47 -0
  125. package/dst/man/ase-service-start.1 +54 -0
  126. package/dst/man/ase-service-status.1 +46 -0
  127. package/dst/man/ase-service-stop.1 +37 -0
  128. package/dst/man/ase-service.1 +85 -0
  129. package/dst/man/ase-setup-disable.1 +44 -0
  130. package/dst/man/ase-setup-enable.1 +45 -0
  131. package/dst/man/ase-setup-install.1 +73 -0
  132. package/dst/man/ase-setup-mcp.1 +124 -0
  133. package/dst/man/ase-setup-status.1 +60 -0
  134. package/dst/man/ase-setup-statusline.1 +92 -0
  135. package/dst/man/ase-setup-telemetry.1 +92 -0
  136. package/dst/man/ase-setup-uninstall.1 +59 -0
  137. package/dst/man/ase-setup-update.1 +64 -0
  138. package/dst/man/ase-setup.1 +93 -0
  139. package/dst/man/ase-spec-export.1 +62 -0
  140. package/dst/man/ase-spec-lint.1 +40 -0
  141. package/dst/man/ase-spec-preview.1 +40 -0
  142. package/dst/man/ase-spec.1 +46 -0
  143. package/dst/man/ase-statusline.1 +189 -0
  144. package/dst/man/ase-task-assign.1 +71 -0
  145. package/dst/man/ase-task-board.1 +384 -0
  146. package/dst/man/ase-task-delete.1 +35 -0
  147. package/dst/man/ase-task-edit.1 +46 -0
  148. package/dst/man/ase-task-idscheme.1 +65 -0
  149. package/dst/man/ase-task-lifecycle.1 +57 -0
  150. package/dst/man/ase-task-list.1 +47 -0
  151. package/dst/man/ase-task-load.1 +36 -0
  152. package/dst/man/ase-task-newid.1 +61 -0
  153. package/dst/man/ase-task-purge.1 +37 -0
  154. package/dst/man/ase-task-rename.1 +44 -0
  155. package/dst/man/ase-task-request.1 +61 -0
  156. package/dst/man/ase-task-save.1 +48 -0
  157. package/dst/man/ase-task-status.1 +55 -0
  158. package/dst/man/ase-task-store.1 +194 -0
  159. package/dst/man/ase-task-view.1 +40 -0
  160. package/dst/man/ase-task.1 +160 -0
  161. package/dst/man/ase-util-compat.1 +25 -0
  162. package/dst/man/ase-util-diagram.1 +76 -0
  163. package/dst/man/ase-util-meta.1 +45 -0
  164. package/dst/man/ase-util-metric.1 +54 -0
  165. package/dst/man/ase-util-mint.1 +59 -0
  166. package/dst/man/ase-util-worktree.1 +72 -0
  167. package/dst/man/ase-util.1 +50 -0
  168. package/dst/man/ase.1 +124 -0
  169. package/package.json +79 -21
  170. package/plugin/.claude-plugin/plugin.json +1 -1
  171. package/plugin/.codex-plugin/plugin.json +1 -1
  172. package/plugin/.github/plugin/plugin.json +1 -1
  173. package/plugin/agents/ase-meta-diagram.md +12 -8
  174. package/plugin/agents/ase-repo-review.md +322 -0
  175. package/plugin/hooks/hooks-codex.json +5 -0
  176. package/plugin/hooks/hooks-copilot.json +5 -0
  177. package/plugin/hooks/hooks.json +8 -0
  178. package/plugin/meta/ase-common-approach.md +117 -0
  179. package/plugin/meta/ase-common-changeset.md +256 -0
  180. package/plugin/meta/ase-common-code.md +10 -173
  181. package/plugin/meta/ase-common-grill.md +317 -0
  182. package/plugin/meta/ase-common-resolve.md +305 -0
  183. package/plugin/meta/ase-common-task.md +83 -4
  184. package/plugin/meta/ase-constitution.md +0 -2
  185. package/plugin/meta/ase-format-specbook.md +208 -25
  186. package/plugin/meta/ase-format-specbook.yaml +242 -851
  187. package/plugin/meta/ase-format-task.md +669 -125
  188. package/plugin/meta/ase-persona.md +3 -4
  189. package/plugin/meta/ase-skill.md +9 -0
  190. package/plugin/mods/ase-mods.ts +409 -0
  191. package/plugin/mods/claude-code.d.ts +13417 -0
  192. package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
  193. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  194. package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
  195. package/plugin/skills/ase-code-analyze/help.md +29 -4
  196. package/plugin/skills/ase-code-edit/SKILL.md +107 -181
  197. package/plugin/skills/ase-code-edit/help.md +118 -31
  198. package/plugin/skills/ase-code-lint/SKILL.md +64 -13
  199. package/plugin/skills/ase-code-lint/help.md +32 -2
  200. package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
  201. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  202. package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
  203. package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
  204. package/plugin/skills/ase-help-intent/data.md +2164 -1087
  205. package/plugin/skills/ase-help-intent/help.md +1 -1
  206. package/plugin/skills/ase-help-skill/catalog.md +12 -9
  207. package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
  208. package/plugin/skills/ase-meta-config/SKILL.md +3 -4
  209. package/plugin/skills/ase-meta-config/help.md +31 -10
  210. package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
  211. package/plugin/skills/ase-meta-mint/help.md +10 -5
  212. package/plugin/skills/ase-meta-why/help.md +1 -1
  213. package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
  214. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  215. package/plugin/skills/ase-meta-workflow/sample.md +3 -3
  216. package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
  217. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
  218. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
  219. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
  220. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
  221. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
  222. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
  223. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
  224. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
  225. package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
  226. package/plugin/skills/ase-repo-merge/help.md +107 -0
  227. package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
  228. package/plugin/skills/ase-repo-resolve/help.md +102 -0
  229. package/plugin/skills/ase-repo-review/SKILL.md +406 -0
  230. package/plugin/skills/ase-repo-review/help.md +181 -0
  231. package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
  232. package/plugin/skills/ase-spec-edit/help.md +107 -26
  233. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  234. package/plugin/skills/ase-task-assign/SKILL.md +111 -0
  235. package/plugin/skills/ase-task-assign/help.md +119 -0
  236. package/plugin/skills/ase-task-condense/SKILL.md +21 -21
  237. package/plugin/skills/ase-task-condense/help.md +12 -10
  238. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  239. package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
  240. package/plugin/skills/ase-task-dissect/help.md +10 -9
  241. package/plugin/skills/ase-task-edit/SKILL.md +379 -94
  242. package/plugin/skills/ase-task-edit/help.md +95 -22
  243. package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
  244. package/plugin/skills/ase-task-estimate/help.md +158 -0
  245. package/plugin/skills/ase-task-grill/SKILL.md +246 -138
  246. package/plugin/skills/ase-task-grill/help.md +129 -26
  247. package/plugin/skills/ase-task-id/SKILL.md +8 -1
  248. package/plugin/skills/ase-task-id/help.md +6 -3
  249. package/plugin/skills/ase-task-implement/SKILL.md +535 -145
  250. package/plugin/skills/ase-task-implement/help.md +138 -47
  251. package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
  252. package/plugin/skills/ase-task-integrate/help.md +187 -0
  253. package/plugin/skills/ase-task-list/SKILL.md +55 -23
  254. package/plugin/skills/ase-task-list/help.md +45 -21
  255. package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
  256. package/plugin/skills/ase-task-reboot/help.md +28 -8
  257. package/plugin/skills/ase-task-rename/SKILL.md +10 -2
  258. package/plugin/skills/ase-task-research/SKILL.md +386 -0
  259. package/plugin/skills/ase-task-research/help.md +102 -0
  260. package/plugin/skills/ase-task-status/SKILL.md +137 -0
  261. package/plugin/skills/ase-task-status/help.md +80 -0
  262. package/plugin/skills/ase-task-view/SKILL.md +49 -24
  263. package/plugin/skills/ase-task-view/help.md +19 -9
  264. package/plugin/agents/ase-meta-review.md +0 -187
  265. package/plugin/skills/ase-code-craft/SKILL.md +0 -264
  266. package/plugin/skills/ase-code-craft/help.md +0 -122
  267. package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
  268. package/plugin/skills/ase-code-refactor/help.md +0 -109
  269. package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
  270. package/plugin/skills/ase-code-resolve/help.md +0 -116
  271. package/plugin/skills/ase-meta-review/SKILL.md +0 -210
  272. package/plugin/skills/ase-meta-review/help.md +0 -96
  273. package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
  274. package/plugin/skills/ase-task-preflight/help.md +0 -82
  275. /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
  276. /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
  277. /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
  278. /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
  279. /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
+ };