tmux-ide 2.6.0 → 2.6.1

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 (209) hide show
  1. package/bin/cli.js +13 -5
  2. package/bin/cli.ts +1 -1
  3. package/bunfig.toml +4 -0
  4. package/package.json +11 -3
  5. package/packages/contracts/package.json +22 -0
  6. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  7. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  8. package/packages/contracts/src/actions-contract.ts +310 -0
  9. package/packages/contracts/src/actions-errors.ts +41 -0
  10. package/packages/contracts/src/domain.ts +36 -0
  11. package/packages/contracts/src/ide-config.ts +170 -0
  12. package/packages/contracts/src/index.ts +24 -0
  13. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  14. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  15. package/packages/contracts/src/terminals.ts +116 -0
  16. package/packages/contracts/src/tmux.ts +60 -0
  17. package/packages/contracts/src/workspace.ts +67 -0
  18. package/packages/daemon/src/agent-explain.ts +270 -0
  19. package/packages/daemon/src/attach.ts +20 -0
  20. package/packages/daemon/src/bin.ts +4 -0
  21. package/packages/daemon/src/canonical.ts +7 -0
  22. package/packages/daemon/src/cli.ts +499 -0
  23. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  24. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  25. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  26. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  27. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  28. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  29. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  30. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  31. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  32. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  33. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  34. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  35. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  36. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  37. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  38. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  39. package/packages/daemon/src/command-center/discovery.ts +96 -0
  40. package/packages/daemon/src/command-center/index.ts +31 -0
  41. package/packages/daemon/src/command-center/schemas.ts +85 -0
  42. package/packages/daemon/src/command-center/server.ts +1260 -0
  43. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  44. package/packages/daemon/src/config.ts +549 -0
  45. package/packages/daemon/src/detect.ts +248 -0
  46. package/packages/daemon/src/doctor.ts +242 -0
  47. package/packages/daemon/src/embed.ts +5 -0
  48. package/packages/daemon/src/index.ts +12 -0
  49. package/packages/daemon/src/init.ts +211 -0
  50. package/packages/daemon/src/inspect.ts +178 -0
  51. package/packages/daemon/src/js-yaml.d.ts +10 -0
  52. package/packages/daemon/src/launch.ts +349 -0
  53. package/packages/daemon/src/lib/active-projects.ts +49 -0
  54. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  55. package/packages/daemon/src/lib/app-config.ts +336 -0
  56. package/packages/daemon/src/lib/app-settings.ts +53 -0
  57. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  58. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  59. package/packages/daemon/src/lib/auth/types.ts +2 -0
  60. package/packages/daemon/src/lib/auth-token.ts +5 -0
  61. package/packages/daemon/src/lib/authorship.ts +280 -0
  62. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  63. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  64. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  65. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  66. package/packages/daemon/src/lib/daemon.ts +79 -0
  67. package/packages/daemon/src/lib/dot-path.ts +17 -0
  68. package/packages/daemon/src/lib/errors.ts +67 -0
  69. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  70. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  71. package/packages/daemon/src/lib/log.ts +134 -0
  72. package/packages/daemon/src/lib/output.ts +76 -0
  73. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  74. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  75. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  76. package/packages/daemon/src/lib/project-probe.ts +92 -0
  77. package/packages/daemon/src/lib/project-registry.ts +296 -0
  78. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  79. package/packages/daemon/src/lib/session-options.ts +100 -0
  80. package/packages/daemon/src/lib/shell.ts +8 -0
  81. package/packages/daemon/src/lib/sizes.ts +36 -0
  82. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  83. package/packages/daemon/src/lib/slugify.ts +10 -0
  84. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  85. package/packages/daemon/src/lib/update-check.ts +298 -0
  86. package/packages/daemon/src/lib/update.ts +158 -0
  87. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  88. package/packages/daemon/src/lib/worktree.ts +289 -0
  89. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  90. package/packages/daemon/src/ls.ts +40 -0
  91. package/packages/daemon/src/restart.ts +24 -0
  92. package/packages/daemon/src/restore.ts +514 -0
  93. package/packages/daemon/src/schemas/domain.ts +2 -0
  94. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  95. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  96. package/packages/daemon/src/schemas/index.ts +59 -0
  97. package/packages/daemon/src/schemas/inspect.ts +67 -0
  98. package/packages/daemon/src/schemas/registry.ts +55 -0
  99. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  100. package/packages/daemon/src/send.ts +171 -0
  101. package/packages/daemon/src/server/README.md +15 -0
  102. package/packages/daemon/src/server/index.ts +74 -0
  103. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  104. package/packages/daemon/src/server/standalone.ts +18 -0
  105. package/packages/daemon/src/server/ws-route.ts +483 -0
  106. package/packages/daemon/src/status.ts +59 -0
  107. package/packages/daemon/src/stop.ts +28 -0
  108. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  109. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  110. package/packages/daemon/src/terminal/README.md +92 -0
  111. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  112. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  113. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  114. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  115. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  116. package/packages/daemon/src/tui/chrome/notify.ts +195 -0
  117. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  118. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  119. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  120. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  121. package/packages/daemon/src/tui/chrome/updater.ts +428 -0
  122. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  123. package/packages/daemon/src/tui/compiled.ts +113 -0
  124. package/packages/daemon/src/tui/detect/classify.ts +193 -0
  125. package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
  126. package/packages/daemon/src/tui/detect/manifest.ts +184 -0
  127. package/packages/daemon/src/tui/detect/manifests.ts +226 -0
  128. package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
  129. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  130. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  131. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  132. package/packages/daemon/src/tui/main.ts +70 -0
  133. package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
  134. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  135. package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
  136. package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
  137. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  138. package/packages/daemon/src/tui/team/entry.ts +11 -0
  139. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  140. package/packages/daemon/src/tui/team/home.ts +170 -0
  141. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  142. package/packages/daemon/src/tui/team/input.ts +34 -0
  143. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  144. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  145. package/packages/daemon/src/tui/team/nav.ts +31 -0
  146. package/packages/daemon/src/tui/team/preview.ts +34 -0
  147. package/packages/daemon/src/tui/team/projects.ts +191 -0
  148. package/packages/daemon/src/tui/team/report.ts +73 -0
  149. package/packages/daemon/src/tui/team/sessions.ts +344 -0
  150. package/packages/daemon/src/tui/team/tree.ts +62 -0
  151. package/packages/daemon/src/types.ts +13 -0
  152. package/packages/daemon/src/ui/index.ts +32 -0
  153. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  154. package/packages/daemon/src/ui/types.ts +91 -0
  155. package/packages/daemon/src/ui/web/base.css +80 -0
  156. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  157. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  158. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  159. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  160. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  161. package/packages/daemon/src/ui/web/index.ts +27 -0
  162. package/packages/daemon/src/ui/web/render.ts +77 -0
  163. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  164. package/packages/daemon/src/validate.ts +217 -0
  165. package/packages/daemon/src/widgets/changes/README.md +3 -0
  166. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  167. package/packages/daemon/src/widgets/config/README.md +3 -0
  168. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  169. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  170. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  171. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  172. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  173. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  174. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  175. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  176. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  177. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  178. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  179. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  180. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  181. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  182. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  183. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  184. package/packages/daemon/src/widgets/preview/README.md +3 -0
  185. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  186. package/packages/daemon/src/widgets/resolve.ts +121 -0
  187. package/packages/daemon/src/widgets/setup/README.md +3 -0
  188. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  189. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  190. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  191. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  192. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  193. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  194. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  195. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  196. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  197. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  198. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  199. package/packages/tmux-bridge/package.json +22 -0
  200. package/packages/tmux-bridge/src/errors.ts +28 -0
  201. package/packages/tmux-bridge/src/index.ts +31 -0
  202. package/packages/tmux-bridge/src/monitor.ts +77 -0
  203. package/packages/tmux-bridge/src/panes.ts +136 -0
  204. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  205. package/packages/tmux-bridge/src/runner.ts +91 -0
  206. package/packages/tmux-bridge/src/sessions.ts +126 -0
  207. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  208. package/packages/tmux-bridge/src/targeting.ts +90 -0
  209. package/scripts/postinstall.js +26 -2
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The one-time INTEGRATION OFFER — shown the first time tmux-ide adopts a
3
+ * session on a machine that has Claude Code but hasn't installed the lifecycle
4
+ * hook.
5
+ *
6
+ * The onboarding gap: the Claude Code integration turns pane state from
7
+ * screen-scraping guesswork into ground truth, but nothing prompts the user to
8
+ * install it — they'd have to read the docs. This offer notices `claude` on
9
+ * PATH at adopt time and asks once: install now? [y/N]. `y` installs; anything
10
+ * else skips. Either way a marker is written so it never asks again.
11
+ *
12
+ * "Once" is enforced by a marker file (`<home>/integration-offered`, `<home>` =
13
+ * `TMUX_IDE_HOME` when set — so tests and the dev box never see it), exactly
14
+ * like the welcome card ({@link ../chrome/welcome.ts}). The offer is ALSO gated
15
+ * by config (`integrations.offer`), so it can be suppressed without touching the
16
+ * marker. {@link shouldOfferIntegration} is the PURE decision; the marker
17
+ * helpers and {@link maybeOfferIntegrationPopup} wire the io. It NEVER fails an
18
+ * adopt — every io path is best-effort.
19
+ */
20
+ import { execFileSync, spawn } from "node:child_process";
21
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ import { getAppConfig } from "../../lib/app-config.ts";
25
+ import { claudeIntegrationStatus } from "./claude.ts";
26
+
27
+ /**
28
+ * Absolute path to the "already offered" marker: `<home>/integration-offered`,
29
+ * where `<home>` is `TMUX_IDE_HOME` when set (tests / per-run overrides), else
30
+ * `~/.tmux-ide`. The env override lets a live test point the marker at a scratch
31
+ * dir so it never touches — or is confused by — the real user's marker.
32
+ */
33
+ export function integrationOfferMarkerPath(): string {
34
+ const home = process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
35
+ return join(home, "integration-offered");
36
+ }
37
+
38
+ /**
39
+ * PURE — the first-adopt offer decision. Show the offer only when ALL hold:
40
+ * - `claudeOnPath` Claude Code is installed on this machine,
41
+ * - `integrationInstalled` is false (nothing to offer if it's already hooked),
42
+ * - `markerPresent` is false (we've never offered before), and
43
+ * - `offerEnabled` config hasn't disabled the offer.
44
+ * Kept pure so every gate is trivially unit-tested.
45
+ */
46
+ export function shouldOfferIntegration(input: {
47
+ claudeOnPath: boolean;
48
+ integrationInstalled: boolean;
49
+ markerPresent: boolean;
50
+ offerEnabled: boolean;
51
+ }): boolean {
52
+ return (
53
+ input.claudeOnPath && !input.integrationInstalled && !input.markerPresent && input.offerEnabled
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Create the marker so the offer shows only once. Best-effort — a marker we
59
+ * can't write means the offer may show again, but it must never crash adopt.
60
+ */
61
+ export function markIntegrationOffered(): void {
62
+ const path = integrationOfferMarkerPath();
63
+ try {
64
+ mkdirSync(dirname(path), { recursive: true });
65
+ writeFileSync(path, new Date().toISOString());
66
+ } catch {
67
+ // can't write the marker — degrade to "may offer again", never throw
68
+ }
69
+ }
70
+
71
+ /**
72
+ * PURE — the offer prompt text shown inside the popup. A single question with a
73
+ * one-line rationale; the `[y/N]` makes the safe default (skip) obvious.
74
+ */
75
+ export function buildOfferText(): string {
76
+ const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
77
+ const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
78
+ const head = (s: string) => `\x1b[1;36m${s}\x1b[0m`;
79
+ return [
80
+ head(" Claude Code detected"),
81
+ "",
82
+ " Install the tmux-ide integration for ground-truth agent status?",
83
+ dim(" It hooks Claude Code's lifecycle so pane state is exact, not guessed."),
84
+ "",
85
+ ` ${bold("[y]")} install ${bold("[N]")} skip (any other key)`,
86
+ dim(" Asked once — press a key."),
87
+ ].join("\n");
88
+ }
89
+
90
+ /**
91
+ * io — float the one-time offer popup on the CURRENT tmux client, best-effort.
92
+ *
93
+ * Called at the end of {@link ../chrome/statusline.ts adoptSession} (right after
94
+ * the welcome card). Gated by {@link shouldOfferIntegration} (PATH + integration
95
+ * status + marker + config) AND by being inside a tmux client — outside tmux
96
+ * there's nowhere to float the popup, so we neither show it nor burn the marker
97
+ * (the next in-tmux adopt still offers).
98
+ *
99
+ * The popup runs `tmux-ide integration offer`, which reads one key and installs
100
+ * on `y` (see bin/cli.ts). It's spawned DETACHED and unref'd so it never blocks
101
+ * the adopt. The marker is written immediately after the spawn attempt so a
102
+ * rapid `adopt --all` offers exactly once, not once per session — matching the
103
+ * welcome card's one-shot discipline.
104
+ */
105
+ export function maybeOfferIntegrationPopup(): void {
106
+ let offer: boolean;
107
+ try {
108
+ const status = claudeIntegrationStatus();
109
+ offer = shouldOfferIntegration({
110
+ claudeOnPath: claudeOnPath(),
111
+ integrationInstalled: status.installed,
112
+ markerPresent: existsSync(integrationOfferMarkerPath()),
113
+ offerEnabled: getAppConfig().integrations.offer,
114
+ });
115
+ } catch {
116
+ return; // any probe failure → never risk the adopt, just don't offer
117
+ }
118
+ if (!offer) return;
119
+ if (!process.env.TMUX) return;
120
+ try {
121
+ const child = spawn(
122
+ "tmux",
123
+ ["display-popup", "-E", "-w", "64", "-h", "12", "tmux-ide integration offer"],
124
+ { stdio: "ignore", detached: true },
125
+ );
126
+ child.unref();
127
+ } catch {
128
+ // tmux missing / no client — best-effort, still mark so we don't retry forever
129
+ }
130
+ markIntegrationOffered();
131
+ }
132
+
133
+ /**
134
+ * io — is `claude` on PATH? A which-probe capped at 2s, swallowing every failure
135
+ * to false. Local (not {@link ../../lib/agent-discovery.ts}) to keep the adopt
136
+ * path's offer check a single cheap probe rather than the full agent sweep.
137
+ */
138
+ function claudeOnPath(): boolean {
139
+ try {
140
+ execFileSync("which", ["claude"], { stdio: "ignore", timeout: 2000 });
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Dispatcher entry for the compiled `tmux-ide-tui` binary.
3
+ *
4
+ * `bun build --compile` bundles every TUI surface (the cockpit + widgets) into
5
+ * ONE standalone executable so the OpenTUI/Solid `.tsx` surfaces run on a clean
6
+ * `npm i -g tmux-ide` — no dev checkout, no `bun` runtime, no bunfig preload.
7
+ * The native OpenTUI dylib rides along via Bun's embedded-asset mechanism (the
8
+ * `import ... with { type: "file" }` in @opentui/core-*), and the Solid JSX
9
+ * transform happens at build time via the @opentui/solid bun plugin, so the
10
+ * binary carries only plain JS.
11
+ *
12
+ * Contract: `tmux-ide-tui <surface> [--flags…]`. The first positional selects
13
+ * the surface; the rest are the surface's own args (theme, session, dir, …),
14
+ * exactly as the `bun <entry> …` invocation passed them. We strip the surface
15
+ * token from `process.argv` before importing so each entry's top-level
16
+ * `parseArgs()` sees the same argv it always has.
17
+ *
18
+ * The `import()` calls use LITERAL specifiers on purpose: Bun's bundler only
19
+ * embeds dynamic imports it can resolve statically, and a literal switch keeps
20
+ * exactly one surface's top-level `render()` side effect from firing.
21
+ */
22
+
23
+ const SURFACES = ["team", "explorer", "changes", "preview", "config", "setup", "sidebar"] as const;
24
+
25
+ type Surface = (typeof SURFACES)[number];
26
+
27
+ function isSurface(value: string | undefined): value is Surface {
28
+ return value !== undefined && (SURFACES as readonly string[]).includes(value);
29
+ }
30
+
31
+ async function main(): Promise<void> {
32
+ const surface = process.argv[2];
33
+
34
+ if (!isSurface(surface)) {
35
+ process.stderr.write(
36
+ `tmux-ide-tui: unknown surface ${surface ? `"${surface}"` : "(none given)"}.\n` +
37
+ `Usage: tmux-ide-tui <${SURFACES.join("|")}> [flags]\n`,
38
+ );
39
+ process.exit(2);
40
+ }
41
+
42
+ // Drop the surface token so each entry's `parseArgs()` sees only its flags.
43
+ process.argv.splice(2, 1);
44
+
45
+ switch (surface) {
46
+ case "team":
47
+ await import("./team/index.tsx");
48
+ break;
49
+ case "explorer":
50
+ await import("../widgets/explorer/index.tsx");
51
+ break;
52
+ case "changes":
53
+ await import("../widgets/changes/index.tsx");
54
+ break;
55
+ case "preview":
56
+ await import("../widgets/preview/index.tsx");
57
+ break;
58
+ case "config":
59
+ await import("../widgets/config/index.tsx");
60
+ break;
61
+ case "setup":
62
+ await import("../widgets/setup/index.tsx");
63
+ break;
64
+ case "sidebar":
65
+ await import("../widgets/sidebar/index.tsx");
66
+ break;
67
+ }
68
+ }
69
+
70
+ void main();
@@ -0,0 +1,143 @@
1
+ /**
2
+ * A tmux control-mode client.
3
+ *
4
+ * Spawns `tmux -C attach -t <target>` and speaks the control protocol over
5
+ * stdio: commands written to stdin get their replies back wrapped in
6
+ * `%begin`/`%end` blocks (FIFO order), while live pane bytes stream in as
7
+ * `%output` notifications between blocks. This is the same integration
8
+ * surface iTerm2 uses for its native tmux mode — tmux stays the multiplexer
9
+ * and persistence layer; the caller renders.
10
+ *
11
+ * stdout is read as latin1 (one JS char per byte) so pane bytes survive
12
+ * intact; `parseControlLine`/`decodeControlBytes` do the pure protocol work.
13
+ */
14
+ import { spawn, type ChildProcess } from "node:child_process";
15
+ import { parseControlLine, textToHexKeys } from "./control.ts";
16
+
17
+ interface PendingReply {
18
+ resolve: (lines: string[]) => void;
19
+ reject: (err: Error) => void;
20
+ lines: string[];
21
+ }
22
+
23
+ export interface ControlClientOptions {
24
+ /** Session (or other tmux target) to attach the control client to. */
25
+ attachTarget: string;
26
+ /** Live pane bytes: `%output` events, decoded. */
27
+ onOutput?: (pane: string, data: Uint8Array) => void;
28
+ /** Non-output notifications (layout-change, window-add, …). */
29
+ onNotify?: (name: string, rest: string) => void;
30
+ /** Control client ended (tmux exited or detached us). */
31
+ onExit?: (reason: string | null) => void;
32
+ }
33
+
34
+ export class ControlModeClient {
35
+ private proc: ChildProcess | null = null;
36
+ private readonly pending: PendingReply[] = [];
37
+ private inReply = false;
38
+ private buffer = "";
39
+ private readonly opts: ControlClientOptions;
40
+
41
+ constructor(opts: ControlClientOptions) {
42
+ this.opts = opts;
43
+ }
44
+
45
+ /** Spawn the control client and resolve once tmux's greeting block passed. */
46
+ start(): Promise<void> {
47
+ const proc = spawn("tmux", ["-C", "attach", "-t", this.opts.attachTarget], {
48
+ stdio: ["pipe", "pipe", "pipe"],
49
+ env: { ...process.env, TMUX: "" },
50
+ });
51
+ this.proc = proc;
52
+ proc.stdout!.setEncoding("latin1");
53
+ proc.stdout!.on("data", (chunk: string) => this.feed(chunk));
54
+ proc.on("exit", () => this.opts.onExit?.(null));
55
+
56
+ // tmux opens the conversation with an unsolicited %begin/%end greeting
57
+ // block; queue a resolver for it so `start()` settles when the protocol
58
+ // is actually live (and so the pending queue stays aligned).
59
+ return new Promise((resolve, reject) => {
60
+ this.pending.push({ resolve: () => resolve(), reject, lines: [] });
61
+ proc.on("error", reject);
62
+ });
63
+ }
64
+
65
+ /** Run a tmux command over the control channel, resolving its reply body. */
66
+ command(cmd: string): Promise<string[]> {
67
+ const proc = this.proc;
68
+ if (!proc?.stdin?.writable) return Promise.reject(new Error("control client not running"));
69
+ return new Promise((resolve, reject) => {
70
+ this.pending.push({ resolve, reject, lines: [] });
71
+ proc.stdin!.write(`${cmd}\n`);
72
+ });
73
+ }
74
+
75
+ /** Type literal text into a pane (UTF-8, sent as hex bytes — quote-proof). */
76
+ sendText(pane: string, text: string): Promise<string[]> {
77
+ return this.command(`send-keys -t ${pane} -H ${textToHexKeys(text).join(" ")}`);
78
+ }
79
+
80
+ /** Send a named tmux key (Enter, Escape, Up, C-c, …) to a pane. */
81
+ sendKey(pane: string, key: string): Promise<string[]> {
82
+ return this.command(`send-keys -t ${pane} ${key}`);
83
+ }
84
+
85
+ dispose(): void {
86
+ const proc = this.proc;
87
+ this.proc = null;
88
+ if (!proc) return;
89
+ try {
90
+ proc.stdin?.write("detach-client\n");
91
+ } catch {
92
+ // already gone
93
+ }
94
+ setTimeout(() => {
95
+ if (proc.exitCode === null) proc.kill();
96
+ }, 250).unref?.();
97
+ }
98
+
99
+ private feed(chunk: string): void {
100
+ this.buffer += chunk;
101
+ let nl: number;
102
+ while ((nl = this.buffer.indexOf("\n")) !== -1) {
103
+ // tmux terminates lines with \r\n in control mode; strip both.
104
+ let line = this.buffer.slice(0, nl);
105
+ if (line.endsWith("\r")) line = line.slice(0, -1);
106
+ this.buffer = this.buffer.slice(nl + 1);
107
+ this.handleLine(line);
108
+ }
109
+ }
110
+
111
+ private handleLine(line: string): void {
112
+ const event = parseControlLine(line, this.inReply);
113
+ switch (event.kind) {
114
+ case "begin":
115
+ this.inReply = true;
116
+ break;
117
+ case "reply-line":
118
+ this.pending[0]?.lines.push(event.line);
119
+ break;
120
+ case "end":
121
+ case "error": {
122
+ this.inReply = false;
123
+ const reply = this.pending.shift();
124
+ if (!reply) break; // unsolicited block (e.g. greeting after a race)
125
+ if (event.kind === "error") {
126
+ reply.reject(new Error(reply.lines.join("\n") || "tmux command failed"));
127
+ } else {
128
+ reply.resolve(reply.lines);
129
+ }
130
+ break;
131
+ }
132
+ case "output":
133
+ this.opts.onOutput?.(event.pane, event.data);
134
+ break;
135
+ case "exit":
136
+ this.opts.onExit?.(event.reason);
137
+ break;
138
+ case "notify":
139
+ this.opts.onNotify?.(event.name, event.rest);
140
+ break;
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pure parsing for tmux control mode (`tmux -C`).
3
+ *
4
+ * In control mode tmux writes newline-terminated event lines: command replies
5
+ * arrive wrapped in `%begin`/`%end` (or `%error`) blocks, and asynchronous
6
+ * notifications (`%output`, `%layout-change`, `%exit`, …) arrive between
7
+ * blocks. Pane output bytes in `%output` are escaped — every C0 control byte
8
+ * and the backslash itself appear as three-digit octal escapes (`\015`,
9
+ * `\033`, `\134`) — so the decoder here resolves those back to raw BYTES.
10
+ *
11
+ * Everything in this module is pure so the protocol handling can be
12
+ * unit-tested without a live tmux. The client reads tmux's stdout with
13
+ * latin1 encoding (one JS char per byte); `decodeControlBytes` returns a
14
+ * Uint8Array that xterm's `write()` interprets as UTF-8, which keeps
15
+ * multi-byte characters intact end to end.
16
+ */
17
+
18
+ export type ControlEvent =
19
+ | { kind: "begin"; num: number }
20
+ | { kind: "end"; num: number }
21
+ | { kind: "error"; num: number }
22
+ | { kind: "output"; pane: string; data: Uint8Array }
23
+ | { kind: "exit"; reason: string | null }
24
+ | { kind: "notify"; name: string; rest: string }
25
+ | { kind: "reply-line"; line: string };
26
+
27
+ /** Decode a latin1 control-mode payload (with \ooo escapes) into raw bytes. */
28
+ export function decodeControlBytes(escaped: string): Uint8Array {
29
+ const out = new Uint8Array(escaped.length);
30
+ let n = 0;
31
+ for (let i = 0; i < escaped.length; i++) {
32
+ const ch = escaped.charCodeAt(i);
33
+ if (
34
+ ch === 0x5c && // backslash
35
+ i + 3 < escaped.length + 1 &&
36
+ isOctal(escaped.charCodeAt(i + 1)) &&
37
+ isOctal(escaped.charCodeAt(i + 2)) &&
38
+ isOctal(escaped.charCodeAt(i + 3))
39
+ ) {
40
+ out[n++] = parseInt(escaped.slice(i + 1, i + 4), 8) & 0xff;
41
+ i += 3;
42
+ } else {
43
+ out[n++] = ch & 0xff;
44
+ }
45
+ }
46
+ return out.subarray(0, n);
47
+ }
48
+
49
+ function isOctal(code: number | undefined): boolean {
50
+ return code !== undefined && code >= 0x30 && code <= 0x37; // '0'..'7'
51
+ }
52
+
53
+ /**
54
+ * Parse one control-mode line into an event.
55
+ *
56
+ * `insideReply` matters because reply-block bodies are arbitrary command
57
+ * output — a line starting with `%` inside a block is still body text unless
58
+ * it's the block terminator itself.
59
+ */
60
+ export function parseControlLine(line: string, insideReply: boolean): ControlEvent {
61
+ if (line.startsWith("%end ") || line.startsWith("%error ")) {
62
+ const isError = line.startsWith("%error ");
63
+ const num = Number(line.split(" ")[2] ?? -1);
64
+ return { kind: isError ? "error" : "end", num };
65
+ }
66
+ if (insideReply) return { kind: "reply-line", line };
67
+
68
+ if (line.startsWith("%begin ")) {
69
+ return { kind: "begin", num: Number(line.split(" ")[2] ?? -1) };
70
+ }
71
+ if (line.startsWith("%output ")) {
72
+ const rest = line.slice("%output ".length);
73
+ const space = rest.indexOf(" ");
74
+ const pane = space === -1 ? rest : rest.slice(0, space);
75
+ const payload = space === -1 ? "" : rest.slice(space + 1);
76
+ return { kind: "output", pane, data: decodeControlBytes(payload) };
77
+ }
78
+ if (line.startsWith("%exit")) {
79
+ const reason = line.length > "%exit ".length ? line.slice("%exit ".length) : null;
80
+ return { kind: "exit", reason };
81
+ }
82
+ if (line.startsWith("%")) {
83
+ const space = line.indexOf(" ");
84
+ const name = space === -1 ? line.slice(1) : line.slice(1, space);
85
+ return { kind: "notify", name, rest: space === -1 ? "" : line.slice(space + 1) };
86
+ }
87
+ // Shouldn't happen outside a reply block, but never throw on protocol noise.
88
+ return { kind: "reply-line", line };
89
+ }
90
+
91
+ /** Encode text as a `send-keys -H` hex-byte argv tail (UTF-8 bytes). */
92
+ export function textToHexKeys(text: string): string[] {
93
+ const bytes = new TextEncoder().encode(text);
94
+ const hex: string[] = [];
95
+ for (const b of bytes) hex.push(b.toString(16).padStart(2, "0"));
96
+ return hex;
97
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A pane mirror: a headless terminal emulator fed by tmux control-mode
3
+ * `%output` bytes, exposing a renderable grid snapshot.
4
+ *
5
+ * @xterm/headless is xterm.js without a DOM — a full VT parser (SGR, cursor,
6
+ * alt-screen, scroll regions) maintaining a cell buffer. We write raw pane
7
+ * bytes in and read the grid out; the TUI draws the snapshot. This is the
8
+ * seam between "tmux owns the PTYs" and "tmux-ide owns the pixels".
9
+ */
10
+ import { Terminal } from "@xterm/headless";
11
+
12
+ /** A run of same-colored text within a row. */
13
+ export interface StyledRun {
14
+ text: string;
15
+ /** 256-palette index, or null for the default foreground. */
16
+ fg: number | null;
17
+ bold: boolean;
18
+ }
19
+
20
+ export interface MirrorSnapshot {
21
+ rows: StyledRun[][];
22
+ cursorX: number;
23
+ cursorY: number;
24
+ }
25
+
26
+ export class PaneMirror {
27
+ private readonly term: Terminal;
28
+ readonly cols: number;
29
+ readonly rows: number;
30
+
31
+ constructor(cols: number, rows: number) {
32
+ this.cols = cols;
33
+ this.rows = rows;
34
+ this.term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 0 });
35
+ }
36
+
37
+ /** Feed raw pane bytes (UTF-8) from a control-mode %output event. */
38
+ write(data: Uint8Array | string): void {
39
+ this.term.write(data);
40
+ }
41
+
42
+ resize(cols: number, rows: number): void {
43
+ this.term.resize(cols, rows);
44
+ }
45
+
46
+ /** Read the visible grid as rows of same-styled runs (plus the cursor). */
47
+ snapshot(): MirrorSnapshot {
48
+ const buf = this.term.buffer.active;
49
+ const rows: StyledRun[][] = [];
50
+ const cell = buf.getNullCell();
51
+
52
+ for (let y = 0; y < this.rows; y++) {
53
+ const line = buf.getLine(buf.viewportY + y);
54
+ const runs: StyledRun[] = [];
55
+ if (line) {
56
+ let text = "";
57
+ let fg: number | null = null;
58
+ let bold = false;
59
+ for (let x = 0; x < this.cols; x++) {
60
+ line.getCell(x, cell);
61
+ const cellFg = cell.isFgDefault() ? null : cell.isFgPalette() ? cell.getFgColor() : null;
62
+ const cellBold = cell.isBold() !== 0;
63
+ if (x > 0 && (cellFg !== fg || cellBold !== bold) && text.length > 0) {
64
+ runs.push({ text, fg, bold });
65
+ text = "";
66
+ }
67
+ fg = cellFg;
68
+ bold = cellBold;
69
+ text += cell.getChars() || " ";
70
+ }
71
+ if (text.length > 0) runs.push({ text, fg, bold });
72
+ }
73
+ rows.push(runs);
74
+ }
75
+ return { rows, cursorX: buf.cursorX, cursorY: buf.cursorY };
76
+ }
77
+
78
+ dispose(): void {
79
+ this.term.dispose();
80
+ }
81
+ }