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,200 @@
1
+ import { createSignal, For } from "solid-js";
2
+ import { RGBA, TextAttributes } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/solid";
4
+ import type { WidgetTheme, RGBA as RGBAType } from "../lib/theme.ts";
5
+
6
+ function toRGBA(c: RGBAType): RGBA {
7
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
8
+ }
9
+
10
+ export interface OrchestratorPanelConfig {
11
+ enabled: boolean;
12
+ auto_dispatch: boolean;
13
+ master_pane: string | null;
14
+ }
15
+
16
+ interface OrchestratorPanelProps {
17
+ claudePaneNames: string[];
18
+ theme: WidgetTheme;
19
+ onContinue: (config: OrchestratorPanelConfig) => void;
20
+ onBack: () => void;
21
+ }
22
+
23
+ type Field = "enabled" | "master_pane" | "auto_dispatch" | "confirm";
24
+
25
+ export function OrchestratorPanel(props: OrchestratorPanelProps) {
26
+ const [enabled, setEnabled] = createSignal(true);
27
+ const [autoDispatch, setAutoDispatch] = createSignal(true);
28
+ const [masterIdx, setMasterIdx] = createSignal(0);
29
+ const [activeField, setActiveField] = createSignal<Field>("enabled");
30
+
31
+ const theme = props.theme;
32
+
33
+ const fields: Field[] = ["enabled", "master_pane", "auto_dispatch", "confirm"];
34
+
35
+ function fieldIndex() {
36
+ return fields.indexOf(activeField());
37
+ }
38
+
39
+ useKeyboard((evt) => {
40
+ if (evt.name === "escape") {
41
+ props.onBack();
42
+ evt.preventDefault();
43
+ return;
44
+ }
45
+
46
+ if (evt.name === "down" || evt.name === "j") {
47
+ const next = Math.min(fieldIndex() + 1, fields.length - 1);
48
+ setActiveField(fields[next]!);
49
+ evt.preventDefault();
50
+ } else if (evt.name === "up" || evt.name === "k") {
51
+ const prev = Math.max(fieldIndex() - 1, 0);
52
+ setActiveField(fields[prev]!);
53
+ evt.preventDefault();
54
+ } else if (evt.name === "space" || evt.name === "return") {
55
+ const field = activeField();
56
+ if (field === "enabled") {
57
+ setEnabled(!enabled());
58
+ } else if (field === "master_pane" && props.claudePaneNames.length > 0) {
59
+ setMasterIdx((masterIdx() + 1) % props.claudePaneNames.length);
60
+ } else if (field === "auto_dispatch") {
61
+ setAutoDispatch(!autoDispatch());
62
+ } else if (field === "confirm") {
63
+ props.onContinue({
64
+ enabled: enabled(),
65
+ auto_dispatch: autoDispatch(),
66
+ master_pane: props.claudePaneNames[masterIdx()] ?? null,
67
+ });
68
+ }
69
+ evt.preventDefault();
70
+ }
71
+ });
72
+
73
+ function toggleLabel(active: boolean): string {
74
+ return active ? "[x]" : "[ ]";
75
+ }
76
+
77
+ function isActive(field: Field): boolean {
78
+ return activeField() === field;
79
+ }
80
+
81
+ return (
82
+ <box paddingLeft={1} paddingRight={1}>
83
+ {/* Header */}
84
+ <box flexShrink={0} paddingBottom={1}>
85
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
86
+ Orchestrator Configuration
87
+ </text>
88
+ <text fg={toRGBA(theme.fgMuted)}>
89
+ Configure multi-agent orchestration for your team layout.
90
+ </text>
91
+ </box>
92
+
93
+ {/* Enabled toggle */}
94
+ <box
95
+ flexShrink={0}
96
+ flexDirection="row"
97
+ gap={1}
98
+ paddingBottom={1}
99
+ onMouseDown={() => {
100
+ setActiveField("enabled");
101
+ setEnabled(!enabled());
102
+ }}
103
+ >
104
+ <text
105
+ fg={toRGBA(isActive("enabled") ? theme.accent : theme.fg)}
106
+ attributes={isActive("enabled") ? TextAttributes.BOLD : undefined}
107
+ >
108
+ {isActive("enabled") ? ">" : " "} {toggleLabel(enabled())} Enabled
109
+ </text>
110
+ </box>
111
+
112
+ {/* Master pane picker */}
113
+ <box flexShrink={0} paddingBottom={1}>
114
+ <text
115
+ fg={toRGBA(isActive("master_pane") ? theme.accent : theme.fg)}
116
+ attributes={isActive("master_pane") ? TextAttributes.BOLD : undefined}
117
+ >
118
+ {isActive("master_pane") ? ">" : " "} Master Pane
119
+ </text>
120
+ <box paddingLeft={4}>
121
+ <For each={props.claudePaneNames}>
122
+ {(name, i) => (
123
+ <text
124
+ fg={toRGBA(i() === masterIdx() ? theme.accent : theme.fgMuted)}
125
+ onMouseDown={() => {
126
+ setActiveField("master_pane");
127
+ setMasterIdx(i());
128
+ }}
129
+ >
130
+ {i() === masterIdx() ? "● " : "○ "}
131
+ {name}
132
+ </text>
133
+ )}
134
+ </For>
135
+ </box>
136
+ <box paddingLeft={4}>
137
+ <text fg={toRGBA(theme.fgMuted)}>Space/Enter to cycle selection</text>
138
+ </box>
139
+ </box>
140
+
141
+ {/* Auto dispatch toggle */}
142
+ <box
143
+ flexShrink={0}
144
+ flexDirection="row"
145
+ gap={1}
146
+ paddingBottom={1}
147
+ onMouseDown={() => {
148
+ setActiveField("auto_dispatch");
149
+ setAutoDispatch(!autoDispatch());
150
+ }}
151
+ >
152
+ <text
153
+ fg={toRGBA(isActive("auto_dispatch") ? theme.accent : theme.fg)}
154
+ attributes={isActive("auto_dispatch") ? TextAttributes.BOLD : undefined}
155
+ >
156
+ {isActive("auto_dispatch") ? ">" : " "} {toggleLabel(autoDispatch())} Auto-dispatch tasks
157
+ </text>
158
+ </box>
159
+
160
+ {/* Spacer */}
161
+ <box flexGrow={1} />
162
+
163
+ {/* Continue button */}
164
+ <box
165
+ flexShrink={0}
166
+ paddingBottom={1}
167
+ onMouseDown={() => {
168
+ setActiveField("confirm");
169
+ props.onContinue({
170
+ enabled: enabled(),
171
+ auto_dispatch: autoDispatch(),
172
+ master_pane: props.claudePaneNames[masterIdx()] ?? null,
173
+ });
174
+ }}
175
+ >
176
+ <text
177
+ fg={toRGBA(isActive("confirm") ? theme.accent : theme.fgMuted)}
178
+ attributes={isActive("confirm") ? TextAttributes.BOLD : undefined}
179
+ >
180
+ {isActive("confirm") ? ">" : " "} [ Continue → ]
181
+ </text>
182
+ </box>
183
+
184
+ {/* Footer */}
185
+ <box flexShrink={0}>
186
+ <box flexShrink={0} height={1}>
187
+ <text fg={toRGBA(theme.border)} wrapMode="none">
188
+ {"─".repeat(40)}
189
+ </text>
190
+ </box>
191
+ <box flexDirection="row" gap={2}>
192
+ <text fg={toRGBA(theme.fgMuted)}>j/k:navigate</text>
193
+ <text fg={toRGBA(theme.fgMuted)}>Space:toggle</text>
194
+ <text fg={toRGBA(theme.fgMuted)}>Enter:select</text>
195
+ <text fg={toRGBA(theme.fgMuted)}>Esc:back</text>
196
+ </box>
197
+ </box>
198
+ </box>
199
+ );
200
+ }
@@ -0,0 +1,140 @@
1
+ import { createSignal, createMemo, Show } from "solid-js";
2
+ import { RGBA, TextAttributes } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/solid";
4
+ import yaml from "js-yaml";
5
+ import type { IdeConfig } from "../../schemas/ide-config.ts";
6
+ import { validateConfig } from "../../validate.ts";
7
+ import { writeConfig } from "../../lib/yaml-io.ts";
8
+ import type { WidgetTheme, RGBA as RGBAType } from "../lib/theme.ts";
9
+
10
+ function toRGBA(c: RGBAType): RGBA {
11
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
12
+ }
13
+
14
+ export type ReviewAction = "write" | "launch";
15
+
16
+ interface ReviewPanelProps {
17
+ config: IdeConfig;
18
+ dir: string;
19
+ theme: WidgetTheme;
20
+ onDone: (action: ReviewAction) => void;
21
+ onBack: () => void;
22
+ }
23
+
24
+ export function ReviewPanel(props: ReviewPanelProps) {
25
+ const [selectedAction, setSelectedAction] = createSignal<0 | 1>(0);
26
+
27
+ const theme = props.theme;
28
+
29
+ const yamlPreview = createMemo(() =>
30
+ yaml.dump(props.config, { lineWidth: -1, noRefs: true, quotingType: '"' }),
31
+ );
32
+
33
+ const errors = createMemo(() => validateConfig(props.config));
34
+ const isValid = createMemo(() => errors().length === 0);
35
+
36
+ useKeyboard((evt) => {
37
+ if (evt.name === "escape") {
38
+ props.onBack();
39
+ evt.preventDefault();
40
+ return;
41
+ }
42
+
43
+ if (evt.name === "j" || evt.name === "down" || evt.name === "tab") {
44
+ setSelectedAction((prev) => (prev === 0 ? 1 : 0) as 0 | 1);
45
+ evt.preventDefault();
46
+ } else if (evt.name === "k" || evt.name === "up") {
47
+ setSelectedAction((prev) => (prev === 0 ? 1 : 0) as 0 | 1);
48
+ evt.preventDefault();
49
+ } else if (evt.name === "return") {
50
+ if (!isValid()) return;
51
+ writeConfig(props.dir, props.config);
52
+ props.onDone(selectedAction() === 0 ? "write" : "launch");
53
+ evt.preventDefault();
54
+ }
55
+ });
56
+
57
+ return (
58
+ <box paddingLeft={1} paddingRight={1}>
59
+ {/* Header */}
60
+ <box flexShrink={0} paddingBottom={1}>
61
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
62
+ Review Configuration
63
+ </text>
64
+ </box>
65
+
66
+ {/* YAML preview */}
67
+ <box flexGrow={1} flexShrink={1}>
68
+ <text fg={toRGBA(theme.fgMuted)} attributes={TextAttributes.BOLD}>
69
+ ide.yml preview:
70
+ </text>
71
+ <scrollbox>
72
+ <text fg={toRGBA(theme.fg)}>{yamlPreview()}</text>
73
+ </scrollbox>
74
+ </box>
75
+
76
+ {/* Validation status */}
77
+ <box flexShrink={0} paddingTop={1} paddingBottom={1}>
78
+ <Show
79
+ when={isValid()}
80
+ fallback={
81
+ <box>
82
+ <text fg={RGBA.fromInts(255, 80, 80, 255)} attributes={TextAttributes.BOLD}>
83
+ Validation errors:
84
+ </text>
85
+ {errors().map((e) => (
86
+ <text fg={RGBA.fromInts(255, 80, 80, 255)}> - {e}</text>
87
+ ))}
88
+ </box>
89
+ }
90
+ >
91
+ <text fg={toRGBA(theme.gitAdded)} attributes={TextAttributes.BOLD}>
92
+ Config is valid
93
+ </text>
94
+ </Show>
95
+ </box>
96
+
97
+ {/* Action buttons */}
98
+ <box flexShrink={0} paddingBottom={1}>
99
+ <text
100
+ fg={toRGBA(selectedAction() === 0 ? theme.accent : theme.fgMuted)}
101
+ attributes={selectedAction() === 0 ? TextAttributes.BOLD : undefined}
102
+ onMouseDown={() => setSelectedAction(0)}
103
+ onMouseUp={() => {
104
+ if (!isValid()) return;
105
+ writeConfig(props.dir, props.config);
106
+ props.onDone("write");
107
+ }}
108
+ >
109
+ {selectedAction() === 0 ? ">" : " "} [ Write ide.yml ]
110
+ </text>
111
+ <text
112
+ fg={toRGBA(selectedAction() === 1 ? theme.accent : theme.fgMuted)}
113
+ attributes={selectedAction() === 1 ? TextAttributes.BOLD : undefined}
114
+ onMouseDown={() => setSelectedAction(1)}
115
+ onMouseUp={() => {
116
+ if (!isValid()) return;
117
+ writeConfig(props.dir, props.config);
118
+ props.onDone("launch");
119
+ }}
120
+ >
121
+ {selectedAction() === 1 ? ">" : " "} [ Write & Launch ]
122
+ </text>
123
+ </box>
124
+
125
+ {/* Footer */}
126
+ <box flexShrink={0}>
127
+ <box flexShrink={0} height={1}>
128
+ <text fg={toRGBA(theme.border)} wrapMode="none">
129
+ {"─".repeat(40)}
130
+ </text>
131
+ </box>
132
+ <box flexDirection="row" gap={2}>
133
+ <text fg={toRGBA(theme.fgMuted)}>j/k:select action</text>
134
+ <text fg={toRGBA(theme.fgMuted)}>Enter:confirm</text>
135
+ <text fg={toRGBA(theme.fgMuted)}>Esc:back</text>
136
+ </box>
137
+ </box>
138
+ </box>
139
+ );
140
+ }
@@ -0,0 +1,303 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ PRESETS,
4
+ getPreset,
5
+ flattenConfigTree,
6
+ updateConfigAtPath,
7
+ addPane,
8
+ removePane,
9
+ validateSetupConfig,
10
+ } from "./setup-model.ts";
11
+ import type { IdeConfig } from "../../schemas/ide-config.ts";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Layout Presets
15
+ // ---------------------------------------------------------------------------
16
+
17
+ describe("PRESETS", () => {
18
+ it("has 4 presets", () => {
19
+ expect(PRESETS.length).toBe(4);
20
+ });
21
+
22
+ it("each preset has required fields", () => {
23
+ for (const preset of PRESETS) {
24
+ expect(preset.id, `preset missing id`).toBeTruthy();
25
+ expect(preset.label, `preset ${preset.id} missing label`).toBeTruthy();
26
+ expect(preset.description, `preset ${preset.id} missing description`).toBeTruthy();
27
+ expect(preset.diagram.length > 0, `preset ${preset.id} missing diagram`).toBeTruthy();
28
+ expect(typeof preset.buildConfig).toBe("function");
29
+ }
30
+ });
31
+
32
+ it("each preset builds a valid IdeConfig", () => {
33
+ for (const preset of PRESETS) {
34
+ const config = preset.buildConfig("test-project");
35
+ const result = validateSetupConfig(config);
36
+ expect(
37
+ result.valid,
38
+ `preset ${preset.id} produced invalid config: ${!result.valid ? result.errors.join(", ") : ""}`,
39
+ ).toBeTruthy();
40
+ }
41
+ });
42
+
43
+ it("dual-claude preset builds 2 claude panes + dev/shell", () => {
44
+ const config = getPreset("dual-claude")!.buildConfig("my-app");
45
+ expect(config.rows.length).toBe(2);
46
+ expect(config.rows[0]!.size).toBe("70%");
47
+ expect(config.rows[0]!.panes.length).toBe(2);
48
+ expect(config.rows[0]!.panes[0]!.command).toBe("claude");
49
+ expect(config.rows[0]!.panes[1]!.command).toBe("claude");
50
+ expect(config.rows[1]!.panes.length).toBe(2);
51
+ });
52
+
53
+ it("triple-claude preset builds 3 claude panes + dev/shell", () => {
54
+ const config = getPreset("triple-claude")!.buildConfig("my-app");
55
+ expect(config.rows[0]!.panes.length).toBe(3);
56
+ expect(config.rows[0]!.size).toBe("70%");
57
+ for (const pane of config.rows[0]!.panes) {
58
+ expect(pane.command).toBe("claude");
59
+ }
60
+ });
61
+
62
+ it("single-claude preset builds 1 claude pane 60% + 3 bottom panes 40%", () => {
63
+ const config = getPreset("single-claude")!.buildConfig("my-app");
64
+ expect(config.rows[0]!.size).toBe("60%");
65
+ expect(config.rows[0]!.panes.length).toBe(1);
66
+ expect(config.rows[0]!.panes[0]!.command).toBe("claude");
67
+ expect(config.rows[1]!.panes.length).toBe(3);
68
+ });
69
+
70
+ it("agent-team preset has team, orchestrator, and role assignments", () => {
71
+ const config = getPreset("agent-team")!.buildConfig("my-app");
72
+ expect(config.team, "should have team config").toBeTruthy();
73
+ expect(config.orchestrator, "should have orchestrator config").toBeTruthy();
74
+ expect(config.orchestrator!.enabled).toBe(true);
75
+ expect(config.rows[0]!.panes[0]!.role).toBe("lead");
76
+ expect(config.rows[0]!.panes[1]!.role).toBe("teammate");
77
+ expect(config.rows[0]!.panes[2]!.role).toBe("teammate");
78
+ expect(config.rows[1]!.panes.length).toBe(3);
79
+ });
80
+
81
+ it("presets use detected devCommand when provided", () => {
82
+ const config = getPreset("dual-claude")!.buildConfig("my-app", {
83
+ devCommand: "pnpm dev",
84
+ packageManager: "pnpm",
85
+ });
86
+ expect(config.rows[1]!.panes[0]!.command).toBe("pnpm dev");
87
+ });
88
+
89
+ it("presets fall back to packageManager for dev command", () => {
90
+ const config = getPreset("dual-claude")!.buildConfig("my-app", {
91
+ devCommand: null,
92
+ packageManager: "yarn",
93
+ });
94
+ expect(config.rows[1]!.panes[0]!.command).toBe("yarn dev");
95
+ });
96
+ });
97
+
98
+ describe("getPreset", () => {
99
+ it("returns preset by id", () => {
100
+ expect(getPreset("dual-claude")!.id).toBe("dual-claude");
101
+ expect(getPreset("agent-team")!.id).toBe("agent-team");
102
+ });
103
+
104
+ it("returns undefined for unknown id", () => {
105
+ expect(getPreset("nonexistent")).toBe(undefined);
106
+ });
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // flattenConfigTree
111
+ // ---------------------------------------------------------------------------
112
+
113
+ describe("flattenConfigTree", () => {
114
+ const simple: IdeConfig = {
115
+ name: "test",
116
+ rows: [
117
+ {
118
+ size: "70%",
119
+ panes: [{ title: "Claude 1", command: "claude" }, { title: "Shell" }],
120
+ },
121
+ ],
122
+ };
123
+
124
+ it("produces nodes for all config values", () => {
125
+ const nodes = flattenConfigTree(simple);
126
+ expect(nodes.length > 0).toBeTruthy();
127
+ });
128
+
129
+ it("includes leaf nodes with string values", () => {
130
+ const nodes = flattenConfigTree(simple);
131
+ const nameNode = nodes.find((n) => n.path.length === 1 && n.path[0] === "name");
132
+ expect(nameNode).toBeTruthy();
133
+ expect(nameNode!.value).toBe("test");
134
+ expect(nameNode!.expandable).toBe(false);
135
+ });
136
+
137
+ it("includes container nodes with null value", () => {
138
+ const nodes = flattenConfigTree(simple);
139
+ const rowsNode = nodes.find((n) => n.path.length === 1 && n.path[0] === "rows");
140
+ expect(rowsNode).toBeTruthy();
141
+ expect(rowsNode!.value).toBe(null);
142
+ expect(rowsNode!.expandable).toBe(true);
143
+ });
144
+
145
+ it("creates correct paths for deeply nested values", () => {
146
+ const nodes = flattenConfigTree(simple);
147
+ const titleNode = nodes.find(
148
+ (n) =>
149
+ n.path.length === 4 &&
150
+ n.path[0] === "rows" &&
151
+ n.path[1] === "0" &&
152
+ n.path[2] === "panes" &&
153
+ n.path[3] === "0",
154
+ );
155
+ expect(titleNode, "should have node for rows.0.panes.0").toBeTruthy();
156
+ });
157
+
158
+ it("sets depth correctly", () => {
159
+ const nodes = flattenConfigTree(simple);
160
+ const nameNode = nodes.find((n) => n.label === "name" && n.path.length === 1);
161
+ expect(nameNode!.depth).toBe(0);
162
+
163
+ const paneTitle = nodes.find((n) => n.label === "title" && n.value === "Claude 1");
164
+ expect(paneTitle).toBeTruthy();
165
+ expect(paneTitle!.depth).toBe(4); // rows.0.panes.0.title
166
+ });
167
+
168
+ it("skips undefined optional fields", () => {
169
+ const config: IdeConfig = { rows: [{ panes: [{ title: "X" }] }] };
170
+ const nodes = flattenConfigTree(config);
171
+ const nameNode = nodes.find((n) => n.label === "name");
172
+ expect(nameNode).toBe(undefined);
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // updateConfigAtPath
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe("updateConfigAtPath", () => {
181
+ const base: IdeConfig = {
182
+ name: "test",
183
+ rows: [
184
+ {
185
+ size: "70%",
186
+ panes: [{ title: "Claude", command: "claude" }],
187
+ },
188
+ ],
189
+ };
190
+
191
+ it("updates a top-level string value", () => {
192
+ const updated = updateConfigAtPath(base, ["name"], "new-name");
193
+ expect(updated.name).toBe("new-name");
194
+ expect(base.name).toBe("test"); // original unchanged
195
+ });
196
+
197
+ it("updates a nested value", () => {
198
+ const updated = updateConfigAtPath(base, ["rows", "0", "panes", "0", "title"], "Renamed");
199
+ expect(updated.rows[0]!.panes[0]!.title).toBe("Renamed");
200
+ expect(base.rows[0]!.panes[0]!.title).toBe("Claude"); // original unchanged
201
+ });
202
+
203
+ it("updates row size", () => {
204
+ const updated = updateConfigAtPath(base, ["rows", "0", "size"], "50%");
205
+ expect(updated.rows[0]!.size).toBe("50%");
206
+ });
207
+ });
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // addPane / removePane
211
+ // ---------------------------------------------------------------------------
212
+
213
+ describe("addPane", () => {
214
+ const base: IdeConfig = {
215
+ name: "test",
216
+ rows: [{ panes: [{ title: "Existing" }] }],
217
+ };
218
+
219
+ it("adds a new pane to the specified row", () => {
220
+ const updated = addPane(base, 0);
221
+ expect(updated.rows[0]!.panes.length).toBe(2);
222
+ expect(updated.rows[0]!.panes[1]!.title).toBe("New Pane");
223
+ expect(base.rows[0]!.panes.length).toBe(1); // original unchanged
224
+ });
225
+
226
+ it("returns clone unchanged for invalid row index", () => {
227
+ const updated = addPane(base, 99);
228
+ expect(updated.rows[0]!.panes.length).toBe(1);
229
+ });
230
+ });
231
+
232
+ describe("removePane", () => {
233
+ const base: IdeConfig = {
234
+ name: "test",
235
+ rows: [{ panes: [{ title: "First" }, { title: "Second" }, { title: "Third" }] }],
236
+ };
237
+
238
+ it("removes the pane at the specified index", () => {
239
+ const updated = removePane(base, 0, 1);
240
+ expect(updated.rows[0]!.panes.length).toBe(2);
241
+ expect(updated.rows[0]!.panes[0]!.title).toBe("First");
242
+ expect(updated.rows[0]!.panes[1]!.title).toBe("Third");
243
+ expect(base.rows[0]!.panes.length).toBe(3); // original unchanged
244
+ });
245
+
246
+ it("does not remove the last pane in a row", () => {
247
+ const single: IdeConfig = {
248
+ rows: [{ panes: [{ title: "Only" }] }],
249
+ };
250
+ const updated = removePane(single, 0, 0);
251
+ expect(updated.rows[0]!.panes.length).toBe(1);
252
+ expect(updated.rows[0]!.panes[0]!.title).toBe("Only");
253
+ });
254
+
255
+ it("returns clone unchanged for invalid row index", () => {
256
+ const updated = removePane(base, 99, 0);
257
+ expect(updated.rows[0]!.panes.length).toBe(3);
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // validateSetupConfig
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe("validateSetupConfig", () => {
266
+ it("returns valid for a correct config", () => {
267
+ const config: IdeConfig = {
268
+ name: "test",
269
+ rows: [{ panes: [{ title: "Shell" }] }],
270
+ };
271
+ const result = validateSetupConfig(config);
272
+ expect(result.valid).toBe(true);
273
+ });
274
+
275
+ it("returns errors for missing rows", () => {
276
+ const result = validateSetupConfig({ name: "test" });
277
+ expect(result.valid).toBe(false);
278
+ expect(result.errors.length > 0).toBeTruthy();
279
+ });
280
+
281
+ it("returns errors for empty rows array", () => {
282
+ const result = validateSetupConfig({ name: "test", rows: [] });
283
+ expect(result.valid).toBe(false);
284
+ });
285
+
286
+ it("returns errors for empty panes array in a row", () => {
287
+ const result = validateSetupConfig({
288
+ name: "test",
289
+ rows: [{ panes: [] }],
290
+ });
291
+ expect(result.valid).toBe(false);
292
+ });
293
+
294
+ it("returns valid for config with optional fields", () => {
295
+ const config = {
296
+ name: "test",
297
+ rows: [{ size: "70%", panes: [{ title: "X", command: "claude", focus: true }] }],
298
+ theme: { accent: "colour75" },
299
+ };
300
+ const result = validateSetupConfig(config);
301
+ expect(result.valid).toBe(true);
302
+ });
303
+ });