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,238 @@
1
+ import { createSignal, createMemo, For, Show } from "solid-js";
2
+ import { RGBA, TextAttributes } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/solid";
4
+ import type { Task } from "../../lib/task-store.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
+ type TaskStatus = Task["status"];
12
+
13
+ const STATUS_ORDER: TaskStatus[] = ["in-progress", "todo", "review", "done"];
14
+
15
+ function statusLabel(status: TaskStatus): string {
16
+ switch (status) {
17
+ case "in-progress":
18
+ return "IN PROGRESS";
19
+ case "todo":
20
+ return "TODO";
21
+ case "review":
22
+ return "REVIEW";
23
+ case "done":
24
+ return "DONE";
25
+ }
26
+ }
27
+
28
+ function statusColor(status: TaskStatus, theme: WidgetTheme): RGBAType {
29
+ switch (status) {
30
+ case "in-progress":
31
+ return theme.gitModified;
32
+ case "todo":
33
+ return theme.fgMuted;
34
+ case "review":
35
+ return theme.diffHunk;
36
+ case "done":
37
+ return theme.gitAdded;
38
+ }
39
+ }
40
+
41
+ function priorityColor(priority: number, theme: WidgetTheme): RGBAType {
42
+ if (priority <= 1) return theme.gitDeleted; // red
43
+ if (priority <= 2) return theme.gitModified; // yellow
44
+ return theme.fgMuted; // dim
45
+ }
46
+
47
+ function priorityLabel(priority: number): string {
48
+ return `P${priority}`;
49
+ }
50
+
51
+ function truncate(text: string, max: number): string {
52
+ return text.length > max ? text.slice(0, max - 1) + "…" : text;
53
+ }
54
+
55
+ type FlatRow =
56
+ | { kind: "header"; status: TaskStatus; count: number }
57
+ | { kind: "task"; task: Task }
58
+ | { kind: "create" };
59
+
60
+ function buildRows(tasks: Task[]): FlatRow[] {
61
+ const groups: Record<TaskStatus, Task[]> = {
62
+ "in-progress": [],
63
+ todo: [],
64
+ review: [],
65
+ done: [],
66
+ };
67
+
68
+ for (const task of tasks) {
69
+ const group = groups[task.status];
70
+ if (group) group.push(task);
71
+ }
72
+
73
+ for (const group of Object.values(groups)) {
74
+ group.sort((a, b) => a.priority - b.priority);
75
+ }
76
+
77
+ const rows: FlatRow[] = [];
78
+ for (const status of STATUS_ORDER) {
79
+ const group = groups[status];
80
+ if (group.length === 0) continue;
81
+ rows.push({ kind: "header", status, count: group.length });
82
+ for (const task of group) {
83
+ rows.push({ kind: "task", task });
84
+ }
85
+ }
86
+
87
+ rows.push({ kind: "create" });
88
+ return rows;
89
+ }
90
+
91
+ interface TaskPanelProps {
92
+ tasks: Task[];
93
+ isActive: boolean;
94
+ theme: WidgetTheme;
95
+ onCreateTask: () => void;
96
+ }
97
+
98
+ export function TaskPanel(props: TaskPanelProps) {
99
+ const [selected, setSelected] = createSignal(0);
100
+
101
+ const rows = createMemo(() => buildRows(props.tasks));
102
+
103
+ // Skip headers when navigating
104
+ function moveSelection(direction: number) {
105
+ const list = rows();
106
+ let next = selected() + direction;
107
+ while (next >= 0 && next < list.length && list[next]?.kind === "header") {
108
+ next += direction;
109
+ }
110
+ if (next >= 0 && next < list.length) setSelected(next);
111
+ }
112
+
113
+ useKeyboard((evt) => {
114
+ if (!props.isActive) return;
115
+
116
+ if (evt.name === "up" || evt.name === "k") {
117
+ moveSelection(-1);
118
+ evt.preventDefault();
119
+ } else if (evt.name === "down" || evt.name === "j") {
120
+ moveSelection(1);
121
+ evt.preventDefault();
122
+ } else if (evt.name === "return") {
123
+ const row = rows()[selected()];
124
+ if (row?.kind === "create") props.onCreateTask();
125
+ evt.preventDefault();
126
+ }
127
+ });
128
+
129
+ return (
130
+ <box flexGrow={1}>
131
+ <Show
132
+ when={rows().length > 1}
133
+ fallback={
134
+ <box>
135
+ <text fg={toRGBA(props.theme.fgMuted)}>No tasks</text>
136
+ <box
137
+ flexShrink={0}
138
+ backgroundColor={
139
+ props.isActive && selected() === 0 ? toRGBA(props.theme.selected) : undefined
140
+ }
141
+ onMouseDown={() => props.onCreateTask()}
142
+ >
143
+ <text
144
+ fg={toRGBA(
145
+ props.isActive && selected() === 0 ? props.theme.accent : props.theme.fgMuted,
146
+ )}
147
+ >
148
+ [+create]
149
+ </text>
150
+ </box>
151
+ </box>
152
+ }
153
+ >
154
+ <scrollbox flexGrow={1}>
155
+ <For each={rows()}>
156
+ {(row, index) => {
157
+ if (row.kind === "header") {
158
+ return (
159
+ <box flexShrink={0} paddingTop={index() > 0 ? 1 : 0}>
160
+ <text
161
+ fg={toRGBA(statusColor(row.status, props.theme))}
162
+ attributes={TextAttributes.BOLD}
163
+ >
164
+ {statusLabel(row.status)} ({row.count})
165
+ </text>
166
+ </box>
167
+ );
168
+ }
169
+
170
+ if (row.kind === "create") {
171
+ const isSel = () => props.isActive && selected() === index();
172
+ return (
173
+ <box
174
+ flexShrink={0}
175
+ backgroundColor={isSel() ? toRGBA(props.theme.selected) : undefined}
176
+ onMouseDown={() => {
177
+ setSelected(index());
178
+ props.onCreateTask();
179
+ }}
180
+ >
181
+ <text fg={toRGBA(isSel() ? props.theme.accent : props.theme.fgMuted)}>
182
+ [+create]
183
+ </text>
184
+ </box>
185
+ );
186
+ }
187
+
188
+ const task = row.task;
189
+ const isSel = () => props.isActive && selected() === index();
190
+ return (
191
+ <box
192
+ flexShrink={0}
193
+ flexDirection="row"
194
+ gap={1}
195
+ backgroundColor={isSel() ? toRGBA(props.theme.selected) : undefined}
196
+ onMouseDown={() => setSelected(index())}
197
+ >
198
+ <text
199
+ fg={toRGBA(priorityColor(task.priority, props.theme))}
200
+ flexShrink={0}
201
+ wrapMode="none"
202
+ >
203
+ {priorityLabel(task.priority)}
204
+ </text>
205
+ <text
206
+ fg={toRGBA(isSel() ? props.theme.accent : props.theme.fg)}
207
+ attributes={isSel() ? TextAttributes.BOLD : 0}
208
+ flexShrink={0}
209
+ wrapMode="none"
210
+ >
211
+ {task.id}
212
+ </text>
213
+ <text
214
+ fg={toRGBA(isSel() ? props.theme.selectedText : props.theme.fg)}
215
+ wrapMode="none"
216
+ flexGrow={1}
217
+ >
218
+ {truncate(task.title, 30)}
219
+ </text>
220
+ <Show when={task.milestone}>
221
+ <text fg={toRGBA(props.theme.diffHunk)} flexShrink={0} wrapMode="none">
222
+ {task.milestone}
223
+ </text>
224
+ </Show>
225
+ <Show when={task.assignee}>
226
+ <text fg={toRGBA(props.theme.fgMuted)} flexShrink={0} wrapMode="none">
227
+ @{truncate(task.assignee!, 10)}
228
+ </text>
229
+ </Show>
230
+ </box>
231
+ );
232
+ }}
233
+ </For>
234
+ </scrollbox>
235
+ </Show>
236
+ </box>
237
+ );
238
+ }
@@ -0,0 +1,415 @@
1
+ import { parseArgs } from "node:util";
2
+ import { readFileSync, existsSync, statSync } from "node:fs";
3
+ import { extname, basename, resolve } from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid";
6
+ import { RGBA, TextAttributes } from "@opentui/core";
7
+ import { createSignal, createMemo, onCleanup, Show, For } from "solid-js";
8
+ import { createTheme, type WidgetTheme } from "../lib/theme.ts";
9
+ import { getFileDiff } from "../lib/git.ts";
10
+
11
+ const { values } = parseArgs({
12
+ options: {
13
+ session: { type: "string" },
14
+ dir: { type: "string" },
15
+ theme: { type: "string" },
16
+ },
17
+ });
18
+
19
+ const session = values.session ?? "";
20
+ const dir = values.dir ?? process.cwd();
21
+ const themeConfig = values.theme ? JSON.parse(values.theme) : undefined;
22
+
23
+ const MAX_PREVIEW_LINES = 500;
24
+ const BINARY_EXTENSIONS = new Set([
25
+ ".png",
26
+ ".jpg",
27
+ ".jpeg",
28
+ ".gif",
29
+ ".bmp",
30
+ ".ico",
31
+ ".webp",
32
+ ".woff",
33
+ ".woff2",
34
+ ".ttf",
35
+ ".otf",
36
+ ".eot",
37
+ ".mp3",
38
+ ".mp4",
39
+ ".wav",
40
+ ".ogg",
41
+ ".webm",
42
+ ".zip",
43
+ ".tar",
44
+ ".gz",
45
+ ".bz2",
46
+ ".7z",
47
+ ".rar",
48
+ ".pdf",
49
+ ".exe",
50
+ ".dll",
51
+ ".so",
52
+ ".dylib",
53
+ ".node",
54
+ ".wasm",
55
+ ".tgz",
56
+ ]);
57
+
58
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
59
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
60
+ }
61
+
62
+ function getPreviewFile(): string | null {
63
+ if (!session) return null;
64
+ try {
65
+ return (
66
+ execFileSync("tmux", ["show-option", "-t", session, "-v", "@preview_file"], {
67
+ encoding: "utf-8",
68
+ stdio: ["ignore", "pipe", "ignore"],
69
+ }).trim() || null
70
+ );
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function formatSize(bytes: number): string {
77
+ if (bytes < 1024) return `${bytes}B`;
78
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
79
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
80
+ }
81
+
82
+ const KEYWORD_RE =
83
+ /^(import|export|from|const|let|var|function|return|if|else|for|while|class|interface|type|async|await|try|catch|throw|new|switch|case|default|break|continue|enum|extends|implements|public|private|protected|static|readonly|abstract|override|declare|module|namespace|def|fn|pub|mut|use|mod|struct|impl|trait|match|loop|where|yield)\b/;
84
+
85
+ function getLineColor(
86
+ line: string,
87
+ theme: WidgetTheme,
88
+ ): { r: number; g: number; b: number; a: number } {
89
+ const trimmed = line.trim();
90
+ if (!trimmed) return theme.fg;
91
+
92
+ // Comments
93
+ if (
94
+ trimmed.startsWith("//") ||
95
+ trimmed.startsWith("#") ||
96
+ trimmed.startsWith("--") ||
97
+ trimmed.startsWith("/*") ||
98
+ trimmed.startsWith("*")
99
+ ) {
100
+ return theme.fgMuted;
101
+ }
102
+
103
+ // Strings
104
+ if (trimmed.startsWith('"') || trimmed.startsWith("'") || trimmed.startsWith("`")) {
105
+ return theme.gitAdded;
106
+ }
107
+
108
+ // Keywords
109
+ if (KEYWORD_RE.test(trimmed)) {
110
+ return theme.accent;
111
+ }
112
+
113
+ return theme.fg;
114
+ }
115
+
116
+ interface FileData {
117
+ content: string;
118
+ totalLines: number;
119
+ binary: boolean;
120
+ size: string;
121
+ }
122
+
123
+ // Parse diff to get line-level change info for gutter markers
124
+ function parseDiffLineMap(diff: string): Map<number, "added" | "modified"> {
125
+ const map = new Map<number, "added" | "modified">();
126
+ if (!diff) return map;
127
+ const lines = diff.split("\n");
128
+ for (const line of lines) {
129
+ // Parse hunk headers: @@ -oldStart,oldCount +newStart,newCount @@
130
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
131
+ if (hunkMatch) continue;
132
+ }
133
+ // Simpler approach: track added lines from the diff
134
+ let newLineNum = 0;
135
+ for (const line of lines) {
136
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)/);
137
+ if (hunkMatch) {
138
+ newLineNum = parseInt(hunkMatch[1]!, 10);
139
+ continue;
140
+ }
141
+ if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff")) continue;
142
+ if (line.startsWith("+")) {
143
+ map.set(newLineNum, "added");
144
+ newLineNum++;
145
+ } else if (line.startsWith("-")) {
146
+ // Deleted line — mark the current position as modified
147
+ if (!map.has(newLineNum)) map.set(newLineNum, "modified");
148
+ } else {
149
+ newLineNum++;
150
+ }
151
+ }
152
+ return map;
153
+ }
154
+
155
+ function loadFile(filePath: string): FileData | null {
156
+ const fullPath = filePath.startsWith("/") ? filePath : resolve(dir, filePath);
157
+ if (!existsSync(fullPath)) return null;
158
+
159
+ let size: string;
160
+ try {
161
+ size = formatSize(statSync(fullPath).size);
162
+ } catch {
163
+ size = "";
164
+ }
165
+
166
+ if (BINARY_EXTENSIONS.has(extname(fullPath).toLowerCase())) {
167
+ return { content: "(binary file)", totalLines: 1, binary: true, size };
168
+ }
169
+ try {
170
+ const raw = readFileSync(fullPath, "utf-8");
171
+ if (raw.includes("\0")) {
172
+ return { content: "(binary file)", totalLines: 1, binary: true, size };
173
+ }
174
+ const allLines = raw.split("\n");
175
+ return {
176
+ content: allLines.slice(0, MAX_PREVIEW_LINES).join("\n"),
177
+ totalLines: allLines.length,
178
+ binary: false,
179
+ size,
180
+ };
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ render(
187
+ () => {
188
+ const theme = createTheme(themeConfig);
189
+ const dimensions = useTerminalDimensions();
190
+ const [filePath, setFilePath] = createSignal<string | null>(null);
191
+ const [fileContent, setFileContent] = createSignal<string | null>(null);
192
+ const [totalLines, setTotalLines] = createSignal(0);
193
+ const [fileSize, setFileSize] = createSignal("");
194
+ const [isBinary, setIsBinary] = createSignal(false);
195
+ const [fileDiff, setFileDiff] = createSignal<string | null>(null);
196
+ const [viewMode, setViewMode] = createSignal<"content" | "diff">("content");
197
+
198
+ // Poll tmux session variable for file path changes
199
+ const interval = setInterval(() => {
200
+ const newPath = getPreviewFile();
201
+ if (newPath !== filePath()) {
202
+ setFilePath(newPath);
203
+ setViewMode("content");
204
+ if (newPath) {
205
+ const result = loadFile(newPath);
206
+ if (result) {
207
+ setFileContent(result.content);
208
+ setTotalLines(result.totalLines);
209
+ setFileSize(result.size);
210
+ setIsBinary(result.binary);
211
+ } else {
212
+ setFileContent(null);
213
+ setTotalLines(0);
214
+ setFileSize("");
215
+ setIsBinary(false);
216
+ }
217
+ // Check for git diff
218
+ try {
219
+ const diff = getFileDiff(dir, newPath, false);
220
+ setFileDiff(diff || null);
221
+ } catch {
222
+ setFileDiff(null);
223
+ }
224
+ } else {
225
+ setFileContent(null);
226
+ setTotalLines(0);
227
+ setFileSize("");
228
+ setIsBinary(false);
229
+ setFileDiff(null);
230
+ }
231
+ }
232
+ }, 200);
233
+
234
+ onCleanup(() => clearInterval(interval));
235
+
236
+ const lines = createMemo(() => {
237
+ const content = fileContent();
238
+ if (!content) return [];
239
+ return content.split("\n");
240
+ });
241
+
242
+ const fileExt = createMemo(() => {
243
+ const fp = filePath();
244
+ return fp ? extname(fp).toLowerCase() : "";
245
+ });
246
+
247
+ const lineNumWidth = createMemo(() => Math.max(3, String(totalLines()).length));
248
+
249
+ const diffLineMap = createMemo(() => parseDiffLineMap(fileDiff() ?? ""));
250
+
251
+ useKeyboard((evt) => {
252
+ if (evt.name === "d") {
253
+ if (fileDiff()) setViewMode((m) => (m === "content" ? "diff" : "content"));
254
+ evt.preventDefault();
255
+ } else if (evt.name === "r" && filePath()) {
256
+ const result = loadFile(filePath()!);
257
+ if (result) {
258
+ setFileContent(result.content);
259
+ setTotalLines(result.totalLines);
260
+ setFileSize(result.size);
261
+ setIsBinary(result.binary);
262
+ }
263
+ try {
264
+ const diff = getFileDiff(dir, filePath()!, false);
265
+ setFileDiff(diff || null);
266
+ } catch {
267
+ setFileDiff(null);
268
+ }
269
+ evt.preventDefault();
270
+ } else if (evt.name === "q") {
271
+ process.exit(0);
272
+ }
273
+ });
274
+
275
+ return (
276
+ <box
277
+ width={dimensions().width}
278
+ height={dimensions().height}
279
+ backgroundColor={toRGBA(theme.bg)}
280
+ >
281
+ {/* Header */}
282
+ <Show
283
+ when={filePath()}
284
+ fallback={
285
+ <box flexGrow={1} paddingLeft={2} paddingTop={2}>
286
+ <text fg={toRGBA(theme.fgMuted)}>Select a file in the explorer</text>
287
+ <text fg={toRGBA(theme.border)} paddingTop={1}>
288
+ Navigate with ↑↓ keys
289
+ </text>
290
+ <text fg={toRGBA(theme.border)}>Preview updates automatically</text>
291
+ </box>
292
+ }
293
+ >
294
+ <box flexShrink={0} paddingLeft={1} flexDirection="row" gap={2}>
295
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
296
+ {basename(filePath()!)}
297
+ </text>
298
+ <Show when={!isBinary()}>
299
+ <text fg={toRGBA(theme.fgMuted)}>{totalLines()} lines</text>
300
+ </Show>
301
+ <text fg={toRGBA(theme.fgMuted)}>{fileSize()}</text>
302
+ <Show when={fileDiff()}>
303
+ {(() => {
304
+ const lines = fileDiff()!.split("\n");
305
+ const added = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).length;
306
+ const removed = lines.filter(
307
+ (l) => l.startsWith("-") && !l.startsWith("---"),
308
+ ).length;
309
+ return (
310
+ <box flexDirection="row" gap={1}>
311
+ <Show when={added > 0}>
312
+ <text fg={toRGBA(theme.gitAdded)}>+{added}</text>
313
+ </Show>
314
+ <Show when={removed > 0}>
315
+ <text fg={toRGBA(theme.gitDeleted)}>-{removed}</text>
316
+ </Show>
317
+ <text fg={toRGBA(viewMode() === "diff" ? theme.gitModified : theme.fgMuted)}>
318
+ {viewMode() === "diff" ? "[diff]" : "[d:diff]"}
319
+ </text>
320
+ </box>
321
+ );
322
+ })()}
323
+ </Show>
324
+ </box>
325
+
326
+ {/* Separator */}
327
+ <box flexShrink={0} height={1}>
328
+ <text fg={toRGBA(theme.border)} wrapMode="none">
329
+ {"─".repeat(dimensions().width)}
330
+ </text>
331
+ </box>
332
+
333
+ {/* Diff view */}
334
+ <Show when={viewMode() === "diff" && fileDiff()}>
335
+ <scrollbox flexGrow={1}>
336
+ <For each={fileDiff()!.split("\n")}>
337
+ {(line) => {
338
+ const color = line.startsWith("+")
339
+ ? theme.diffAdded
340
+ : line.startsWith("-")
341
+ ? theme.diffRemoved
342
+ : line.startsWith("@@")
343
+ ? theme.diffHunk
344
+ : theme.diffContext;
345
+ const bg = line.startsWith("+")
346
+ ? theme.diffAddedBg
347
+ : line.startsWith("-")
348
+ ? theme.diffRemovedBg
349
+ : theme.diffContextBg;
350
+ return (
351
+ <box backgroundColor={toRGBA(bg)}>
352
+ <text fg={toRGBA(color)} wrapMode="none">
353
+ {line || " "}
354
+ </text>
355
+ </box>
356
+ );
357
+ }}
358
+ </For>
359
+ </scrollbox>
360
+ </Show>
361
+
362
+ {/* Content view */}
363
+ <Show when={viewMode() === "content" && fileContent()}>
364
+ <scrollbox flexGrow={1}>
365
+ <For each={lines()}>
366
+ {(line, lineNum) => {
367
+ const color = isBinary() ? theme.fgMuted : getLineColor(line, theme);
368
+ const lineNumber = lineNum() + 1;
369
+ const changeType = diffLineMap().get(lineNumber);
370
+ const gutterColor =
371
+ changeType === "added"
372
+ ? theme.gitAdded
373
+ : changeType === "modified"
374
+ ? theme.gitModified
375
+ : null;
376
+ const gutterChar = gutterColor ? "│" : " ";
377
+ return (
378
+ <box flexDirection="row">
379
+ <Show when={!isBinary()}>
380
+ <text
381
+ fg={toRGBA(gutterColor ?? theme.diffLineNumber)}
382
+ flexShrink={0}
383
+ wrapMode="none"
384
+ >
385
+ {gutterChar}
386
+ {String(lineNumber).padStart(lineNumWidth())}{" "}
387
+ </text>
388
+ </Show>
389
+ <text fg={toRGBA(color)} wrapMode="none">
390
+ {line || " "}
391
+ </text>
392
+ </box>
393
+ );
394
+ }}
395
+ </For>
396
+ </scrollbox>
397
+ </Show>
398
+
399
+ {/* Footer */}
400
+ <box flexShrink={0} paddingLeft={1}>
401
+ <text fg={toRGBA(theme.fgMuted)} wrapMode="none">
402
+ d:diff view r:refresh q:quit
403
+ </text>
404
+ </box>
405
+ </Show>
406
+ </box>
407
+ );
408
+ },
409
+ {
410
+ targetFps: 30,
411
+ exitOnCtrlC: true,
412
+ useKittyKeyboard: {},
413
+ autoFocus: false,
414
+ },
415
+ );
@@ -0,0 +1,40 @@
1
+ import { resolve, dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { ThemeConfig } from "../types.ts";
4
+ import { shellEscape } from "../lib/shell.ts";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ interface WidgetOptions {
9
+ session: string;
10
+ dir: string;
11
+ target: string | null;
12
+ theme: ThemeConfig | null;
13
+ }
14
+
15
+ // The .tsx extension is used at runtime; Bun handles JSX via preload plugin
16
+ const WIDGET_ENTRY_POINTS: Record<string, string> = {
17
+ explorer: "explorer/index.tsx",
18
+ changes: "changes/index.tsx",
19
+ preview: "preview/index.tsx",
20
+ tasks: "tasks/index.tsx",
21
+ costs: "costs/index.tsx",
22
+ setup: "setup/index.tsx",
23
+ config: "config/index.tsx",
24
+ "mission-control": "mission-control/index.tsx",
25
+ };
26
+
27
+ export function resolveWidgetCommand(type: string, opts: WidgetOptions): string {
28
+ const entry = WIDGET_ENTRY_POINTS[type];
29
+ if (!entry) throw new Error(`Unknown widget type: ${type}`);
30
+
31
+ const scriptPath = resolve(__dirname, entry);
32
+ const args = [`--session=${opts.session}`, `--dir=${opts.dir}`];
33
+ if (opts.target) args.push(`--target=${opts.target}`);
34
+ if (opts.theme) args.push(`--theme=${JSON.stringify(opts.theme)}`);
35
+
36
+ const escapedArgs = args.map(shellEscape).join(" ");
37
+
38
+ // cd to project root first so bunfig.toml preload is found
39
+ return `cd ${shellEscape(opts.dir)} && bun ${shellEscape(scriptPath)} ${escapedArgs}`;
40
+ }