tmux-ide 1.2.1 → 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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,440 @@
1
+ import "@opentui/solid/runtime-plugin-support";
2
+ import { parseArgs } from "node:util";
3
+ import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid";
4
+ import { RGBA, TextAttributes } from "@opentui/core";
5
+ import { createSignal, createMemo, Show } from "solid-js";
6
+ import { createTheme, type RGBA as RGBAType, type WidgetTheme } from "../lib/theme.ts";
7
+ import {
8
+ flattenConfigTree,
9
+ updateConfigAtPath,
10
+ addPane,
11
+ removePane,
12
+ addRow,
13
+ removeRow,
14
+ validateSetupConfig,
15
+ type TreeNode,
16
+ } from "../lib/config-model.ts";
17
+ import { readConfig, writeConfig } from "../../lib/yaml-io.ts";
18
+ import { hasSession } from "../../lib/tmux.ts";
19
+ import type { IdeConfig } from "../../schemas/ide-config.ts";
20
+ import { ConfigTree } from "../setup/config-tree.tsx";
21
+ import { FieldEditor, type FieldType } from "../setup/field-editor.tsx";
22
+
23
+ const { values } = parseArgs({
24
+ options: {
25
+ session: { type: "string" },
26
+ dir: { type: "string" },
27
+ theme: { type: "string" },
28
+ },
29
+ });
30
+
31
+ const dir = values.dir ?? process.cwd();
32
+ const themeConfig = values.theme ? JSON.parse(values.theme) : undefined;
33
+
34
+ function toRGBA(c: RGBAType): RGBA {
35
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
36
+ }
37
+
38
+ type Tab = "layout" | "team" | "orchestrator" | "theme";
39
+ const TABS: { id: Tab; label: string }[] = [
40
+ { id: "layout", label: "Layout" },
41
+ { id: "team", label: "Team" },
42
+ { id: "orchestrator", label: "Orch." },
43
+ { id: "theme", label: "Theme" },
44
+ ];
45
+
46
+ function inferFieldType(path: string[]): { type: FieldType; enumValues?: string[] } {
47
+ const last = path[path.length - 1] ?? "";
48
+
49
+ // Boolean fields
50
+ if (["focus", "enabled", "auto_dispatch", "widgets"].includes(last)) {
51
+ return { type: "boolean" };
52
+ }
53
+
54
+ // Enum fields
55
+ if (last === "role") return { type: "enum", enumValues: ["lead", "teammate", "planner"] };
56
+ if (last === "type")
57
+ return {
58
+ type: "enum",
59
+ enumValues: ["explorer", "changes", "preview", "tasks", "costs", "config", "mission-control"],
60
+ };
61
+ if (last === "dispatch_mode") return { type: "enum", enumValues: ["tasks", "goals"] };
62
+
63
+ // Size fields
64
+ if (last === "size") return { type: "size" };
65
+
66
+ // Number fields rendered as string (will be coerced)
67
+ return { type: "string" };
68
+ }
69
+
70
+ function coerceValue(raw: unknown, path: string[]): unknown {
71
+ if (typeof raw !== "string") return raw;
72
+ if (raw === "true") return true;
73
+ if (raw === "false") return false;
74
+ const last = path[path.length - 1] ?? "";
75
+ if (["stall_timeout", "poll_interval", "max_concurrent_agents", "priority"].includes(last)) {
76
+ const num = parseInt(raw, 10);
77
+ if (!isNaN(num)) return num;
78
+ }
79
+ return raw;
80
+ }
81
+
82
+ function getValueAtPath(config: IdeConfig, path: string[]): string {
83
+ let current: unknown = config;
84
+ for (const key of path) {
85
+ if (current === null || current === undefined) return "";
86
+ if (typeof current === "object") {
87
+ current = (current as Record<string, unknown>)[key];
88
+ } else {
89
+ return "";
90
+ }
91
+ }
92
+ return current === null || current === undefined ? "" : String(current);
93
+ }
94
+
95
+ render(
96
+ () => {
97
+ const theme = createTheme(themeConfig);
98
+ const dimensions = useTerminalDimensions();
99
+
100
+ // Load config
101
+ let initialConfig: IdeConfig;
102
+ try {
103
+ const result = readConfig(dir);
104
+ initialConfig = result.config;
105
+ } catch {
106
+ console.error("No ide.yml found. Run 'tmux-ide init' first.");
107
+ process.exit(1);
108
+ }
109
+
110
+ const [config, setConfig] = createSignal<IdeConfig>(initialConfig);
111
+ const [savedConfig, setSavedConfig] = createSignal(JSON.stringify(initialConfig));
112
+ const [activeTab, setActiveTab] = createSignal<Tab>("layout");
113
+ const [editingField, setEditingField] = createSignal<string[] | null>(null);
114
+ const [statusMsg, setStatusMsg] = createSignal<string | null>(null);
115
+
116
+ const dirty = createMemo(() => JSON.stringify(config()) !== savedConfig());
117
+ const validation = createMemo(() => validateSetupConfig(config()));
118
+
119
+ function handleSave() {
120
+ writeConfig(dir, config());
121
+ setSavedConfig(JSON.stringify(config()));
122
+ setStatusMsg("Saved");
123
+ setTimeout(() => setStatusMsg(null), 2000);
124
+ }
125
+
126
+ function handleEditField(path: string[]) {
127
+ setEditingField(path);
128
+ }
129
+
130
+ function handleFieldSave(path: string[], value: unknown) {
131
+ const coerced = coerceValue(value, path);
132
+ setConfig(updateConfigAtPath(config(), path, coerced));
133
+ setEditingField(null);
134
+ }
135
+
136
+ function handleAddPane(rowIdx: number) {
137
+ setConfig(addPane(config(), rowIdx));
138
+ }
139
+
140
+ function handleDelete(path: string[]) {
141
+ // Handle row deletion
142
+ if (path.length === 2 && path[0] === "rows" && /^\d+$/.test(path[1]!)) {
143
+ setConfig(removeRow(config(), parseInt(path[1]!, 10)));
144
+ return;
145
+ }
146
+ // Handle pane deletion
147
+ if (path.length >= 4 && path[0] === "rows" && path[2] === "panes") {
148
+ const rowIdx = parseInt(path[1]!, 10);
149
+ const paneIdx = parseInt(path[3]!, 10);
150
+ if (!isNaN(rowIdx) && !isNaN(paneIdx)) {
151
+ setConfig(removePane(config(), rowIdx, paneIdx));
152
+ }
153
+ }
154
+ }
155
+
156
+ function handleConfigChange(newConfig: IdeConfig) {
157
+ setConfig(newConfig);
158
+ }
159
+
160
+ // Tab-level keyboard handling (when not editing a field)
161
+ useKeyboard((evt) => {
162
+ if (editingField() !== null) return; // Let field editor handle keys
163
+
164
+ if (evt.name === "tab") {
165
+ const tabs = TABS.map((t) => t.id);
166
+ const idx = tabs.indexOf(activeTab());
167
+ setActiveTab(tabs[(idx + 1) % tabs.length]!);
168
+ evt.preventDefault();
169
+ } else if (evt.name === "q") {
170
+ if (dirty()) {
171
+ setStatusMsg("Unsaved changes! Ctrl+S to save, then q to quit.");
172
+ setTimeout(() => setStatusMsg(null), 3000);
173
+ } else {
174
+ process.exit(0);
175
+ }
176
+ evt.preventDefault();
177
+ } else if (evt.ctrl && evt.name === "s") {
178
+ handleSave();
179
+ evt.preventDefault();
180
+ } else if (evt.ctrl && evt.name === "r") {
181
+ handleSave();
182
+ const sessionName = config().name ?? dir.split("/").pop() ?? "ide";
183
+ if (hasSession(sessionName)) {
184
+ const { execFileSync } = require("node:child_process");
185
+ try {
186
+ execFileSync("tmux-ide", ["restart"], { cwd: dir, stdio: "inherit" });
187
+ } catch {
188
+ /* restart may close our pane */
189
+ }
190
+ }
191
+ process.exit(0);
192
+ } else if (evt.name === "1") {
193
+ setActiveTab("layout");
194
+ evt.preventDefault();
195
+ } else if (evt.name === "2") {
196
+ setActiveTab("team");
197
+ evt.preventDefault();
198
+ } else if (evt.name === "3") {
199
+ setActiveTab("orchestrator");
200
+ evt.preventDefault();
201
+ } else if (evt.name === "4") {
202
+ setActiveTab("theme");
203
+ evt.preventDefault();
204
+ }
205
+ });
206
+
207
+ // Team panel: simple form for team config
208
+ function TeamPanel() {
209
+ const cfg = config();
210
+ const teamEnabled = !!cfg.team;
211
+ const teamName = cfg.team?.name ?? "";
212
+
213
+ const panes: { row: number; pane: number; title: string; role: string | undefined }[] = [];
214
+ cfg.rows.forEach((row, ri) => {
215
+ row.panes.forEach((p, pi) => {
216
+ panes.push({ row: ri, pane: pi, title: p.title ?? `Row ${ri} Pane ${pi}`, role: p.role });
217
+ });
218
+ });
219
+
220
+ return (
221
+ <box paddingLeft={1}>
222
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
223
+ Team Configuration
224
+ </text>
225
+ <box paddingTop={1}>
226
+ <text fg={toRGBA(theme.fg)}>
227
+ Team: {teamEnabled ? `enabled (${teamName})` : "disabled"}
228
+ </text>
229
+ </box>
230
+ <box paddingTop={1}>
231
+ <text fg={toRGBA(theme.fgMuted)}>
232
+ {teamEnabled
233
+ ? "Use 'tmux-ide config disable-team' to disable"
234
+ : "Use 'tmux-ide config enable-team --name <N>' to enable"}
235
+ </text>
236
+ </box>
237
+ <box paddingTop={1}>
238
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
239
+ Pane Roles
240
+ </text>
241
+ </box>
242
+ {panes.map((p) => (
243
+ <box paddingLeft={1}>
244
+ <text fg={toRGBA(theme.fg)}>
245
+ {p.title}: {p.role ?? "none"}
246
+ </text>
247
+ </box>
248
+ ))}
249
+ </box>
250
+ );
251
+ }
252
+
253
+ // Orchestrator panel: display orchestrator settings
254
+ function OrchestratorPanel() {
255
+ const cfg = config();
256
+ const orch = cfg.orchestrator;
257
+ const fields: [string, string][] = [
258
+ ["enabled", String(orch?.enabled ?? false)],
259
+ ["auto_dispatch", String(orch?.auto_dispatch ?? true)],
260
+ ["dispatch_mode", orch?.dispatch_mode ?? "tasks"],
261
+ ["poll_interval", String(orch?.poll_interval ?? 5000)],
262
+ ["stall_timeout", String(orch?.stall_timeout ?? 300000)],
263
+ ["max_concurrent_agents", String(orch?.max_concurrent_agents ?? 10)],
264
+ ["widgets", String(orch?.widgets ?? false)],
265
+ ];
266
+
267
+ return (
268
+ <box paddingLeft={1}>
269
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
270
+ Orchestrator Settings
271
+ </text>
272
+ <box paddingTop={1}>
273
+ {fields.map(([key, val]) => (
274
+ <box flexDirection="row" gap={1}>
275
+ <text fg={toRGBA(theme.fgMuted)}>{key!.padEnd(24)}</text>
276
+ <text fg={toRGBA(theme.fg)}>{val}</text>
277
+ </box>
278
+ ))}
279
+ </box>
280
+ <box paddingTop={1}>
281
+ <text fg={toRGBA(theme.fgMuted)}>
282
+ Edit via Layout tab or: tmux-ide config set orchestrator.enabled true
283
+ </text>
284
+ </box>
285
+ </box>
286
+ );
287
+ }
288
+
289
+ // Theme panel: display theme settings
290
+ function ThemePanel() {
291
+ const cfg = config();
292
+ const t = cfg.theme;
293
+ const fields: [string, string][] = [
294
+ ["accent", t?.accent ?? "(default)"],
295
+ ["border", t?.border ?? "(default)"],
296
+ ["bg", t?.bg ?? "(default)"],
297
+ ["fg", t?.fg ?? "(default)"],
298
+ ];
299
+
300
+ return (
301
+ <box paddingLeft={1}>
302
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
303
+ Theme Colors
304
+ </text>
305
+ <box paddingTop={1}>
306
+ {fields.map(([key, val]) => (
307
+ <box flexDirection="row" gap={1}>
308
+ <text fg={toRGBA(theme.fgMuted)}>{key!.padEnd(12)}</text>
309
+ <text fg={toRGBA(theme.fg)}>{val}</text>
310
+ </box>
311
+ ))}
312
+ </box>
313
+ <box paddingTop={1}>
314
+ <text fg={toRGBA(theme.fgMuted)}>
315
+ Edit via Layout tab or: tmux-ide config set theme.accent colour75
316
+ </text>
317
+ </box>
318
+ </box>
319
+ );
320
+ }
321
+
322
+ return (
323
+ <box
324
+ width={dimensions().width}
325
+ height={dimensions().height}
326
+ backgroundColor={toRGBA(theme.bg)}
327
+ flexDirection="column"
328
+ >
329
+ {/* Title bar */}
330
+ <box
331
+ flexShrink={0}
332
+ flexDirection="row"
333
+ justifyContent="space-between"
334
+ paddingLeft={1}
335
+ paddingRight={1}
336
+ >
337
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
338
+ Settings
339
+ </text>
340
+ <box flexDirection="row" gap={2}>
341
+ {dirty() ? <text fg={toRGBA(theme.gitModified)}>unsaved</text> : null}
342
+ <text fg={toRGBA(theme.fgMuted)}>Ctrl+S:save</text>
343
+ </box>
344
+ </box>
345
+
346
+ {/* Main content */}
347
+ <box flexGrow={1} flexDirection="row">
348
+ {/* Tab sidebar */}
349
+ <box flexShrink={0} width={10} paddingLeft={1}>
350
+ {TABS.map((tab) => {
351
+ const isActive = () => activeTab() === tab.id;
352
+ return (
353
+ <box
354
+ flexShrink={0}
355
+ backgroundColor={isActive() ? toRGBA(theme.selected) : undefined}
356
+ onMouseUp={() => setActiveTab(tab.id)}
357
+ >
358
+ <text
359
+ fg={toRGBA(isActive() ? theme.accent : theme.fgMuted)}
360
+ attributes={isActive() ? TextAttributes.BOLD : 0}
361
+ >
362
+ {isActive() ? ">" : " "} {tab.label}
363
+ </text>
364
+ </box>
365
+ );
366
+ })}
367
+ </box>
368
+
369
+ {/* Content area */}
370
+ <box flexGrow={1}>
371
+ <Show
372
+ when={editingField() !== null}
373
+ fallback={
374
+ <>
375
+ <Show when={activeTab() === "layout"}>
376
+ <ConfigTree
377
+ config={config()}
378
+ onEditField={handleEditField}
379
+ onAddPane={handleAddPane}
380
+ onDelete={handleDelete}
381
+ onSave={handleSave}
382
+ onConfigChange={handleConfigChange}
383
+ theme={theme}
384
+ />
385
+ </Show>
386
+ <Show when={activeTab() === "team"}>
387
+ <TeamPanel />
388
+ </Show>
389
+ <Show when={activeTab() === "orchestrator"}>
390
+ <OrchestratorPanel />
391
+ </Show>
392
+ <Show when={activeTab() === "theme"}>
393
+ <ThemePanel />
394
+ </Show>
395
+ </>
396
+ }
397
+ >
398
+ <FieldEditor
399
+ path={editingField()!}
400
+ value={getValueAtPath(config(), editingField()!)}
401
+ fieldType={inferFieldType(editingField()!).type}
402
+ enumValues={inferFieldType(editingField()!).enumValues}
403
+ onSave={handleFieldSave}
404
+ onCancel={() => setEditingField(null)}
405
+ theme={theme}
406
+ />
407
+ </Show>
408
+ </box>
409
+ </box>
410
+
411
+ {/* Status bar */}
412
+ <box flexShrink={0} paddingLeft={1} paddingRight={1}>
413
+ <box flexShrink={0} height={1}>
414
+ <text fg={toRGBA(theme.border)} wrapMode="none">
415
+ {"─".repeat(Math.max(1, dimensions().width - 2))}
416
+ </text>
417
+ </box>
418
+ <box flexDirection="row" gap={2}>
419
+ {validation().valid ? (
420
+ <text fg={toRGBA(theme.gitAdded)}>Valid</text>
421
+ ) : (
422
+ <text fg={toRGBA(theme.gitDeleted)}>Invalid</text>
423
+ )}
424
+ {statusMsg() ? <text fg={toRGBA(theme.accent)}>{statusMsg()}</text> : null}
425
+ <text fg={toRGBA(theme.fgMuted)}>Tab:switch</text>
426
+ <text fg={toRGBA(theme.fgMuted)}>1-4:tabs</text>
427
+ <text fg={toRGBA(theme.fgMuted)}>Ctrl+R:restart</text>
428
+ <text fg={toRGBA(theme.fgMuted)}>q:quit</text>
429
+ </box>
430
+ </box>
431
+ </box>
432
+ );
433
+ },
434
+ {
435
+ targetFps: 30,
436
+ exitOnCtrlC: true,
437
+ useKittyKeyboard: {},
438
+ autoFocus: false,
439
+ },
440
+ );
@@ -0,0 +1,216 @@
1
+ import { parseArgs } from "node:util";
2
+ import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid";
3
+ import { RGBA, TextAttributes } from "@opentui/core";
4
+ import { createSignal, createMemo, onCleanup, For, Show } from "solid-js";
5
+ import { createTheme } from "../lib/theme.ts";
6
+ import { loadAccounting, formatDuration, type AgentAccounting } from "../../lib/token-tracker.ts";
7
+
8
+ const { values } = parseArgs({
9
+ options: {
10
+ session: { type: "string" },
11
+ dir: { type: "string" },
12
+ theme: { type: "string" },
13
+ },
14
+ });
15
+
16
+ const dir = values.dir ?? process.cwd();
17
+ const themeConfig = values.theme ? JSON.parse(values.theme) : undefined;
18
+
19
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
20
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
21
+ }
22
+
23
+ interface AgentRow {
24
+ name: string;
25
+ data: AgentAccounting;
26
+ avgMs: number;
27
+ }
28
+
29
+ render(
30
+ () => {
31
+ const theme = createTheme(themeConfig);
32
+ const dimensions = useTerminalDimensions();
33
+
34
+ const [accounting, setAccounting] = createSignal(loadAccounting(dir));
35
+ const [selectedRow, setSelectedRow] = createSignal(-1);
36
+ const [inputMode, setInputMode] = createSignal<"keyboard" | "mouse">("keyboard");
37
+
38
+ // Poll every 5 seconds
39
+ const interval = setInterval(() => {
40
+ setAccounting(loadAccounting(dir));
41
+ }, 5000);
42
+
43
+ onCleanup(() => clearInterval(interval));
44
+
45
+ const agentRows = createMemo((): AgentRow[] => {
46
+ const agents = accounting().agents;
47
+ return Object.entries(agents)
48
+ .map(([name, data]) => ({
49
+ name,
50
+ data,
51
+ avgMs: data.taskCount > 0 ? data.totalTimeMs / data.taskCount : 0,
52
+ }))
53
+ .sort((a, b) => b.data.totalTimeMs - a.data.totalTimeMs);
54
+ });
55
+
56
+ const totalTime = createMemo(() => agentRows().reduce((sum, r) => sum + r.data.totalTimeMs, 0));
57
+
58
+ const totalTasks = createMemo(() => agentRows().reduce((sum, r) => sum + r.data.taskCount, 0));
59
+
60
+ const sessionElapsed = createMemo(() => {
61
+ const start = new Date(accounting().sessionStart).getTime();
62
+ return Date.now() - start;
63
+ });
64
+
65
+ useKeyboard((evt) => {
66
+ setInputMode("keyboard");
67
+ if (evt.name === "up" || evt.name === "k") {
68
+ setSelectedRow((i) => Math.max(0, i - 1));
69
+ evt.preventDefault();
70
+ } else if (evt.name === "down" || evt.name === "j") {
71
+ setSelectedRow((i) => Math.min(agentRows().length - 1, i + 1));
72
+ evt.preventDefault();
73
+ } else if (evt.name === "r") {
74
+ setAccounting(loadAccounting(dir));
75
+ evt.preventDefault();
76
+ } else if (evt.name === "q") {
77
+ process.exit(0);
78
+ }
79
+ });
80
+
81
+ return (
82
+ <box
83
+ width={dimensions().width}
84
+ height={dimensions().height}
85
+ backgroundColor={toRGBA(theme.bg)}
86
+ >
87
+ {/* Header */}
88
+ <box flexShrink={0} paddingLeft={1} paddingBottom={1}>
89
+ <text fg={toRGBA(theme.fg)} attributes={TextAttributes.BOLD}>
90
+ Session Costs
91
+ </text>
92
+ <box flexDirection="row" gap={2}>
93
+ <text fg={toRGBA(theme.fgMuted)}>Elapsed: {formatDuration(sessionElapsed())}</text>
94
+ <text fg={toRGBA(theme.fgMuted)}>Agent time: {formatDuration(totalTime())}</text>
95
+ <text fg={toRGBA(theme.fgMuted)}>Tasks: {totalTasks()}</text>
96
+ </box>
97
+ </box>
98
+
99
+ {/* Separator */}
100
+ <box flexShrink={0} height={1}>
101
+ <text fg={toRGBA(theme.border)} wrapMode="none">
102
+ {"─".repeat(dimensions().width)}
103
+ </text>
104
+ </box>
105
+
106
+ {/* Agent table */}
107
+ <scrollbox flexGrow={1}>
108
+ <Show
109
+ when={agentRows().length > 0}
110
+ fallback={
111
+ <box paddingLeft={1} paddingTop={1}>
112
+ <text fg={toRGBA(theme.fgMuted)}>No task activity recorded yet.</text>
113
+ </box>
114
+ }
115
+ >
116
+ {/* Column headers */}
117
+ <box flexDirection="row" gap={2} paddingLeft={1} paddingTop={1}>
118
+ <text
119
+ fg={toRGBA(theme.fgMuted)}
120
+ attributes={TextAttributes.BOLD}
121
+ width={20}
122
+ wrapMode="none"
123
+ >
124
+ Agent
125
+ </text>
126
+ <text
127
+ fg={toRGBA(theme.fgMuted)}
128
+ attributes={TextAttributes.BOLD}
129
+ width={10}
130
+ wrapMode="none"
131
+ >
132
+ Tasks
133
+ </text>
134
+ <text
135
+ fg={toRGBA(theme.fgMuted)}
136
+ attributes={TextAttributes.BOLD}
137
+ width={12}
138
+ wrapMode="none"
139
+ >
140
+ Total Time
141
+ </text>
142
+ <text
143
+ fg={toRGBA(theme.fgMuted)}
144
+ attributes={TextAttributes.BOLD}
145
+ width={12}
146
+ wrapMode="none"
147
+ >
148
+ Avg/Task
149
+ </text>
150
+ </box>
151
+
152
+ {/* Agent rows */}
153
+ <For each={agentRows()}>
154
+ {(row, index) => {
155
+ const isSelected = createMemo(() => index() === selectedRow());
156
+ return (
157
+ <box
158
+ flexDirection="row"
159
+ gap={2}
160
+ paddingLeft={1}
161
+ backgroundColor={isSelected() ? toRGBA(theme.selected) : undefined}
162
+ onMouseMove={() => {
163
+ setInputMode("mouse");
164
+ setSelectedRow(index());
165
+ }}
166
+ onMouseDown={() => setSelectedRow(index())}
167
+ >
168
+ <text
169
+ fg={toRGBA(isSelected() ? theme.selectedText : theme.fg)}
170
+ width={20}
171
+ wrapMode="none"
172
+ >
173
+ {row.name}
174
+ </text>
175
+ <text fg={toRGBA(theme.fgMuted)} width={10} wrapMode="none">
176
+ {String(row.data.taskCount)}
177
+ </text>
178
+ <text fg={toRGBA(theme.gitAdded)} width={12} wrapMode="none">
179
+ {formatDuration(row.data.totalTimeMs)}
180
+ </text>
181
+ <text fg={toRGBA(theme.fgMuted)} width={12} wrapMode="none">
182
+ {row.data.taskCount > 0 ? formatDuration(row.avgMs) : "-"}
183
+ </text>
184
+ </box>
185
+ );
186
+ }}
187
+ </For>
188
+ </Show>
189
+ </scrollbox>
190
+
191
+ {/* Footer */}
192
+ <box flexShrink={0} paddingLeft={1} paddingTop={1} flexDirection="row" gap={1}>
193
+ <text fg={toRGBA(theme.fgMuted)} wrapMode="none">
194
+ ↑↓:nav
195
+ </text>
196
+ <text
197
+ fg={toRGBA(theme.fgMuted)}
198
+ wrapMode="none"
199
+ onMouseUp={() => setAccounting(loadAccounting(dir))}
200
+ >
201
+ r:refresh
202
+ </text>
203
+ <text fg={toRGBA(theme.fgMuted)} wrapMode="none">
204
+ q:quit
205
+ </text>
206
+ </box>
207
+ </box>
208
+ );
209
+ },
210
+ {
211
+ targetFps: 30,
212
+ exitOnCtrlC: true,
213
+ useKittyKeyboard: {},
214
+ autoFocus: false,
215
+ },
216
+ );