tmux-ide 2.6.1 → 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 (83) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1013 -516
  3. package/bin/cli.ts +62 -5
  4. package/package.json +9 -6
  5. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  6. package/packages/daemon/dist/agent-explain.js +19 -3
  7. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  8. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  9. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  10. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  11. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  12. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  13. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  14. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  15. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  16. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  17. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  18. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  19. package/packages/daemon/src/agent-explain.ts +34 -6
  20. package/packages/daemon/src/lib/app-config.ts +93 -2
  21. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  22. package/packages/daemon/src/tui/chrome/notify.ts +221 -34
  23. package/packages/daemon/src/tui/chrome/updater.ts +89 -7
  24. package/packages/daemon/src/tui/compiled.ts +11 -3
  25. package/packages/daemon/src/tui/detect/classify.ts +15 -0
  26. package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
  27. package/packages/daemon/src/tui/detect/manifest.ts +15 -0
  28. package/packages/daemon/src/tui/detect/manifests.ts +161 -33
  29. package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
  30. package/packages/daemon/src/tui/main.ts +13 -1
  31. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  32. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  33. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  34. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  35. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  36. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  37. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  38. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  39. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  40. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  41. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  42. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  43. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  44. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  45. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  46. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  47. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  48. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  49. package/packages/daemon/src/tui/mirror/pane-mirror.ts +499 -19
  50. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  51. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  52. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  53. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  54. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  55. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  56. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  57. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  58. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  59. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  60. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  61. package/packages/daemon/src/tui/team/entry.ts +34 -7
  62. package/packages/daemon/src/tui/team/report.ts +11 -1
  63. package/packages/daemon/src/tui/team/sessions.ts +102 -13
  64. package/scripts/build-tui.mjs +11 -4
  65. package/scripts/perf-mirror.mjs +313 -0
  66. package/skill/SKILL.md +22 -0
  67. package/templates/AGENTS.md +14 -7
  68. package/templates/agent-team-monorepo.yml +8 -0
  69. package/templates/agent-team-nextjs.yml +8 -0
  70. package/templates/agent-team.yml +10 -0
  71. package/templates/convex.yml +2 -0
  72. package/templates/default.yml +11 -5
  73. package/templates/go.yml +4 -0
  74. package/templates/missions.yml +6 -0
  75. package/templates/nextjs.yml +4 -0
  76. package/templates/python.yml +4 -0
  77. package/templates/skills/backend.md +5 -12
  78. package/templates/skills/frontend.md +5 -12
  79. package/templates/skills/general-worker.md +5 -12
  80. package/templates/skills/researcher.md +7 -12
  81. package/templates/skills/reviewer.md +7 -16
  82. package/templates/vite.yml +4 -0
  83. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -14,10 +14,11 @@ import { execFileSync } from "node:child_process";
14
14
  import {
15
15
  classifyInstant,
16
16
  parseAuthority,
17
+ parseAuthorityEpoch,
17
18
  type AgentStatus,
18
19
  type StatusTracker,
19
20
  } from "../detect/classify.ts";
20
- import type { AgentManifest } from "../detect/manifest.ts";
21
+ import type { AgentManifest, ManifestConfidence } from "../detect/manifest.ts";
21
22
  import { readProcessTable, resolveAgentCommand } from "../detect/process-tree.ts";
22
23
  import { readPaneSnapshot } from "../detect/snapshot.ts";
23
24
 
@@ -33,6 +34,80 @@ export interface TeamSession {
33
34
  * this is the richer breakdown the switcher/cockpit navigate.
34
35
  */
35
36
  windowList: TeamWindow[];
37
+ /**
38
+ * Per-pane agent detail — one entry for every pane the two-layer detection
39
+ * classifies as an agent (a manifest resolved and it isn't the `shell`
40
+ * catch-all). A FLAT list across the session's windows; each entry carries
41
+ * its own `windowIndex`, so a consumer that wants a per-window view groups by
42
+ * it. ADDITIVE: the rollup fields above (`status`, `windowList`, `panes`) are
43
+ * unchanged — this only SURFACES per-pane truth the rollup already computes.
44
+ * Optional so pre-existing constructors stay valid; {@link listTeamSessions}
45
+ * always populates it (possibly empty).
46
+ */
47
+ agents?: PaneAgentEntry[];
48
+ }
49
+
50
+ /**
51
+ * Per-pane agent detail surfaced alongside a session's rollup. Emitted only for
52
+ * panes that resolve to a real agent (a manifest, excluding the `shell`
53
+ * catch-all). Everything here is already computed during the status rollup —
54
+ * this record just keeps it instead of discarding it.
55
+ */
56
+ export interface PaneAgentEntry {
57
+ /** tmux pane id, e.g. `%5`. */
58
+ paneId: string;
59
+ /** `window_index` of the window (tab) this pane lives in. */
60
+ windowIndex: number;
61
+ /** Owning session name (repeated per entry so a flattened list stays keyed). */
62
+ session: string;
63
+ /** The resolved agent kind — the manifest id (`claude`, `codex`, …). */
64
+ kind: string;
65
+ /** Final per-pane status (authority when fresh, else scraped/tracked). */
66
+ state: AgentStatus;
67
+ /** The manifest's evidence confidence (`conservative` when the manifest omits it). */
68
+ confidence: ManifestConfidence;
69
+ /**
70
+ * The authority state's epoch stamp when the AUTHORITY layer provided the
71
+ * state (`@agent_state` = `"<state>:<epoch>"`); null for a scraped/tracked
72
+ * pane, which has no authoritative timestamp.
73
+ */
74
+ since: number | null;
75
+ /** `pane_title`. */
76
+ title: string;
77
+ /** `pane_current_command` — the pane's immediate process (often node/bun/sh). */
78
+ command: string;
79
+ /** `pane_current_path` — the pane's working directory. */
80
+ dir: string;
81
+ }
82
+
83
+ /**
84
+ * Build a {@link PaneAgentEntry} from a classified pane — PURE. Returns null
85
+ * for a NON-agent pane (no manifest resolved, or the `shell` catch-all), which
86
+ * gets no entry. `since` is threaded from the caller (the authority epoch when
87
+ * the authority layer supplied the state, else null). No tmux/`ps` access —
88
+ * every input is already resolved by the rollup.
89
+ */
90
+ export function buildAgentEntry(input: {
91
+ sessionName: string;
92
+ pane: Pick<PaneRecord, "id" | "windowIndex" | "title" | "cmd" | "dir">;
93
+ manifest: AgentManifest | undefined;
94
+ state: AgentStatus;
95
+ since: number | null;
96
+ }): PaneAgentEntry | null {
97
+ const { manifest, pane } = input;
98
+ if (!manifest || manifest.id === "shell") return null;
99
+ return {
100
+ paneId: pane.id,
101
+ windowIndex: pane.windowIndex,
102
+ session: input.sessionName,
103
+ kind: manifest.id,
104
+ state: input.state,
105
+ confidence: manifest.confidence ?? "conservative",
106
+ since: input.since,
107
+ title: pane.title,
108
+ command: pane.cmd,
109
+ dir: pane.dir,
110
+ };
36
111
  }
37
112
 
38
113
  /**
@@ -74,6 +149,8 @@ interface PaneRecord {
74
149
  cmd: string;
75
150
  /** `pane_title`. */
76
151
  title: string;
152
+ /** `pane_current_path` — the pane's working directory. */
153
+ dir: string;
77
154
  /** Raw `@agent_state` pane option (authority layer), if set. */
78
155
  authority: string;
79
156
  /** Raw `@agent_hint` pane option — forces a manifest when set. */
@@ -177,16 +254,21 @@ export function listTeamSessions(
177
254
  const seen = opts.viewed === name;
178
255
 
179
256
  const nowSec = Math.floor(Date.now() / 1000);
180
- const wantPane = typeof opts.onPane === "function";
257
+ const agents: PaneAgentEntry[] = [];
181
258
  const statuses = panes.map((pane) => {
182
259
  // AUTHORITY first: a fresh hook-reported state outranks scraping.
183
260
  const authority = parseAuthority(pane.authority, nowSec);
184
261
  let status: AgentStatus;
185
- // The manifest is resolved in the fallback anyway; for authority panes
186
- // we only resolve it (cheaply — the process table is already loaded, no
187
- // capture) when a pane sink wants the agent name for a chip.
262
+ // The agent kind is needed for BOTH the chip (onPane) and the per-pane
263
+ // agent entry, so the manifest is resolved for every pane. It's cheap —
264
+ // the process table is already loaded, and authority panes take NO
265
+ // capture-pane round-trip.
188
266
  let manifest: AgentManifest | undefined;
267
+ // The authority state's own timestamp (its "since"); only fresh
268
+ // authority states carry one — scraped panes have no stamp.
269
+ let since: number | null = null;
189
270
  if (authority !== null) {
271
+ since = parseAuthorityEpoch(pane.authority);
190
272
  if (authority === "done" && seen) {
191
273
  // Viewing acknowledges a finished agent — persist the ack so the
192
274
  // pane doesn't flip back to done on the next tick.
@@ -195,11 +277,9 @@ export function listTeamSessions(
195
277
  } else {
196
278
  status = authority;
197
279
  }
198
- if (wantPane) {
199
- manifest = resolveAgentCommand(pane.cmd, pane.pid, processTable, {
200
- hint: pane.hint,
201
- }).manifest;
202
- }
280
+ manifest = resolveAgentCommand(pane.cmd, pane.pid, processTable, {
281
+ hint: pane.hint,
282
+ }).manifest;
203
283
  } else {
204
284
  // FALLBACK: snapshot scraping. Resolve the real agent from the pane's
205
285
  // process tree (pane_current_command alone is usually just node/bun/sh).
@@ -221,6 +301,10 @@ export function listTeamSessions(
221
301
  agent: manifest && manifest.id !== "shell" ? manifest.id : null,
222
302
  status,
223
303
  });
304
+ // Surface per-pane agent detail (same resolved manifest/status — nothing
305
+ // re-derived). Non-agent panes yield null and are skipped.
306
+ const entry = buildAgentEntry({ sessionName: name, pane, manifest, state: status, since });
307
+ if (entry) agents.push(entry);
224
308
  return status;
225
309
  });
226
310
 
@@ -233,6 +317,7 @@ export function listTeamSessions(
233
317
  // `panes` and `statuses` are parallel (statuses = panes.map(...)), so
234
318
  // the pure rollup can group each pane's window with its resolved status.
235
319
  windowList: rollupWindows(panes, statuses),
320
+ agents,
236
321
  };
237
322
  });
238
323
  }
@@ -243,9 +328,11 @@ function collectPanes(): Map<string, PaneRecord[]> {
243
328
  "list-panes",
244
329
  "-a",
245
330
  "-F",
246
- // Window fields sit before pane_title so the (tab-safe) title stays the
247
- // trailing catch-all — window names don't contain tabs in practice.
248
- `#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{@agent_state}\t#{@agent_hint}\t#{${SIDEBAR_PANE_OPTION}}\t#{window_index}\t#{window_name}\t#{window_active}\t#{pane_title}`,
331
+ // Window fields + pane_current_path sit before pane_title so the (tab-safe)
332
+ // title stays the trailing catch-all — window names/paths don't contain tabs
333
+ // in practice. pane_current_path rides this SAME list-panes call (no extra
334
+ // tmux round-trip) so per-pane agent entries can carry a working dir.
335
+ `#{session_name}\t#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{@agent_state}\t#{@agent_hint}\t#{${SIDEBAR_PANE_OPTION}}\t#{window_index}\t#{window_name}\t#{window_active}\t#{pane_current_path}\t#{pane_title}`,
249
336
  ]);
250
337
  const bySession = new Map<string, PaneRecord[]>();
251
338
  for (const line of raw.split("\n").filter(Boolean)) {
@@ -260,6 +347,7 @@ function collectPanes(): Map<string, PaneRecord[]> {
260
347
  windowIndex = "0",
261
348
  windowName = "",
262
349
  windowActive = "0",
350
+ dir = "",
263
351
  ...titleParts
264
352
  ] = line.split("\t");
265
353
  if (!session) continue;
@@ -274,6 +362,7 @@ function collectPanes(): Map<string, PaneRecord[]> {
274
362
  windowIndex: Number(windowIndex) || 0,
275
363
  windowName,
276
364
  windowActive: windowActive === "1",
365
+ dir,
277
366
  title: titleParts.join("\t"),
278
367
  });
279
368
  bySession.set(session, list);
@@ -16,7 +16,9 @@
16
16
  * node-only for CI) — run `pnpm build:tui` on a machine with bun.
17
17
  *
18
18
  * Cross-compile: pass `--target bun-<os>-<arch>` (e.g. bun-linux-x64) to build
19
- * for another platform. Defaults to the host target.
19
+ * for another platform. Defaults to the host target. Pass `--outfile <path>` to
20
+ * write somewhere other than the default dist path (the release workflow uses
21
+ * this to emit per-platform artifacts side by side).
20
22
  */
21
23
 
22
24
  import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin";
@@ -27,8 +29,7 @@ import { fileURLToPath } from "node:url";
27
29
  const here = dirname(fileURLToPath(import.meta.url));
28
30
  const repoRoot = resolve(here, "..");
29
31
  const entry = resolve(repoRoot, "packages/daemon/src/tui/main.ts");
30
- const outDir = resolve(repoRoot, "packages/daemon/dist/tui");
31
- const outfile = resolve(outDir, "tmux-ide-tui");
32
+ const defaultOutDir = resolve(repoRoot, "packages/daemon/dist/tui");
32
33
 
33
34
  if (!existsSync(entry)) {
34
35
  throw new Error(`[build-tui] entry not found: ${entry}`);
@@ -38,7 +39,13 @@ const targetArg = process.argv.indexOf("--target");
38
39
  const target =
39
40
  targetArg !== -1 ? process.argv[targetArg + 1] : `bun-${process.platform}-${process.arch}`;
40
41
 
41
- mkdirSync(outDir, { recursive: true });
42
+ const outfileArg = process.argv.indexOf("--outfile");
43
+ const outfile =
44
+ outfileArg !== -1
45
+ ? resolve(process.argv[outfileArg + 1])
46
+ : resolve(defaultOutDir, "tmux-ide-tui");
47
+
48
+ mkdirSync(dirname(outfile), { recursive: true });
42
49
 
43
50
  const start = Date.now();
44
51
  const result = await Bun.build({
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * perf-mirror — the M21 baseline harness.
4
+ *
5
+ * Drives the real unified app (`tmux-ide app`) against a scripted tmux session
6
+ * on the default socket, exercising the three mirror-pipeline taps that gate
7
+ * behind TMUX_IDE_ZZ_PERF, then prints a p50/p95 table for:
8
+ * - feed-parse ms per stdout chunk (/tmp/zz-feed.log, control-client)
9
+ * - snapshot ms per 8ms paint tick (/tmp/zz-perf.log, app.tsx)
10
+ * - input-echo ms keystroke→echo→paint (/tmp/zz-input.log, perf-tap)
11
+ * across three scenarios: IDLE, FLOOD (scrolling), ALT (alt-screen redraw).
12
+ *
13
+ * Everything it creates is `zz-perf-*` on the default socket and is killed on
14
+ * exit (success OR failure). It NEVER touches any other session. Run it from
15
+ * the repo root: bun scripts/perf-mirror.mjs
16
+ *
17
+ * Env knobs (all optional): PERF_FLOOD_MS, PERF_ALT_MS, PERF_IDLE_MS,
18
+ * PERF_KEYS (input samples), PERF_KEY_GAP_MS, PERF_WARMUP_MS.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
23
+ import { dirname, resolve } from "node:path";
24
+ import os from "node:os";
25
+ import { summarize } from "../packages/daemon/src/tui/mirror/perf-tap.ts";
26
+
27
+ const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
+ const APP = "packages/daemon/src/tui/mirror/app.tsx";
29
+ const TARGET = "zz-perf-target";
30
+ const HOST = "zz-perf-host";
31
+ const FEED_LOG = "/tmp/zz-feed.log";
32
+ const SNAP_LOG = "/tmp/zz-perf.log";
33
+ const INPUT_LOG = "/tmp/zz-input.log";
34
+
35
+ const cfg = {
36
+ floodMs: num(process.env.PERF_FLOOD_MS, 6000),
37
+ altMs: num(process.env.PERF_ALT_MS, 6000),
38
+ idleMs: num(process.env.PERF_IDLE_MS, 4000),
39
+ keys: num(process.env.PERF_KEYS, 50),
40
+ keyGapMs: num(process.env.PERF_KEY_GAP_MS, 90),
41
+ warmupMs: num(process.env.PERF_WARMUP_MS, 5000),
42
+ };
43
+
44
+ function num(v, d) {
45
+ const n = Number(v);
46
+ return Number.isFinite(n) && n > 0 ? n : d;
47
+ }
48
+
49
+ /** tmux on the DEFAULT socket, un-nested (TMUX/TMUX_TMPDIR cleared) — the socket
50
+ * the app's control client attaches to and where the user's real sessions live.
51
+ * We only ever name zz-perf-* targets. */
52
+ function tmux(args, opts = {}) {
53
+ return execFileSync("tmux", args, {
54
+ env: { ...process.env, TMUX: "", TMUX_TMPDIR: "" },
55
+ encoding: "utf8",
56
+ stdio: opts.inherit ? "inherit" : ["ignore", "pipe", "pipe"],
57
+ ...opts,
58
+ });
59
+ }
60
+ function tmuxQuiet(args) {
61
+ try {
62
+ return tmux(args);
63
+ } catch {
64
+ return "";
65
+ }
66
+ }
67
+ function sessionExists(name) {
68
+ try {
69
+ tmux(["has-session", "-t", name]);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
76
+ const truncate = (p) => writeFileSync(p, "");
77
+ function readCol(path, col) {
78
+ if (!existsSync(path)) return [];
79
+ return readFileSync(path, "utf8")
80
+ .split("\n")
81
+ .filter(Boolean)
82
+ .map((l) => Number(l.trim().split(/\s+/)[col]))
83
+ .filter((n) => Number.isFinite(n));
84
+ }
85
+
86
+ // ── keystroke / flood injection ─────────────────────────────────────────────
87
+
88
+ /** Type into the HOST (the app), which forwards to the focused target pane. */
89
+ const sendHostKey = (key) => tmuxQuiet(["send-keys", "-t", HOST, key]);
90
+ const sendHostChar = (ch) => tmuxQuiet(["send-keys", "-t", HOST, "-l", ch]);
91
+ /** Run a command directly in the TARGET pane (bypasses the app — pure output). */
92
+ function runInTarget(line) {
93
+ tmuxQuiet(["send-keys", "-t", TARGET, "-l", line]);
94
+ tmuxQuiet(["send-keys", "-t", TARGET, "Enter"]);
95
+ }
96
+ /** Interrupt whatever the target pane is running, back to a clean prompt. */
97
+ function calmTarget() {
98
+ tmuxQuiet(["send-keys", "-t", TARGET, "C-c"]);
99
+ tmuxQuiet(["send-keys", "-t", TARGET, "-l", "printf '\\033[?1049l'"]);
100
+ tmuxQuiet(["send-keys", "-t", TARGET, "Enter"]);
101
+ tmuxQuiet(["send-keys", "-t", TARGET, "-l", "clear"]);
102
+ tmuxQuiet(["send-keys", "-t", TARGET, "Enter"]);
103
+ }
104
+
105
+ // ── lifecycle ───────────────────────────────────────────────────────────────
106
+
107
+ function cleanup() {
108
+ for (const s of [HOST, TARGET]) {
109
+ if (sessionExists(s)) tmuxQuiet(["kill-session", "-t", s]);
110
+ }
111
+ }
112
+
113
+ async function setup() {
114
+ cleanup(); // clear any leftovers from a crashed prior run
115
+ truncate(FEED_LOG);
116
+ truncate(SNAP_LOG);
117
+ truncate(INPUT_LOG);
118
+
119
+ // Target: a plain shell on the default socket. The app mirrors this session;
120
+ // its control client (`tmux -C attach`, TMUX="") reaches the default socket.
121
+ tmux(["new-session", "-d", "-s", TARGET, "-x", "200", "-y", "50"]);
122
+ // A generous host so the app has room and never wraps the flood oddly.
123
+ tmux([
124
+ "new-session",
125
+ "-d",
126
+ "-s",
127
+ HOST,
128
+ "-x",
129
+ "220",
130
+ "-y",
131
+ "60",
132
+ // Forward TMUX_IDE_FB_PANES verbatim so the default incremental blit path
133
+ // (M21.4) and the `=0` StyledRun kill switch can be measured on the same
134
+ // harness/taps. Unset → the app's default (blit).
135
+ `cd ${REPO} && TMUX_IDE_ZZ_PERF=1 ${process.env.TMUX_IDE_FB_PANES !== undefined ? `TMUX_IDE_FB_PANES=${process.env.TMUX_IDE_FB_PANES} ` : ""}exec bun ${APP} --target=${TARGET}`,
136
+ ]);
137
+
138
+ process.stdout.write(` warming up (${cfg.warmupMs}ms) — app attaching + seeding…\n`);
139
+ await sleep(cfg.warmupMs);
140
+ sendHostKey("F2"); // force the Terminal surface (pane-forward keyboard path)
141
+ await sleep(400);
142
+ }
143
+
144
+ /** Assert the mirror is actually live: a brief flood must grow the feed log. */
145
+ async function assertLive() {
146
+ truncate(FEED_LOG);
147
+ runInTarget("for i in 1 2 3 4 5; do echo probe-$i; done");
148
+ await sleep(800);
149
+ const n = readCol(FEED_LOG, 1).length;
150
+ if (n === 0) {
151
+ const pane = tmuxQuiet(["capture-pane", "-t", HOST, "-p"]).slice(0, 800);
152
+ throw new Error(
153
+ `mirror never went live — no %output feed after a probe.\n--- host pane ---\n${pane}`,
154
+ );
155
+ }
156
+ calmTarget();
157
+ await sleep(500);
158
+ }
159
+
160
+ // ── scenarios ────────────────────────────────────────────────────────────────
161
+
162
+ /** Idle: quiet pane; capture the low-water feed/snapshot floor. */
163
+ async function scenarioIdle() {
164
+ calmTarget();
165
+ await sleep(600);
166
+ truncate(FEED_LOG);
167
+ truncate(SNAP_LOG);
168
+ await sleep(cfg.idleMs);
169
+ return { feed: readCol(FEED_LOG, 1), snap: readCol(SNAP_LOG, 0) };
170
+ }
171
+
172
+ /** Flood: a steady stream of new lines (scrollback growth + re-snapshot). */
173
+ async function scenarioFlood() {
174
+ calmTarget();
175
+ await sleep(500);
176
+ truncate(FEED_LOG);
177
+ truncate(SNAP_LOG);
178
+ // ~200 lines/sec — a busy build/test log, not a fork bomb.
179
+ runInTarget(
180
+ 'i=0; while true; do echo "flood line $i $(date +%s%N)"; i=$((i+1)); sleep 0.005; done',
181
+ );
182
+ await sleep(cfg.floodMs);
183
+ const out = { feed: readCol(FEED_LOG, 1), snap: readCol(SNAP_LOG, 0) };
184
+ calmTarget();
185
+ await sleep(500);
186
+ return out;
187
+ }
188
+
189
+ /** Alt-screen: an app that repaints in place (cursor addressing, no scrollback). */
190
+ async function scenarioAlt() {
191
+ calmTarget();
192
+ await sleep(500);
193
+ truncate(FEED_LOG);
194
+ truncate(SNAP_LOG);
195
+ runInTarget(
196
+ "printf '\\033[?1049h'; while true; do printf '\\033[H\\033[2J'; date +%s.%N; seq 1 30; sleep 0.05; done",
197
+ );
198
+ await sleep(cfg.altMs);
199
+ const out = { feed: readCol(FEED_LOG, 1), snap: readCol(SNAP_LOG, 0) };
200
+ calmTarget();
201
+ await sleep(500);
202
+ return out;
203
+ }
204
+
205
+ /** Input latency: type single chars into the app at a shell prompt; each echoes. */
206
+ async function scenarioInput() {
207
+ calmTarget();
208
+ await sleep(600);
209
+ truncate(INPUT_LOG);
210
+ const glyphs = "abcdefghijklmnopqrstuvwxyz0123456789";
211
+ for (let k = 0; k < cfg.keys; k++) {
212
+ sendHostChar(glyphs[k % glyphs.length]);
213
+ await sleep(cfg.keyGapMs);
214
+ }
215
+ await sleep(400);
216
+ const out = { echo: readCol(INPUT_LOG, 1), paint: readCol(INPUT_LOG, 2) };
217
+ // Drop the half-typed line by interrupting the TARGET pane directly. NEVER
218
+ // send C-c to the host: the app exits on ctrl-c, which would kill the mirror
219
+ // and silence every later scenario.
220
+ tmuxQuiet(["send-keys", "-t", TARGET, "C-c"]);
221
+ return out;
222
+ }
223
+
224
+ // ── reporting ────────────────────────────────────────────────────────────────
225
+
226
+ function fmt(n) {
227
+ return n.toFixed(2).padStart(8);
228
+ }
229
+ function row(label, s) {
230
+ return ` ${label.padEnd(26)} ${String(s.count).padStart(6)} ${fmt(s.p50)} ${fmt(s.p95)} ${fmt(s.max)} ${fmt(s.mean)}`;
231
+ }
232
+ function table(rows) {
233
+ const head = ` ${"metric".padEnd(26)} ${"n".padStart(6)} ${"p50".padStart(8)} ${"p95".padStart(8)} ${"max".padStart(8)} ${"mean".padStart(8)}`;
234
+ return [head, " " + "-".repeat(head.length - 2), ...rows].join("\n");
235
+ }
236
+
237
+ function machineInfo() {
238
+ const cpu = os.cpus()[0]?.model ?? "unknown";
239
+ let tmuxV = "unknown";
240
+ try {
241
+ tmuxV = tmux(["-V"]).trim();
242
+ } catch {
243
+ /* ignore */
244
+ }
245
+ let opentui = "unknown";
246
+ try {
247
+ const pkg = JSON.parse(readFileSync(resolve(REPO, "packages/daemon/package.json"), "utf8"));
248
+ opentui =
249
+ pkg.dependencies?.["@opentui/core"] ?? pkg.dependencies?.["@opentui/solid"] ?? "unknown";
250
+ } catch {
251
+ /* ignore */
252
+ }
253
+ return {
254
+ date: new Date().toISOString(),
255
+ platform: `${os.platform()} ${os.release()} (${os.arch()})`,
256
+ cpu: `${cpu} × ${os.cpus().length}`,
257
+ mem: `${(os.totalmem() / 1024 ** 3).toFixed(0)} GB`,
258
+ bun: process.versions.bun ?? "n/a",
259
+ node: process.versions.node,
260
+ tmux: tmuxV,
261
+ opentui,
262
+ };
263
+ }
264
+
265
+ async function main() {
266
+ const info = machineInfo();
267
+ process.stdout.write("perf-mirror — M21 baseline harness\n");
268
+ process.stdout.write(
269
+ ` ${info.platform} · ${info.cpu} · bun ${info.bun} · ${info.tmux} · @opentui ${info.opentui}\n\n`,
270
+ );
271
+
272
+ await setup();
273
+ await assertLive();
274
+
275
+ process.stdout.write(" running IDLE…\n");
276
+ const idle = await scenarioIdle();
277
+ process.stdout.write(" running INPUT…\n");
278
+ const input = await scenarioInput();
279
+ process.stdout.write(" running FLOOD…\n");
280
+ const flood = await scenarioFlood();
281
+ process.stdout.write(" running ALT-SCREEN…\n\n");
282
+ const alt = await scenarioAlt();
283
+
284
+ const rows = [
285
+ row("feed-parse ms/chunk [idle]", summarize(idle.feed)),
286
+ row("feed-parse ms/chunk [flood]", summarize(flood.feed)),
287
+ row("feed-parse ms/chunk [alt]", summarize(alt.feed)),
288
+ row("snapshot ms/tick [idle]", summarize(idle.snap)),
289
+ row("snapshot ms/tick [flood]", summarize(flood.snap)),
290
+ row("snapshot ms/tick [alt]", summarize(alt.snap)),
291
+ row("input echo ms (t1-t0)", summarize(input.echo)),
292
+ row("input paint ms (t2-t0)", summarize(input.paint)),
293
+ ];
294
+ process.stdout.write(table(rows) + "\n");
295
+
296
+ return { info, rows };
297
+ }
298
+
299
+ let exitCode = 0;
300
+ try {
301
+ await main();
302
+ } catch (err) {
303
+ exitCode = 1;
304
+ process.stderr.write(`\nperf-mirror FAILED: ${err?.stack ?? err}\n`);
305
+ } finally {
306
+ cleanup();
307
+ // Best-effort confirmation the zz-perf-* sessions are gone.
308
+ const leftover = [HOST, TARGET].filter(sessionExists);
309
+ if (leftover.length)
310
+ process.stderr.write(` WARNING leftover sessions: ${leftover.join(", ")}\n`);
311
+ else process.stdout.write("\n cleanup: zz-perf-* sessions removed.\n");
312
+ }
313
+ process.exit(exitCode);
package/skill/SKILL.md CHANGED
@@ -123,6 +123,28 @@ One interaction grammar everywhere: `j`/`k` move, `enter` opens, `/` filters,
123
123
  `esc` backs out, `?` asks. Bare `tmux-ide` with no `ide.yml` opens the **home
124
124
  cockpit** (the fleet home screen). `tmux-ide cheatsheet` prints the full sheet.
125
125
 
126
+ ## The app — `tmux-ide app` (the terminal IDE)
127
+
128
+ v2.7 adds a full-screen unified app: tmux stays the engine (PTYs, agents,
129
+ persistence); the app is the IDE around it. Launch `tmux-ide app` (bare = home
130
+ screen) or `tmux-ide app <session>`. Needs `bun`, or a downloaded binary:
131
+ `tmux-ide update --tui-binary`.
132
+
133
+ - **Tabs** `F1`–`F4`: Home (fleet, pick a session = set the workspace) ·
134
+ Terminal (the session mirrored live — it keeps streaming while you're on
135
+ other tabs) · Files (tree + built-in editor: `^s` save, `^z` undo, click to
136
+ place the cursor) · Diff (colored working-tree changes, `^e` opens the file
137
+ in the editor). `F5` = command palette (fuzzy everything).
138
+ - **Mouse-native**: hover highlights; right-click = context menus (split/zoom/
139
+ kill panes, layouts, synchronize-panes, kill/rename sessions & windows —
140
+ destructive actions confirm); drag pane borders to resize; drag-select text →
141
+ clipboard via OSC52 (works through ssh); scrollbars; clickable buttons.
142
+ - **tmux parity**: `[⛶]` zoom, window verbs, layout presets, `/` scrollback
143
+ search with `n`/`N`, paste-buffer picker. `^q` quits — the session is
144
+ untouched, like you were never there.
145
+ - State persists across launches (~/.tmux-ide/app-state.json): last tab,
146
+ session, open file.
147
+
126
148
  ## ide.yml (optional)
127
149
 
128
150
  Adopt works on any session. If you'd rather have tmux-ide build the layout, describe
@@ -5,16 +5,23 @@
5
5
  ## Boundaries
6
6
 
7
7
  - Stay within the scope of your assigned task
8
- - Report out-of-scope issues via --discovered-issues
8
+ - Surface out-of-scope issues to the lead instead of silently expanding scope
9
9
  - Do not modify files outside your task scope without coordination
10
10
 
11
11
  ## Conventions
12
12
 
13
- - Run tests before marking tasks done
14
- - Use the project existing code style
15
- - Commit messages should reference the task ID
13
+ - Run tests before reporting your work done
14
+ - Use the project's existing code style
15
+ - Reference the task or branch in commit messages
16
16
 
17
- ## Completion Protocol
17
+ ## Status & Reporting
18
18
 
19
- - tmux-ide task done <ID> --proof "summary" --summary "key learnings"
20
- - Report issues: include --discovered-issues for anything out of scope
19
+ Report your state so the fleet tabs show ground truth. Claude Code does this
20
+ automatically once `tmux-ide integration install claude` is run; any agent can
21
+ self-report the same way:
22
+
23
+ tmux set-option -p @agent_state "working:$(date +%s)" # working|blocked|done|idle
24
+
25
+ Coordinate with the lead or a teammate pane:
26
+
27
+ tmux-ide send <session-or-pane> "what changed, how you verified it, follow-ups"
@@ -1,8 +1,16 @@
1
1
  name: my-monorepo
2
2
 
3
+ # Ground-truth agent status (working/blocked/done glyphs on the fleet tabs)
4
+ # comes from Claude Code's lifecycle hooks. Wire them once with:
5
+ # tmux-ide integration install claude
6
+
3
7
  team:
4
8
  name: my-monorepo
5
9
 
10
+ sidebar: true # fleet nav column — jump between agents (toggle: prefix b)
11
+
12
+ before: pnpm install
13
+
6
14
  rows:
7
15
  - size: 70%
8
16
  panes:
@@ -1,8 +1,14 @@
1
1
  name: my-app
2
2
 
3
+ # Ground-truth agent status (working/blocked/done glyphs on the fleet tabs)
4
+ # comes from Claude Code's lifecycle hooks. Wire them once with:
5
+ # tmux-ide integration install claude
6
+
3
7
  team:
4
8
  name: my-app
5
9
 
10
+ sidebar: true # fleet nav column — jump between agents (toggle: prefix b)
11
+
6
12
  before: pnpm install
7
13
 
8
14
  rows:
@@ -24,4 +30,6 @@ rows:
24
30
  - panes:
25
31
  - title: Next.js
26
32
  command: pnpm dev
33
+ - title: Changes
34
+ type: changes # live git changes widget
27
35
  - title: Shell
@@ -1,8 +1,16 @@
1
1
  name: my-project
2
2
 
3
+ # Ground-truth agent status (working/blocked/done glyphs on the fleet tabs)
4
+ # comes from Claude Code's lifecycle hooks. Wire them once with:
5
+ # tmux-ide integration install claude
6
+
3
7
  team:
4
8
  name: my-project
5
9
 
10
+ sidebar: true # fleet nav column — jump between agents (toggle: prefix b)
11
+
12
+ # before: npm install
13
+
6
14
  rows:
7
15
  - size: 70%
8
16
  panes:
@@ -18,6 +26,8 @@ rows:
18
26
  role: teammate
19
27
 
20
28
  - panes:
29
+ - title: Changes
30
+ type: changes # live git changes widget
21
31
  - title: Dev Server
22
32
  # command: npm run dev
23
33
  - title: Shell
@@ -1,5 +1,7 @@
1
1
  name: my-app
2
2
 
3
+ before: pnpm install
4
+
3
5
  rows:
4
6
  - size: 70%
5
7
  panes: