opencode-dux 1.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 (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. package/src/utils/zip-extractor.ts +102 -0
package/src/tui.ts ADDED
@@ -0,0 +1,1539 @@
1
+ import type { TuiPluginModule } from '@opencode-ai/plugin/tui';
2
+ import type { JSX } from '@opentui/solid';
3
+ import { createElement, insert, setProp } from '@opentui/solid';
4
+ import { createSignal } from 'solid-js';
5
+ import { AGENT_SIDEBAR_DESCRIPTIONS } from './agents/descriptions';
6
+ import type {
7
+ NeuralwattUsage,
8
+ NeuralwattUsageEntry,
9
+ } from './subscriptions/types';
10
+ import type { SubscriptionUsageEntry } from './tui-state';
11
+ import {
12
+ deriveSessionContextPct,
13
+ mergedOrchestrationSigmaAccum,
14
+ mergedSessionTree,
15
+ mergedSessionUsage,
16
+ readTuiSnapshot,
17
+ readTuiSnapshotAsync,
18
+ type SessionNode,
19
+ type TuiSnapshot,
20
+ } from './tui-state';
21
+
22
+ const PLUGIN_NAME = 'opencode-dux';
23
+ const BORDER = { type: 'single' };
24
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
25
+
26
+ const AGENT_SORT_PRIORITY: Record<string, number> = {
27
+ orchestrator: 0,
28
+ explorer: 1,
29
+ librarian: 2,
30
+ steward: 3,
31
+ fixer: 4,
32
+ oracle: 5,
33
+ designer: 6,
34
+ interpreter: 7,
35
+ };
36
+
37
+ /** Model id display cap for the Agents sidebar (active session list). */
38
+ const SIDEBAR_MODEL_DISPLAY_MAX = 20;
39
+
40
+ /**
41
+ * Orchestrating panel - root session (orchestrator row only).
42
+ * Tune independently from child rows and from the Agents sidebar.
43
+ */
44
+ const ORCH_ROOT_TITLE_DISPLAY_MAX = 22;
45
+ const ORCH_ROOT_SESSION_ID_DISPLAY_MAX = 27;
46
+ /** Hyphen-segment model id cap (incl. ellipsis); OpenCode variant suffix stays full. */
47
+ const ORCH_ROOT_MODEL_DISPLAY_MAX = 28;
48
+
49
+ /**
50
+ * Orchestrating panel - every nested subagent under the root (recursive).
51
+ */
52
+ const ORCH_CHILD_MODEL_DISPLAY_MAX = 22;
53
+
54
+ const ORCH_DEFAULT_TITLE_LABEL = 'New session';
55
+
56
+ type Child = JSX.Element | string | number | null | undefined | false;
57
+
58
+ function element(
59
+ tag: string,
60
+ props: Record<string, unknown>,
61
+ children: Child[] = [],
62
+ ) {
63
+ const node = createElement(tag);
64
+
65
+ for (const [key, value] of Object.entries(props)) {
66
+ if (value !== undefined) setProp(node, key, value);
67
+ }
68
+
69
+ for (const child of children) {
70
+ if (child === null || child === undefined || child === false) continue;
71
+ insert(node, child);
72
+ }
73
+
74
+ return node as JSX.Element;
75
+ }
76
+
77
+ function text(props: Record<string, unknown>, children: Child[]) {
78
+ return element('text', props, children);
79
+ }
80
+
81
+ function box(props: Record<string, unknown>, children: Child[] = []) {
82
+ return element('box', props, children);
83
+ }
84
+
85
+ function truncate(value: string, max = 24): string {
86
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
87
+ }
88
+
89
+ export function formatTokenAbbrev(value: number): string {
90
+ if (!Number.isFinite(value) || value <= 0) return '0';
91
+ if (value < 1000) return Math.round(value).toString();
92
+ if (value < 1_000_000) {
93
+ const k = Math.round(value / 1000);
94
+ if (k >= 1000) return `${Math.round(value / 1_000_000)}M`;
95
+ return `${k}K`;
96
+ }
97
+ return `${Math.round(value / 1_000_000)}M`;
98
+ }
99
+
100
+ export function formatTokenAbbrevDecimal(value: number): string {
101
+ if (!Number.isFinite(value) || value <= 0) return '0';
102
+ if (value < 1000) return Math.round(value).toString();
103
+ if (value < 1_000_000) {
104
+ return `${(value / 1000).toFixed(1)}K`;
105
+ }
106
+ return `${(value / 1_000_000).toFixed(1)}M`;
107
+ }
108
+
109
+ function formatTokenExact(value: number): string {
110
+ if (!Number.isFinite(value) || value <= 0) return '0';
111
+ return new Intl.NumberFormat('en-US').format(Math.round(value));
112
+ }
113
+
114
+ export function formatSidebarModelName(model: string): string {
115
+ const lastSlash = model.lastIndexOf('/');
116
+ return lastSlash === -1 ? model : model.slice(lastSlash + 1);
117
+ }
118
+
119
+ const ELLIPSIS_CHAR = '…';
120
+
121
+ /**
122
+ * Shorten a model id (basename after `/`) to at most `maxTotalLen` characters,
123
+ * keeping full `-` segments (e.g. `Qwen3.5-397B-A17B-FP8` → `Qwen3.5-397B…`).
124
+ */
125
+ function truncateModelBasenameByHyphenSegments(
126
+ name: string,
127
+ maxTotalLen: number,
128
+ ): string {
129
+ if (name.length <= maxTotalLen) return name;
130
+ const budget = maxTotalLen - ELLIPSIS_CHAR.length;
131
+ if (budget <= 0) return truncate(name, maxTotalLen);
132
+
133
+ const parts = name.split('-').filter((p) => p.length > 0);
134
+ if (parts.length === 0) return truncate(name, maxTotalLen);
135
+ const head = parts[0];
136
+ if (parts.length === 1) {
137
+ return head ? truncate(head, maxTotalLen) : truncate(name, maxTotalLen);
138
+ }
139
+ if (!head) return truncate(name, maxTotalLen);
140
+ if (head.length > budget) return truncate(head, maxTotalLen);
141
+
142
+ let acc = head;
143
+ for (let i = 1; i < parts.length; i++) {
144
+ const piece = parts[i];
145
+ if (!piece) continue;
146
+ const next = `${acc}-${piece}`;
147
+ if (next.length > budget) break;
148
+ acc = next;
149
+ }
150
+ if (acc.length >= name.length) return name;
151
+ return `${acc}${ELLIPSIS_CHAR}`;
152
+ }
153
+
154
+ /**
155
+ * Show `provider/model-id` compactly: shorten long basenames on hyphen
156
+ * boundaries, then append the OpenCode `variant` in full (`model… - High`).
157
+ */
158
+ export function formatSidebarModelAndVariant(
159
+ rawModel: string | undefined,
160
+ variant: string | undefined,
161
+ maxModelDisplayLen: number = SIDEBAR_MODEL_DISPLAY_MAX,
162
+ ): string {
163
+ const name = rawModel ? formatSidebarModelName(rawModel) : '';
164
+ const extraVariant = variant?.trim() ?? '';
165
+
166
+ if (!name) return extraVariant;
167
+
168
+ const modelShown = truncateModelBasenameByHyphenSegments(
169
+ name,
170
+ maxModelDisplayLen,
171
+ );
172
+ if (!extraVariant) return modelShown;
173
+ return `${modelShown} - ${extraVariant}`;
174
+ }
175
+
176
+ export function formatAgentName(name: string): string {
177
+ if (name.length <= 16) return name;
178
+ return `${name.slice(0, 13)}...`;
179
+ }
180
+
181
+ export function formatDuration(ms: number): string {
182
+ if (!Number.isFinite(ms) || ms < 0) return '0:00';
183
+ const totalSeconds = Math.floor(ms / 1000);
184
+ const hours = Math.floor(totalSeconds / 3600);
185
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
186
+ const seconds = totalSeconds % 60;
187
+ if (hours > 0) {
188
+ return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
189
+ }
190
+ return `${minutes}:${String(seconds).padStart(2, '0')}`;
191
+ }
192
+
193
+ export function formatSessionUsageRows(
194
+ snapshot: TuiSnapshot,
195
+ sessionID: string,
196
+ options?: { abbreviateLeft?: boolean },
197
+ ): {
198
+ contextPct: number;
199
+ ctxLabel: string;
200
+ ctxValue: string;
201
+ ioInputAbbrev: string;
202
+ ioOutputAbbrev: string;
203
+ cacheLabel: string;
204
+ cacheValue: string;
205
+ cacheReadAbbrev: string;
206
+ cacheWriteAbbrev: string;
207
+ } {
208
+ const abbreviateLeft = options?.abbreviateLeft ?? false;
209
+ const usage = mergedSessionUsage(snapshot)[sessionID];
210
+ const contextUsed = usage?.contextUsed ?? 0;
211
+ const contextLimit = usage?.contextLimit ?? 0;
212
+ const contextPct = Math.round(
213
+ deriveSessionContextPct(contextUsed, contextLimit),
214
+ );
215
+ const inputTotal = usage?.input ?? 0;
216
+ const outputTotal = usage?.output ?? 0;
217
+ const cacheRead = usage?.cacheRead ?? 0;
218
+ const cacheWrite = usage?.cacheWrite ?? 0;
219
+ const cacheTotal = cacheRead + cacheWrite;
220
+
221
+ return {
222
+ contextPct,
223
+ ctxLabel: 'CTX',
224
+ ctxValue: `${abbreviateLeft ? formatTokenAbbrevDecimal(contextUsed) : formatTokenExact(contextUsed)}/${abbreviateLeft ? formatTokenAbbrev(contextLimit) : formatTokenExact(contextLimit)} (${contextPct}%)`,
225
+ ioInputAbbrev: formatTokenAbbrev(inputTotal),
226
+ ioOutputAbbrev: formatTokenAbbrev(outputTotal),
227
+ cacheLabel: 'CACHE',
228
+ // User preference: don't abbreviate cache usage in the sidebar.
229
+ cacheValue: formatTokenExact(cacheTotal),
230
+ cacheReadAbbrev: formatTokenExact(cacheRead),
231
+ cacheWriteAbbrev: formatTokenExact(cacheWrite),
232
+ };
233
+ }
234
+
235
+ export function aggregateOrchestrationUsage(
236
+ snapshot: TuiSnapshot,
237
+ rootSessionID: string,
238
+ ): {
239
+ inputTotal: number;
240
+ outputTotal: number;
241
+ cacheRead: number;
242
+ cacheWrite: number;
243
+ contextUsed: number;
244
+ } {
245
+ const accum = mergedOrchestrationSigmaAccum(snapshot)[rootSessionID];
246
+ if (!accum) {
247
+ return {
248
+ inputTotal: 0,
249
+ outputTotal: 0,
250
+ cacheRead: 0,
251
+ cacheWrite: 0,
252
+ contextUsed: 0,
253
+ };
254
+ }
255
+ return {
256
+ inputTotal: accum.input,
257
+ outputTotal: accum.output,
258
+ cacheRead: accum.cacheRead,
259
+ cacheWrite: accum.cacheWrite,
260
+ contextUsed: accum.contextUsed,
261
+ };
262
+ }
263
+
264
+ export function getSidebarAgentNames(snapshot: TuiSnapshot): string[] {
265
+ void snapshot;
266
+ const names = Object.keys(AGENT_SIDEBAR_DESCRIPTIONS);
267
+ return names.sort((a, b) => {
268
+ const pa = AGENT_SORT_PRIORITY[a] ?? 99;
269
+ const pb = AGENT_SORT_PRIORITY[b] ?? 99;
270
+ if (pa !== pb) return pa - pb;
271
+ return a.localeCompare(b);
272
+ });
273
+ }
274
+
275
+ function formatUsageTime(iso: string): string {
276
+ const diff = new Date(iso).getTime() - Date.now();
277
+ if (diff <= 0) return 'now';
278
+ const totalMin = Math.ceil(diff / 60000);
279
+ const hours = Math.floor(totalMin / 60);
280
+ const minutes = totalMin % 60;
281
+ if (hours > 24) {
282
+ const days = Math.floor(hours / 24);
283
+ return `${days}d ${hours % 24}h`;
284
+ }
285
+ if (hours > 0) return `${hours}h ${minutes}m`;
286
+ return `${minutes}m`;
287
+ }
288
+
289
+ /** Neuralwatt token counts: integer with grouping (e.g. 66,837,723), no K/M/B shorthand. */
290
+ function neuralwattTokensFormatted(tokens: number): string {
291
+ if (!Number.isFinite(tokens)) return '0';
292
+ return Math.trunc(tokens).toLocaleString('en-US');
293
+ }
294
+
295
+ function pushNeuralwattMonthlyTokensRow(
296
+ rows: Child[],
297
+ theme: { textMuted: unknown },
298
+ u: NeuralwattUsage,
299
+ ): void {
300
+ rows.push(
301
+ box({ width: '100%', flexDirection: 'row' }, [
302
+ text({ fg: theme.textMuted }, [
303
+ ` ${neuralwattTokensFormatted(u.current_month.tokens)} Tokens this month`,
304
+ ]),
305
+ ]),
306
+ );
307
+ }
308
+
309
+ const BAR_WIDTH = 18;
310
+ const SIGMA_TOTAL_COLOR = '#F5B041';
311
+
312
+ /** Gap between the two compact metrics on the right (icon+value each). */
313
+ const METRIC_PAIR_GAP = ' ';
314
+
315
+ type MetricPairTheme = {
316
+ leftFg: unknown;
317
+ rightFg: unknown;
318
+ gapFg: unknown;
319
+ };
320
+
321
+ function renderMetricPairRight(
322
+ leftIcon: string,
323
+ leftValue: string,
324
+ rightIcon: string,
325
+ rightValue: string,
326
+ colors: MetricPairTheme,
327
+ ): Child {
328
+ return box({ flexDirection: 'row', flexShrink: 0 }, [
329
+ text({ fg: colors.leftFg }, [`${leftIcon} ${leftValue}`]),
330
+ text({ fg: colors.gapFg }, [METRIC_PAIR_GAP]),
331
+ text({ fg: colors.rightFg }, [`${rightIcon} ${rightValue}`]),
332
+ ]);
333
+ }
334
+
335
+ function renderUsageBar(percent: number): string {
336
+ const filled = Math.round((percent / 100) * BAR_WIDTH);
337
+ const empty = BAR_WIDTH - filled;
338
+ return '█'.repeat(filled) + '░'.repeat(empty);
339
+ }
340
+
341
+ function getUsageColor(percentRemaining: number): string {
342
+ if (percentRemaining < 25) return '#E74C3C'; // red
343
+ if (percentRemaining < 50) return '#F39C12'; // amber/yellow
344
+ return ''; // empty = use default theme color
345
+ }
346
+
347
+ function renderOpenCodeGoBars(
348
+ entry: SubscriptionUsageEntry & { provider: 'opencode-go' },
349
+ rows: Child[],
350
+ theme: { text: unknown; textMuted: unknown; accent: unknown },
351
+ ): void {
352
+ const windows: Array<{
353
+ label: string;
354
+ w: { percentRemaining: number; resetTimeIso: string };
355
+ }> = [];
356
+
357
+ if (entry.rolling) windows.push({ label: 'R', w: entry.rolling });
358
+ if (entry.weekly) windows.push({ label: 'W', w: entry.weekly });
359
+ if (entry.monthly) windows.push({ label: 'M', w: entry.monthly });
360
+
361
+ for (let i = 0; i < windows.length; i++) {
362
+ const { label, w } = windows[i];
363
+ if (!w) continue;
364
+ const usageColor = getUsageColor(w.percentRemaining);
365
+ const bar = renderUsageBar(w.percentRemaining);
366
+ const pct = w.percentRemaining.toFixed(0).padStart(3);
367
+ const timeLeft = formatUsageTime(w.resetTimeIso);
368
+
369
+ rows.push(
370
+ box(
371
+ {
372
+ width: '100%',
373
+ flexDirection: 'row',
374
+ justifyContent: 'space-between',
375
+ },
376
+ [
377
+ box({ flexDirection: 'row' }, [
378
+ text({ fg: theme.accent }, [`${label} `]),
379
+ text({ fg: usageColor || theme.text }, [bar]),
380
+ text({ fg: usageColor || theme.textMuted }, [` ${pct}%`]),
381
+ ]),
382
+ text({ fg: theme.textMuted }, [timeLeft]),
383
+ ],
384
+ ),
385
+ );
386
+ }
387
+ }
388
+
389
+ function renderNeuralwattUsage(
390
+ entry: NeuralwattUsageEntry,
391
+ rows: Child[],
392
+ theme: { text: unknown; textMuted: unknown; accent: unknown },
393
+ ): void {
394
+ const { subscription, balance, usage: u } = entry;
395
+
396
+ if (subscription && subscription.status === 'active') {
397
+ // Active subscription: show kWh bar with remaining and reset time
398
+ const kwhIncluded = subscription.kwh_included ?? 0;
399
+ const kwhUsed = subscription.kwh_used ?? 0;
400
+ const kwhRemaining = subscription.kwh_remaining ?? 0;
401
+
402
+ if (kwhIncluded > 0) {
403
+ const kwhPct = Math.min((kwhUsed / kwhIncluded) * 100, 100);
404
+ const bar = renderUsageBar(100 - kwhPct); // remaining percent
405
+ const remaining = kwhRemaining.toFixed(1);
406
+ const resetTime = subscription.current_period_end
407
+ ? formatUsageTime(subscription.current_period_end)
408
+ : '';
409
+ const color = kwhPct > 90 ? '#E74C3C' : kwhPct > 75 ? '#F39C12' : '';
410
+
411
+ rows.push(
412
+ box(
413
+ {
414
+ width: '100%',
415
+ flexDirection: 'row',
416
+ justifyContent: 'space-between',
417
+ },
418
+ [
419
+ box({ flexDirection: 'row' }, [
420
+ text({ fg: theme.accent }, ['⚡ ']),
421
+ text({ fg: color || theme.text }, [bar]),
422
+ text({ fg: color || theme.textMuted }, [` ${remaining}kWh`]),
423
+ ]),
424
+ text({ fg: theme.textMuted }, [resetTime]),
425
+ ],
426
+ ),
427
+ );
428
+ }
429
+
430
+ // Also show monthly cost
431
+ rows.push(
432
+ box(
433
+ {
434
+ width: '100%',
435
+ flexDirection: 'row',
436
+ justifyContent: 'space-between',
437
+ },
438
+ [
439
+ text({ fg: theme.textMuted }, [
440
+ ` $${u.current_month.cost_usd.toFixed(2)} this month`,
441
+ ]),
442
+ text({ fg: theme.textMuted }, [
443
+ `⚡ ${u.current_month.energy_kwh.toFixed(1)} kWh`,
444
+ ]),
445
+ ],
446
+ ),
447
+ );
448
+ pushNeuralwattMonthlyTokensRow(rows, theme, u);
449
+ } else if (subscription && subscription.status !== 'active') {
450
+ // Non-active subscription (canceling, past_due, paused, trialing)
451
+ const statusColor =
452
+ subscription.status === 'past_due' || subscription.status === 'canceling'
453
+ ? '#E74C3C'
454
+ : '#F39C12';
455
+
456
+ rows.push(
457
+ box(
458
+ {
459
+ width: '100%',
460
+ flexDirection: 'row',
461
+ justifyContent: 'space-between',
462
+ },
463
+ [
464
+ text({ fg: statusColor }, [` Status: ${subscription.status}`]),
465
+ text({ fg: theme.textMuted }, [
466
+ `⚡ ${u.current_month.energy_kwh.toFixed(1)} kWh`,
467
+ ]),
468
+ ],
469
+ ),
470
+ );
471
+
472
+ // Show kWh bar if available
473
+ const kwhIncluded = subscription.kwh_included ?? 0;
474
+ const kwhUsed = subscription.kwh_used ?? 0;
475
+ const kwhRemaining = subscription.kwh_remaining ?? 0;
476
+ if (kwhIncluded > 0) {
477
+ const kwhPct = Math.min((kwhUsed / kwhIncluded) * 100, 100);
478
+ const bar = renderUsageBar(100 - kwhPct);
479
+ const remaining = kwhRemaining.toFixed(1);
480
+ const resetTime = subscription.current_period_end
481
+ ? formatUsageTime(subscription.current_period_end)
482
+ : '';
483
+ const color = kwhPct > 90 ? '#E74C3C' : kwhPct > 75 ? '#F39C12' : '';
484
+
485
+ rows.push(
486
+ box(
487
+ {
488
+ width: '100%',
489
+ flexDirection: 'row',
490
+ justifyContent: 'space-between',
491
+ },
492
+ [
493
+ box({ flexDirection: 'row' }, [
494
+ text({ fg: theme.accent }, ['⚡ ']),
495
+ text({ fg: color || theme.text }, [bar]),
496
+ text({ fg: color || theme.textMuted }, [` ${remaining}kWh`]),
497
+ ]),
498
+ text({ fg: theme.textMuted }, [resetTime]),
499
+ ],
500
+ ),
501
+ );
502
+ }
503
+
504
+ // Show credits if available
505
+ if (balance.credits_remaining_usd > 0) {
506
+ rows.push(
507
+ box(
508
+ {
509
+ width: '100%',
510
+ flexDirection: 'row',
511
+ justifyContent: 'space-between',
512
+ },
513
+ [
514
+ text({ fg: theme.textMuted }, [
515
+ ` 💰 $${balance.credits_remaining_usd.toFixed(2)} remaining`,
516
+ ]),
517
+ text({ fg: theme.textMuted }, [
518
+ `$${u.current_month.cost_usd.toFixed(2)}/mo`,
519
+ ]),
520
+ ],
521
+ ),
522
+ );
523
+ }
524
+ pushNeuralwattMonthlyTokensRow(rows, theme, u);
525
+ } else {
526
+ // No subscription (credit-only): show credits and monthly usage
527
+ rows.push(
528
+ box(
529
+ {
530
+ width: '100%',
531
+ flexDirection: 'row',
532
+ justifyContent: 'space-between',
533
+ },
534
+ [
535
+ text({ fg: theme.text }, [
536
+ `💰 $${balance.credits_remaining_usd.toFixed(2)} remaining`,
537
+ ]),
538
+ text({ fg: theme.textMuted }, [
539
+ `⚡ ${u.current_month.energy_kwh.toFixed(3)} kWh/mo`,
540
+ ]),
541
+ ],
542
+ ),
543
+ );
544
+ rows.push(
545
+ box(
546
+ {
547
+ width: '100%',
548
+ flexDirection: 'row',
549
+ justifyContent: 'space-between',
550
+ },
551
+ [
552
+ text({ fg: theme.textMuted }, [
553
+ ` $${u.current_month.cost_usd.toFixed(2)} this month`,
554
+ ]),
555
+ ],
556
+ ),
557
+ );
558
+ pushNeuralwattMonthlyTokensRow(rows, theme, u);
559
+ }
560
+ }
561
+
562
+ function renderSubscriptionPanel(
563
+ snapshot: TuiSnapshot,
564
+ theme: {
565
+ text: unknown;
566
+ textMuted: unknown;
567
+ accent: unknown;
568
+ borderActive: unknown;
569
+ },
570
+ ): Child[] {
571
+ const usage = snapshot.subscriptionUsage ?? {};
572
+ const usageEntries = Object.entries(usage).sort(([, a], [, b]) => {
573
+ if (a.provider !== b.provider) return a.provider.localeCompare(b.provider);
574
+ return a.accountName.localeCompare(b.accountName);
575
+ });
576
+ if (usageEntries.length === 0) return [];
577
+
578
+ const rows: Child[] = [];
579
+ let isFirstAccount = true;
580
+
581
+ for (const [, entry] of usageEntries) {
582
+ const name = entry.accountName;
583
+ const activeName = snapshot.activeSubscriptionByProvider?.[entry.provider];
584
+ const isActive = activeName === name;
585
+ const providerLabel = entry.provider === 'neuralwatt' ? ' [nw]' : ' [go]';
586
+
587
+ if (!isFirstAccount) {
588
+ rows.push(box({ width: '100%', height: 1 }));
589
+ }
590
+ isFirstAccount = false;
591
+
592
+ if (entry.error) {
593
+ rows.push(
594
+ box({ width: '100%', flexDirection: 'row' }, [
595
+ text(isActive ? { fg: theme.accent } : { fg: theme.text }, [
596
+ isActive
597
+ ? `★ ${truncate(name, 18)}${providerLabel}`
598
+ : `${truncate(name, 16)}${providerLabel}`,
599
+ ]),
600
+ text({ fg: theme.textMuted }, [' ⚠️']),
601
+ ]),
602
+ );
603
+ rows.push(
604
+ text({ fg: theme.textMuted }, [` ${truncate(entry.error, 56)}`]),
605
+ );
606
+ continue;
607
+ }
608
+
609
+ const displayName = isActive
610
+ ? `★ ${truncate(name, 18)}${providerLabel}`
611
+ : `${truncate(name, 16)}${providerLabel}`;
612
+
613
+ rows.push(
614
+ box({ width: '100%', flexDirection: 'row' }, [
615
+ text(isActive ? { fg: theme.accent } : { fg: theme.text }, [
616
+ displayName,
617
+ ]),
618
+ ]),
619
+ );
620
+
621
+ // Provider-specific rendering
622
+ if (entry.provider === 'opencode-go') {
623
+ renderOpenCodeGoBars(entry, rows, theme);
624
+ } else if (entry.provider === 'neuralwatt') {
625
+ renderNeuralwattUsage(entry, rows, theme);
626
+ } else {
627
+ rows.push(
628
+ text({ fg: '#F39C12' }, [
629
+ ' ⚠️ Provider field missing - re-add account with /subscriptions',
630
+ ]),
631
+ );
632
+ }
633
+ }
634
+
635
+ return rows;
636
+ }
637
+
638
+ const FLASH_DURATION_MS = 2000;
639
+
640
+ function getStatusText(snapshot: TuiSnapshot, sessionID: string): string {
641
+ return mergedSessionTree(snapshot)[sessionID]?.status ?? '-';
642
+ }
643
+
644
+ function getStatusWithDuration(
645
+ snapshot: TuiSnapshot,
646
+ sessionID: string,
647
+ node: SessionNode,
648
+ now: number,
649
+ ): string {
650
+ const status = getStatusText(snapshot, sessionID);
651
+ // Only show duration for running sessions (busy/retry)
652
+ if (node.status === 'busy' || node.status === 'retry') {
653
+ const elapsed = now - node.createdAt;
654
+ return `${status} (${formatDuration(elapsed)})`;
655
+ }
656
+ return status;
657
+ }
658
+
659
+ function getSpinnerChar(now: number): string {
660
+ return SPINNER_FRAMES[Math.floor(now / 80) % SPINNER_FRAMES.length];
661
+ }
662
+
663
+ function getStatusColor(
664
+ status: string,
665
+ theme: {
666
+ text: unknown;
667
+ textMuted: unknown;
668
+ accent: unknown;
669
+ error?: unknown; // Add optional error color
670
+ },
671
+ ): unknown {
672
+ const normalized = status.trim();
673
+ if (normalized === 'busy' || normalized.startsWith('busy '))
674
+ return theme.accent;
675
+ if (normalized === 'retry' || normalized.startsWith('retry '))
676
+ return theme.error ?? '#EF4444'; // Red for retry
677
+ if (status === 'idle') return theme.textMuted;
678
+ return theme.text;
679
+ }
680
+
681
+ /** `busy (0:12)` → status + timer; timer uses normal text color in the UI. */
682
+ function splitStatusAndTimer(
683
+ full: string,
684
+ ): { status: string; timer: string } | null {
685
+ const m = full.match(/^(\S+)\s+(\([^)]+\))$/);
686
+ if (!m) return null;
687
+ return { status: m[1], timer: full.slice(m[1].length) };
688
+ }
689
+
690
+ function renderStatusLineWithOptionalTimer(
691
+ full: string,
692
+ theme: {
693
+ text: unknown;
694
+ textMuted: unknown;
695
+ accent: unknown;
696
+ error?: unknown;
697
+ },
698
+ ): Child {
699
+ const split = splitStatusAndTimer(full);
700
+ if (!split) {
701
+ return text({ fg: getStatusColor(full, theme) }, [full]);
702
+ }
703
+ return box({ flexDirection: 'row', flexShrink: 0 }, [
704
+ text({ fg: getStatusColor(split.status, theme) }, [split.status]),
705
+ text({ fg: theme.text }, [split.timer]),
706
+ ]);
707
+ }
708
+
709
+ interface SessionEntry {
710
+ sessionID: string;
711
+ agentName: string;
712
+ running: boolean;
713
+ finished: boolean;
714
+ }
715
+
716
+ function buildOrchestratingRows(
717
+ snapshot: TuiSnapshot,
718
+ now: number,
719
+ theme: {
720
+ text: unknown;
721
+ textMuted: unknown;
722
+ accent: unknown;
723
+ error?: unknown;
724
+ },
725
+ ): [string, ...Child[]] {
726
+ const tree = mergedSessionTree(snapshot);
727
+ const usageBySession = mergedSessionUsage(snapshot);
728
+ const spinner = getSpinnerChar(now);
729
+ const isVisibleSession = (node: SessionNode): boolean => {
730
+ if (node.status === 'busy' || node.status === 'retry') return true;
731
+ if (node.status !== 'idle' || !node.finishedAt) return false;
732
+ return now - node.finishedAt < FLASH_DURATION_MS + 1000;
733
+ };
734
+ const getVisibleChildren = (parentID: string): Array<[string, SessionNode]> =>
735
+ Object.entries(tree).filter(
736
+ ([, child]) => child.parentId === parentID && isVisibleSession(child),
737
+ );
738
+ const pushUsageRows = (
739
+ rows: Child[],
740
+ sessionID: string,
741
+ prefix: string,
742
+ abbreviateLeft: boolean,
743
+ ): void => {
744
+ const metrics = formatSessionUsageRows(snapshot, sessionID, {
745
+ abbreviateLeft,
746
+ });
747
+ const isChild = !!tree[sessionID]?.parentId;
748
+
749
+ if (isChild) {
750
+ // Child session: 4-row vertical stack (after tree header + model).
751
+ // Row 1: CTX | Row 2: CACHE total | Row 3: Input/Output | Row 4: Read/Write
752
+ rows.push(
753
+ box({ width: '100%', flexDirection: 'row' }, [
754
+ text({ fg: theme.textMuted }, [prefix]),
755
+ text({ fg: theme.accent }, [`${metrics.ctxLabel} `]),
756
+ text({ fg: theme.text }, [metrics.ctxValue]),
757
+ ]),
758
+ );
759
+ const cacheTotalForRow =
760
+ (usageBySession[sessionID]?.cacheRead ?? 0) +
761
+ (usageBySession[sessionID]?.cacheWrite ?? 0);
762
+ rows.push(
763
+ box({ width: '100%', flexDirection: 'row' }, [
764
+ text({ fg: theme.textMuted }, [prefix]),
765
+ text({ fg: theme.accent }, [`${metrics.cacheLabel} `]),
766
+ text({ fg: theme.text }, [formatTokenExact(cacheTotalForRow)]),
767
+ ]),
768
+ );
769
+ rows.push(
770
+ box({ width: '100%', flexDirection: 'row' }, [
771
+ text({ fg: theme.textMuted }, [prefix]),
772
+ renderMetricPairRight(
773
+ '↓',
774
+ `Input ${metrics.ioInputAbbrev}`,
775
+ '↑',
776
+ `Output ${metrics.ioOutputAbbrev}`,
777
+ {
778
+ leftFg: '#5DADE2',
779
+ rightFg: '#58D68D',
780
+ gapFg: theme.textMuted,
781
+ },
782
+ ),
783
+ ]),
784
+ );
785
+ rows.push(
786
+ box({ width: '100%', flexDirection: 'row' }, [
787
+ text({ fg: theme.textMuted }, [prefix]),
788
+ renderMetricPairRight(
789
+ '📖',
790
+ `Read ${metrics.cacheReadAbbrev}`,
791
+ '📝',
792
+ `Write ${metrics.cacheWriteAbbrev}`,
793
+ {
794
+ leftFg: '#5DADE2',
795
+ rightFg: '#AF7AC5',
796
+ gapFg: theme.textMuted,
797
+ },
798
+ ),
799
+ ]),
800
+ );
801
+ } else {
802
+ // Orchestrator session: 2-row left-right compact layout
803
+ // Row 1: CTX ... ↓ input ↑ output
804
+ rows.push(
805
+ box(
806
+ {
807
+ width: '100%',
808
+ flexDirection: 'row',
809
+ justifyContent: 'space-between',
810
+ },
811
+ [
812
+ box({ flexDirection: 'row' }, [
813
+ text({ fg: theme.textMuted }, [prefix]),
814
+ text({ fg: theme.accent }, [`${metrics.ctxLabel} `]),
815
+ text({ fg: theme.text }, [metrics.ctxValue]),
816
+ ]),
817
+ renderMetricPairRight(
818
+ '↓',
819
+ metrics.ioInputAbbrev,
820
+ '↑',
821
+ metrics.ioOutputAbbrev,
822
+ {
823
+ leftFg: '#5DADE2',
824
+ rightFg: '#58D68D',
825
+ gapFg: theme.textMuted,
826
+ },
827
+ ),
828
+ ],
829
+ ),
830
+ );
831
+ // Row 2: CACHE ... 📖 read 📝 write
832
+ rows.push(
833
+ box(
834
+ {
835
+ width: '100%',
836
+ flexDirection: 'row',
837
+ justifyContent: 'space-between',
838
+ },
839
+ [
840
+ box({ flexDirection: 'row' }, [
841
+ text({ fg: theme.textMuted }, [prefix]),
842
+ text({ fg: theme.accent }, [`${metrics.cacheLabel} `]),
843
+ text({ fg: theme.text }, [metrics.cacheValue]),
844
+ ]),
845
+ renderMetricPairRight(
846
+ '📖',
847
+ metrics.cacheReadAbbrev,
848
+ '📝',
849
+ metrics.cacheWriteAbbrev,
850
+ {
851
+ leftFg: '#5DADE2',
852
+ rightFg: '#AF7AC5',
853
+ gapFg: theme.textMuted,
854
+ },
855
+ ),
856
+ ],
857
+ ),
858
+ );
859
+ }
860
+ };
861
+ const pushAggregateRows = (
862
+ rows: Child[],
863
+ sessionID: string,
864
+ prefix: string,
865
+ ): void => {
866
+ const totals = aggregateOrchestrationUsage(snapshot, sessionID);
867
+ const totalIo = totals.contextUsed;
868
+ const totalCache = totals.cacheRead + totals.cacheWrite;
869
+ const isChild = !!tree[sessionID]?.parentId;
870
+
871
+ if (isChild) {
872
+ rows.push(
873
+ box({ width: '100%', flexDirection: 'row' }, [
874
+ text({ fg: theme.textMuted }, [prefix]),
875
+ text({ fg: SIGMA_TOTAL_COLOR }, ['Σ TOTAL ']),
876
+ text({ fg: theme.text }, [formatTokenExact(totalIo)]),
877
+ ]),
878
+ );
879
+ rows.push(
880
+ box({ width: '100%', flexDirection: 'row' }, [
881
+ text({ fg: theme.textMuted }, [prefix]),
882
+ text({ fg: SIGMA_TOTAL_COLOR }, ['Σ CACHE ']),
883
+ text({ fg: theme.text }, [formatTokenAbbrev(totalCache)]),
884
+ ]),
885
+ );
886
+ rows.push(
887
+ box({ width: '100%', flexDirection: 'row' }, [
888
+ text({ fg: theme.textMuted }, [prefix]),
889
+ renderMetricPairRight(
890
+ '↓',
891
+ `Input ${formatTokenAbbrev(totals.inputTotal)}`,
892
+ '↑',
893
+ `Output ${formatTokenAbbrev(totals.outputTotal)}`,
894
+ {
895
+ leftFg: '#5DADE2',
896
+ rightFg: '#58D68D',
897
+ gapFg: theme.textMuted,
898
+ },
899
+ ),
900
+ ]),
901
+ );
902
+ rows.push(
903
+ box({ width: '100%', flexDirection: 'row' }, [
904
+ text({ fg: theme.textMuted }, [prefix]),
905
+ renderMetricPairRight(
906
+ '📖',
907
+ `Read ${formatTokenAbbrev(totals.cacheRead)}`,
908
+ '📝',
909
+ `Write ${formatTokenAbbrev(totals.cacheWrite)}`,
910
+ {
911
+ leftFg: '#5DADE2',
912
+ rightFg: '#AF7AC5',
913
+ gapFg: theme.textMuted,
914
+ },
915
+ ),
916
+ ]),
917
+ );
918
+ } else {
919
+ // Orchestrator session: 2-row left-right compact layout
920
+ // Row 1: Σ TOTAL [value] ... ↓ Input [value] ↑ Output [value]
921
+ rows.push(
922
+ box(
923
+ {
924
+ width: '100%',
925
+ flexDirection: 'row',
926
+ justifyContent: 'space-between',
927
+ },
928
+ [
929
+ box({ flexDirection: 'row' }, [
930
+ text({ fg: theme.textMuted }, [prefix]),
931
+ text({ fg: SIGMA_TOTAL_COLOR }, ['Σ TOTAL ']),
932
+ text({ fg: theme.text }, [formatTokenExact(totalIo)]),
933
+ ]),
934
+ renderMetricPairRight(
935
+ '↓',
936
+ formatTokenAbbrev(totals.inputTotal),
937
+ '↑',
938
+ formatTokenAbbrev(totals.outputTotal),
939
+ {
940
+ leftFg: '#5DADE2',
941
+ rightFg: '#58D68D',
942
+ gapFg: theme.textMuted,
943
+ },
944
+ ),
945
+ ],
946
+ ),
947
+ );
948
+ // Row 2: Σ CACHE [value] ... 📖 Read [value] 📝 Write [value]
949
+ rows.push(
950
+ box(
951
+ {
952
+ width: '100%',
953
+ flexDirection: 'row',
954
+ justifyContent: 'space-between',
955
+ },
956
+ [
957
+ box({ flexDirection: 'row' }, [
958
+ text({ fg: theme.textMuted }, [prefix]),
959
+ text({ fg: SIGMA_TOTAL_COLOR }, ['Σ CACHE ']),
960
+ text({ fg: theme.text }, [formatTokenExact(totalCache)]),
961
+ ]),
962
+ renderMetricPairRight(
963
+ '📖',
964
+ formatTokenAbbrev(totals.cacheRead),
965
+ '📝',
966
+ formatTokenAbbrev(totals.cacheWrite),
967
+ {
968
+ leftFg: '#5DADE2',
969
+ rightFg: '#AF7AC5',
970
+ gapFg: theme.textMuted,
971
+ },
972
+ ),
973
+ ],
974
+ ),
975
+ );
976
+ }
977
+ };
978
+
979
+ // Collect visible orchestrator sessions (running + flashing done)
980
+ const visibleOrchSessions: Array<[string, SessionNode]> = [];
981
+
982
+ for (const [id, node] of Object.entries(tree)) {
983
+ if (node.agent !== 'orchestrator') continue;
984
+ if (node.status === 'busy' || node.status === 'retry') {
985
+ visibleOrchSessions.push([id, node]);
986
+ } else if (node.status === 'idle') {
987
+ // Check if any children are still visible (running or flashing)
988
+ const hasVisibleChildren = getVisibleChildren(id).length > 0;
989
+ if (hasVisibleChildren) {
990
+ // Children still active - keep orchestrator visible (will show spinner)
991
+ visibleOrchSessions.push([id, node]);
992
+ } else if (node.finishedAt) {
993
+ // No children - flash timeout applies
994
+ const elapsed = now - node.finishedAt;
995
+ if (elapsed < FLASH_DURATION_MS + 1000) {
996
+ visibleOrchSessions.push([id, node]);
997
+ }
998
+ } else {
999
+ // Idle without finishedAt (edge case)
1000
+ visibleOrchSessions.push([id, node]);
1001
+ }
1002
+ }
1003
+ }
1004
+
1005
+ const countLabel = `${visibleOrchSessions.length} active`;
1006
+
1007
+ if (visibleOrchSessions.length === 0) {
1008
+ return [
1009
+ countLabel,
1010
+ text({ fg: theme.textMuted }, ['No active orchestrations']),
1011
+ ];
1012
+ }
1013
+
1014
+ const rows: Child[] = [];
1015
+
1016
+ const renderChildren = (parentID: string, indentPrefix: string): void => {
1017
+ const visibleChildren = getVisibleChildren(parentID);
1018
+ for (let i = 0; i < visibleChildren.length; i++) {
1019
+ const [childId, child] = visibleChildren[i];
1020
+ const isLast = i === visibleChildren.length - 1;
1021
+ const branchChar = isLast ? '└' : '├';
1022
+ const pipeChar = isLast ? ' ' : '│';
1023
+
1024
+ const childFlash =
1025
+ child.status === 'idle' &&
1026
+ child.finishedAt &&
1027
+ Math.floor((now - child.finishedAt) / 200) % 2 === 0;
1028
+ const indicator =
1029
+ child.status === 'busy' || child.status === 'retry'
1030
+ ? spinner
1031
+ : childFlash
1032
+ ? '·'
1033
+ : ' ';
1034
+ const childStatusText = getStatusWithDuration(
1035
+ snapshot,
1036
+ childId,
1037
+ child,
1038
+ now,
1039
+ );
1040
+ const childVariant = child.variant;
1041
+ const detailPrefix = `${indentPrefix}${pipeChar} `;
1042
+
1043
+ rows.push(
1044
+ box(
1045
+ {
1046
+ width: '100%',
1047
+ flexDirection: 'row',
1048
+ justifyContent: 'space-between',
1049
+ },
1050
+ [
1051
+ box({ flexDirection: 'row', flexShrink: 0 }, [
1052
+ text({ fg: theme.textMuted }, [`${indentPrefix}${branchChar}─ `]),
1053
+ text({ fg: theme.text }, [`${indicator} ${child.agent}`]),
1054
+ ]),
1055
+ renderStatusLineWithOptionalTimer(childStatusText, theme),
1056
+ ],
1057
+ ),
1058
+ );
1059
+ rows.push(
1060
+ box({ width: '100%', flexDirection: 'row' }, [
1061
+ text({ fg: theme.textMuted }, [detailPrefix]),
1062
+ text({ fg: theme.textMuted }, [
1063
+ formatSidebarModelAndVariant(
1064
+ child.model,
1065
+ childVariant,
1066
+ ORCH_CHILD_MODEL_DISPLAY_MAX,
1067
+ ),
1068
+ ]),
1069
+ ]),
1070
+ );
1071
+ pushUsageRows(rows, childId, detailPrefix, false);
1072
+
1073
+ renderChildren(childId, `${indentPrefix}${pipeChar} `);
1074
+ }
1075
+ };
1076
+
1077
+ for (const [orchId, orchNode] of visibleOrchSessions) {
1078
+ const visibleChildren = getVisibleChildren(orchId);
1079
+
1080
+ // Orchestrator dot: spinner while busy or while idle but children still
1081
+ // visible; flash dot only when idle AND all children have cleared.
1082
+ const orchShowSpinner =
1083
+ orchNode.status === 'busy' ||
1084
+ orchNode.status === 'retry' ||
1085
+ (orchNode.status === 'idle' && visibleChildren.length > 0);
1086
+ const orchFlash =
1087
+ orchNode.status === 'idle' &&
1088
+ !orchShowSpinner &&
1089
+ orchNode.finishedAt &&
1090
+ now >= orchNode.finishedAt &&
1091
+ Math.floor((now - orchNode.finishedAt) / 200) % 2 === 0;
1092
+ const orchDot = orchShowSpinner ? spinner : orchFlash ? '·' : ' ';
1093
+
1094
+ const row1Title = orchNode.title?.trim()
1095
+ ? truncate(orchNode.title, ORCH_ROOT_TITLE_DISPLAY_MAX)
1096
+ : ORCH_DEFAULT_TITLE_LABEL;
1097
+
1098
+ rows.push(
1099
+ box(
1100
+ {
1101
+ flexDirection: 'row',
1102
+ justifyContent: 'space-between',
1103
+ },
1104
+ [
1105
+ box({ flexDirection: 'row' }, [
1106
+ text({ fg: theme.accent }, [`${orchDot} `]),
1107
+ text({ fg: theme.text }, [row1Title]),
1108
+ ]),
1109
+ text({ fg: theme.text }, [
1110
+ orchNode.status === 'busy' || orchNode.status === 'retry'
1111
+ ? `(${formatDuration(now - orchNode.createdAt)})`
1112
+ : '',
1113
+ ]),
1114
+ ],
1115
+ ),
1116
+ );
1117
+ const orchStatusText = getStatusText(snapshot, orchId);
1118
+ rows.push(
1119
+ box(
1120
+ {
1121
+ width: '100%',
1122
+ flexDirection: 'row',
1123
+ justifyContent: 'space-between',
1124
+ },
1125
+ [
1126
+ box({ flexDirection: 'row', flexShrink: 0 }, [
1127
+ text({ fg: theme.textMuted }, [' ']),
1128
+ text({ fg: theme.text }, [
1129
+ truncate(orchId, ORCH_ROOT_SESSION_ID_DISPLAY_MAX),
1130
+ ]),
1131
+ ]),
1132
+ renderStatusLineWithOptionalTimer(orchStatusText, theme),
1133
+ ],
1134
+ ),
1135
+ );
1136
+ const modelLine = formatSidebarModelAndVariant(
1137
+ orchNode.model,
1138
+ orchNode.variant,
1139
+ ORCH_ROOT_MODEL_DISPLAY_MAX,
1140
+ );
1141
+ rows.push(
1142
+ box({ width: '100%', flexDirection: 'row' }, [
1143
+ text({ fg: theme.textMuted }, [' ']),
1144
+ text({ fg: theme.textMuted }, [
1145
+ modelLine.length > 0 ? modelLine : 'pending',
1146
+ ]),
1147
+ ]),
1148
+ );
1149
+ pushUsageRows(rows, orchId, ' ', true);
1150
+ pushAggregateRows(rows, orchId, ' ');
1151
+ renderChildren(orchId, ' ');
1152
+
1153
+ rows.push(box({ width: '100%', height: 1 }));
1154
+ }
1155
+
1156
+ return [countLabel, ...rows];
1157
+ }
1158
+
1159
+ function getActiveSessions(snapshot: TuiSnapshot, now: number): SessionEntry[] {
1160
+ const entries: SessionEntry[] = [];
1161
+ const tree = mergedSessionTree(snapshot);
1162
+
1163
+ for (const [sessionID, node] of Object.entries(tree)) {
1164
+ const agentName = node.agent;
1165
+ if (!agentName) continue;
1166
+
1167
+ if (node.status === 'busy' || node.status === 'retry') {
1168
+ entries.push({ sessionID, agentName, running: true, finished: false });
1169
+ } else if (node.status === 'idle' && node.finishedAt) {
1170
+ // For the orchestrator, don't flash until all children have cleared.
1171
+ // Show spinner (running: true) while any child is still visible.
1172
+ let running = false;
1173
+ if (agentName === 'orchestrator') {
1174
+ const hasVisibleChildren = Object.entries(tree).some(
1175
+ ([_cid, cnode]) =>
1176
+ cnode.parentId === sessionID &&
1177
+ (cnode.status === 'busy' ||
1178
+ cnode.status === 'retry' ||
1179
+ (cnode.status === 'idle' &&
1180
+ cnode.finishedAt &&
1181
+ now - cnode.finishedAt < FLASH_DURATION_MS + 1000)),
1182
+ );
1183
+ if (hasVisibleChildren) running = true;
1184
+ }
1185
+ // Account for polling delay: TUI may not see the finish until 1s later
1186
+ if (now - node.finishedAt < FLASH_DURATION_MS + 1000) {
1187
+ entries.push({
1188
+ sessionID,
1189
+ agentName,
1190
+ running,
1191
+ finished: !running,
1192
+ });
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ return entries;
1198
+ }
1199
+
1200
+ function renderSidebar(
1201
+ snapshot: TuiSnapshot,
1202
+ theme: {
1203
+ accent: unknown;
1204
+ borderActive: unknown;
1205
+ text: unknown;
1206
+ textMuted: unknown;
1207
+ error?: unknown;
1208
+ },
1209
+ ): JSX.Element {
1210
+ const now = Date.now();
1211
+ const mergedTreeSidebar = mergedSessionTree(snapshot);
1212
+ const sessions = getActiveSessions(snapshot, now);
1213
+ const totalActive = sessions.filter((s) => s.running).length;
1214
+ const spinner = getSpinnerChar(now);
1215
+
1216
+ const ourSessions = sessions
1217
+ .filter((s) => s.agentName in AGENT_SORT_PRIORITY)
1218
+ .sort((a, b) => {
1219
+ const pa = AGENT_SORT_PRIORITY[a.agentName] ?? 99;
1220
+ const pb = AGENT_SORT_PRIORITY[b.agentName] ?? 99;
1221
+ if (pa !== pb) return pa - pb;
1222
+ return a.agentName.localeCompare(b.agentName);
1223
+ });
1224
+
1225
+ const customSessions = sessions
1226
+ .filter((s) => !(s.agentName in AGENT_SORT_PRIORITY))
1227
+ .sort((a, b) => a.agentName.localeCompare(b.agentName));
1228
+
1229
+ const agentRows: Child[] = [];
1230
+
1231
+ interface SessionGroup {
1232
+ sessionID: string;
1233
+ agentName: string;
1234
+ running: boolean;
1235
+ finished: boolean;
1236
+ count: number;
1237
+ model: string;
1238
+ variant: string | undefined;
1239
+ }
1240
+
1241
+ const ourGroups = new Map<string, SessionGroup>();
1242
+ for (const entry of ourSessions) {
1243
+ const { sessionID, agentName, running, finished } = entry;
1244
+ const rawModel = mergedTreeSidebar[sessionID]?.model;
1245
+ const model = rawModel ? formatSidebarModelName(rawModel) : 'pending';
1246
+ const variant = mergedTreeSidebar[sessionID]?.variant;
1247
+ const key = `${agentName}\x00${model}\x00${variant ?? ''}`;
1248
+
1249
+ const group = ourGroups.get(key);
1250
+ if (group) {
1251
+ group.count++;
1252
+ group.running = group.running || running;
1253
+ group.finished = group.finished || finished;
1254
+ } else {
1255
+ ourGroups.set(key, {
1256
+ sessionID,
1257
+ agentName,
1258
+ running,
1259
+ finished,
1260
+ count: 1,
1261
+ model,
1262
+ variant,
1263
+ });
1264
+ }
1265
+ }
1266
+
1267
+ for (const entry of ourGroups.values()) {
1268
+ const { sessionID, agentName, running, finished, count, variant } = entry;
1269
+ const elapsed = finished
1270
+ ? now - (mergedTreeSidebar[sessionID]?.finishedAt ?? 0)
1271
+ : 0;
1272
+ const flashDot = finished && Math.floor(elapsed / 200) % 2 === 0;
1273
+ const indicator = running ? spinner : flashDot ? '·' : ' ';
1274
+ const desc = AGENT_SIDEBAR_DESCRIPTIONS[agentName] ?? agentName;
1275
+ const indicatorColor = theme.accent;
1276
+ const nameStr = formatAgentName(agentName);
1277
+ const descStr = truncate(desc, 10);
1278
+
1279
+ agentRows.push(
1280
+ box(
1281
+ {
1282
+ width: '100%',
1283
+ flexDirection: 'row',
1284
+ justifyContent: 'space-between',
1285
+ },
1286
+ [
1287
+ box({ flexDirection: 'row' }, [
1288
+ text({ fg: indicatorColor }, [`${indicator} `]),
1289
+ text({ fg: theme.text }, [nameStr]),
1290
+ text({ fg: theme.accent }, [` x${count}`]),
1291
+ ]),
1292
+ box({ flexDirection: 'row' }, [text({ fg: theme.text }, [descStr])]),
1293
+ ],
1294
+ ),
1295
+ );
1296
+
1297
+ const rawModel = mergedTreeSidebar[sessionID]?.model;
1298
+ const modelVariantLine = formatSidebarModelAndVariant(rawModel, variant);
1299
+ const statusText = getStatusText(snapshot, sessionID);
1300
+
1301
+ agentRows.push(
1302
+ box(
1303
+ {
1304
+ width: '100%',
1305
+ flexDirection: 'row',
1306
+ justifyContent: 'space-between',
1307
+ },
1308
+ [
1309
+ text({ fg: theme.textMuted }, [
1310
+ modelVariantLine.length > 0 ? ` ${modelVariantLine}` : ' pending',
1311
+ ]),
1312
+ text(
1313
+ {
1314
+ fg: getStatusColor(statusText, theme),
1315
+ },
1316
+ [statusText],
1317
+ ),
1318
+ ],
1319
+ ),
1320
+ );
1321
+ }
1322
+
1323
+ if (customSessions.length > 0) {
1324
+ agentRows.push(box({ width: '100%' }));
1325
+
1326
+ const customGroups = new Map<string, SessionGroup>();
1327
+ for (const entry of customSessions) {
1328
+ const { sessionID, agentName, running, finished } = entry;
1329
+ const rawModel = mergedTreeSidebar[sessionID]?.model;
1330
+ const model = rawModel ? formatSidebarModelName(rawModel) : 'pending';
1331
+ const variant = mergedTreeSidebar[sessionID]?.variant;
1332
+ const key = `${agentName}\x00${model}\x00${variant ?? ''}`;
1333
+
1334
+ const group = customGroups.get(key);
1335
+ if (group) {
1336
+ group.count++;
1337
+ group.running = group.running || running;
1338
+ group.finished = group.finished || finished;
1339
+ } else {
1340
+ customGroups.set(key, {
1341
+ sessionID,
1342
+ agentName,
1343
+ running,
1344
+ finished,
1345
+ count: 1,
1346
+ model,
1347
+ variant,
1348
+ });
1349
+ }
1350
+ }
1351
+
1352
+ for (const entry of customGroups.values()) {
1353
+ const { sessionID, agentName, running, finished, count, variant } = entry;
1354
+ const elapsed = finished
1355
+ ? now - (mergedTreeSidebar[sessionID]?.finishedAt ?? 0)
1356
+ : 0;
1357
+ const flashDot = finished && Math.floor(elapsed / 200) % 2 === 0;
1358
+ const indicator = running ? spinner : flashDot ? '·' : ' ';
1359
+ const nameStr = formatAgentName(agentName);
1360
+ const rawModelChild = mergedTreeSidebar[sessionID]?.model;
1361
+ const modelVariantLineCustom = formatSidebarModelAndVariant(
1362
+ rawModelChild,
1363
+ variant,
1364
+ );
1365
+ const customStatusText = getStatusText(snapshot, sessionID);
1366
+
1367
+ agentRows.push(
1368
+ box(
1369
+ {
1370
+ width: '100%',
1371
+ flexDirection: 'row',
1372
+ justifyContent: 'space-between',
1373
+ },
1374
+ [
1375
+ box({ flexDirection: 'row' }, [
1376
+ text({ fg: theme.accent }, [`${indicator} `]),
1377
+ text({ fg: theme.text }, [nameStr]),
1378
+ text({ fg: theme.accent }, [` x${count}`]),
1379
+ ]),
1380
+ ],
1381
+ ),
1382
+ );
1383
+
1384
+ agentRows.push(
1385
+ box(
1386
+ {
1387
+ width: '100%',
1388
+ flexDirection: 'row',
1389
+ justifyContent: 'space-between',
1390
+ },
1391
+ [
1392
+ text({ fg: theme.textMuted }, [
1393
+ modelVariantLineCustom.length > 0
1394
+ ? ` ${modelVariantLineCustom}`
1395
+ : ' pending',
1396
+ ]),
1397
+ text({ fg: getStatusColor(customStatusText, theme) }, [
1398
+ customStatusText,
1399
+ ]),
1400
+ ],
1401
+ ),
1402
+ );
1403
+ }
1404
+ }
1405
+
1406
+ if (agentRows.length === 0) {
1407
+ agentRows.push(text({ fg: theme.textMuted }, ['No active agents']));
1408
+ }
1409
+
1410
+ const orchestratingRows = buildOrchestratingRows(snapshot, now, theme);
1411
+
1412
+ // Build usage panel rows
1413
+ const usageRows = renderSubscriptionPanel(snapshot, theme);
1414
+
1415
+ return box(
1416
+ {
1417
+ width: '100%',
1418
+ flexDirection: 'column',
1419
+ border: BORDER,
1420
+ borderColor: theme.borderActive,
1421
+ paddingTop: 0,
1422
+ paddingBottom: 0,
1423
+ paddingLeft: 0,
1424
+ paddingRight: 0,
1425
+ },
1426
+ [
1427
+ box(
1428
+ {
1429
+ width: '100%',
1430
+ flexDirection: 'row',
1431
+ justifyContent: 'space-between',
1432
+ },
1433
+ [
1434
+ text({ fg: theme.text }, ['Agents']),
1435
+ text({ fg: theme.textMuted }, [`[${totalActive} active]`]),
1436
+ ],
1437
+ ),
1438
+ ...agentRows,
1439
+ ...(orchestratingRows.length > 0
1440
+ ? [
1441
+ box({ width: '100%', height: 1 }),
1442
+ box(
1443
+ {
1444
+ width: '100%',
1445
+ flexDirection: 'column',
1446
+ border: BORDER,
1447
+ borderColor: theme.borderActive,
1448
+ paddingTop: 0,
1449
+ paddingBottom: 0,
1450
+ paddingLeft: 0,
1451
+ paddingRight: 0,
1452
+ },
1453
+ [
1454
+ box(
1455
+ {
1456
+ width: '100%',
1457
+ flexDirection: 'row',
1458
+ justifyContent: 'space-between',
1459
+ },
1460
+ [
1461
+ text({ fg: theme.text }, ['Orchestrating']),
1462
+ text({ fg: theme.textMuted }, [
1463
+ `[${orchestratingRows[0] as string}]`,
1464
+ ]),
1465
+ ],
1466
+ ),
1467
+ ...(orchestratingRows.slice(1) as Child[]),
1468
+ ],
1469
+ ),
1470
+ ]
1471
+ : []),
1472
+ ...(usageRows.length > 0
1473
+ ? [
1474
+ box({ width: '100%', height: 1 }),
1475
+ box(
1476
+ {
1477
+ width: '100%',
1478
+ flexDirection: 'column',
1479
+ border: BORDER,
1480
+ borderColor: theme.borderActive,
1481
+ paddingTop: 0,
1482
+ paddingBottom: 0,
1483
+ paddingLeft: 0,
1484
+ paddingRight: 0,
1485
+ },
1486
+ [
1487
+ box(
1488
+ {
1489
+ width: '100%',
1490
+ flexDirection: 'row',
1491
+ justifyContent: 'space-between',
1492
+ },
1493
+ [text({ fg: theme.text }, ['API Usage'])],
1494
+ ),
1495
+ ...(usageRows as Child[]),
1496
+ ],
1497
+ ),
1498
+ ]
1499
+ : []),
1500
+ ],
1501
+ );
1502
+ }
1503
+
1504
+ const plugin: TuiPluginModule & { id: string } = {
1505
+ id: `${PLUGIN_NAME}:tui`,
1506
+ tui: async (api, _options, _meta) => {
1507
+ const [snapshot, setSnapshot] = createSignal(readTuiSnapshot());
1508
+ const [tick, setTick] = createSignal(0);
1509
+
1510
+ const dataTimer = setInterval(async () => {
1511
+ try {
1512
+ setSnapshot(await readTuiSnapshotAsync());
1513
+ } catch {
1514
+ // Ignore render errors; this is best-effort live status.
1515
+ }
1516
+ }, 1000);
1517
+
1518
+ const animTimer = setInterval(() => {
1519
+ setTick(tick() + 1);
1520
+ }, 50);
1521
+
1522
+ api.lifecycle.onDispose(() => {
1523
+ clearInterval(dataTimer);
1524
+ clearInterval(animTimer);
1525
+ });
1526
+
1527
+ api.slots.register({
1528
+ order: 150,
1529
+ slots: {
1530
+ sidebar_content() {
1531
+ tick();
1532
+ return renderSidebar(snapshot(), api.theme.current);
1533
+ },
1534
+ },
1535
+ });
1536
+ },
1537
+ };
1538
+
1539
+ export default plugin;