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,576 @@
1
+ import { parseArgs } from "node:util";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } 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 {
8
+ createSignal,
9
+ createMemo,
10
+ createEffect,
11
+ onMount,
12
+ onCleanup,
13
+ batch,
14
+ Show,
15
+ For,
16
+ } from "solid-js";
17
+ import { createTheme } from "../lib/theme.ts";
18
+ import { getGitBranch } from "../lib/git.ts";
19
+ import { watchDirectory } from "../lib/watcher.ts";
20
+ import { findPaneByTitle, sendCommand, findPaneByPattern } from "../lib/pane-comms.ts";
21
+
22
+ const { values } = parseArgs({
23
+ options: {
24
+ session: { type: "string" },
25
+ dir: { type: "string" },
26
+ target: { type: "string" },
27
+ theme: { type: "string" },
28
+ },
29
+ });
30
+
31
+ const session = values.session ?? "";
32
+ const dir = values.dir ?? process.cwd();
33
+ const targetTitle = values.target ?? null;
34
+ const themeConfig = values.theme ? JSON.parse(values.theme) : undefined;
35
+
36
+ // --- Git helpers ---
37
+
38
+ function execGit(args: string[]): string {
39
+ try {
40
+ return execFileSync(
41
+ "git",
42
+ ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", ...args],
43
+ { cwd: dir, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
44
+ );
45
+ } catch {
46
+ return "";
47
+ }
48
+ }
49
+
50
+ interface ChangedFile {
51
+ path: string;
52
+ status: "M" | "A" | "D" | "?";
53
+ staged: boolean;
54
+ additions: number;
55
+ deletions: number;
56
+ }
57
+
58
+ interface ChangesState {
59
+ staged: ChangedFile[];
60
+ unstaged: ChangedFile[];
61
+ untracked: ChangedFile[];
62
+ }
63
+
64
+ function getChanges(): ChangesState {
65
+ const staged: ChangedFile[] = [];
66
+ const unstaged: ChangedFile[] = [];
67
+ const untracked: ChangedFile[] = [];
68
+
69
+ // Staged deleted files
70
+ const stagedDeleted = new Set(
71
+ execGit(["diff", "--cached", "--name-only", "--diff-filter=D"]).split("\n").filter(Boolean),
72
+ );
73
+
74
+ // Staged changes
75
+ for (const line of execGit(["diff", "--cached", "--numstat"]).split("\n").filter(Boolean)) {
76
+ const parts = line.split("\t");
77
+ if (parts.length < 3 || parts[0] === "-") continue;
78
+ const filepath = parts.slice(2).join("\t");
79
+ staged.push({
80
+ path: filepath,
81
+ status: stagedDeleted.has(filepath) ? "D" : "M",
82
+ staged: true,
83
+ additions: parseInt(parts[0]!, 10) || 0,
84
+ deletions: parseInt(parts[1]!, 10) || 0,
85
+ });
86
+ }
87
+ // Staged deleted not in numstat
88
+ for (const filepath of stagedDeleted) {
89
+ if (!staged.some((f) => f.path === filepath)) {
90
+ staged.push({ path: filepath, status: "D", staged: true, additions: 0, deletions: 0 });
91
+ }
92
+ }
93
+
94
+ // Unstaged deleted files
95
+ const unstagedDeleted = new Set(
96
+ execGit(["diff", "--name-only", "--diff-filter=D"]).split("\n").filter(Boolean),
97
+ );
98
+
99
+ // Unstaged changes
100
+ for (const line of execGit(["diff", "--numstat"]).split("\n").filter(Boolean)) {
101
+ const parts = line.split("\t");
102
+ if (parts.length < 3 || parts[0] === "-") continue;
103
+ const filepath = parts.slice(2).join("\t");
104
+ unstaged.push({
105
+ path: filepath,
106
+ status: unstagedDeleted.has(filepath) ? "D" : "M",
107
+ staged: false,
108
+ additions: parseInt(parts[0]!, 10) || 0,
109
+ deletions: parseInt(parts[1]!, 10) || 0,
110
+ });
111
+ }
112
+ for (const filepath of unstagedDeleted) {
113
+ if (!unstaged.some((f) => f.path === filepath)) {
114
+ unstaged.push({ path: filepath, status: "D", staged: false, additions: 0, deletions: 0 });
115
+ }
116
+ }
117
+
118
+ // Untracked files
119
+ for (const filepath of execGit(["ls-files", "--others", "--exclude-standard"])
120
+ .split("\n")
121
+ .filter(Boolean)) {
122
+ let additions = 0;
123
+ try {
124
+ additions = readFileSync(join(dir, filepath), "utf-8").split("\n").length;
125
+ } catch {
126
+ /* ignore */
127
+ }
128
+ untracked.push({ path: filepath, status: "?", staged: false, additions, deletions: 0 });
129
+ }
130
+
131
+ return { staged, unstaged, untracked };
132
+ }
133
+
134
+ function getChangeDiff(file: ChangedFile): string {
135
+ if (file.status === "?") {
136
+ // Untracked: show all content as additions
137
+ try {
138
+ const content = readFileSync(join(dir, file.path), "utf-8");
139
+ return content
140
+ .split("\n")
141
+ .map((l) => `+${l}`)
142
+ .join("\n");
143
+ } catch {
144
+ return "";
145
+ }
146
+ }
147
+ const args = file.staged ? ["diff", "--cached", "--", file.path] : ["diff", "--", file.path];
148
+ return execGit(args);
149
+ }
150
+
151
+ function stageFile(path: string): void {
152
+ execGit(["add", "--", path]);
153
+ }
154
+
155
+ function unstageFile(path: string): void {
156
+ execGit(["reset", "HEAD", "--", path]);
157
+ }
158
+
159
+ function stageAll(): void {
160
+ execGit(["add", "-A"]);
161
+ }
162
+
163
+ function unstageAll(): void {
164
+ execGit(["reset", "HEAD"]);
165
+ }
166
+
167
+ // --- Rendering ---
168
+
169
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
170
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
171
+ }
172
+
173
+ const TRANSPARENT = RGBA.fromInts(0, 0, 0, 0);
174
+
175
+ type ListItem =
176
+ | { kind: "header"; label: string }
177
+ | { kind: "file"; file: ChangedFile; globalIndex: number };
178
+
179
+ function buildListItems(changes: ChangesState): { items: ListItem[]; fileCount: number } {
180
+ const items: ListItem[] = [];
181
+ let globalIndex = 0;
182
+
183
+ if (changes.staged.length > 0) {
184
+ items.push({ kind: "header", label: `Staged (${changes.staged.length})` });
185
+ for (const file of changes.staged) {
186
+ items.push({ kind: "file", file, globalIndex });
187
+ globalIndex++;
188
+ }
189
+ }
190
+ if (changes.unstaged.length > 0) {
191
+ items.push({ kind: "header", label: `Unstaged (${changes.unstaged.length})` });
192
+ for (const file of changes.unstaged) {
193
+ items.push({ kind: "file", file, globalIndex });
194
+ globalIndex++;
195
+ }
196
+ }
197
+ if (changes.untracked.length > 0) {
198
+ items.push({ kind: "header", label: `Untracked (${changes.untracked.length})` });
199
+ for (const file of changes.untracked) {
200
+ items.push({ kind: "file", file, globalIndex });
201
+ globalIndex++;
202
+ }
203
+ }
204
+ return { items, fileCount: globalIndex };
205
+ }
206
+
207
+ render(
208
+ () => {
209
+ const theme = createTheme(themeConfig);
210
+ const dimensions = useTerminalDimensions();
211
+
212
+ const [changes, setChanges] = createSignal<ChangesState>(getChanges());
213
+ const [branch, setBranch] = createSignal(getGitBranch(dir));
214
+ const [selected, setSelected] = createSignal(0);
215
+ const [diffContent, setDiffContent] = createSignal<string | null>(null);
216
+ const [inputMode, setInputMode] = createSignal<"keyboard" | "mouse">("keyboard");
217
+
218
+ const allFiles = createMemo(() => [
219
+ ...changes().staged,
220
+ ...changes().unstaged,
221
+ ...changes().untracked,
222
+ ]);
223
+
224
+ const listData = createMemo(() => buildListItems(changes()));
225
+
226
+ const totalAdditions = createMemo(() => allFiles().reduce((s, f) => s + f.additions, 0));
227
+ const totalDeletions = createMemo(() => allFiles().reduce((s, f) => s + f.deletions, 0));
228
+
229
+ // Load diff when selection changes
230
+ createEffect(() => {
231
+ const file = allFiles()[selected()];
232
+ if (file) {
233
+ try {
234
+ setDiffContent(getChangeDiff(file));
235
+ } catch {
236
+ setDiffContent(null);
237
+ }
238
+ } else {
239
+ setDiffContent(null);
240
+ }
241
+ });
242
+
243
+ // File watcher
244
+ let stopWatch: (() => Promise<void>) | null = null;
245
+ onMount(async () => {
246
+ try {
247
+ stopWatch = await watchDirectory(
248
+ dir,
249
+ () => {
250
+ batch(() => {
251
+ setChanges(getChanges());
252
+ setBranch(getGitBranch(dir));
253
+ });
254
+ },
255
+ { debounceMs: 500 },
256
+ );
257
+ } catch {
258
+ /* watcher unavailable */
259
+ }
260
+ });
261
+ onCleanup(async () => {
262
+ await stopWatch?.();
263
+ });
264
+
265
+ function resolveTargetPane(): string | null {
266
+ if (!session) return null;
267
+ if (targetTitle) return findPaneByTitle(session, targetTitle);
268
+ return findPaneByPattern(session, "claude");
269
+ }
270
+
271
+ // Keyboard
272
+ useKeyboard((evt) => {
273
+ setInputMode("keyboard");
274
+ const files = allFiles();
275
+
276
+ if (evt.name === "up" || evt.name === "k") {
277
+ setSelected((i) => Math.max(0, i - 1));
278
+ evt.preventDefault();
279
+ } else if (evt.name === "down" || evt.name === "j") {
280
+ setSelected((i) => Math.min(files.length - 1, i + 1));
281
+ evt.preventDefault();
282
+ } else if (evt.name === "s" && !evt.shift) {
283
+ const file = files[selected()];
284
+ if (file && !file.staged) {
285
+ stageFile(file.path);
286
+ setChanges(getChanges());
287
+ }
288
+ evt.preventDefault();
289
+ } else if (evt.name === "u" && !evt.shift) {
290
+ const file = files[selected()];
291
+ if (file && file.staged) {
292
+ unstageFile(file.path);
293
+ setChanges(getChanges());
294
+ }
295
+ evt.preventDefault();
296
+ } else if (evt.shift && evt.name === "s") {
297
+ stageAll();
298
+ setChanges(getChanges());
299
+ evt.preventDefault();
300
+ } else if (evt.shift && evt.name === "u") {
301
+ unstageAll();
302
+ setChanges(getChanges());
303
+ evt.preventDefault();
304
+ } else if (evt.name === "c") {
305
+ const file = files[selected()];
306
+ if (file) {
307
+ const targetId = resolveTargetPane();
308
+ if (targetId) sendCommand(session, targetId, `read ${file.path}`);
309
+ }
310
+ evt.preventDefault();
311
+ } else if (evt.name === "r") {
312
+ batch(() => {
313
+ setChanges(getChanges());
314
+ setBranch(getGitBranch(dir));
315
+ });
316
+ evt.preventDefault();
317
+ } else if (evt.name === "g" && !evt.shift) {
318
+ setSelected(0);
319
+ evt.preventDefault();
320
+ } else if (evt.shift && evt.name === "g") {
321
+ setSelected(files.length - 1);
322
+ evt.preventDefault();
323
+ } else if (evt.name === "q") {
324
+ process.exit(0);
325
+ }
326
+ });
327
+
328
+ const listHeight = createMemo(() => Math.max(5, Math.floor(dimensions().height * 0.4)));
329
+
330
+ const diffLines = createMemo(() => {
331
+ const d = diffContent();
332
+ if (!d) return [];
333
+ return d.split("\n");
334
+ });
335
+
336
+ return (
337
+ <box
338
+ width={dimensions().width}
339
+ height={dimensions().height}
340
+ backgroundColor={toRGBA(theme.bg)}
341
+ >
342
+ {/* Header */}
343
+ <box flexShrink={0} paddingLeft={1} paddingBottom={1} flexDirection="row" gap={2}>
344
+ <Show when={branch()}>
345
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
346
+ {"⎇ "}
347
+ {branch()}
348
+ </text>
349
+ </Show>
350
+ <text fg={toRGBA(theme.fg)} attributes={TextAttributes.BOLD}>
351
+ Changes
352
+ </text>
353
+ <Show when={totalAdditions() > 0}>
354
+ <text fg={toRGBA(theme.gitAdded)}>+{totalAdditions()}</text>
355
+ </Show>
356
+ <Show when={totalDeletions() > 0}>
357
+ <text fg={toRGBA(theme.gitDeleted)}>-{totalDeletions()}</text>
358
+ </Show>
359
+ <text fg={toRGBA(theme.fgMuted)}>{allFiles().length} files</text>
360
+ </box>
361
+
362
+ {/* File list */}
363
+ <scrollbox maxHeight={listHeight()} flexShrink={0}>
364
+ <Show
365
+ when={allFiles().length > 0}
366
+ fallback={
367
+ <box paddingLeft={2} paddingTop={1}>
368
+ <text fg={toRGBA(theme.fgMuted)}>All clean ✓</text>
369
+ </box>
370
+ }
371
+ >
372
+ <For each={listData().items}>
373
+ {(item) => {
374
+ if (item.kind === "header") {
375
+ return (
376
+ <box paddingTop={1}>
377
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
378
+ {" "}
379
+ {item.label}
380
+ </text>
381
+ </box>
382
+ );
383
+ }
384
+ const isSelected = createMemo(() => item.globalIndex === selected());
385
+ const statusColor = () => {
386
+ if (item.file.staged) return theme.gitAdded;
387
+ switch (item.file.status) {
388
+ case "M":
389
+ return theme.gitModified;
390
+ case "D":
391
+ return theme.gitDeleted;
392
+ case "?":
393
+ return theme.gitUntracked;
394
+ default:
395
+ return theme.fgMuted;
396
+ }
397
+ };
398
+ return (
399
+ <box
400
+ flexDirection="row"
401
+ paddingLeft={1}
402
+ paddingRight={1}
403
+ backgroundColor={isSelected() ? toRGBA(theme.selected) : TRANSPARENT}
404
+ onMouseMove={() => setInputMode("mouse")}
405
+ onMouseDown={() => setSelected(item.globalIndex)}
406
+ onMouseOver={() => {
407
+ if (inputMode() === "mouse") setSelected(item.globalIndex);
408
+ }}
409
+ >
410
+ <text fg={toRGBA(statusColor())} wrapMode="none" flexShrink={0}>
411
+ {" "}
412
+ {item.file.status}{" "}
413
+ </text>
414
+ <text
415
+ fg={toRGBA(isSelected() ? theme.selectedText : theme.fg)}
416
+ wrapMode="none"
417
+ flexGrow={1}
418
+ >
419
+ {item.file.path}
420
+ </text>
421
+ <Show when={item.file.additions > 0}>
422
+ <text fg={toRGBA(theme.gitAdded)} flexShrink={0} wrapMode="none">
423
+ {" +"}
424
+ {item.file.additions}
425
+ </text>
426
+ </Show>
427
+ <Show when={item.file.deletions > 0}>
428
+ <text fg={toRGBA(theme.gitDeleted)} flexShrink={0} wrapMode="none">
429
+ {" -"}
430
+ {item.file.deletions}
431
+ </text>
432
+ </Show>
433
+ <Show when={isSelected()}>
434
+ <text
435
+ fg={toRGBA(theme.fgMuted)}
436
+ flexShrink={0}
437
+ wrapMode="none"
438
+ onMouseUp={() => {
439
+ if (item.file.staged) {
440
+ unstageFile(item.file.path);
441
+ } else {
442
+ stageFile(item.file.path);
443
+ }
444
+ setChanges(getChanges());
445
+ }}
446
+ >
447
+ {item.file.staged ? " u:unstage" : " s:stage"}
448
+ </text>
449
+ </Show>
450
+ </box>
451
+ );
452
+ }}
453
+ </For>
454
+ </Show>
455
+ </scrollbox>
456
+
457
+ {/* Separator */}
458
+ <box flexShrink={0} height={1}>
459
+ <text fg={toRGBA(theme.border)} wrapMode="none">
460
+ {"─".repeat(dimensions().width)}
461
+ </text>
462
+ </box>
463
+
464
+ {/* Diff preview */}
465
+ <Show
466
+ when={diffContent()}
467
+ fallback={
468
+ <box flexGrow={1} paddingLeft={2} paddingTop={1}>
469
+ <text fg={toRGBA(theme.fgMuted)}>Select a file to preview changes</text>
470
+ </box>
471
+ }
472
+ >
473
+ <scrollbox flexGrow={1}>
474
+ <For each={diffLines()}>
475
+ {(line) => {
476
+ const fg = line.startsWith("+")
477
+ ? theme.diffAdded
478
+ : line.startsWith("-")
479
+ ? theme.diffRemoved
480
+ : line.startsWith("@@")
481
+ ? theme.diffHunk
482
+ : theme.diffContext;
483
+ const bg = line.startsWith("+")
484
+ ? theme.diffAddedBg
485
+ : line.startsWith("-")
486
+ ? theme.diffRemovedBg
487
+ : theme.diffContextBg;
488
+ return (
489
+ <box backgroundColor={toRGBA(bg)}>
490
+ <text fg={toRGBA(fg)} wrapMode="none">
491
+ {line || " "}
492
+ </text>
493
+ </box>
494
+ );
495
+ }}
496
+ </For>
497
+ </scrollbox>
498
+ </Show>
499
+
500
+ {/* Footer */}
501
+ <box flexShrink={0} paddingLeft={1} paddingTop={1} flexDirection="row" gap={1}>
502
+ <text fg={toRGBA(theme.fgMuted)} wrapMode="none">
503
+ ↑↓:nav
504
+ </text>
505
+ <text
506
+ fg={toRGBA(theme.fgMuted)}
507
+ wrapMode="none"
508
+ onMouseUp={() => {
509
+ const file = allFiles()[selected()];
510
+ if (file && !file.staged) {
511
+ stageFile(file.path);
512
+ setChanges(getChanges());
513
+ }
514
+ }}
515
+ >
516
+ s:stage
517
+ </text>
518
+ <text
519
+ fg={toRGBA(theme.fgMuted)}
520
+ wrapMode="none"
521
+ onMouseUp={() => {
522
+ const file = allFiles()[selected()];
523
+ if (file && file.staged) {
524
+ unstageFile(file.path);
525
+ setChanges(getChanges());
526
+ }
527
+ }}
528
+ >
529
+ u:unstage
530
+ </text>
531
+ <text
532
+ fg={toRGBA(theme.fgMuted)}
533
+ wrapMode="none"
534
+ onMouseUp={() => {
535
+ stageAll();
536
+ setChanges(getChanges());
537
+ }}
538
+ >
539
+ S:all
540
+ </text>
541
+ <text
542
+ fg={toRGBA(theme.fgMuted)}
543
+ wrapMode="none"
544
+ onMouseUp={() => {
545
+ unstageAll();
546
+ setChanges(getChanges());
547
+ }}
548
+ >
549
+ U:all
550
+ </text>
551
+ <text
552
+ fg={toRGBA(theme.fgMuted)}
553
+ wrapMode="none"
554
+ onMouseUp={() => {
555
+ batch(() => {
556
+ setChanges(getChanges());
557
+ setBranch(getGitBranch(dir));
558
+ });
559
+ }}
560
+ >
561
+ r:refresh
562
+ </text>
563
+ <text fg={toRGBA(theme.fgMuted)} wrapMode="none">
564
+ q:quit
565
+ </text>
566
+ </box>
567
+ </box>
568
+ );
569
+ },
570
+ {
571
+ targetFps: 30,
572
+ exitOnCtrlC: true,
573
+ useKittyKeyboard: {},
574
+ autoFocus: false,
575
+ },
576
+ );