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,77 @@
1
+ import { For, Show, createMemo } from "solid-js";
2
+ import { RGBA, TextAttributes } from "@opentui/core";
3
+ import { relative, basename, join } from "node:path";
4
+ import type { WidgetTheme } from "../lib/theme.ts";
5
+
6
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
7
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
8
+ }
9
+
10
+ interface BreadcrumbsProps {
11
+ projectRoot: string;
12
+ currentDir: string;
13
+ branch: string | null;
14
+ theme: WidgetTheme;
15
+ onNavigate: (absolutePath: string) => void;
16
+ }
17
+
18
+ interface BreadcrumbSegment {
19
+ name: string;
20
+ absolutePath: string;
21
+ isLast: boolean;
22
+ }
23
+
24
+ export function Breadcrumbs(props: BreadcrumbsProps) {
25
+ const segments = createMemo(() => {
26
+ const rel = relative(props.projectRoot, props.currentDir);
27
+ const parts = rel ? rel.split("/") : [];
28
+ const result: BreadcrumbSegment[] = [
29
+ {
30
+ name: basename(props.projectRoot),
31
+ absolutePath: props.projectRoot,
32
+ isLast: parts.length === 0,
33
+ },
34
+ ];
35
+ let currentPath = props.projectRoot;
36
+ for (let i = 0; i < parts.length; i++) {
37
+ currentPath = join(currentPath, parts[i]!);
38
+ result.push({
39
+ name: parts[i]!,
40
+ absolutePath: currentPath,
41
+ isLast: i === parts.length - 1,
42
+ });
43
+ }
44
+ return result;
45
+ });
46
+
47
+ return (
48
+ <box flexShrink={0} paddingLeft={1} paddingBottom={1} flexDirection="row" gap={0}>
49
+ <Show when={props.branch}>
50
+ <text fg={toRGBA(props.theme.accent)} attributes={TextAttributes.BOLD}>
51
+ {"⎇ "}
52
+ {props.branch}{" "}
53
+ </text>
54
+ </Show>
55
+ <For each={segments()}>
56
+ {(seg) => (
57
+ <box flexDirection="row">
58
+ <text
59
+ fg={seg.isLast ? toRGBA(props.theme.fg) : toRGBA(props.theme.fgMuted)}
60
+ attributes={seg.isLast ? TextAttributes.BOLD : undefined}
61
+ onMouseUp={() => {
62
+ if (!seg.isLast) {
63
+ props.onNavigate(seg.absolutePath);
64
+ }
65
+ }}
66
+ >
67
+ {seg.name}
68
+ </text>
69
+ <Show when={!seg.isLast}>
70
+ <text fg={toRGBA(props.theme.border)}>{" / "}</text>
71
+ </Show>
72
+ </box>
73
+ )}
74
+ </For>
75
+ </box>
76
+ );
77
+ }
@@ -0,0 +1,20 @@
1
+ import { RGBA } from "@opentui/core";
2
+ import type { WidgetTheme } from "../lib/theme.ts";
3
+
4
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
5
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
6
+ }
7
+
8
+ interface FooterProps {
9
+ theme: WidgetTheme;
10
+ }
11
+
12
+ export function Footer(props: FooterProps) {
13
+ return (
14
+ <box flexShrink={0} paddingLeft={1} paddingTop={1}>
15
+ <text fg={toRGBA(props.theme.fgMuted)} wrapMode="none">
16
+ ↑↓:nav ⏎:enter ⌫:back /:search []:changes c:claude o:edit q:quit
17
+ </text>
18
+ </box>
19
+ );
20
+ }
@@ -0,0 +1,23 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core";
2
+ import type { WidgetTheme } from "../lib/theme.ts";
3
+
4
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
5
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
6
+ }
7
+
8
+ interface HeaderProps {
9
+ branch: string | null;
10
+ fileCount: number;
11
+ theme: WidgetTheme;
12
+ }
13
+
14
+ export function Header(props: HeaderProps) {
15
+ return (
16
+ <box flexShrink={0} flexDirection="row" gap={1} paddingLeft={1} paddingBottom={1}>
17
+ <text fg={toRGBA(props.theme.accent)} attributes={TextAttributes.BOLD}>
18
+ {props.branch ? `⎇ ${props.branch}` : "FILES"}
19
+ </text>
20
+ <text fg={toRGBA(props.theme.fgMuted)}>{props.fileCount}</text>
21
+ </box>
22
+ );
23
+ }
@@ -0,0 +1,408 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { parseArgs } from "node:util";
3
+ import { relative } from "node:path";
4
+ import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid";
5
+ import { RGBA } from "@opentui/core";
6
+ import { createSignal, createMemo, createEffect, onMount, onCleanup, batch, Show } from "solid-js";
7
+ import { Breadcrumbs } from "./breadcrumbs.tsx";
8
+ import { FileTree } from "./tree.tsx";
9
+ import { Footer } from "./footer.tsx";
10
+ import {
11
+ buildRootNodes,
12
+ flattenVisibleNodes,
13
+ refreshExpandedNodes,
14
+ type TreeNode,
15
+ } from "./tree-model.ts";
16
+ import { createIgnoreFilter } from "../lib/files.ts";
17
+ import { getGitStatusMap, getGitBranch, isGitRepo } from "../lib/git.ts";
18
+ import { watchDirectory, watchGitHead } from "../lib/watcher.ts";
19
+ import { createTheme } from "../lib/theme.ts";
20
+ import {
21
+ findPaneByTitle,
22
+ sendCommand,
23
+ openFileInEditor,
24
+ findPaneByPattern,
25
+ } from "../lib/pane-comms.ts";
26
+
27
+ const { values } = parseArgs({
28
+ options: {
29
+ session: { type: "string" },
30
+ dir: { type: "string" },
31
+ target: { type: "string" },
32
+ theme: { type: "string" },
33
+ },
34
+ });
35
+
36
+ const session = values.session ?? "";
37
+ const dir = values.dir ?? process.cwd();
38
+ const targetTitle = values.target ?? null;
39
+ const themeConfig = values.theme ? JSON.parse(values.theme) : undefined;
40
+
41
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
42
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
43
+ }
44
+
45
+ function getTmuxOption(key: string): string | null {
46
+ if (!session) return null;
47
+ try {
48
+ return (
49
+ execFileSync("tmux", ["show-option", "-t", session, "-v", key], {
50
+ encoding: "utf-8",
51
+ stdio: ["ignore", "pipe", "ignore"],
52
+ }).trim() || null
53
+ );
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function setTmuxOption(key: string, value: string): void {
60
+ if (!session) return;
61
+ try {
62
+ execFileSync("tmux", ["set-option", "-t", session, key, value], { stdio: "ignore" });
63
+ } catch {}
64
+ }
65
+
66
+ function setPreviewFile(filePath: string | null): void {
67
+ if (!session) return;
68
+ try {
69
+ if (filePath) {
70
+ execFileSync("tmux", ["set-option", "-t", session, "@preview_file", filePath], {
71
+ stdio: "ignore",
72
+ });
73
+ } else {
74
+ execFileSync("tmux", ["set-option", "-t", session, "-u", "@preview_file"], {
75
+ stdio: "ignore",
76
+ });
77
+ }
78
+ } catch {
79
+ // tmux not available or session doesn't exist
80
+ }
81
+ }
82
+
83
+ render(
84
+ () => {
85
+ const theme = createTheme(themeConfig);
86
+ const dimensions = useTerminalDimensions();
87
+ const ig = createIgnoreFilter(dir);
88
+ const hasGit = isGitRepo(dir);
89
+
90
+ const [gitMap, setGitMap] = createSignal(
91
+ hasGit ? getGitStatusMap(dir) : new Map<string, string>(),
92
+ );
93
+ const [branch, setBranch] = createSignal(hasGit ? getGitBranch(dir) : null);
94
+ const [currentDir, setCurrentDir] = createSignal(dir);
95
+ const [history, setHistory] = createSignal<string[]>([]);
96
+ const [showHidden, setShowHidden] = createSignal(
97
+ getTmuxOption("@explorer_show_hidden") === "1",
98
+ );
99
+ const [showIgnored, setShowIgnored] = createSignal(
100
+ getTmuxOption("@explorer_show_ignored") === "1",
101
+ );
102
+ const [rootNodes, setRootNodes] = createSignal(
103
+ buildRootNodes(dir, dir, ig, gitMap(), showHidden(), showIgnored()),
104
+ );
105
+ const [selected, setSelected] = createSignal(0);
106
+ const [inputMode, setInputMode] = createSignal<"keyboard" | "mouse">("keyboard");
107
+ const [searchMode, setSearchMode] = createSignal(false);
108
+ const [searchQuery, setSearchQuery] = createSignal("");
109
+
110
+ const flatNodes = createMemo(() => flattenVisibleNodes(rootNodes()));
111
+
112
+ const visibleNodes = createMemo(() => {
113
+ const nodes = flatNodes();
114
+ if (!searchMode() || !searchQuery()) return nodes;
115
+ const q = searchQuery().toLowerCase();
116
+ return nodes.filter((n) => n.entry.name.toLowerCase().includes(q));
117
+ });
118
+
119
+ // Set tmux session variable when selection changes
120
+ createEffect(() => {
121
+ const nodes = visibleNodes();
122
+ const current = nodes[selected()];
123
+ if (current && !current.entry.isDir) {
124
+ setPreviewFile(current.entry.path);
125
+ } else {
126
+ setPreviewFile(null);
127
+ }
128
+ });
129
+
130
+ // Navigation functions
131
+ // Check if a directory is inside a gitignored path
132
+ function isInsideIgnored(dirPath: string): boolean {
133
+ const rel = relative(dir, dirPath);
134
+ if (!rel || rel === ".") return false;
135
+ // Check each parent segment
136
+ const parts = rel.split("/");
137
+ let current = "";
138
+ for (const part of parts) {
139
+ current = current ? current + "/" + part : part;
140
+ try {
141
+ if (ig.ignores(current + "/")) return true;
142
+ } catch {}
143
+ }
144
+ return false;
145
+ }
146
+
147
+ function effectiveShowIgnored(dirPath: string): boolean {
148
+ return showIgnored() || isInsideIgnored(dirPath);
149
+ }
150
+
151
+ function navigateInto(dirPath: string): void {
152
+ setHistory((h) => [...h, currentDir()]);
153
+ setCurrentDir(dirPath);
154
+ setSelected(0);
155
+ setRootNodes(
156
+ buildRootNodes(dirPath, dir, ig, gitMap(), showHidden(), effectiveShowIgnored(dirPath)),
157
+ );
158
+ }
159
+
160
+ function navigateUp(): void {
161
+ const h = history();
162
+ if (h.length > 0) {
163
+ const prev = h[h.length - 1]!;
164
+ setHistory(h.slice(0, -1));
165
+ setCurrentDir(prev);
166
+ setSelected(0);
167
+ setRootNodes(
168
+ buildRootNodes(prev, dir, ig, gitMap(), showHidden(), effectiveShowIgnored(prev)),
169
+ );
170
+ }
171
+ }
172
+
173
+ function navigateTo(absolutePath: string): void {
174
+ setHistory((h) => [...h, currentDir()]);
175
+ setCurrentDir(absolutePath);
176
+ setSelected(0);
177
+ setRootNodes(
178
+ buildRootNodes(
179
+ absolutePath,
180
+ dir,
181
+ ig,
182
+ gitMap(),
183
+ showHidden(),
184
+ effectiveShowIgnored(absolutePath),
185
+ ),
186
+ );
187
+ }
188
+
189
+ // File watcher
190
+ let stopFileWatch: (() => Promise<void>) | null = null;
191
+ let stopHeadWatch: (() => Promise<void>) | null = null;
192
+
193
+ onMount(async () => {
194
+ try {
195
+ stopFileWatch = await watchDirectory(dir, () => {
196
+ batch(() => {
197
+ if (hasGit) setGitMap(getGitStatusMap(dir));
198
+ setRootNodes(refreshExpandedNodes(rootNodes(), dir, ig, gitMap(), showHidden()));
199
+ });
200
+ });
201
+ } catch {
202
+ /* watcher unavailable */
203
+ }
204
+
205
+ if (hasGit) {
206
+ try {
207
+ stopHeadWatch = await watchGitHead(dir, () => {
208
+ setBranch(getGitBranch(dir));
209
+ });
210
+ } catch {
211
+ /* git head watcher unavailable */
212
+ }
213
+ }
214
+ });
215
+
216
+ onCleanup(async () => {
217
+ await stopFileWatch?.();
218
+ await stopHeadWatch?.();
219
+ });
220
+
221
+ function resolveTargetPane(): string | null {
222
+ if (!session) return null;
223
+ if (targetTitle) return findPaneByTitle(session, targetTitle);
224
+ return findPaneByPattern(session, "claude");
225
+ }
226
+
227
+ function activateNode(node: TreeNode): void {
228
+ if (node.entry.isDir) {
229
+ navigateInto(node.entry.absolutePath);
230
+ }
231
+ // Files: do nothing on click/enter — preview updates via selection effect
232
+ }
233
+
234
+ // Keyboard navigation
235
+ useKeyboard((evt) => {
236
+ setInputMode("keyboard");
237
+
238
+ // Search mode handling
239
+ if (searchMode()) {
240
+ if (evt.name === "escape") {
241
+ setSearchMode(false);
242
+ setSearchQuery("");
243
+ evt.preventDefault();
244
+ return;
245
+ } else if (evt.name === "backspace") {
246
+ setSearchQuery((q) => q.slice(0, -1));
247
+ evt.preventDefault();
248
+ return;
249
+ } else if (evt.name === "return") {
250
+ setSearchMode(false);
251
+ evt.preventDefault();
252
+ return;
253
+ } else if (evt.name.length === 1 && !evt.ctrl && !evt.alt && !evt.meta) {
254
+ const newQuery = searchQuery() + evt.name;
255
+ setSearchQuery(newQuery);
256
+ const q = newQuery.toLowerCase();
257
+ const matchIdx = flatNodes().findIndex((n) => n.entry.name.toLowerCase().includes(q));
258
+ if (matchIdx !== -1) setSelected(matchIdx);
259
+ evt.preventDefault();
260
+ return;
261
+ }
262
+ }
263
+
264
+ const nodes = visibleNodes();
265
+ const current = nodes[selected()];
266
+
267
+ if (evt.name === "up" || evt.name === "k") {
268
+ setSelected((i) => Math.max(0, i - 1));
269
+ evt.preventDefault();
270
+ } else if (evt.name === "down" || evt.name === "j") {
271
+ setSelected((i) => Math.min(nodes.length - 1, i + 1));
272
+ evt.preventDefault();
273
+ } else if (evt.name === "return" || evt.name === "l" || evt.name === "right") {
274
+ if (current) activateNode(current);
275
+ evt.preventDefault();
276
+ } else if (
277
+ evt.name === "h" ||
278
+ evt.name === "left" ||
279
+ evt.name === "backspace" ||
280
+ evt.name === "-"
281
+ ) {
282
+ navigateUp();
283
+ evt.preventDefault();
284
+ } else if (evt.name === "/") {
285
+ setSearchMode(true);
286
+ setSearchQuery("");
287
+ evt.preventDefault();
288
+ } else if (evt.name === "]") {
289
+ // Jump to next changed file
290
+ const allNodes = flatNodes();
291
+ const start = selected() + 1;
292
+ for (let i = 0; i < allNodes.length; i++) {
293
+ const idx = (start + i) % allNodes.length;
294
+ if (allNodes[idx]!.gitStatus && !allNodes[idx]!.entry.isDir) {
295
+ setSelected(idx);
296
+ break;
297
+ }
298
+ }
299
+ evt.preventDefault();
300
+ } else if (evt.name === "[") {
301
+ // Jump to prev changed file
302
+ const allNodes = flatNodes();
303
+ const start = selected() - 1 + allNodes.length;
304
+ for (let i = 0; i < allNodes.length; i++) {
305
+ const idx = (start - i + allNodes.length) % allNodes.length;
306
+ if (allNodes[idx]!.gitStatus && !allNodes[idx]!.entry.isDir) {
307
+ setSelected(idx);
308
+ break;
309
+ }
310
+ }
311
+ evt.preventDefault();
312
+ } else if (evt.name === "c" && current && !current.entry.isDir) {
313
+ const targetId = resolveTargetPane();
314
+ if (targetId) {
315
+ sendCommand(session, targetId, `read ${current.entry.path}`);
316
+ }
317
+ evt.preventDefault();
318
+ } else if (evt.name === "o" && current && !current.entry.isDir) {
319
+ const targetId = resolveTargetPane();
320
+ if (targetId) {
321
+ openFileInEditor(session, targetId, current.entry.path);
322
+ }
323
+ evt.preventDefault();
324
+ } else if (evt.shift && evt.name === "h") {
325
+ const next = !showHidden();
326
+ setShowHidden(next);
327
+ setTmuxOption("@explorer_show_hidden", next ? "1" : "0");
328
+ setRootNodes(
329
+ buildRootNodes(currentDir(), dir, ig, gitMap(), next, effectiveShowIgnored(currentDir())),
330
+ );
331
+ setSelected(0);
332
+ evt.preventDefault();
333
+ } else if (evt.shift && evt.name === "i") {
334
+ const next = !showIgnored();
335
+ setShowIgnored(next);
336
+ setTmuxOption("@explorer_show_ignored", next ? "1" : "0");
337
+ setRootNodes(
338
+ buildRootNodes(
339
+ currentDir(),
340
+ dir,
341
+ ig,
342
+ gitMap(),
343
+ showHidden(),
344
+ next || isInsideIgnored(currentDir()),
345
+ ),
346
+ );
347
+ setSelected(0);
348
+ evt.preventDefault();
349
+ } else if (evt.name === "r") {
350
+ batch(() => {
351
+ if (hasGit) {
352
+ setGitMap(getGitStatusMap(dir));
353
+ setBranch(getGitBranch(dir));
354
+ }
355
+ setRootNodes(refreshExpandedNodes(rootNodes(), dir, ig, gitMap(), showHidden()));
356
+ });
357
+ evt.preventDefault();
358
+ } else if (evt.name === "g" && !evt.shift) {
359
+ setSelected(0);
360
+ evt.preventDefault();
361
+ } else if (evt.shift && evt.name === "g") {
362
+ setSelected(nodes.length - 1);
363
+ evt.preventDefault();
364
+ } else if (evt.name === "q") {
365
+ process.exit(0);
366
+ }
367
+ });
368
+
369
+ return (
370
+ <box
371
+ width={dimensions().width}
372
+ height={dimensions().height}
373
+ backgroundColor={toRGBA(theme.bg)}
374
+ >
375
+ <Breadcrumbs
376
+ projectRoot={dir}
377
+ currentDir={currentDir()}
378
+ branch={branch()}
379
+ theme={theme}
380
+ onNavigate={navigateTo}
381
+ />
382
+ <Show when={searchMode()}>
383
+ <box flexShrink={0} paddingLeft={1} flexDirection="row" gap={1}>
384
+ <text fg={toRGBA(theme.accent)}>/</text>
385
+ <text fg={toRGBA(theme.fg)}>{searchQuery()}</text>
386
+ <text fg={toRGBA(theme.fgMuted)}>_</text>
387
+ </box>
388
+ </Show>
389
+ <FileTree
390
+ nodes={visibleNodes()}
391
+ selected={selected()}
392
+ theme={theme}
393
+ inputMode={inputMode()}
394
+ onSelect={setSelected}
395
+ onActivate={activateNode}
396
+ onInputModeChange={setInputMode}
397
+ />
398
+ <Footer theme={theme} />
399
+ </box>
400
+ );
401
+ },
402
+ {
403
+ targetFps: 60,
404
+ exitOnCtrlC: true,
405
+ useKittyKeyboard: {},
406
+ autoFocus: false,
407
+ },
408
+ );