tmux-ide 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1024 -519
  3. package/bin/cli.ts +63 -6
  4. package/bunfig.toml +4 -0
  5. package/package.json +18 -7
  6. package/packages/contracts/package.json +22 -0
  7. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  8. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  9. package/packages/contracts/src/actions-contract.ts +310 -0
  10. package/packages/contracts/src/actions-errors.ts +41 -0
  11. package/packages/contracts/src/domain.ts +36 -0
  12. package/packages/contracts/src/ide-config.ts +170 -0
  13. package/packages/contracts/src/index.ts +24 -0
  14. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  15. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  16. package/packages/contracts/src/terminals.ts +116 -0
  17. package/packages/contracts/src/tmux.ts +60 -0
  18. package/packages/contracts/src/workspace.ts +67 -0
  19. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  20. package/packages/daemon/dist/agent-explain.js +19 -3
  21. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  22. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  23. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  24. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  25. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  26. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  27. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  28. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  29. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  30. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  31. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  32. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  33. package/packages/daemon/src/agent-explain.ts +298 -0
  34. package/packages/daemon/src/attach.ts +20 -0
  35. package/packages/daemon/src/bin.ts +4 -0
  36. package/packages/daemon/src/canonical.ts +7 -0
  37. package/packages/daemon/src/cli.ts +499 -0
  38. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  39. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  40. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  41. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  42. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  43. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  44. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  45. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  46. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  47. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  48. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  49. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  50. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  51. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  52. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  53. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  54. package/packages/daemon/src/command-center/discovery.ts +96 -0
  55. package/packages/daemon/src/command-center/index.ts +31 -0
  56. package/packages/daemon/src/command-center/schemas.ts +85 -0
  57. package/packages/daemon/src/command-center/server.ts +1260 -0
  58. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  59. package/packages/daemon/src/config.ts +549 -0
  60. package/packages/daemon/src/detect.ts +248 -0
  61. package/packages/daemon/src/doctor.ts +242 -0
  62. package/packages/daemon/src/embed.ts +5 -0
  63. package/packages/daemon/src/index.ts +12 -0
  64. package/packages/daemon/src/init.ts +211 -0
  65. package/packages/daemon/src/inspect.ts +178 -0
  66. package/packages/daemon/src/js-yaml.d.ts +10 -0
  67. package/packages/daemon/src/launch.ts +349 -0
  68. package/packages/daemon/src/lib/active-projects.ts +49 -0
  69. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  70. package/packages/daemon/src/lib/app-config.ts +427 -0
  71. package/packages/daemon/src/lib/app-settings.ts +53 -0
  72. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  73. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  74. package/packages/daemon/src/lib/auth/types.ts +2 -0
  75. package/packages/daemon/src/lib/auth-token.ts +5 -0
  76. package/packages/daemon/src/lib/authorship.ts +280 -0
  77. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  78. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  79. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  80. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  81. package/packages/daemon/src/lib/daemon.ts +79 -0
  82. package/packages/daemon/src/lib/dot-path.ts +17 -0
  83. package/packages/daemon/src/lib/errors.ts +67 -0
  84. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  85. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  86. package/packages/daemon/src/lib/log.ts +134 -0
  87. package/packages/daemon/src/lib/output.ts +76 -0
  88. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  89. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  90. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  91. package/packages/daemon/src/lib/project-probe.ts +92 -0
  92. package/packages/daemon/src/lib/project-registry.ts +296 -0
  93. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  94. package/packages/daemon/src/lib/session-options.ts +100 -0
  95. package/packages/daemon/src/lib/shell.ts +8 -0
  96. package/packages/daemon/src/lib/sizes.ts +36 -0
  97. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  98. package/packages/daemon/src/lib/slugify.ts +10 -0
  99. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  100. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  101. package/packages/daemon/src/lib/update-check.ts +298 -0
  102. package/packages/daemon/src/lib/update.ts +158 -0
  103. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  104. package/packages/daemon/src/lib/worktree.ts +289 -0
  105. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  106. package/packages/daemon/src/ls.ts +40 -0
  107. package/packages/daemon/src/restart.ts +24 -0
  108. package/packages/daemon/src/restore.ts +514 -0
  109. package/packages/daemon/src/schemas/domain.ts +2 -0
  110. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  111. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  112. package/packages/daemon/src/schemas/index.ts +59 -0
  113. package/packages/daemon/src/schemas/inspect.ts +67 -0
  114. package/packages/daemon/src/schemas/registry.ts +55 -0
  115. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  116. package/packages/daemon/src/send.ts +171 -0
  117. package/packages/daemon/src/server/README.md +15 -0
  118. package/packages/daemon/src/server/index.ts +74 -0
  119. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  120. package/packages/daemon/src/server/standalone.ts +18 -0
  121. package/packages/daemon/src/server/ws-route.ts +483 -0
  122. package/packages/daemon/src/status.ts +59 -0
  123. package/packages/daemon/src/stop.ts +28 -0
  124. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  125. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  126. package/packages/daemon/src/terminal/README.md +92 -0
  127. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  128. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  129. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  130. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  131. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  132. package/packages/daemon/src/tui/chrome/notify.ts +382 -0
  133. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  134. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  135. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  136. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  137. package/packages/daemon/src/tui/chrome/updater.ts +510 -0
  138. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  139. package/packages/daemon/src/tui/compiled.ts +121 -0
  140. package/packages/daemon/src/tui/detect/classify.ts +208 -0
  141. package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
  142. package/packages/daemon/src/tui/detect/manifest.ts +199 -0
  143. package/packages/daemon/src/tui/detect/manifests.ts +354 -0
  144. package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
  145. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  146. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  147. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  148. package/packages/daemon/src/tui/main.ts +82 -0
  149. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  150. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  151. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  152. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  153. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  154. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  155. package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
  156. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  157. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  158. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  159. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  160. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  161. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  162. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  163. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  164. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  165. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  166. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  167. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  168. package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
  169. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  170. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  171. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  172. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  173. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  174. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  175. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  176. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  177. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  178. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  179. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  180. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  181. package/packages/daemon/src/tui/team/entry.ts +38 -0
  182. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  183. package/packages/daemon/src/tui/team/home.ts +170 -0
  184. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  185. package/packages/daemon/src/tui/team/input.ts +34 -0
  186. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  187. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  188. package/packages/daemon/src/tui/team/nav.ts +31 -0
  189. package/packages/daemon/src/tui/team/preview.ts +34 -0
  190. package/packages/daemon/src/tui/team/projects.ts +191 -0
  191. package/packages/daemon/src/tui/team/report.ts +83 -0
  192. package/packages/daemon/src/tui/team/sessions.ts +433 -0
  193. package/packages/daemon/src/tui/team/tree.ts +62 -0
  194. package/packages/daemon/src/types.ts +13 -0
  195. package/packages/daemon/src/ui/index.ts +32 -0
  196. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  197. package/packages/daemon/src/ui/types.ts +91 -0
  198. package/packages/daemon/src/ui/web/base.css +80 -0
  199. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  200. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  201. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  202. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  203. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  204. package/packages/daemon/src/ui/web/index.ts +27 -0
  205. package/packages/daemon/src/ui/web/render.ts +77 -0
  206. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  207. package/packages/daemon/src/validate.ts +217 -0
  208. package/packages/daemon/src/widgets/changes/README.md +3 -0
  209. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  210. package/packages/daemon/src/widgets/config/README.md +3 -0
  211. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  212. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  213. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  214. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  215. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  216. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  217. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  218. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  219. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  220. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  221. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  222. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  223. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  224. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  225. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  226. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  227. package/packages/daemon/src/widgets/preview/README.md +3 -0
  228. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  229. package/packages/daemon/src/widgets/resolve.ts +121 -0
  230. package/packages/daemon/src/widgets/setup/README.md +3 -0
  231. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  232. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  233. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  234. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  235. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  236. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  237. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  238. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  239. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  240. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  241. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  242. package/packages/tmux-bridge/package.json +22 -0
  243. package/packages/tmux-bridge/src/errors.ts +28 -0
  244. package/packages/tmux-bridge/src/index.ts +31 -0
  245. package/packages/tmux-bridge/src/monitor.ts +77 -0
  246. package/packages/tmux-bridge/src/panes.ts +136 -0
  247. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  248. package/packages/tmux-bridge/src/runner.ts +91 -0
  249. package/packages/tmux-bridge/src/sessions.ts +126 -0
  250. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  251. package/packages/tmux-bridge/src/targeting.ts +90 -0
  252. package/scripts/build-tui.mjs +11 -4
  253. package/scripts/perf-mirror.mjs +313 -0
  254. package/scripts/postinstall.js +26 -2
  255. package/skill/SKILL.md +22 -0
  256. package/templates/AGENTS.md +14 -7
  257. package/templates/agent-team-monorepo.yml +8 -0
  258. package/templates/agent-team-nextjs.yml +8 -0
  259. package/templates/agent-team.yml +10 -0
  260. package/templates/convex.yml +2 -0
  261. package/templates/default.yml +11 -5
  262. package/templates/go.yml +4 -0
  263. package/templates/missions.yml +6 -0
  264. package/templates/nextjs.yml +4 -0
  265. package/templates/python.yml +4 -0
  266. package/templates/skills/backend.md +5 -12
  267. package/templates/skills/frontend.md +5 -12
  268. package/templates/skills/general-worker.md +5 -12
  269. package/templates/skills/researcher.md +7 -12
  270. package/templates/skills/reviewer.md +7 -16
  271. package/templates/vite.yml +4 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * AckWriter — ack-paced, coalescing writes into an async sink (M21.5).
3
+ *
4
+ * xterm-headless's `write()` is asynchronous: bytes queue in its WriteBuffer
5
+ * and parse on its own schedule, with a completion callback per write. Feeding
6
+ * every control-mode `%output` chunk straight in piles up unbounded entries in
7
+ * that internal queue during a flood. This writer paces instead: chunks
8
+ * arriving while a write is in flight buffer HERE, and the completion callback
9
+ * triggers exactly one follow-up write of everything buffered, joined. At most
10
+ * ONE sink write is ever outstanding, order and content are preserved
11
+ * byte-for-byte, and the caller (the control-channel reader loop) never waits.
12
+ *
13
+ * Pure core: the sink is injected (`term.write(data, done)` in production, a
14
+ * recording stub in tests); no timers, no io.
15
+ */
16
+ export class AckWriter {
17
+ private queue: Uint8Array[] = [];
18
+ private inFlight = false;
19
+
20
+ /** `onAck` fires after EACH sink write completes — i.e. when those bytes are
21
+ * actually visible in the sink (parsed, for xterm). Consumers that gate
22
+ * rendering on "content changed" must re-arm on this, not on enqueue: with
23
+ * pacing, an enqueue-time signal can be consumed before the parse lands and
24
+ * the final frame would never render. */
25
+ constructor(
26
+ private readonly sink: (data: Uint8Array, done: () => void) => void,
27
+ private readonly onAck?: () => void,
28
+ ) {}
29
+
30
+ /** Enqueue bytes; writes through immediately when the sink is idle. */
31
+ write(data: Uint8Array): void {
32
+ if (data.length === 0) return;
33
+ this.queue.push(data);
34
+ this.pump();
35
+ }
36
+
37
+ /** Bytes buffered here awaiting the in-flight write's ack (tests only). */
38
+ pendingBytes(): number {
39
+ let n = 0;
40
+ for (const c of this.queue) n += c.length;
41
+ return n;
42
+ }
43
+
44
+ /** A sink write is currently outstanding (tests only). */
45
+ busy(): boolean {
46
+ return this.inFlight;
47
+ }
48
+
49
+ private pump(): void {
50
+ if (this.inFlight || this.queue.length === 0) return;
51
+ const chunks = this.queue;
52
+ this.queue = [];
53
+ const data = chunks.length === 1 ? chunks[0]! : concatBytes(chunks);
54
+ this.inFlight = true;
55
+ let acked = false;
56
+ this.sink(data, () => {
57
+ if (acked) return; // a double-fired callback must not unleash overlap
58
+ acked = true;
59
+ this.inFlight = false;
60
+ this.onAck?.();
61
+ this.pump();
62
+ });
63
+ }
64
+ }
65
+
66
+ /** Join byte chunks into one contiguous array (exported for tests). */
67
+ export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
68
+ let total = 0;
69
+ for (const c of chunks) total += c.length;
70
+ const out = new Uint8Array(total);
71
+ let off = 0;
72
+ for (const c of chunks) {
73
+ out.set(c, off);
74
+ off += c.length;
75
+ }
76
+ return out;
77
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Per-pane agent chips for the unified app (M22.3) — PURE.
3
+ *
4
+ * The fleet payload (`tmux-ide team --json`) carries per-pane agent entries
5
+ * (`agents: PaneAgentEntry[]`, M22.1). This module is the io-free half of the
6
+ * Terminal surface's pane chips: the paneId→entry join, the label builder, and
7
+ * the width-degrading truncation. The render side (app.tsx) styles the label
8
+ * with the app's own STATUS_COLOR/STATUS_GLYPH grammar — the OpenTUI-side
9
+ * mirror of the dock chip grammar in tui/chrome/chip.ts — so a pane chip in
10
+ * the app reads the same as its sidebar/home glyphs. The glyph travels IN as a
11
+ * parameter: this module carries no grammar constants of its own (they live
12
+ * with the app; see the status-grammar coordination note in the M22 board).
13
+ *
14
+ * Staleness is NOT re-implemented here: the report already applies the
15
+ * authority staleness window and falls back to scraping, so `state` is always
16
+ * the final answer — this module only joins and formats.
17
+ */
18
+ import type { AgentStatus } from "../detect/classify.ts";
19
+
20
+ /** The slice of a fleet `PaneAgentEntry` the chip needs (structural — the app
21
+ * declares its fleet shapes locally instead of importing the data layer). */
22
+ export interface ChipAgent {
23
+ /** tmux pane id (`%N`) — the mirror's pane ids are the same ids, so the join
24
+ * is a straight map lookup. */
25
+ paneId: string;
26
+ /** Resolved agent kind (manifest id: `claude`, `codex`, …). */
27
+ kind: string;
28
+ /** Final per-pane status (authority when fresh, else scraped/tracked). */
29
+ state: AgentStatus;
30
+ /** Authority epoch (SECONDS) when the authority layer supplied the state;
31
+ * null for scraped panes. Feeds the "blocked 4m" age suffix. */
32
+ since: number | null;
33
+ }
34
+
35
+ /**
36
+ * Flatten a fleet payload into a paneId→entry map. Tolerates sessions without
37
+ * an `agents` field (older payloads degrade to no chips, never a crash).
38
+ * First entry wins on a duplicate paneId (a session listed under two projects
39
+ * repeats its panes; pane ids are server-global so the entries are identical).
40
+ */
41
+ export function agentsByPane(
42
+ projects: ReadonlyArray<{
43
+ sessions: ReadonlyArray<{ agents?: ReadonlyArray<ChipAgent> }>;
44
+ }>,
45
+ ): Map<string, ChipAgent> {
46
+ const map = new Map<string, ChipAgent>();
47
+ for (const p of projects)
48
+ for (const s of p.sessions)
49
+ for (const a of s.agents ?? []) if (!map.has(a.paneId)) map.set(a.paneId, a);
50
+ return map;
51
+ }
52
+
53
+ /**
54
+ * Compact age of a state stamp — "32s" / "4m" / "2h" / "3d" — or null when
55
+ * there is no authoritative timestamp (scraped pane). Negative skew clamps to
56
+ * "0s" rather than inventing time travel.
57
+ */
58
+ export function stateAge(since: number | null, nowMs: number): string | null {
59
+ if (since === null) return null;
60
+ const s = Math.max(0, Math.floor(nowMs / 1000 - since));
61
+ if (s < 60) return `${s}s`;
62
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
63
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
64
+ return `${Math.floor(s / 86400)}d`;
65
+ }
66
+
67
+ /**
68
+ * Build a chip label that fits `budget` cells (the printable text, WITHOUT the
69
+ * one-cell padding the renderer adds each side). Degrades in steps rather than
70
+ * mid-word ellipsis so a narrow pane still reads:
71
+ *
72
+ * `● claude · blocked 4m` blocked, authority age known
73
+ * `● claude · blocked` blocked, scraped (no stamp)
74
+ * `● claude` any other state (color carries the state)
75
+ * `●` narrow pane
76
+ * null no room at all (or a hidden-worthy budget < 1)
77
+ *
78
+ * Only `blocked` spells its state out — it is the attention state; everything
79
+ * else leans on the glyph color, matching the dock chips' quiet default.
80
+ */
81
+ export function chipLabel(
82
+ entry: ChipAgent,
83
+ glyph: string,
84
+ nowMs: number,
85
+ budget: number,
86
+ ): string | null {
87
+ const base = `${glyph} ${entry.kind}`;
88
+ const candidates: string[] = [];
89
+ if (entry.state === "blocked") {
90
+ const age = stateAge(entry.since, nowMs);
91
+ if (age) candidates.push(`${base} · blocked ${age}`);
92
+ candidates.push(`${base} · blocked`);
93
+ }
94
+ candidates.push(base, glyph);
95
+ for (const c of candidates) if (c.length <= budget) return c;
96
+ return null;
97
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The sidebar AGENTS section's pure model (M22.2) — PURE so it unit-tests
3
+ * without OpenTUI. app.tsx flattens the fleet payload's per-session
4
+ * `agents: PaneAgentEntry[]` (M22.1) into one fleet-wide list, sorts it
5
+ * attention-first, renders one row per agent (reusing app.tsx's existing
6
+ * STATUS_GLYPH / STATUS_COLOR grammar keyed by state — NOT reinvented here), and
7
+ * lets a click JUMP to the exact session/window/pane. Ordering, row-label
8
+ * truncation, state-age formatting, and the sidebar row hit-test all live here;
9
+ * the render/router just read them.
10
+ *
11
+ * The sort order mirrors {@link ../team/home.ts}'s `ROLLUP_ORDER` so the sidebar
12
+ * list and the header rollup chips agree on what "attention-first" means.
13
+ */
14
+ import type { AgentStatus } from "../detect/classify.ts";
15
+ import { ROLLUP_ORDER } from "../team/home.ts";
16
+
17
+ /** The per-agent fields the sidebar needs — a structural subset of the fleet
18
+ * payload's `PaneAgentEntry` (app.tsx keeps its fleet types local + io-free, so
19
+ * this narrower shape is what it flattens into). */
20
+ export interface AgentRowInput {
21
+ /** tmux pane id, e.g. `%5` — the exact pane a click focuses. */
22
+ paneId: string;
23
+ /** `window_index` of the tab this pane lives in — the window a click selects. */
24
+ windowIndex: number;
25
+ /** Owning session name — the workspace a click switches to. */
26
+ session: string;
27
+ /** Resolved agent kind (manifest id: `claude`, `codex`, …). */
28
+ kind: string;
29
+ /** Final per-pane status (drives the glyph + color, reusing the app grammar). */
30
+ state: AgentStatus;
31
+ /** Authority state's epoch-seconds stamp, or null for a scraped pane. */
32
+ since: number | null;
33
+ }
34
+
35
+ /** Attention-first rank: blocked, working, done, idle (from `ROLLUP_ORDER`), then
36
+ * `unknown` last. Lower sorts earlier. */
37
+ const STATE_RANK: Record<AgentStatus, number> = (() => {
38
+ const rank = {} as Record<AgentStatus, number>;
39
+ ROLLUP_ORDER.forEach((s, i) => (rank[s] = i));
40
+ rank.unknown = ROLLUP_ORDER.length;
41
+ return rank;
42
+ })();
43
+
44
+ /**
45
+ * PURE — order agents attention-first (blocked → working → done → idle →
46
+ * unknown), STABLE within a group so rows don't jitter between polls when states
47
+ * are unchanged (`Array.prototype.sort` is stable). Returns a new array; the
48
+ * input is untouched.
49
+ */
50
+ export function sortAgentRows<T extends { state: AgentStatus }>(agents: readonly T[]): T[] {
51
+ return [...agents].sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state]);
52
+ }
53
+
54
+ /**
55
+ * PURE — the label shown on a sidebar agent row: `"<kind> · <session>"`,
56
+ * truncated with a trailing ellipsis to at most `maxChars` columns (the sidebar
57
+ * is width-constrained and drag-resizable). `maxChars <= 0` yields "".
58
+ */
59
+ export function agentRowLabel(kind: string, session: string, maxChars: number): string {
60
+ const full = `${kind} · ${session}`;
61
+ if (maxChars <= 0) return "";
62
+ if (full.length <= maxChars) return full;
63
+ if (maxChars === 1) return "…";
64
+ return full.slice(0, maxChars - 1) + "…";
65
+ }
66
+
67
+ /**
68
+ * PURE — the AGENTS section header, `"agents · <count>"`, truncated to
69
+ * `maxChars`. The count is always shown so the header doubles as the tally the
70
+ * card asks for.
71
+ */
72
+ export function agentsHeaderLabel(count: number, maxChars: number): string {
73
+ const full = `agents · ${count}`;
74
+ if (maxChars <= 0) return "";
75
+ return full.length <= maxChars ? full : full.slice(0, Math.max(1, maxChars - 1)) + "…";
76
+ }
77
+
78
+ /** The quiet empty-state line shown under the header when no agents are running. */
79
+ export const AGENTS_EMPTY_LINE = "no agents running — panes running claude or codex show up here";
80
+
81
+ /**
82
+ * PURE — a compact state-age like `"blocked 4m"` from a `since` epoch-seconds
83
+ * stamp, for the hovered row. Returns null when there is no timestamp (a scraped
84
+ * pane) or the dwell rounds to zero — nothing worth showing. Granularity:
85
+ * seconds under a minute, then minutes, then hours.
86
+ */
87
+ export function agentAgeLabel(
88
+ state: AgentStatus,
89
+ since: number | null,
90
+ nowSec: number,
91
+ ): string | null {
92
+ if (since == null) return null;
93
+ const secs = Math.max(0, Math.floor(nowSec - since));
94
+ let span: string;
95
+ if (secs < 60) span = `${secs}s`;
96
+ else if (secs < 3600) span = `${Math.floor(secs / 60)}m`;
97
+ else span = `${Math.floor(secs / 3600)}h`;
98
+ return `${state} ${span}`;
99
+ }
100
+
101
+ /** Blank rows the render leaves between the session list and the agents header
102
+ * (the agents section's `marginTop`). Kept here so `sidebarHit` and the render
103
+ * agree on the row the header sits on — a mismatch lands clicks one row off. */
104
+ export const AGENTS_GAP_ROWS = 1;
105
+
106
+ /** What a sidebar content row (a tab-bar-adjusted `gy`) targets. `agents-header`,
107
+ * the gap row(s), and the empty-state row are inert (the router does nothing). */
108
+ export type SidebarHit =
109
+ | { kind: "session"; index: number }
110
+ | { kind: "agent"; index: number }
111
+ | { kind: "agents-header" }
112
+ | null;
113
+
114
+ /**
115
+ * PURE — map a sidebar content row `gy` (already `y - TABBAR_H`) to what it
116
+ * targets, shared by the click router and the hover resolver so a click can
117
+ * never land where a row isn't drawn. Layout, top to bottom: `gy 0` title,
118
+ * `gy 1` rule, then `sessionCount` session rows from `gy 2`, then
119
+ * `AGENTS_GAP_ROWS` blank row(s), then the agents section — one header row, then
120
+ * `agentCount` agent rows (or a single inert empty-state row when
121
+ * `agentCount === 0`).
122
+ */
123
+ export function sidebarHit(gy: number, sessionCount: number, agentCount: number): SidebarHit {
124
+ if (gy < 2) return null;
125
+ const si = gy - 2;
126
+ if (si < sessionCount) return { kind: "session", index: si };
127
+ const headerGy = 2 + sessionCount + AGENTS_GAP_ROWS;
128
+ if (gy < headerGy) return null; // the gap row(s) between sessions and agents
129
+ if (gy === headerGy) return { kind: "agents-header" };
130
+ const ai = gy - headerGy - 1;
131
+ if (agentCount === 0 || ai < 0 || ai >= agentCount) return null;
132
+ return { kind: "agent", index: ai };
133
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Persisted app state for the unified IDE (M18.4) — the one small JSON that
3
+ * makes the app remember where you were: the last surface TAB, the workspace
4
+ * CONTEXT session, and the file that was open in the editor / selected in the
5
+ * diff panel.
6
+ *
7
+ * PURE parse/serialize live here so they unit-test as tables (like
8
+ * {@link ./editor-buffer.ts} / {@link ./diff-model.ts}); the io wrappers
9
+ * ({@link loadAppState}/{@link saveAppState}) are thin. The file lives at
10
+ * `~/.tmux-ide/app-state.json`, overridable via `TMUX_IDE_HOME` (tests /
11
+ * per-run isolation point the whole home elsewhere).
12
+ *
13
+ * {@link parseAppState} is TOLERANT: a missing file, malformed JSON, or a
14
+ * mistyped field each falls back to its default and it never throws — the same
15
+ * discipline as {@link ../../lib/app-config.ts}. Restoring stale state must
16
+ * never crash the launch.
17
+ */
18
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
19
+ import { writeFile } from "node:fs/promises";
20
+ import { homedir } from "node:os";
21
+ import { dirname, join } from "node:path";
22
+
23
+ /** The four top-level surfaces. `terminal` is the SessionMirror, `files` the
24
+ * editor + file list, `diff` the git panel, `home` the fleet cockpit. */
25
+ export type Tab = "home" | "terminal" | "files" | "diff";
26
+
27
+ const TABS: readonly Tab[] = ["home", "terminal", "files", "diff"];
28
+
29
+ /** PURE — is `x` one of the four tab keys? */
30
+ export function isTab(x: unknown): x is Tab {
31
+ return typeof x === "string" && (TABS as readonly string[]).includes(x);
32
+ }
33
+
34
+ /** Sidebar-width bounds (M19.3). The user drags the sidebar/main boundary; the
35
+ * resulting width is clamped to this range and persisted. `DEFAULT` is the
36
+ * historical fixed width. */
37
+ export const SIDEBAR_W_MIN = 16;
38
+ export const SIDEBAR_W_MAX = 48;
39
+ export const SIDEBAR_W_DEFAULT = 24;
40
+
41
+ /** PURE — clamp a candidate sidebar width to `[SIDEBAR_W_MIN, SIDEBAR_W_MAX]`,
42
+ * falling back to the default for a non-finite value. */
43
+ export function clampSidebarWidth(w: number): number {
44
+ if (!Number.isFinite(w)) return SIDEBAR_W_DEFAULT;
45
+ return Math.max(SIDEBAR_W_MIN, Math.min(SIDEBAR_W_MAX, Math.round(w)));
46
+ }
47
+
48
+ /** How many recently-opened folders home remembers (M22.5). Oldest fall off. */
49
+ export const RECENTS_CAP = 8;
50
+
51
+ /** The persisted shape. `null` means "nothing remembered" for that slot. */
52
+ export interface AppState {
53
+ /** The tab the app was showing when it last saved. */
54
+ lastTab: Tab;
55
+ /** The workspace-context session name (drives terminal target + files/diff dir). */
56
+ contextSession: string | null;
57
+ /** Absolute path of the file open in the editor. */
58
+ openFile: string | null;
59
+ /** Repo-relative path of the file selected in the diff panel. */
60
+ diffFile: string | null;
61
+ /** The sidebar column width (M19.3), clamped to the bounds above. */
62
+ sidebarW: number;
63
+ /** Recently-opened folder paths (M22.5), most-recent first, deduped and
64
+ * capped at {@link RECENTS_CAP}. Home renders these under a "recent" header. */
65
+ recentFolders: string[];
66
+ }
67
+
68
+ export const DEFAULT_APP_STATE: AppState = {
69
+ lastTab: "home",
70
+ contextSession: null,
71
+ openFile: null,
72
+ diffFile: null,
73
+ sidebarW: SIDEBAR_W_DEFAULT,
74
+ recentFolders: [],
75
+ };
76
+
77
+ /** PURE — the recents list after opening `dir`: it moves to the front,
78
+ * any earlier occurrence is removed (dedupe), and the tail past `cap` drops.
79
+ * Blank paths are ignored (return the list unchanged). */
80
+ export function addRecentFolder(
81
+ list: readonly string[],
82
+ dir: string,
83
+ cap: number = RECENTS_CAP,
84
+ ): string[] {
85
+ if (dir.length === 0) return [...list];
86
+ return [dir, ...list.filter((d) => d !== dir)].slice(0, cap);
87
+ }
88
+
89
+ /** PURE — a persisted recents value coerced to clean strings: non-empty,
90
+ * deduped (first wins), capped. Anything not a string[] yields []. */
91
+ function sanitizeRecents(v: unknown): string[] {
92
+ if (!Array.isArray(v)) return [];
93
+ const out: string[] = [];
94
+ for (const item of v) {
95
+ if (typeof item === "string" && item.length > 0 && !out.includes(item)) out.push(item);
96
+ if (out.length >= RECENTS_CAP) break;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** The tmux-ide home dir: `TMUX_IDE_HOME` when set, else `~/.tmux-ide`. */
102
+ export function appStateHome(): string {
103
+ return process.env.TMUX_IDE_HOME ?? join(homedir(), ".tmux-ide");
104
+ }
105
+
106
+ /** Absolute path to `app-state.json` under {@link appStateHome}. */
107
+ export function appStatePath(): string {
108
+ return join(appStateHome(), "app-state.json");
109
+ }
110
+
111
+ /** A string field that must be non-empty, else `null`. */
112
+ function optString(v: unknown): string | null {
113
+ return typeof v === "string" && v.length > 0 ? v : null;
114
+ }
115
+
116
+ /**
117
+ * PURE — parse a raw JSON string into a fully-populated {@link AppState}. Any
118
+ * missing/mistyped field falls back to its default; invalid JSON yields the
119
+ * defaults. Never throws.
120
+ */
121
+ export function parseAppState(raw: string): AppState {
122
+ let obj: Record<string, unknown>;
123
+ try {
124
+ const parsed = JSON.parse(raw);
125
+ if (!parsed || typeof parsed !== "object") return { ...DEFAULT_APP_STATE };
126
+ obj = parsed as Record<string, unknown>;
127
+ } catch {
128
+ return { ...DEFAULT_APP_STATE };
129
+ }
130
+ return {
131
+ lastTab: isTab(obj.lastTab) ? obj.lastTab : DEFAULT_APP_STATE.lastTab,
132
+ contextSession: optString(obj.contextSession),
133
+ openFile: optString(obj.openFile),
134
+ diffFile: optString(obj.diffFile),
135
+ sidebarW:
136
+ typeof obj.sidebarW === "number"
137
+ ? clampSidebarWidth(obj.sidebarW)
138
+ : DEFAULT_APP_STATE.sidebarW,
139
+ recentFolders: sanitizeRecents(obj.recentFolders),
140
+ };
141
+ }
142
+
143
+ /** PURE — serialize an {@link AppState} to the exact four-key JSON we persist
144
+ * (extra runtime keys are dropped; stable key order for tidy diffs). */
145
+ export function serializeAppState(state: AppState): string {
146
+ const clean: AppState = {
147
+ lastTab: isTab(state.lastTab) ? state.lastTab : "home",
148
+ contextSession: optString(state.contextSession),
149
+ openFile: optString(state.openFile),
150
+ diffFile: optString(state.diffFile),
151
+ sidebarW: clampSidebarWidth(state.sidebarW),
152
+ recentFolders: sanitizeRecents(state.recentFolders),
153
+ };
154
+ return JSON.stringify(clean, null, 2);
155
+ }
156
+
157
+ /** Read the persisted state (one-shot at launch — NOT on the render loop).
158
+ * Missing/unreadable file → defaults. */
159
+ export function loadAppState(): AppState {
160
+ const path = appStatePath();
161
+ if (!existsSync(path)) return { ...DEFAULT_APP_STATE };
162
+ try {
163
+ return parseAppState(readFileSync(path, "utf8"));
164
+ } catch {
165
+ return { ...DEFAULT_APP_STATE };
166
+ }
167
+ }
168
+
169
+ /** Write the state asynchronously (debounced by the caller), creating the home
170
+ * dir if needed. Swallows io errors — persistence is best-effort. */
171
+ export async function saveAppState(state: AppState): Promise<void> {
172
+ const path = appStatePath();
173
+ try {
174
+ mkdirSync(dirname(path), { recursive: true });
175
+ await writeFile(path, serializeAppState(state));
176
+ } catch {
177
+ // best-effort: a failed save must never disturb the running app
178
+ }
179
+ }