tmux-ide 2.6.0 → 2.7.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 (271) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1024 -519
  3. package/bin/cli.ts +63 -6
  4. package/bunfig.toml +4 -0
  5. package/package.json +18 -7
  6. package/packages/contracts/package.json +22 -0
  7. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  8. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  9. package/packages/contracts/src/actions-contract.ts +310 -0
  10. package/packages/contracts/src/actions-errors.ts +41 -0
  11. package/packages/contracts/src/domain.ts +36 -0
  12. package/packages/contracts/src/ide-config.ts +170 -0
  13. package/packages/contracts/src/index.ts +24 -0
  14. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  15. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  16. package/packages/contracts/src/terminals.ts +116 -0
  17. package/packages/contracts/src/tmux.ts +60 -0
  18. package/packages/contracts/src/workspace.ts +67 -0
  19. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  20. package/packages/daemon/dist/agent-explain.js +19 -3
  21. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  22. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  23. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  24. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  25. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  26. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  27. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  28. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  29. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  30. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  31. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  32. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  33. package/packages/daemon/src/agent-explain.ts +298 -0
  34. package/packages/daemon/src/attach.ts +20 -0
  35. package/packages/daemon/src/bin.ts +4 -0
  36. package/packages/daemon/src/canonical.ts +7 -0
  37. package/packages/daemon/src/cli.ts +499 -0
  38. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  39. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  40. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  41. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  42. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  43. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  44. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  45. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  46. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  47. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  48. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  49. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  50. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  51. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  52. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  53. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  54. package/packages/daemon/src/command-center/discovery.ts +96 -0
  55. package/packages/daemon/src/command-center/index.ts +31 -0
  56. package/packages/daemon/src/command-center/schemas.ts +85 -0
  57. package/packages/daemon/src/command-center/server.ts +1260 -0
  58. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  59. package/packages/daemon/src/config.ts +549 -0
  60. package/packages/daemon/src/detect.ts +248 -0
  61. package/packages/daemon/src/doctor.ts +242 -0
  62. package/packages/daemon/src/embed.ts +5 -0
  63. package/packages/daemon/src/index.ts +12 -0
  64. package/packages/daemon/src/init.ts +211 -0
  65. package/packages/daemon/src/inspect.ts +178 -0
  66. package/packages/daemon/src/js-yaml.d.ts +10 -0
  67. package/packages/daemon/src/launch.ts +349 -0
  68. package/packages/daemon/src/lib/active-projects.ts +49 -0
  69. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  70. package/packages/daemon/src/lib/app-config.ts +427 -0
  71. package/packages/daemon/src/lib/app-settings.ts +53 -0
  72. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  73. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  74. package/packages/daemon/src/lib/auth/types.ts +2 -0
  75. package/packages/daemon/src/lib/auth-token.ts +5 -0
  76. package/packages/daemon/src/lib/authorship.ts +280 -0
  77. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  78. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  79. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  80. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  81. package/packages/daemon/src/lib/daemon.ts +79 -0
  82. package/packages/daemon/src/lib/dot-path.ts +17 -0
  83. package/packages/daemon/src/lib/errors.ts +67 -0
  84. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  85. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  86. package/packages/daemon/src/lib/log.ts +134 -0
  87. package/packages/daemon/src/lib/output.ts +76 -0
  88. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  89. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  90. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  91. package/packages/daemon/src/lib/project-probe.ts +92 -0
  92. package/packages/daemon/src/lib/project-registry.ts +296 -0
  93. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  94. package/packages/daemon/src/lib/session-options.ts +100 -0
  95. package/packages/daemon/src/lib/shell.ts +8 -0
  96. package/packages/daemon/src/lib/sizes.ts +36 -0
  97. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  98. package/packages/daemon/src/lib/slugify.ts +10 -0
  99. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  100. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  101. package/packages/daemon/src/lib/update-check.ts +298 -0
  102. package/packages/daemon/src/lib/update.ts +158 -0
  103. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  104. package/packages/daemon/src/lib/worktree.ts +289 -0
  105. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  106. package/packages/daemon/src/ls.ts +40 -0
  107. package/packages/daemon/src/restart.ts +24 -0
  108. package/packages/daemon/src/restore.ts +514 -0
  109. package/packages/daemon/src/schemas/domain.ts +2 -0
  110. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  111. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  112. package/packages/daemon/src/schemas/index.ts +59 -0
  113. package/packages/daemon/src/schemas/inspect.ts +67 -0
  114. package/packages/daemon/src/schemas/registry.ts +55 -0
  115. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  116. package/packages/daemon/src/send.ts +171 -0
  117. package/packages/daemon/src/server/README.md +15 -0
  118. package/packages/daemon/src/server/index.ts +74 -0
  119. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  120. package/packages/daemon/src/server/standalone.ts +18 -0
  121. package/packages/daemon/src/server/ws-route.ts +483 -0
  122. package/packages/daemon/src/status.ts +59 -0
  123. package/packages/daemon/src/stop.ts +28 -0
  124. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  125. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  126. package/packages/daemon/src/terminal/README.md +92 -0
  127. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  128. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  129. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  130. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  131. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  132. package/packages/daemon/src/tui/chrome/notify.ts +382 -0
  133. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  134. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  135. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  136. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  137. package/packages/daemon/src/tui/chrome/updater.ts +510 -0
  138. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  139. package/packages/daemon/src/tui/compiled.ts +121 -0
  140. package/packages/daemon/src/tui/detect/classify.ts +208 -0
  141. package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
  142. package/packages/daemon/src/tui/detect/manifest.ts +199 -0
  143. package/packages/daemon/src/tui/detect/manifests.ts +354 -0
  144. package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
  145. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  146. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  147. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  148. package/packages/daemon/src/tui/main.ts +82 -0
  149. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  150. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  151. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  152. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  153. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  154. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  155. package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
  156. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  157. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  158. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  159. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  160. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  161. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  162. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  163. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  164. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  165. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  166. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  167. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  168. package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
  169. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  170. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  171. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  172. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  173. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  174. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  175. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  176. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  177. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  178. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  179. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  180. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  181. package/packages/daemon/src/tui/team/entry.ts +38 -0
  182. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  183. package/packages/daemon/src/tui/team/home.ts +170 -0
  184. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  185. package/packages/daemon/src/tui/team/input.ts +34 -0
  186. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  187. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  188. package/packages/daemon/src/tui/team/nav.ts +31 -0
  189. package/packages/daemon/src/tui/team/preview.ts +34 -0
  190. package/packages/daemon/src/tui/team/projects.ts +191 -0
  191. package/packages/daemon/src/tui/team/report.ts +83 -0
  192. package/packages/daemon/src/tui/team/sessions.ts +433 -0
  193. package/packages/daemon/src/tui/team/tree.ts +62 -0
  194. package/packages/daemon/src/types.ts +13 -0
  195. package/packages/daemon/src/ui/index.ts +32 -0
  196. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  197. package/packages/daemon/src/ui/types.ts +91 -0
  198. package/packages/daemon/src/ui/web/base.css +80 -0
  199. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  200. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  201. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  202. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  203. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  204. package/packages/daemon/src/ui/web/index.ts +27 -0
  205. package/packages/daemon/src/ui/web/render.ts +77 -0
  206. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  207. package/packages/daemon/src/validate.ts +217 -0
  208. package/packages/daemon/src/widgets/changes/README.md +3 -0
  209. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  210. package/packages/daemon/src/widgets/config/README.md +3 -0
  211. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  212. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  213. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  214. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  215. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  216. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  217. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  218. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  219. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  220. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  221. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  222. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  223. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  224. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  225. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  226. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  227. package/packages/daemon/src/widgets/preview/README.md +3 -0
  228. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  229. package/packages/daemon/src/widgets/resolve.ts +121 -0
  230. package/packages/daemon/src/widgets/setup/README.md +3 -0
  231. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  232. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  233. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  234. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  235. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  236. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  237. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  238. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  239. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  240. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  241. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  242. package/packages/tmux-bridge/package.json +22 -0
  243. package/packages/tmux-bridge/src/errors.ts +28 -0
  244. package/packages/tmux-bridge/src/index.ts +31 -0
  245. package/packages/tmux-bridge/src/monitor.ts +77 -0
  246. package/packages/tmux-bridge/src/panes.ts +136 -0
  247. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  248. package/packages/tmux-bridge/src/runner.ts +91 -0
  249. package/packages/tmux-bridge/src/sessions.ts +126 -0
  250. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  251. package/packages/tmux-bridge/src/targeting.ts +90 -0
  252. package/scripts/build-tui.mjs +11 -4
  253. package/scripts/perf-mirror.mjs +313 -0
  254. package/scripts/postinstall.js +26 -2
  255. package/skill/SKILL.md +22 -0
  256. package/templates/AGENTS.md +14 -7
  257. package/templates/agent-team-monorepo.yml +8 -0
  258. package/templates/agent-team-nextjs.yml +8 -0
  259. package/templates/agent-team.yml +10 -0
  260. package/templates/convex.yml +2 -0
  261. package/templates/default.yml +11 -5
  262. package/templates/go.yml +4 -0
  263. package/templates/missions.yml +6 -0
  264. package/templates/nextjs.yml +4 -0
  265. package/templates/python.yml +4 -0
  266. package/templates/skills/backend.md +5 -12
  267. package/templates/skills/frontend.md +5 -12
  268. package/templates/skills/general-worker.md +5 -12
  269. package/templates/skills/researcher.md +7 -12
  270. package/templates/skills/reviewer.md +7 -16
  271. package/templates/vite.yml +4 -0
@@ -0,0 +1,443 @@
1
+ /**
2
+ * SessionMirror — a whole tmux window rendered through one control client.
3
+ *
4
+ * The multi-pane composition of the proven single-pane pipeline: one
5
+ * {@link ControlModeClient} attaches to the session, `refresh-client -C`
6
+ * pins the virtual client size to our render area (so tmux computes pane
7
+ * layout for OUR grid), and every pane of the active window gets a
8
+ * {@link PaneMirror} fed by routed `%output` bytes. Layout notifications
9
+ * (`%layout-change`, `%window-pane-changed`, …) trigger a re-sync that
10
+ * diffs geometry and creates/resizes/disposes mirrors incrementally.
11
+ *
12
+ * tmux remains the multiplexer, PTY owner, and source of layout truth —
13
+ * this class owns nothing but mirrors and routing. The pure helpers
14
+ * ({@link parsePaneGeometry}, {@link diffPanes}) carry the logic and are
15
+ * unit-tested without tmux.
16
+ */
17
+ import { appendFileSync } from "node:fs";
18
+ import { execFileSync } from "node:child_process";
19
+ import { ControlModeClient } from "./control-client.ts";
20
+ import { InputCoalescer } from "./input-coalescer.ts";
21
+ import {
22
+ PaneMirror,
23
+ type MirrorSnapshot,
24
+ type BlitOptions,
25
+ type CursorState,
26
+ } from "./pane-mirror.ts";
27
+ import type { CellArrays } from "./blit.ts";
28
+ import { tapInputOutput, tapRepin } from "./perf-tap.ts";
29
+
30
+ /** One pane's geometry inside the window, in cells (tmux coordinates). */
31
+ export interface PaneGeometry {
32
+ id: string;
33
+ left: number;
34
+ top: number;
35
+ width: number;
36
+ height: number;
37
+ active: boolean;
38
+ /** The pane's APP turned mouse reporting on (forward real SGR events). */
39
+ appMouse: boolean;
40
+ /** The pane's WINDOW is zoomed (`#{window_zoomed_flag}`). A window property, so
41
+ * every pane of the same window reports the same value; the app reads it off
42
+ * the focused pane to tint the zoom button and show the `[Z]` chip. */
43
+ zoomed: boolean;
44
+ }
45
+
46
+ /** PURE — parse `list-panes -F "#{pane_id} #{pane_left} …"` reply lines. The
47
+ * trailing `window_zoomed_flag` field is optional (absent lines parse as not
48
+ * zoomed) so older format strings and fixtures stay valid. */
49
+ export function parsePaneGeometry(lines: string[]): PaneGeometry[] {
50
+ const panes: PaneGeometry[] = [];
51
+ for (const line of lines) {
52
+ const parts = line.trim().split(/\s+/);
53
+ if (parts.length < 6) continue;
54
+ const [
55
+ id = "",
56
+ left = "",
57
+ top = "",
58
+ width = "",
59
+ height = "",
60
+ active = "",
61
+ mouse = "",
62
+ zoomed = "",
63
+ ] = parts;
64
+ if (!id.startsWith("%")) continue;
65
+ const nums = [left, top, width, height].map(Number);
66
+ if (nums.some((n) => !Number.isInteger(n) || n < 0)) continue;
67
+ panes.push({
68
+ id,
69
+ left: nums[0]!,
70
+ top: nums[1]!,
71
+ width: nums[2]!,
72
+ height: nums[3]!,
73
+ active: active === "1",
74
+ appMouse: mouse === "1",
75
+ zoomed: zoomed === "1",
76
+ });
77
+ }
78
+ return panes;
79
+ }
80
+
81
+ /** PURE — what changed between two layouts, keyed by pane id. */
82
+ export function diffPanes(
83
+ prev: PaneGeometry[],
84
+ next: PaneGeometry[],
85
+ ): { added: PaneGeometry[]; removed: string[]; resized: PaneGeometry[]; moved: PaneGeometry[] } {
86
+ const prevById = new Map(prev.map((p) => [p.id, p]));
87
+ const nextIds = new Set(next.map((p) => p.id));
88
+ const added: PaneGeometry[] = [];
89
+ const resized: PaneGeometry[] = [];
90
+ const moved: PaneGeometry[] = [];
91
+ for (const pane of next) {
92
+ const was = prevById.get(pane.id);
93
+ if (!was) {
94
+ added.push(pane);
95
+ } else if (was.width !== pane.width || was.height !== pane.height) {
96
+ resized.push(pane);
97
+ } else if (was.left !== pane.left || was.top !== pane.top) {
98
+ moved.push(pane);
99
+ }
100
+ }
101
+ const removed = prev.filter((p) => !nextIds.has(p.id)).map((p) => p.id);
102
+ return { added, removed, resized, moved };
103
+ }
104
+
105
+ /** A live pane: geometry + its mirror snapshot. */
106
+ export interface LivePane extends PaneGeometry {
107
+ snapshot: MirrorSnapshot;
108
+ /** Lines available above the live viewport (scrollback budget). */
109
+ scrollbackDepth: number;
110
+ /** Per-pane content version (M21.4) — the `<pane_surface>` gates its walk on
111
+ * this, so an unchanged pane never re-reads its grid. */
112
+ version: number;
113
+ }
114
+
115
+ export interface SessionMirrorOptions {
116
+ target: string;
117
+ /** Render-area size in cells (the control client is pinned to this). */
118
+ cols: number;
119
+ rows: number;
120
+ /** Called whenever any pane's content or the layout changed (coalesce upstream). */
121
+ onDirty?: () => void;
122
+ onStatus?: (msg: string) => void;
123
+ onExit?: () => void;
124
+ }
125
+
126
+ /** Control-mode notifications (sans `%`) that mean "the layout changed". */
127
+ const STRUCTURAL_NOTIFICATIONS = new Set([
128
+ "layout-change",
129
+ "window-add",
130
+ "window-close",
131
+ "window-renamed",
132
+ "window-pane-changed",
133
+ "session-window-changed",
134
+ "unlinked-window-close",
135
+ ]);
136
+
137
+ export class SessionMirror {
138
+ private readonly client: ControlModeClient;
139
+ private readonly mirrors = new Map<string, PaneMirror>();
140
+ private geometry: PaneGeometry[] = [];
141
+ private focused = "";
142
+ private syncQueued = false;
143
+ /** Size policy (M22.8). "auto" (default): we pin our virtual client size via
144
+ * `refresh-client -C` and let tmux's `window-size latest` cooperate — a
145
+ * co-attached terminal may win, which we surface honestly rather than fight.
146
+ * "manual": the user asked us to reclaim the window ({@link resizeToFit}), so
147
+ * we set `window-size manual` + `resize-window` (the ONLY mechanism that holds
148
+ * against a bigger real client — measured; plain `refresh-client -C` does not
149
+ * re-win once another client is latest). Manual is a WINDOW option that
150
+ * lingers past our detach, so {@link dispose} reverts it. */
151
+ private sizeMode: "auto" | "manual" = "auto";
152
+ private readonly opts: SessionMirrorOptions;
153
+ /** The input fast path (M21.5): literals coalesce per pane and flush on a
154
+ * microtask (same macrotask as the keystroke — no added latency); named
155
+ * keys flush pending literals first so ordering is preserved; everything
156
+ * leaves via the control client's fire-and-forget write. */
157
+ private readonly input = new InputCoalescer(
158
+ (a) => {
159
+ if (a.kind === "literal") this.client.sendText(a.pane, a.text);
160
+ else this.client.sendKey(a.pane, a.key);
161
+ },
162
+ (flush) => queueMicrotask(flush),
163
+ );
164
+
165
+ constructor(opts: SessionMirrorOptions) {
166
+ this.opts = opts;
167
+ this.client = new ControlModeClient({
168
+ attachTarget: opts.target,
169
+ onOutput: (pane, data) => {
170
+ tapInputOutput(pane); // t1: first echo back for a key we just forwarded
171
+ this.mirrors.get(pane)?.write(data);
172
+ opts.onDirty?.();
173
+ },
174
+ onNotify: (name) => {
175
+ if (process.env.TMUX_IDE_MIRROR_DEBUG) {
176
+ try {
177
+ appendFileSync("/tmp/zz-notify.log", name + "\n");
178
+ } catch {
179
+ // debug tap only
180
+ }
181
+ }
182
+ // Any structural notification re-syncs the layout; the debounce keeps
183
+ // bursts (e.g. a resize storm) to one list-panes round-trip. NOTE:
184
+ // parseControlLine strips the leading `%` from notification names.
185
+ if (STRUCTURAL_NOTIFICATIONS.has(name)) {
186
+ this.queueSync();
187
+ }
188
+ },
189
+ onExit: () => opts.onExit?.(),
190
+ });
191
+ }
192
+
193
+ async start(): Promise<void> {
194
+ await this.client.start();
195
+ await this.client.command(`refresh-client -C ${this.opts.cols}x${this.opts.rows}`);
196
+ await this.sync();
197
+ }
198
+
199
+ /**
200
+ * The current panes with fresh grid snapshots, render-ready.
201
+ *
202
+ * @param scrollOffsets Per-pane scrollback offsets (lines above live view);
203
+ * panes absent from the map render live. The focused pane gets its cursor
204
+ * painted.
205
+ * @param includeRows Serialize each pane's styled rows. `false` (the
206
+ * framebuffer-blit path, M21.3) returns geometry + cursor/offset only and
207
+ * skips the run rebuild — the `<pane_surface>` reads cells via {@link blitPane}.
208
+ */
209
+ panes(scrollOffsets?: ReadonlyMap<string, number>, includeRows = true): LivePane[] {
210
+ const focused = this.focusedPane();
211
+ return this.geometry.map((g) => {
212
+ const mirror = this.mirrors.get(g.id);
213
+ const offset = scrollOffsets?.get(g.id) ?? 0;
214
+ return {
215
+ ...g,
216
+ active: g.id === this.focused || (this.focused === "" && g.active),
217
+ scrollbackDepth: mirror?.scrollbackDepth() ?? 0,
218
+ version: mirror?.contentVersion() ?? 0,
219
+ snapshot:
220
+ mirror?.snapshot(offset, g.id === focused, includeRows) ??
221
+ ({ rows: [], cursorX: 0, cursorY: 0, scrollOffset: 0 } as const),
222
+ };
223
+ });
224
+ }
225
+
226
+ /** Blit a pane's visible grid into a framebuffer's packed arrays (M21.3) — the
227
+ * `<pane_surface>` render path. No-op for an unknown pane. See
228
+ * {@link PaneMirror.blit}. */
229
+ blitPane(
230
+ id: string,
231
+ buffers: CellArrays,
232
+ width: number,
233
+ height: number,
234
+ scrollOffset: number,
235
+ defaultFg: number,
236
+ defaultBg: number,
237
+ opts: BlitOptions,
238
+ ): void {
239
+ this.mirrors.get(id)?.blit(buffers, width, height, scrollOffset, defaultFg, defaultBg, opts);
240
+ }
241
+
242
+ /** A pane's visible rows as plain text — the on-demand OSC52 copy read for the
243
+ * blit path (which omits styled rows). Empty for an unknown pane. */
244
+ visibleRowTexts(id: string, scrollOffset = 0): string[] {
245
+ return this.mirrors.get(id)?.visibleRowTexts(scrollOffset) ?? [];
246
+ }
247
+
248
+ /** A pane's live cursor state (position + DECTCEM/DECSCUSR), for the hardware
249
+ * cursor (M21.6). Null for an unknown pane. */
250
+ cursorState(id: string): CursorState | null {
251
+ return this.mirrors.get(id)?.cursorState() ?? null;
252
+ }
253
+
254
+ focusedPane(): string {
255
+ return this.focused || this.geometry.find((g) => g.active)?.id || "";
256
+ }
257
+
258
+ /** The whole mirror buffer (scrollback + viewport) of a pane as plain text
259
+ * lines — the corpus for scrollback search. Empty for an unknown pane. */
260
+ bufferLines(paneId: string): string[] {
261
+ return this.mirrors.get(paneId)?.bufferLines() ?? [];
262
+ }
263
+
264
+ /** Focus a pane locally AND in tmux (so splits/new panes open where expected). */
265
+ focus(id: string): void {
266
+ if (!this.geometry.some((g) => g.id === id)) return;
267
+ this.focused = id;
268
+ void this.command(`select-pane -t ${id}`).catch(() => {});
269
+ this.opts.onDirty?.();
270
+ }
271
+
272
+ /** Type literal text into the focused pane — coalesced, fire-and-forget. */
273
+ sendText(text: string): void {
274
+ const pane = this.focusedPane();
275
+ if (pane) this.input.literal(pane, text);
276
+ }
277
+
278
+ /** Send a named tmux key to the focused pane — fire-and-forget, after any
279
+ * pending literal batch (ordering invariant). */
280
+ sendKey(key: string): void {
281
+ const pane = this.focusedPane();
282
+ if (pane) this.input.key(pane, key);
283
+ }
284
+
285
+ /** Run any tmux command over the control channel (splits, zoom, …).
286
+ * Pending coalesced input flushes FIRST so a structural command can never
287
+ * overtake keystrokes typed before it. */
288
+ command(cmd: string): Promise<string[]> {
289
+ this.input.flush();
290
+ return this.client.command(cmd);
291
+ }
292
+
293
+ /** Windows (tabs) of the mirrored session, for the app's tab strip. `sync` is
294
+ * the window's `synchronize-panes` option (`#{?synchronize-panes,1,0}`) — a
295
+ * window property, so it rides here alongside index/name/active and the app
296
+ * reads the active window's value to drive the `[SYNC]` chip. */
297
+ async windows(): Promise<Array<{ index: number; name: string; active: boolean; sync: boolean }>> {
298
+ const lines = await this.client
299
+ .command(
300
+ `list-windows -t ${this.opts.target} -F "#{window_index}\t#{window_name}\t#{window_active}\t#{?synchronize-panes,1,0}"`,
301
+ )
302
+ .catch(() => [] as string[]);
303
+ return lines
304
+ .map((l) => l.split("\t"))
305
+ .filter((p) => p.length >= 3)
306
+ .map(([i = "", name = "", active = "", sync = ""]) => ({
307
+ index: Number(i),
308
+ name,
309
+ active: active === "1",
310
+ sync: sync === "1",
311
+ }))
312
+ .filter((w) => Number.isInteger(w.index));
313
+ }
314
+
315
+ /** Switch the mirrored session's active window (the tab click). */
316
+ switchWindow(index: number): void {
317
+ void this.command(`select-window -t ${this.opts.target}:${index}`).catch(() => {});
318
+ this.queueSync();
319
+ }
320
+
321
+ /** Type raw text (incl. escape sequences — mouse SGR, paste chunks) into a
322
+ * SPECIFIC pane — coalesced with typed literals, fire-and-forget. */
323
+ sendTextTo(pane: string, text: string): void {
324
+ this.input.literal(pane, text);
325
+ }
326
+
327
+ async resize(cols: number, rows: number): Promise<void> {
328
+ this.opts.cols = cols;
329
+ this.opts.rows = rows;
330
+ tapRepin(cols, rows); // debug tap: assert one re-pin per settled size change
331
+ await this.client.command(`refresh-client -C ${cols}x${rows}`).catch(() => {});
332
+ // Under the manual policy `refresh-client -C` no longer drives the window
333
+ // size, so a terminal/sidebar resize after a reclaim must resize the window
334
+ // directly to keep it matched to our canvas.
335
+ if (this.sizeMode === "manual") {
336
+ await this.client
337
+ .command(`resize-window -t ${this.opts.target} -x ${cols} -y ${rows}`)
338
+ .catch(() => {});
339
+ }
340
+ this.queueSync();
341
+ }
342
+
343
+ /**
344
+ * Reclaim the window at our current canvas size (M22.8 — the palette's "Resize
345
+ * to fit this window"). A co-attached smaller terminal wins `window-size
346
+ * latest`; the ONLY mechanism that overrides it and HOLDS is `window-size
347
+ * manual` + `resize-window` (measured — a bare `refresh-client -C` re-issue
348
+ * does not re-win). We flip to the manual policy so subsequent resizes keep the
349
+ * window matched, and {@link dispose} reverts the option on detach.
350
+ */
351
+ async resizeToFit(): Promise<void> {
352
+ const { cols, rows } = this.opts;
353
+ this.sizeMode = "manual";
354
+ await this.client.command(`refresh-client -C ${cols}x${rows}`).catch(() => {});
355
+ await this.client
356
+ .command(`set-window-option -t ${this.opts.target} window-size manual`)
357
+ .catch(() => {});
358
+ await this.client
359
+ .command(`resize-window -t ${this.opts.target} -x ${cols} -y ${rows}`)
360
+ .catch(() => {});
361
+ this.queueSync();
362
+ }
363
+
364
+ dispose(): void {
365
+ this.input.flush(); // last typed bytes leave before the detach
366
+ // Detach cleanliness (M22.8): a `window-size manual` override we set to
367
+ // reclaim the window lingers on the session past our client's death — it
368
+ // would leave a still-attached real terminal permanently letterboxed. Revert
369
+ // it out-of-band (a plain tmux call, independent of the control client we are
370
+ // about to tear down) so the remaining clients reclaim their own size. The
371
+ // "auto" policy needs no cleanup: tmux drops our size vote when the control
372
+ // client dies (measured). Sync + guarded — dispose runs at shutdown/re-attach,
373
+ // not the render loop, and correctness here outranks the brief block.
374
+ if (this.sizeMode === "manual") {
375
+ try {
376
+ execFileSync("tmux", ["set-window-option", "-t", this.opts.target, "-u", "window-size"], {
377
+ stdio: "ignore",
378
+ });
379
+ } catch {
380
+ // best-effort revert; the session may already be gone
381
+ }
382
+ this.sizeMode = "auto";
383
+ }
384
+ this.client.dispose();
385
+ for (const m of this.mirrors.values()) m.dispose();
386
+ this.mirrors.clear();
387
+ }
388
+
389
+ private queueSync(): void {
390
+ if (this.syncQueued) return;
391
+ this.syncQueued = true;
392
+ setTimeout(() => {
393
+ this.syncQueued = false;
394
+ void this.sync().catch(() => {});
395
+ }, 40);
396
+ }
397
+
398
+ private async sync(): Promise<void> {
399
+ const lines = await this.client.command(
400
+ `list-panes -t ${this.opts.target} -F "#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height} #{pane_active} #{mouse_any_flag} #{window_zoomed_flag}"`,
401
+ );
402
+ const next = parsePaneGeometry(lines);
403
+ const { added, removed, resized } = diffPanes(this.geometry, next);
404
+
405
+ for (const id of removed) {
406
+ this.mirrors.get(id)?.dispose();
407
+ this.mirrors.delete(id);
408
+ if (this.focused === id) this.focused = "";
409
+ }
410
+ for (const pane of added) {
411
+ const mirror = new PaneMirror(pane.width, pane.height);
412
+ // Dirty must re-arm when bytes have PARSED, not just when they were
413
+ // enqueued (onOutput) — with ack-paced writes an enqueue-time dirty can
414
+ // be consumed by the tick before the grid changed, dropping the frame.
415
+ mirror.onParsed = () => this.opts.onDirty?.();
416
+ this.mirrors.set(pane.id, mirror);
417
+ // Seed with history + current screen (-e keeps colors, -S reaches back
418
+ // into tmux's scrollback, 2000 lines. The old 300 cap guarded a SYNC seed
419
+ // write that blocked the event loop; with ack-paced writes (M21.5) the
420
+ // write just enqueues (~0.01ms) and xterm parses async — 2000 lines parse
421
+ // in ~8ms off the render loop (measured), so the deeper history is free.
422
+ // -J joins wrapped lines so re-wrapping stays sane.
423
+ const seed = await this.client
424
+ .command(`capture-pane -p -e -J -S -2000 -t ${pane.id}`)
425
+ .catch(() => []);
426
+ // The control client reads replies as latin1 (one JS char per byte —
427
+ // required for the protocol), so the seed is a byte string in disguise:
428
+ // re-encode latin1 → bytes before feeding the VT parser, or every
429
+ // multibyte glyph shatters into mojibake (…→â¦, ⇡→â¡ — user-reported,
430
+ // "weird a's with a roof"). The live %output path already decodes bytes.
431
+ if (seed.length > 0) {
432
+ mirror.write(Buffer.from(seed.join("\r\n") + "\r\n", "latin1"));
433
+ }
434
+ }
435
+ for (const pane of resized) {
436
+ this.mirrors.get(pane.id)?.resize(pane.width, pane.height);
437
+ }
438
+
439
+ this.geometry = next;
440
+ this.opts.onStatus?.(`${next.length} panes`);
441
+ this.opts.onDirty?.();
442
+ }
443
+ }