tmux-ide 1.3.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 (236) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +27 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/attach.ts +20 -0
  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.ts +248 -0
  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.ts +212 -0
  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.ts +76 -0
  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.ts +100 -0
  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.ts +36 -0
  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.ts +27 -0
  103. package/src/ls.test.ts +76 -0
  104. package/src/ls.ts +40 -0
  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/dist/attach.d.ts +0 -3
  194. package/dist/attach.js +0 -14
  195. package/dist/config.d.ts +0 -5
  196. package/dist/config.js +0 -269
  197. package/dist/detect.d.ts +0 -15
  198. package/dist/detect.js +0 -228
  199. package/dist/doctor.d.ts +0 -3
  200. package/dist/doctor.js +0 -66
  201. package/dist/init.d.ts +0 -4
  202. package/dist/init.js +0 -67
  203. package/dist/inspect.d.ts +0 -54
  204. package/dist/inspect.js +0 -118
  205. package/dist/launch.d.ts +0 -22
  206. package/dist/launch.js +0 -174
  207. package/dist/lib/dot-path.d.ts +0 -2
  208. package/dist/lib/dot-path.js +0 -17
  209. package/dist/lib/errors.d.ts +0 -29
  210. package/dist/lib/errors.js +0 -37
  211. package/dist/lib/launch-plan.d.ts +0 -6
  212. package/dist/lib/launch-plan.js +0 -40
  213. package/dist/lib/output.d.ts +0 -9
  214. package/dist/lib/output.js +0 -65
  215. package/dist/lib/session-monitor.d.ts +0 -12
  216. package/dist/lib/session-monitor.js +0 -160
  217. package/dist/lib/session-options.d.ts +0 -13
  218. package/dist/lib/session-options.js +0 -85
  219. package/dist/lib/sizes.d.ts +0 -16
  220. package/dist/lib/sizes.js +0 -36
  221. package/dist/lib/tmux.d.ts +0 -42
  222. package/dist/lib/tmux.js +0 -225
  223. package/dist/lib/yaml-io.d.ts +0 -10
  224. package/dist/lib/yaml-io.js +0 -24
  225. package/dist/ls.d.ts +0 -3
  226. package/dist/ls.js +0 -35
  227. package/dist/restart.d.ts +0 -4
  228. package/dist/restart.js +0 -13
  229. package/dist/status.d.ts +0 -3
  230. package/dist/status.js +0 -29
  231. package/dist/stop.d.ts +0 -3
  232. package/dist/stop.js +0 -21
  233. package/dist/types.d.ts +0 -41
  234. package/dist/types.js +0 -1
  235. package/dist/validate.d.ts +0 -4
  236. package/dist/validate.js +0 -172
@@ -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
+ );