tmux-ide 2.6.1 → 2.8.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 +36 -14
- package/bin/cli.js +4174 -1235
- package/bin/cli.ts +426 -72
- package/package.json +10 -6
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- 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/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- 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 +34 -6
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +194 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +737 -52
- package/packages/daemon/src/tui/chrome/updater.ts +318 -30
- package/packages/daemon/src/tui/compiled.ts +11 -3
- package/packages/daemon/src/tui/detect/classify.ts +49 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
- package/packages/daemon/src/tui/detect/manifest.ts +39 -3
- package/packages/daemon/src/tui/detect/manifests.ts +395 -33
- package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/main.ts +13 -1
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
- package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +322 -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/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
- package/packages/daemon/src/tui/mirror/palette.ts +564 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -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 +376 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +130 -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/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/entry.ts +34 -7
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/report.ts +11 -1
- package/packages/daemon/src/tui/team/sessions.ts +184 -17
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- package/skill/SKILL.md +110 -2
- 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
- package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-terminal modes owned by the unified app.
|
|
3
|
+
*
|
|
4
|
+
* OpenTUI renders each changed row as ANSI runs. With DECAWM (host autowrap)
|
|
5
|
+
* enabled, a wide/right-edge run can physically wrap into column 1 of the next
|
|
6
|
+
* row while OpenTUI's shadow records the intended cells. That strands pane text
|
|
7
|
+
* over the left sidebar until an outer full repaint. The app never relies on
|
|
8
|
+
* host autowrap—it positions runs absolutely—so disable it for the app's
|
|
9
|
+
* lifetime and restore it exactly once on every exit path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const HOST_AUTOWRAP_DISABLE = "\x1b[?7l";
|
|
13
|
+
export const HOST_AUTOWRAP_ENABLE = "\x1b[?7h";
|
|
14
|
+
|
|
15
|
+
export interface HostTerminalExitLifecycle {
|
|
16
|
+
onExit(listener: () => void): void;
|
|
17
|
+
offExit(listener: () => void): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HostAutowrapGuard {
|
|
21
|
+
restore(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Disable host autowrap immediately and arm an exit fallback. `restore` is
|
|
26
|
+
* idempotent and removes the fallback before writing the enable sequence, so a
|
|
27
|
+
* normal renderer teardown followed by process exit cannot double-write it.
|
|
28
|
+
*
|
|
29
|
+
* The caller supplies a synchronous writer: process `exit` listeners cannot
|
|
30
|
+
* rely on queued/asynchronous stdout writes being flushed.
|
|
31
|
+
*/
|
|
32
|
+
export function installHostAutowrapGuard(
|
|
33
|
+
write: (sequence: string) => void,
|
|
34
|
+
lifecycle: HostTerminalExitLifecycle,
|
|
35
|
+
): HostAutowrapGuard {
|
|
36
|
+
let restored = false;
|
|
37
|
+
|
|
38
|
+
const restore = () => {
|
|
39
|
+
if (restored) return;
|
|
40
|
+
restored = true;
|
|
41
|
+
lifecycle.offExit(restore);
|
|
42
|
+
write(HOST_AUTOWRAP_ENABLE);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
write(HOST_AUTOWRAP_DISABLE);
|
|
46
|
+
lifecycle.onExit(restore);
|
|
47
|
+
|
|
48
|
+
return { restore };
|
|
49
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The detachable cockpit (M23.2) — tmux keeps the app itself alive.
|
|
3
|
+
*
|
|
4
|
+
* `tmux-ide app --detachable` (alias `--hosted`) doesn't run the app in the
|
|
5
|
+
* invoking terminal: it ensures an internal `_tmux-ide-app` session exists
|
|
6
|
+
* running the app full-screen, then attaches the terminal to it. ^q under
|
|
7
|
+
* hosting DETACHES the client (the app keeps running); re-invocation from any
|
|
8
|
+
* terminal — including a phone over ssh — reattaches the SAME cockpit with
|
|
9
|
+
* scroll positions, dialogs, and workspace context intact.
|
|
10
|
+
*
|
|
11
|
+
* These are the PURE pieces of that contract: the entry decision, the tmux
|
|
12
|
+
* argv builders, and the shell quoting for the host pane's command line. The
|
|
13
|
+
* io (spawning tmux, resolving bun vs the compiled binary) stays in the CLI;
|
|
14
|
+
* the app side only reads {@link HOSTED_ENV} to flip ^q from quit to detach.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The internal host session. `_`-prefixed so every fleet surface (team --json,
|
|
18
|
+
* sidebar, home) and the snapshot/restore path already filter it out. */
|
|
19
|
+
export const APP_HOST_SESSION = "_tmux-ide-app";
|
|
20
|
+
|
|
21
|
+
/** The env marker the launcher sets on the hosted app process. The app flips
|
|
22
|
+
* ^q from "quit" to "detach the client" when it sees `=1`; the CLI treats it
|
|
23
|
+
* as a recursion guard (a hosted app never re-hosts). */
|
|
24
|
+
export const HOSTED_ENV = "TMUX_IDE_HOSTED";
|
|
25
|
+
|
|
26
|
+
/** Everything the entry decision reads — flags, config, and the guard. */
|
|
27
|
+
export interface HostedEntryInput {
|
|
28
|
+
/** `--detachable` (the primary flag). */
|
|
29
|
+
flagDetachable: boolean;
|
|
30
|
+
/** `--hosted` (the alias). */
|
|
31
|
+
flagHosted: boolean;
|
|
32
|
+
/** `app.detachable` from the typed config — makes bare `tmux-ide app` (and
|
|
33
|
+
* the frontDoor entry) hosted without the flag. */
|
|
34
|
+
configDetachable: boolean;
|
|
35
|
+
/** Whether WE are already the hosted app ({@link HOSTED_ENV} set). */
|
|
36
|
+
hostedEnv: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* PURE — should this `tmux-ide app` invocation run hosted? Flags and config
|
|
41
|
+
* both opt in; the env marker vetoes everything (the app inside the host
|
|
42
|
+
* session must launch plain, or it would try to attach to itself).
|
|
43
|
+
*/
|
|
44
|
+
export function wantsHostedApp(input: HostedEntryInput): boolean {
|
|
45
|
+
if (input.hostedEnv) return false;
|
|
46
|
+
return input.flagDetachable || input.flagHosted || input.configDetachable;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PURE — POSIX single-quote a word for a tmux `new-session` shell command
|
|
51
|
+
* (tmux hands the string to `sh -c`). Single quotes pass everything literally;
|
|
52
|
+
* an embedded `'` closes, escapes, and reopens.
|
|
53
|
+
*/
|
|
54
|
+
export function shellQuote(word: string): string {
|
|
55
|
+
return `'${word.replaceAll("'", `'\\''`)}'`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* PURE — the env vars the host pane's app process needs, assembled for the
|
|
60
|
+
* command line rather than tmux's session environment: the tmux server may
|
|
61
|
+
* have been started elsewhere with a different environment, so nothing can be
|
|
62
|
+
* assumed to inherit. PATH rides along for the same reason (the `bun` launch
|
|
63
|
+
* mode resolves the binary by name).
|
|
64
|
+
*/
|
|
65
|
+
export function hostedEnvVars(base: {
|
|
66
|
+
/** The user's real invocation dir (in-app prompts default here). */
|
|
67
|
+
cwd: string;
|
|
68
|
+
/** The node-runnable CLI path (`TMUX_IDE_CLI`) for in-app subprocesses. */
|
|
69
|
+
cli: string;
|
|
70
|
+
/** The invoking shell's PATH. */
|
|
71
|
+
path?: string;
|
|
72
|
+
/** `TMUX_IDE_HOME` / `TMUX_IDE_CONFIG` / `TMUX_IDE_TUI_BIN` pass-throughs
|
|
73
|
+
* (set in test rigs; must reach the hosted app or it reads real state). */
|
|
74
|
+
home?: string;
|
|
75
|
+
config?: string;
|
|
76
|
+
tuiBin?: string;
|
|
77
|
+
}): Record<string, string> {
|
|
78
|
+
const env: Record<string, string> = {
|
|
79
|
+
[HOSTED_ENV]: "1",
|
|
80
|
+
TMUX_IDE_CWD: base.cwd,
|
|
81
|
+
TMUX_IDE_CLI: base.cli,
|
|
82
|
+
};
|
|
83
|
+
if (base.path) env.PATH = base.path;
|
|
84
|
+
if (base.home) env.TMUX_IDE_HOME = base.home;
|
|
85
|
+
if (base.config) env.TMUX_IDE_CONFIG = base.config;
|
|
86
|
+
if (base.tuiBin) env.TMUX_IDE_TUI_BIN = base.tuiBin;
|
|
87
|
+
return env;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* PURE — the shell command the host pane runs: `exec env K=V… bin args…`,
|
|
92
|
+
* every value quoted. `exec` replaces the pane's shell with the app so a quit
|
|
93
|
+
* (the palette verb) ends the pane — and with it the single-window session.
|
|
94
|
+
*/
|
|
95
|
+
export function hostedCommandLine(
|
|
96
|
+
bin: string,
|
|
97
|
+
argv: readonly string[],
|
|
98
|
+
env: Record<string, string>,
|
|
99
|
+
): string {
|
|
100
|
+
const assigns = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
|
|
101
|
+
return ["exec", "env", ...assigns, shellQuote(bin), ...argv.map(shellQuote)].join(" ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** PURE — exact-match existence probe (`=` prefix: `has-session -t` would
|
|
105
|
+
* otherwise PREFIX-match, e.g. a user session named `_tmux-ide-app-notes`). */
|
|
106
|
+
export function hostExistsArgv(): string[] {
|
|
107
|
+
return ["has-session", "-t", `=${APP_HOST_SESSION}`];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** PURE — create the detached host session running the app command line. */
|
|
111
|
+
export function hostCreateArgv(opts: { cwd: string; commandLine: string }): string[] {
|
|
112
|
+
return ["new-session", "-d", "-s", APP_HOST_SESSION, "-c", opts.cwd, opts.commandLine];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The client events that re-assert `window-size latest` on the host (M25.5).
|
|
117
|
+
* Each hook's effect was MEASURED on tmux 3.7b in an isolated two-client rig
|
|
118
|
+
* (220x60 local + 120x40 ssh-sim):
|
|
119
|
+
*
|
|
120
|
+
* - `client-attached` / `client-focus-in` / `client-session-changed`: tmux
|
|
121
|
+
* 3.7b already re-adopts the event client's size natively on all of these
|
|
122
|
+
* (attach ~8ms, focus-in ~17ms, switch-client ~17ms — window-resized hook
|
|
123
|
+
* timestamps; detach of the latest client also re-adopts natively, ~13ms).
|
|
124
|
+
* The hooks are NOT what makes those paths work; they are the SELF-HEAL for
|
|
125
|
+
* the one measured way the host gets permanently stuck: any `resize-window`
|
|
126
|
+
* against it (a stray tool or user command) flips `window-size` to manual —
|
|
127
|
+
* after which NO client event re-adopts, ever (measured: a fresh 220x60
|
|
128
|
+
* attach left a manually-80x24 host at 80x24). Re-asserting the option is
|
|
129
|
+
* the heal (measured: instant re-adopt) — and it is safe to fire
|
|
130
|
+
* redundantly: setting `window-size latest` when it is already latest just
|
|
131
|
+
* recomputes the same size. Fire counts are bounded and linear (measured:
|
|
132
|
+
* 10 rapid focus alternations → exactly 20 focus-in fires; 10 attach cycles
|
|
133
|
+
* → 10 attached + 10 session-changed fires — attach fires both — each a
|
|
134
|
+
* single in-server set-option, no storm).
|
|
135
|
+
*
|
|
136
|
+
* NO `client-detached` hook: measured on 3.7b it never fires as a SESSION
|
|
137
|
+
* hook (the detaching client has already left the session when hooks are
|
|
138
|
+
* resolved — 0 fires across 10 detach cycles while the same rig's global
|
|
139
|
+
* hook logged every one), and the native detach re-adopt covers the path.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately NOT `resize-window -a`: per the tmux manual, EVERY
|
|
142
|
+
* resize-window form — -a included — "will automatically set window-size to
|
|
143
|
+
* manual", i.e. it would cause the exact stuck state it is meant to fix. And
|
|
144
|
+
* no `run-shell`: these are tmux-native commands executed in-server
|
|
145
|
+
* (run-shell hooks serialize the server — prior measurement).
|
|
146
|
+
*/
|
|
147
|
+
export const HOST_RESIZE_HOOKS = [
|
|
148
|
+
"client-attached",
|
|
149
|
+
"client-focus-in",
|
|
150
|
+
"client-session-changed",
|
|
151
|
+
] as const;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* PURE — the post-create/ensure session setup: status OFF so the app owns
|
|
155
|
+
* every row (under `status on` the host steals the bottom row and the app
|
|
156
|
+
* renders one short), and `window-size latest` pinned explicitly so the most
|
|
157
|
+
* recently active client dictates the size — a smaller second client
|
|
158
|
+
* letterboxes the larger one (tmux's own dot-fill), which is the documented
|
|
159
|
+
* behavior.
|
|
160
|
+
*
|
|
161
|
+
* M25.5 additions (each measured on 3.7b — see {@link HOST_RESIZE_HOOKS}):
|
|
162
|
+
*
|
|
163
|
+
* - `focus-events on` (server option — the ONE non-session-scoped line, and
|
|
164
|
+
* the load-bearing one): it defaults OFF, so real terminals are never asked
|
|
165
|
+
* for focus reporting and `client-focus-in` never fires. With it on, coming
|
|
166
|
+
* BACK to a terminal that stayed attached re-adopts that client's size on
|
|
167
|
+
* the focus event alone (measured: focus-in → client-active →
|
|
168
|
+
* window-resized in ~17ms) — no keystroke needed. This is the user's exact
|
|
169
|
+
* "reopen it on my computer locally" moment when the local client never
|
|
170
|
+
* detached. Side effect is the widely-recommended one (panes that request
|
|
171
|
+
* focus, e.g. editors, start receiving it).
|
|
172
|
+
* - the {@link HOST_RESIZE_HOOKS} self-heal hooks, session-scoped to the host
|
|
173
|
+
* (zero effect on user sessions).
|
|
174
|
+
*
|
|
175
|
+
* The whole list is idempotent — the CLI applies it on EVERY ensure, not just
|
|
176
|
+
* create, so upgrading tmux-ide fixes an already-running cockpit on its next
|
|
177
|
+
* `tmux-ide app` (and un-sticks a manually-resized host).
|
|
178
|
+
*
|
|
179
|
+
* No `=` exact-match prefix here: tmux (measured on 3.7b) rejects it on
|
|
180
|
+
* `set-option` session targets ("no such session") even though has-session
|
|
181
|
+
* and attach accept it. Plain names are safe in THIS builder only because
|
|
182
|
+
* setup runs right after the exists-probe/create — the exact session exists,
|
|
183
|
+
* and tmux prefers an exact match over a prefix match when one does.
|
|
184
|
+
*/
|
|
185
|
+
export function hostSetupArgvs(): string[][] {
|
|
186
|
+
const heal = `set-option -w -t ${APP_HOST_SESSION}: window-size latest`;
|
|
187
|
+
return [
|
|
188
|
+
["set-option", "-t", APP_HOST_SESSION, "status", "off"],
|
|
189
|
+
["set-option", "-w", "-t", `${APP_HOST_SESSION}:`, "window-size", "latest"],
|
|
190
|
+
["set-option", "-s", "focus-events", "on"],
|
|
191
|
+
...HOST_RESIZE_HOOKS.map((hook) => ["set-hook", "-t", APP_HOST_SESSION, hook, heal]),
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* PURE — how the invoking terminal reaches the cockpit: inside tmux the
|
|
197
|
+
* client is already attached to a server, so `switch-client` moves it (a
|
|
198
|
+
* nested `attach` would complain and double-render); a plain terminal
|
|
199
|
+
* attaches. Both exact-match the host name.
|
|
200
|
+
*/
|
|
201
|
+
export function hostAttachArgv(insideTmux: boolean): string[] {
|
|
202
|
+
return insideTmux
|
|
203
|
+
? ["switch-client", "-t", `=${APP_HOST_SESSION}`]
|
|
204
|
+
: ["attach-session", "-t", `=${APP_HOST_SESSION}`];
|
|
205
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputCoalescer — the pure core of the input fast path (M21.5).
|
|
3
|
+
*
|
|
4
|
+
* Burst-typed LITERAL characters coalesce into one buffered run per pane and
|
|
5
|
+
* leave as a single `send-keys -H <hex…>` write per flush; named/ctrl keys
|
|
6
|
+
* (Enter, C-c, Up, …) are emitted immediately. THE ordering invariant: any
|
|
7
|
+
* buffered literal run flushes BEFORE a named key — or a literal for a
|
|
8
|
+
* DIFFERENT pane — is emitted, so bytes reach the pane in exactly the order
|
|
9
|
+
* the user produced them.
|
|
10
|
+
*
|
|
11
|
+
* Pure core, io shell: this class owns no timers and does no io. The caller
|
|
12
|
+
* injects `emit` (where flushed actions go — the control client's
|
|
13
|
+
* fire-and-forget write in production) and `schedule` (when a pending literal
|
|
14
|
+
* buffer should auto-flush — a microtask in production, a hand-cranked stub in
|
|
15
|
+
* tests). A microtask flush still happens inside the same macrotask that
|
|
16
|
+
* buffered the keystroke — strictly before the process can read more input —
|
|
17
|
+
* so coalescing adds no perceivable latency; it only merges keystrokes that
|
|
18
|
+
* arrived together.
|
|
19
|
+
*
|
|
20
|
+
* Flushes re-chunk the buffered run under {@link SEND_KEYS_CHUNK_BYTES} so a
|
|
21
|
+
* large paste routed through the same path never produces an oversized
|
|
22
|
+
* control-mode command.
|
|
23
|
+
*/
|
|
24
|
+
import { chunkByBytes } from "./selection.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Max UTF-8 payload bytes per `send-keys -H` write. Every byte travels as its
|
|
28
|
+
* own `xx` argv token, and each token hits tmux's yacc command parser — which
|
|
29
|
+
* is the REAL constraint, measured empirically against tmux 3.6b control mode
|
|
30
|
+
* (M21.5), not a line-length cap:
|
|
31
|
+
*
|
|
32
|
+
* - HARD LIMIT: a 16 KB payload (≈16k tokens) is REJECTED outright with
|
|
33
|
+
* `%error: yacc stack overflow` (yacc's ~10k-entry stack).
|
|
34
|
+
* - PARSE COST is superlinear in tokens: one command's reply takes ~3 ms at
|
|
35
|
+
* 1 KB, ~9 ms at 2 KB, ~56 ms at 4 KB, ~170 ms at 8 KB — big chunks STALL
|
|
36
|
+
* the tmux server (and every interleaved keystroke behind it).
|
|
37
|
+
* - END-TO-END, a 100 KB paste lands byte-perfect in ~246/155/180/233/255/
|
|
38
|
+
* 561/1483 ms at 64/128/256/512/1024/2048/4096-byte chunks — the optimum
|
|
39
|
+
* is ~128–256 B; the pre-M21.5 1024 was past the knee, and RAISING the
|
|
40
|
+
* chunk (the naive read of "fewer commands = faster") is strictly worse.
|
|
41
|
+
*
|
|
42
|
+
* 256 B is chosen: within noise of the throughput optimum, ~1 ms of server
|
|
43
|
+
* parse per command (imperceptible for input interleaving), 5× headroom from
|
|
44
|
+
* nothing, and 40× from the yacc cliff.
|
|
45
|
+
*/
|
|
46
|
+
export const SEND_KEYS_CHUNK_BYTES = 256;
|
|
47
|
+
|
|
48
|
+
/** One flushed input action, ready to become a control-mode write. */
|
|
49
|
+
export type InputAction =
|
|
50
|
+
| { kind: "literal"; pane: string; text: string }
|
|
51
|
+
| { kind: "key"; pane: string; key: string };
|
|
52
|
+
|
|
53
|
+
export class InputCoalescer {
|
|
54
|
+
private pane = "";
|
|
55
|
+
private buf = "";
|
|
56
|
+
private scheduled = false;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly emit: (action: InputAction) => void,
|
|
60
|
+
private readonly schedule: (flush: () => void) => void,
|
|
61
|
+
private readonly maxChunkBytes: number = SEND_KEYS_CHUNK_BYTES,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
/** Buffer literal text for `pane`; a pending run for ANOTHER pane flushes
|
|
65
|
+
* first so cross-pane order is preserved. Schedules an auto-flush once per
|
|
66
|
+
* pending run. */
|
|
67
|
+
literal(pane: string, text: string): void {
|
|
68
|
+
if (!pane || !text) return;
|
|
69
|
+
if (this.buf.length > 0 && this.pane !== pane) this.flush();
|
|
70
|
+
this.pane = pane;
|
|
71
|
+
this.buf += text;
|
|
72
|
+
if (!this.scheduled) {
|
|
73
|
+
this.scheduled = true;
|
|
74
|
+
this.schedule(() => {
|
|
75
|
+
this.scheduled = false;
|
|
76
|
+
this.flush();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Emit a named tmux key (Enter, C-c, Up, …) — pending literals flush first
|
|
82
|
+
* (synchronously) so the key can never overtake buffered characters. */
|
|
83
|
+
key(pane: string, key: string): void {
|
|
84
|
+
if (!pane || !key) return;
|
|
85
|
+
this.flush();
|
|
86
|
+
this.emit({ kind: "key", pane, key });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Drain the pending literal run now (chunked under the byte cap). Also the
|
|
90
|
+
* ordering barrier callers place before reply-matched structural commands. */
|
|
91
|
+
flush(): void {
|
|
92
|
+
if (this.buf.length === 0) return;
|
|
93
|
+
const pane = this.pane;
|
|
94
|
+
const text = this.buf;
|
|
95
|
+
this.buf = "";
|
|
96
|
+
for (const chunk of chunkByBytes(text, this.maxChunkBytes)) {
|
|
97
|
+
this.emit({ kind: "literal", pane, text: chunk });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Buffered-but-unflushed character count (tests/introspection only). */
|
|
102
|
+
pending(): number {
|
|
103
|
+
return this.buf.length;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE — tmux layout-string and control-notification parsing (M23.5).
|
|
3
|
+
*
|
|
4
|
+
* `%layout-change` arrives sub-millisecond after the server applies a layout
|
|
5
|
+
* and ALWAYS precedes the first new-size `%output` (measured on tmux 3.7b:
|
|
6
|
+
* the follow-up output can trail by as little as 0.2ms). The mirror therefore
|
|
7
|
+
* derives pane geometry from the notification PAYLOAD itself instead of a
|
|
8
|
+
* debounced `list-panes` round-trip — these parsers are that push path.
|
|
9
|
+
*
|
|
10
|
+
* The layout grammar mirrors tmux's `layout_parse.c`: a 4-hex-digit checksum,
|
|
11
|
+
* a comma, then a cell. A cell is `WxH,X,Y` followed by either `,<paneId>`
|
|
12
|
+
* (a leaf; the numeric pane id sans `%`), `{…}` (horizontal split) or `[…]`
|
|
13
|
+
* (vertical split) with comma-separated child cells. The ROOT cell's WxH is
|
|
14
|
+
* the authoritative window size. Parse the VISIBLE layout — the THIRD field
|
|
15
|
+
* of `%layout-change @win <layout> <visible-layout> <flags>` — because zoom
|
|
16
|
+
* collapses it to the single zoomed pane (`*Z` in flags = zoomed); the second
|
|
17
|
+
* field keeps reporting the saved multi-pane layout.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is unit-tested against layout strings captured from a real
|
|
20
|
+
* tmux 3.7b server (splits, zoom, storms) — no tmux at test time.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One visible pane rectangle, in window cells. `id` is `%`-prefixed. */
|
|
24
|
+
export interface LayoutLeaf {
|
|
25
|
+
id: string;
|
|
26
|
+
left: number;
|
|
27
|
+
top: number;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A parsed (visible) layout: the window size + the leaves in layout order. */
|
|
33
|
+
export interface ParsedLayout {
|
|
34
|
+
/** The root cell's WxH — the authoritative window size. */
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
leaves: LayoutLeaf[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse a tmux layout string (`csum,WxH,X,Y…`). Null on any malformed input
|
|
41
|
+
* (the caller falls back to the slow list-panes path — never throw here). */
|
|
42
|
+
export function parseLayout(layout: string): ParsedLayout | null {
|
|
43
|
+
if (!/^[0-9a-fA-F]{4},/.test(layout)) return null;
|
|
44
|
+
const s = layout.slice(5);
|
|
45
|
+
const leaves: LayoutLeaf[] = [];
|
|
46
|
+
const root = parseCell(s, 0, leaves);
|
|
47
|
+
if (!root || root.pos !== s.length) return null;
|
|
48
|
+
return { width: root.width, height: root.height, leaves };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Recursive-descent cell parse from `pos`; appends leaves in layout order. */
|
|
52
|
+
function parseCell(
|
|
53
|
+
s: string,
|
|
54
|
+
pos: number,
|
|
55
|
+
leaves: LayoutLeaf[],
|
|
56
|
+
): { width: number; height: number; pos: number } | null {
|
|
57
|
+
const dims = readDims(s, pos);
|
|
58
|
+
if (!dims) return null;
|
|
59
|
+
const { width, height, left, top } = dims;
|
|
60
|
+
pos = dims.pos;
|
|
61
|
+
const ch = s[pos];
|
|
62
|
+
if (ch === ",") {
|
|
63
|
+
// Leaf: the numeric pane id.
|
|
64
|
+
const id = readInt(s, pos + 1);
|
|
65
|
+
if (!id) return null;
|
|
66
|
+
leaves.push({ id: `%${id.value}`, left, top, width, height });
|
|
67
|
+
return { width, height, pos: id.pos };
|
|
68
|
+
}
|
|
69
|
+
if (ch === "{" || ch === "[") {
|
|
70
|
+
const close = ch === "{" ? "}" : "]";
|
|
71
|
+
pos++;
|
|
72
|
+
for (;;) {
|
|
73
|
+
const child = parseCell(s, pos, leaves);
|
|
74
|
+
if (!child) return null;
|
|
75
|
+
pos = child.pos;
|
|
76
|
+
if (s[pos] === ",") {
|
|
77
|
+
pos++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (s[pos] === close) return { width, height, pos: pos + 1 };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// A bare root leaf ends the string (`…,0,0,445`): ch is undefined only when
|
|
85
|
+
// the leaf id was consumed above, so anything else here is malformed.
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read `WxH,X,Y` at `pos`. */
|
|
90
|
+
function readDims(
|
|
91
|
+
s: string,
|
|
92
|
+
pos: number,
|
|
93
|
+
): { width: number; height: number; left: number; top: number; pos: number } | null {
|
|
94
|
+
const w = readInt(s, pos);
|
|
95
|
+
if (!w || s[w.pos] !== "x") return null;
|
|
96
|
+
const h = readInt(s, w.pos + 1);
|
|
97
|
+
if (!h || s[h.pos] !== ",") return null;
|
|
98
|
+
const x = readInt(s, h.pos + 1);
|
|
99
|
+
if (!x || s[x.pos] !== ",") return null;
|
|
100
|
+
const y = readInt(s, x.pos + 1);
|
|
101
|
+
if (!y) return null;
|
|
102
|
+
return { width: w.value, height: h.value, left: x.value, top: y.value, pos: y.pos };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Read a decimal integer at `pos` (at least one digit). */
|
|
106
|
+
function readInt(s: string, pos: number): { value: number; pos: number } | null {
|
|
107
|
+
let end = pos;
|
|
108
|
+
while (end < s.length && s.charCodeAt(end) >= 0x30 && s.charCodeAt(end) <= 0x39) end++;
|
|
109
|
+
if (end === pos) return null;
|
|
110
|
+
return { value: Number(s.slice(pos, end)), pos: end };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A parsed `%layout-change` notification body. */
|
|
114
|
+
export interface LayoutChange {
|
|
115
|
+
windowId: string;
|
|
116
|
+
/** The saved (full) layout — kept for debugging; geometry uses `visible`. */
|
|
117
|
+
layout: string;
|
|
118
|
+
/** The VISIBLE layout — collapses to the single zoomed pane under zoom. */
|
|
119
|
+
visible: string;
|
|
120
|
+
/** `Z` present in the flags field (`*Z`). */
|
|
121
|
+
zoomed: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Parse the body after `%layout-change ` (tmux 3.7b:
|
|
125
|
+
* `@387 <layout> <visible-layout> *Z`). Null when the shape is off. */
|
|
126
|
+
export function parseLayoutChange(rest: string): LayoutChange | null {
|
|
127
|
+
const parts = rest.trim().split(/\s+/);
|
|
128
|
+
const [windowId = "", layout = "", visible = "", flags = ""] = parts;
|
|
129
|
+
if (parts.length < 3 || !windowId.startsWith("@")) return null;
|
|
130
|
+
return { windowId, layout, visible, zoomed: flags.includes("Z") };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse the body after `%window-pane-changed ` (`@387 %443`). */
|
|
134
|
+
export function parseWindowPaneChanged(rest: string): { windowId: string; paneId: string } | null {
|
|
135
|
+
const [windowId = "", paneId = ""] = rest.trim().split(/\s+/);
|
|
136
|
+
if (!windowId.startsWith("@") || !paneId.startsWith("%")) return null;
|
|
137
|
+
return { windowId, paneId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Parse the body after `%session-window-changed ` (`$353 @388`). */
|
|
141
|
+
export function parseSessionWindowChanged(rest: string): { windowId: string } | null {
|
|
142
|
+
const [, windowId = ""] = rest.trim().split(/\s+/);
|
|
143
|
+
if (!windowId.startsWith("@")) return null;
|
|
144
|
+
return { windowId };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Parse the body after `%subscription-changed ` for the mirror's `mouse`
|
|
148
|
+
* subscription (tmux 3.7b: `mouse $353 @387 0 %445 : 1`). Null for other
|
|
149
|
+
* subscription names or an off shape. */
|
|
150
|
+
export function parseMouseSubscription(rest: string): { paneId: string; on: boolean } | null {
|
|
151
|
+
const m = /^mouse\s+\$\S+\s+@\S+\s+\S+\s+(%\S+)\s*:\s*(.*)$/.exec(rest.trim());
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
return { paneId: m[1]!, on: m[2]!.trim() === "1" };
|
|
154
|
+
}
|