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,112 @@
1
+ import { createSignal, onMount } from "solid-js";
2
+ import { RGBA, TextAttributes, type InputRenderable } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/solid";
4
+ import type { IdeConfig } from "../../schemas/ide-config.ts";
5
+ import type { WidgetTheme, RGBA as RGBAType } from "../lib/theme.ts";
6
+
7
+ function toRGBA(c: RGBAType): RGBA {
8
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
9
+ }
10
+
11
+ /** Extract titles of claude panes from an IdeConfig. */
12
+ function extractClaudePaneTitles(config: IdeConfig): string[] {
13
+ const titles: string[] = [];
14
+ for (const row of config.rows) {
15
+ for (const pane of row.panes) {
16
+ if (pane.command === "claude") {
17
+ titles.push(pane.title ?? "Claude");
18
+ }
19
+ }
20
+ }
21
+ return titles;
22
+ }
23
+
24
+ interface AgentNamingProps {
25
+ config: IdeConfig;
26
+ onContinue: (names: string[]) => void;
27
+ theme: WidgetTheme;
28
+ }
29
+
30
+ export function AgentNaming(props: AgentNamingProps) {
31
+ const defaults = extractClaudePaneTitles(props.config);
32
+ const theme = props.theme;
33
+
34
+ // Create a signal per pane name
35
+ const nameSignals = defaults.map((d) => createSignal(d));
36
+ const [activeField, setActiveField] = createSignal(0);
37
+
38
+ const inputRefs: (InputRenderable | undefined)[] = new Array(defaults.length).fill(undefined);
39
+
40
+ onMount(() => {
41
+ setTimeout(() => inputRefs[0]?.focus(), 50);
42
+ });
43
+
44
+ useKeyboard((evt) => {
45
+ if (evt.name === "tab") {
46
+ const next = (activeField() + 1) % nameSignals.length;
47
+ setActiveField(next);
48
+ setTimeout(() => inputRefs[next]?.focus(), 10);
49
+ evt.preventDefault();
50
+ } else if (evt.name === "return") {
51
+ const names = nameSignals.map(([getter]) => getter());
52
+ props.onContinue(names);
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
+ Name Your Agents
63
+ </text>
64
+ <text fg={toRGBA(theme.fgMuted)}>Customize the names for each Claude pane.</text>
65
+ </box>
66
+
67
+ {/* Input fields */}
68
+ {nameSignals.map(([getter, setter], index) => {
69
+ const isActive = () => activeField() === index;
70
+ return (
71
+ <box
72
+ flexShrink={0}
73
+ paddingBottom={1}
74
+ onMouseDown={() => {
75
+ setActiveField(index);
76
+ setTimeout(() => inputRefs[index]?.focus(), 10);
77
+ }}
78
+ >
79
+ <text fg={toRGBA(isActive() ? theme.accent : theme.fgMuted)}>Pane {index + 1}</text>
80
+ <input
81
+ value={getter()}
82
+ placeholder={defaults[index] ?? "Agent"}
83
+ onInput={(v: string) => setter(v)}
84
+ focusedBackgroundColor={toRGBA(theme.selected)}
85
+ cursorColor={toRGBA(theme.accent)}
86
+ focusedTextColor={toRGBA(theme.fg)}
87
+ ref={(r: InputRenderable) => {
88
+ inputRefs[index] = r;
89
+ }}
90
+ />
91
+ </box>
92
+ );
93
+ })}
94
+
95
+ {/* Spacer */}
96
+ <box flexGrow={1} />
97
+
98
+ {/* Footer */}
99
+ <box flexShrink={0}>
100
+ <box flexShrink={0} height={1}>
101
+ <text fg={toRGBA(theme.border)} wrapMode="none">
102
+ {"─".repeat(40)}
103
+ </text>
104
+ </box>
105
+ <box flexDirection="row" gap={2}>
106
+ <text fg={toRGBA(theme.fgMuted)}>Tab:next field</text>
107
+ <text fg={toRGBA(theme.fgMuted)}>Enter:continue</text>
108
+ </box>
109
+ </box>
110
+ </box>
111
+ );
112
+ }
@@ -0,0 +1,238 @@
1
+ import { createSignal, createMemo } from "solid-js";
2
+ import { RGBA, TextAttributes } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/solid";
4
+ import { flattenConfigTree, validateSetupConfig, addPane, type TreeNode } from "./setup-model.ts";
5
+ import type { IdeConfig } from "../../schemas/ide-config.ts";
6
+ import type { WidgetTheme, RGBA as RGBAType } from "../lib/theme.ts";
7
+
8
+ function toRGBA(c: RGBAType): RGBA {
9
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
10
+ }
11
+
12
+ interface ConfigTreeProps {
13
+ config: IdeConfig;
14
+ onEditField: (path: string[]) => void;
15
+ onAddPane: (rowIdx: number) => void;
16
+ onDelete: (path: string[]) => void;
17
+ onSave: (config: IdeConfig) => void;
18
+ onConfigChange: (config: IdeConfig) => void;
19
+ theme: WidgetTheme;
20
+ }
21
+
22
+ /** Check if a path is a row-level container (e.g. ["rows", "0"]). */
23
+ function isRowPath(path: string[]): boolean {
24
+ return path.length === 2 && path[0] === "rows" && /^\d+$/.test(path[1]!);
25
+ }
26
+
27
+ /** Extract row index from a pane-level path (e.g. ["rows","0","panes","1","title"] → 0). */
28
+ function rowIndexFromPath(path: string[]): number | null {
29
+ if (path.length >= 2 && path[0] === "rows" && /^\d+$/.test(path[1]!)) {
30
+ return parseInt(path[1]!, 10);
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function ConfigTree(props: ConfigTreeProps) {
36
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
37
+ const [collapsed, setCollapsed] = createSignal<Set<string>>(new Set());
38
+ const [confirmDelete, setConfirmDelete] = createSignal<string | null>(null);
39
+ const [inputMode, setInputMode] = createSignal<"keyboard" | "mouse">("keyboard");
40
+
41
+ const theme = props.theme;
42
+
43
+ const allNodes = createMemo(() => flattenConfigTree(props.config));
44
+
45
+ // Filter out children of collapsed nodes
46
+ const visibleNodes = createMemo(() => {
47
+ const all = allNodes();
48
+ const collapsedSet = collapsed();
49
+ const result: TreeNode[] = [];
50
+ for (const node of all) {
51
+ // Check if any ancestor is collapsed
52
+ let hidden = false;
53
+ for (let len = 1; len < node.path.length; len++) {
54
+ const ancestorKey = node.path.slice(0, len).join(".");
55
+ if (collapsedSet.has(ancestorKey)) {
56
+ hidden = true;
57
+ break;
58
+ }
59
+ }
60
+ if (!hidden) result.push(node);
61
+ }
62
+ return result;
63
+ });
64
+
65
+ const validation = createMemo(() => validateSetupConfig(props.config));
66
+
67
+ function toggleCollapse(node: TreeNode) {
68
+ const key = node.path.join(".");
69
+ setCollapsed((prev) => {
70
+ const next = new Set(prev);
71
+ if (next.has(key)) {
72
+ next.delete(key);
73
+ } else {
74
+ next.add(key);
75
+ }
76
+ return next;
77
+ });
78
+ }
79
+
80
+ function isCollapsed(node: TreeNode): boolean {
81
+ return collapsed().has(node.path.join("."));
82
+ }
83
+
84
+ useKeyboard((evt) => {
85
+ setInputMode("keyboard");
86
+
87
+ // Delete confirmation mode
88
+ if (confirmDelete() !== null) {
89
+ if (evt.name === "y") {
90
+ const pathStr = confirmDelete()!;
91
+ setConfirmDelete(null);
92
+ props.onDelete(pathStr.split("."));
93
+ evt.preventDefault();
94
+ return;
95
+ }
96
+ setConfirmDelete(null);
97
+ evt.preventDefault();
98
+ return;
99
+ }
100
+
101
+ const nodes = visibleNodes();
102
+
103
+ if (evt.name === "k" || evt.name === "up") {
104
+ setSelectedIndex((i) => Math.max(0, i - 1));
105
+ evt.preventDefault();
106
+ } else if (evt.name === "j" || evt.name === "down") {
107
+ setSelectedIndex((i) => Math.min(nodes.length - 1, i + 1));
108
+ evt.preventDefault();
109
+ } else if (evt.name === "return") {
110
+ const node = nodes[selectedIndex()];
111
+ if (!node) return;
112
+ if (node.expandable) {
113
+ toggleCollapse(node);
114
+ } else {
115
+ props.onEditField(node.path);
116
+ }
117
+ evt.preventDefault();
118
+ } else if (evt.name === "a") {
119
+ const node = nodes[selectedIndex()];
120
+ if (!node) return;
121
+ const rowIdx = rowIndexFromPath(node.path);
122
+ if (rowIdx !== null) {
123
+ props.onAddPane(rowIdx);
124
+ }
125
+ evt.preventDefault();
126
+ } else if (evt.name === "d") {
127
+ const node = nodes[selectedIndex()];
128
+ if (!node) return;
129
+ setConfirmDelete(node.path.join("."));
130
+ evt.preventDefault();
131
+ } else if (evt.ctrl && evt.name === "s") {
132
+ props.onSave(props.config);
133
+ evt.preventDefault();
134
+ }
135
+ });
136
+
137
+ function nodeLabel(node: TreeNode): string {
138
+ const indent = " ".repeat(node.depth);
139
+ const prefix = node.expandable ? (isCollapsed(node) ? "▸ " : "▾ ") : " ";
140
+ if (node.value !== null) {
141
+ return `${indent}${prefix}${node.label}: ${node.value}`;
142
+ }
143
+ return `${indent}${prefix}${node.label}`;
144
+ }
145
+
146
+ return (
147
+ <box paddingLeft={1} paddingRight={1}>
148
+ {/* Header */}
149
+ <box flexShrink={0} flexDirection="row" justifyContent="space-between" paddingBottom={1}>
150
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
151
+ Config Editor
152
+ </text>
153
+ <text fg={toRGBA(theme.fgMuted)} onMouseUp={() => props.onSave(props.config)}>
154
+ Ctrl+S:save
155
+ </text>
156
+ </box>
157
+
158
+ {/* Tree */}
159
+ <box flexGrow={1}>
160
+ {visibleNodes().map((node, index) => {
161
+ const isSelected = () => selectedIndex() === index;
162
+ const isRow = isRowPath(node.path);
163
+ return (
164
+ <box
165
+ flexShrink={0}
166
+ backgroundColor={isSelected() ? toRGBA(theme.selected) : undefined}
167
+ onMouseMove={() => {
168
+ setInputMode("mouse");
169
+ setSelectedIndex(index);
170
+ }}
171
+ onMouseDown={() => setSelectedIndex(index)}
172
+ onMouseUp={() => {
173
+ if (node.expandable) toggleCollapse(node);
174
+ else props.onEditField(node.path);
175
+ }}
176
+ >
177
+ <box flexDirection="row">
178
+ <text
179
+ fg={toRGBA(
180
+ isSelected() ? theme.accent : node.expandable ? theme.fg : theme.fgMuted,
181
+ )}
182
+ attributes={isSelected() ? TextAttributes.BOLD : 0}
183
+ wrapMode="none"
184
+ >
185
+ {nodeLabel(node)}
186
+ </text>
187
+ {isSelected() && isRow ? (
188
+ <text fg={toRGBA(theme.fgMuted)}> [a:add pane]</text>
189
+ ) : null}
190
+ </box>
191
+ </box>
192
+ );
193
+ })}
194
+ </box>
195
+
196
+ {/* Delete confirmation */}
197
+ {confirmDelete() !== null ? (
198
+ <box flexShrink={0} paddingTop={1}>
199
+ <text fg={toRGBA(theme.gitDeleted)} attributes={TextAttributes.BOLD}>
200
+ Delete {confirmDelete()}? y/n
201
+ </text>
202
+ </box>
203
+ ) : null}
204
+
205
+ {/* Validation status */}
206
+ <box flexShrink={0} paddingTop={1}>
207
+ {validation().valid ? (
208
+ <text fg={toRGBA(theme.gitAdded)}>Config valid</text>
209
+ ) : (
210
+ <box>
211
+ <text fg={toRGBA(theme.gitDeleted)} attributes={TextAttributes.BOLD}>
212
+ Validation errors:
213
+ </text>
214
+ {(validation() as { valid: false; errors: string[] }).errors.slice(0, 3).map((err) => (
215
+ <text fg={toRGBA(theme.gitDeleted)}> {err}</text>
216
+ ))}
217
+ </box>
218
+ )}
219
+ </box>
220
+
221
+ {/* Footer */}
222
+ <box flexShrink={0}>
223
+ <box flexShrink={0} height={1}>
224
+ <text fg={toRGBA(theme.border)} wrapMode="none">
225
+ {"─".repeat(40)}
226
+ </text>
227
+ </box>
228
+ <box flexDirection="row" gap={2}>
229
+ <text fg={toRGBA(theme.fgMuted)}>j/k:nav</text>
230
+ <text fg={toRGBA(theme.fgMuted)}>Enter:edit/toggle</text>
231
+ <text fg={toRGBA(theme.fgMuted)}>a:add pane</text>
232
+ <text fg={toRGBA(theme.fgMuted)}>d:delete</text>
233
+ <text fg={toRGBA(theme.fgMuted)}>Ctrl+S:save</text>
234
+ </box>
235
+ </box>
236
+ </box>
237
+ );
238
+ }
@@ -0,0 +1,72 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core";
2
+ import { useKeyboard } from "@opentui/solid";
3
+ import type { WidgetTheme, RGBA as RGBAType } from "../lib/theme.ts";
4
+
5
+ function toRGBA(c: RGBAType): RGBA {
6
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
7
+ }
8
+
9
+ export interface DetectedStackInfo {
10
+ packageManager: string | null;
11
+ language: string | null;
12
+ frameworks: string[];
13
+ devCommand: string | null;
14
+ }
15
+
16
+ interface DetectPanelProps {
17
+ detected: DetectedStackInfo;
18
+ onContinue: () => void;
19
+ theme: WidgetTheme;
20
+ }
21
+
22
+ export function DetectPanel(props: DetectPanelProps) {
23
+ const theme = props.theme;
24
+
25
+ useKeyboard((evt) => {
26
+ if (evt.name === "return") {
27
+ props.onContinue();
28
+ evt.preventDefault();
29
+ }
30
+ });
31
+
32
+ function field(label: string, value: string | null): string {
33
+ return `${label}: ${value ?? "not detected"}`;
34
+ }
35
+
36
+ return (
37
+ <box paddingLeft={1} paddingRight={1}>
38
+ {/* Header */}
39
+ <box flexShrink={0} paddingBottom={1}>
40
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
41
+ Project Detected
42
+ </text>
43
+ </box>
44
+
45
+ {/* Detection results */}
46
+ <box flexShrink={0} paddingBottom={1}>
47
+ <text fg={toRGBA(theme.fg)}>{field("Package Manager", props.detected.packageManager)}</text>
48
+ <text fg={toRGBA(theme.fg)}>{field("Language", props.detected.language)}</text>
49
+ <text fg={toRGBA(theme.fg)}>
50
+ {field(
51
+ "Frameworks",
52
+ props.detected.frameworks.length > 0 ? props.detected.frameworks.join(", ") : null,
53
+ )}
54
+ </text>
55
+ <text fg={toRGBA(theme.fg)}>{field("Dev Command", props.detected.devCommand)}</text>
56
+ </box>
57
+
58
+ {/* Spacer */}
59
+ <box flexGrow={1} />
60
+
61
+ {/* Footer */}
62
+ <box flexShrink={0} onMouseDown={() => props.onContinue()}>
63
+ <box flexShrink={0} height={1}>
64
+ <text fg={toRGBA(theme.border)} wrapMode="none">
65
+ {"─".repeat(40)}
66
+ </text>
67
+ </box>
68
+ <text fg={toRGBA(theme.accent)}>Enter:continue</text>
69
+ </box>
70
+ </box>
71
+ );
72
+ }
@@ -0,0 +1,265 @@
1
+ import { createSignal, onMount } from "solid-js";
2
+ import { RGBA, TextAttributes, type InputRenderable } 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 type FieldType = "string" | "enum" | "boolean" | "size";
11
+
12
+ export interface FieldEditorProps {
13
+ path: string[];
14
+ value: string;
15
+ fieldType: FieldType;
16
+ enumValues?: string[];
17
+ onSave: (path: string[], newValue: unknown) => void;
18
+ onCancel: () => void;
19
+ theme: WidgetTheme;
20
+ }
21
+
22
+ /** Validate a size string (e.g. "70%", "50%"). */
23
+ function isValidSize(v: string): boolean {
24
+ return /^\d{1,3}%$/.test(v);
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // String / Size editor (input-based)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function StringEditor(props: {
32
+ value: string;
33
+ isSize: boolean;
34
+ onSave: (v: string) => void;
35
+ onCancel: () => void;
36
+ theme: WidgetTheme;
37
+ }) {
38
+ const [text, setText] = createSignal(props.value);
39
+ const [error, setError] = createSignal<string | null>(null);
40
+ let inputRef: InputRenderable | undefined;
41
+
42
+ onMount(() => {
43
+ setTimeout(() => inputRef?.focus(), 50);
44
+ });
45
+
46
+ useKeyboard((evt) => {
47
+ if (evt.name === "escape") {
48
+ props.onCancel();
49
+ evt.preventDefault();
50
+ } else if (evt.name === "return") {
51
+ const val = text();
52
+ if (props.isSize && !isValidSize(val)) {
53
+ setError("Must be a percentage (e.g. 70%)");
54
+ return;
55
+ }
56
+ props.onSave(val);
57
+ evt.preventDefault();
58
+ }
59
+ });
60
+
61
+ return (
62
+ <box>
63
+ <input
64
+ value={text()}
65
+ placeholder={props.isSize ? "e.g. 70%" : "Enter value..."}
66
+ onInput={(v: string) => {
67
+ setText(v);
68
+ setError(null);
69
+ }}
70
+ focusedBackgroundColor={toRGBA(props.theme.selected)}
71
+ cursorColor={toRGBA(props.theme.accent)}
72
+ focusedTextColor={toRGBA(props.theme.fg)}
73
+ onMouseDown={() => inputRef?.focus()}
74
+ ref={(r: InputRenderable) => {
75
+ inputRef = r;
76
+ }}
77
+ />
78
+ {error() ? <text fg={toRGBA(props.theme.gitDeleted)}>{error()}</text> : null}
79
+ </box>
80
+ );
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Enum editor (list selection)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function EnumEditor(props: {
88
+ value: string;
89
+ values: string[];
90
+ onSave: (v: string) => void;
91
+ onCancel: () => void;
92
+ theme: WidgetTheme;
93
+ }) {
94
+ const initial = Math.max(0, props.values.indexOf(props.value));
95
+ const [selectedIndex, setSelectedIndex] = createSignal(initial);
96
+ const [inputMode, setInputMode] = createSignal<"keyboard" | "mouse">("keyboard");
97
+
98
+ useKeyboard((evt) => {
99
+ setInputMode("keyboard");
100
+ if (evt.name === "escape") {
101
+ props.onCancel();
102
+ evt.preventDefault();
103
+ } else if (evt.name === "k" || evt.name === "up") {
104
+ setSelectedIndex((i) => Math.max(0, i - 1));
105
+ evt.preventDefault();
106
+ } else if (evt.name === "j" || evt.name === "down") {
107
+ setSelectedIndex((i) => Math.min(props.values.length - 1, i + 1));
108
+ evt.preventDefault();
109
+ } else if (evt.name === "return") {
110
+ const val = props.values[selectedIndex()];
111
+ if (val !== undefined) props.onSave(val);
112
+ evt.preventDefault();
113
+ }
114
+ });
115
+
116
+ return (
117
+ <box>
118
+ {props.values.map((val, index) => {
119
+ const isSelected = () => selectedIndex() === index;
120
+ return (
121
+ <box
122
+ flexShrink={0}
123
+ backgroundColor={isSelected() ? toRGBA(props.theme.selected) : undefined}
124
+ onMouseMove={() => {
125
+ setInputMode("mouse");
126
+ setSelectedIndex(index);
127
+ }}
128
+ onMouseDown={() => setSelectedIndex(index)}
129
+ onMouseUp={() => props.onSave(val)}
130
+ >
131
+ <text
132
+ fg={toRGBA(isSelected() ? props.theme.accent : props.theme.fg)}
133
+ attributes={isSelected() ? TextAttributes.BOLD : 0}
134
+ >
135
+ {isSelected() ? "▸ " : " "}
136
+ {val}
137
+ </text>
138
+ </box>
139
+ );
140
+ })}
141
+ </box>
142
+ );
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Boolean editor (toggle)
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function BooleanEditor(props: {
150
+ value: string;
151
+ onSave: (v: boolean) => void;
152
+ onCancel: () => void;
153
+ theme: WidgetTheme;
154
+ }) {
155
+ const [val, setVal] = createSignal(props.value === "true");
156
+
157
+ useKeyboard((evt) => {
158
+ if (evt.name === "escape") {
159
+ props.onCancel();
160
+ evt.preventDefault();
161
+ } else if (evt.name === "space") {
162
+ setVal((v) => !v);
163
+ evt.preventDefault();
164
+ } else if (evt.name === "return") {
165
+ props.onSave(val());
166
+ evt.preventDefault();
167
+ }
168
+ });
169
+
170
+ return (
171
+ <box flexDirection="row" gap={1}>
172
+ <text
173
+ fg={toRGBA(props.theme.accent)}
174
+ attributes={TextAttributes.BOLD}
175
+ onMouseUp={() => setVal((v) => !v)}
176
+ >
177
+ {val() ? "[x]" : "[ ]"}
178
+ </text>
179
+ <text fg={toRGBA(props.theme.fg)} onMouseUp={() => setVal((v) => !v)}>
180
+ {val() ? "true" : "false"}
181
+ </text>
182
+ <text fg={toRGBA(props.theme.fgMuted)}>(Space to toggle, click to flip)</text>
183
+ </box>
184
+ );
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Main FieldEditor
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export function FieldEditor(props: FieldEditorProps) {
192
+ const theme = props.theme;
193
+ const pathLabel = props.path.join(".");
194
+
195
+ return (
196
+ <box paddingLeft={1} paddingRight={1}>
197
+ {/* Header */}
198
+ <box flexShrink={0} flexDirection="row" justifyContent="space-between" paddingBottom={1}>
199
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
200
+ Edit: {pathLabel}
201
+ </text>
202
+ <text fg={toRGBA(theme.fgMuted)} onMouseUp={() => props.onCancel()}>
203
+ Esc:cancel
204
+ </text>
205
+ </box>
206
+
207
+ {/* Current value */}
208
+ <box flexShrink={0} paddingBottom={1}>
209
+ <text fg={toRGBA(theme.fgMuted)}>Current: {props.value || "(empty)"}</text>
210
+ </box>
211
+
212
+ {/* Editor */}
213
+ <box flexShrink={0} paddingBottom={1}>
214
+ {props.fieldType === "enum" ? (
215
+ <EnumEditor
216
+ value={props.value}
217
+ values={props.enumValues ?? []}
218
+ onSave={(v) => props.onSave(props.path, v)}
219
+ onCancel={props.onCancel}
220
+ theme={theme}
221
+ />
222
+ ) : props.fieldType === "boolean" ? (
223
+ <BooleanEditor
224
+ value={props.value}
225
+ onSave={(v) => props.onSave(props.path, v)}
226
+ onCancel={props.onCancel}
227
+ theme={theme}
228
+ />
229
+ ) : (
230
+ <StringEditor
231
+ value={props.value}
232
+ isSize={props.fieldType === "size"}
233
+ onSave={(v) => props.onSave(props.path, v)}
234
+ onCancel={props.onCancel}
235
+ theme={theme}
236
+ />
237
+ )}
238
+ </box>
239
+
240
+ {/* Spacer */}
241
+ <box flexGrow={1} />
242
+
243
+ {/* Footer */}
244
+ <box flexShrink={0}>
245
+ <box flexShrink={0} height={1}>
246
+ <text fg={toRGBA(theme.border)} wrapMode="none">
247
+ {"─".repeat(40)}
248
+ </text>
249
+ </box>
250
+ <box flexDirection="row" gap={2}>
251
+ {props.fieldType === "enum" ? (
252
+ <text fg={toRGBA(theme.fgMuted)}>j/k:navigate Enter:select</text>
253
+ ) : props.fieldType === "boolean" ? (
254
+ <text fg={toRGBA(theme.fgMuted)}>Space:toggle Enter:confirm</text>
255
+ ) : (
256
+ <text fg={toRGBA(theme.fgMuted)}>Enter:save</text>
257
+ )}
258
+ <text fg={toRGBA(theme.fgMuted)} onMouseUp={() => props.onCancel()}>
259
+ Esc:cancel
260
+ </text>
261
+ </box>
262
+ </box>
263
+ </box>
264
+ );
265
+ }