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.
- package/README.md +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +427 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +382 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- 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
|
+
}
|