tmux-ide 2.6.0 → 2.6.1

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 (209) hide show
  1. package/bin/cli.js +13 -5
  2. package/bin/cli.ts +1 -1
  3. package/bunfig.toml +4 -0
  4. package/package.json +11 -3
  5. package/packages/contracts/package.json +22 -0
  6. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  7. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  8. package/packages/contracts/src/actions-contract.ts +310 -0
  9. package/packages/contracts/src/actions-errors.ts +41 -0
  10. package/packages/contracts/src/domain.ts +36 -0
  11. package/packages/contracts/src/ide-config.ts +170 -0
  12. package/packages/contracts/src/index.ts +24 -0
  13. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  14. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  15. package/packages/contracts/src/terminals.ts +116 -0
  16. package/packages/contracts/src/tmux.ts +60 -0
  17. package/packages/contracts/src/workspace.ts +67 -0
  18. package/packages/daemon/src/agent-explain.ts +270 -0
  19. package/packages/daemon/src/attach.ts +20 -0
  20. package/packages/daemon/src/bin.ts +4 -0
  21. package/packages/daemon/src/canonical.ts +7 -0
  22. package/packages/daemon/src/cli.ts +499 -0
  23. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  24. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  25. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  26. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  27. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  28. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  29. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  30. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  31. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  32. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  33. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  34. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  35. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  36. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  37. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  38. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  39. package/packages/daemon/src/command-center/discovery.ts +96 -0
  40. package/packages/daemon/src/command-center/index.ts +31 -0
  41. package/packages/daemon/src/command-center/schemas.ts +85 -0
  42. package/packages/daemon/src/command-center/server.ts +1260 -0
  43. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  44. package/packages/daemon/src/config.ts +549 -0
  45. package/packages/daemon/src/detect.ts +248 -0
  46. package/packages/daemon/src/doctor.ts +242 -0
  47. package/packages/daemon/src/embed.ts +5 -0
  48. package/packages/daemon/src/index.ts +12 -0
  49. package/packages/daemon/src/init.ts +211 -0
  50. package/packages/daemon/src/inspect.ts +178 -0
  51. package/packages/daemon/src/js-yaml.d.ts +10 -0
  52. package/packages/daemon/src/launch.ts +349 -0
  53. package/packages/daemon/src/lib/active-projects.ts +49 -0
  54. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  55. package/packages/daemon/src/lib/app-config.ts +336 -0
  56. package/packages/daemon/src/lib/app-settings.ts +53 -0
  57. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  58. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  59. package/packages/daemon/src/lib/auth/types.ts +2 -0
  60. package/packages/daemon/src/lib/auth-token.ts +5 -0
  61. package/packages/daemon/src/lib/authorship.ts +280 -0
  62. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  63. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  64. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  65. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  66. package/packages/daemon/src/lib/daemon.ts +79 -0
  67. package/packages/daemon/src/lib/dot-path.ts +17 -0
  68. package/packages/daemon/src/lib/errors.ts +67 -0
  69. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  70. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  71. package/packages/daemon/src/lib/log.ts +134 -0
  72. package/packages/daemon/src/lib/output.ts +76 -0
  73. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  74. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  75. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  76. package/packages/daemon/src/lib/project-probe.ts +92 -0
  77. package/packages/daemon/src/lib/project-registry.ts +296 -0
  78. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  79. package/packages/daemon/src/lib/session-options.ts +100 -0
  80. package/packages/daemon/src/lib/shell.ts +8 -0
  81. package/packages/daemon/src/lib/sizes.ts +36 -0
  82. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  83. package/packages/daemon/src/lib/slugify.ts +10 -0
  84. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  85. package/packages/daemon/src/lib/update-check.ts +298 -0
  86. package/packages/daemon/src/lib/update.ts +158 -0
  87. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  88. package/packages/daemon/src/lib/worktree.ts +289 -0
  89. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  90. package/packages/daemon/src/ls.ts +40 -0
  91. package/packages/daemon/src/restart.ts +24 -0
  92. package/packages/daemon/src/restore.ts +514 -0
  93. package/packages/daemon/src/schemas/domain.ts +2 -0
  94. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  95. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  96. package/packages/daemon/src/schemas/index.ts +59 -0
  97. package/packages/daemon/src/schemas/inspect.ts +67 -0
  98. package/packages/daemon/src/schemas/registry.ts +55 -0
  99. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  100. package/packages/daemon/src/send.ts +171 -0
  101. package/packages/daemon/src/server/README.md +15 -0
  102. package/packages/daemon/src/server/index.ts +74 -0
  103. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  104. package/packages/daemon/src/server/standalone.ts +18 -0
  105. package/packages/daemon/src/server/ws-route.ts +483 -0
  106. package/packages/daemon/src/status.ts +59 -0
  107. package/packages/daemon/src/stop.ts +28 -0
  108. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  109. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  110. package/packages/daemon/src/terminal/README.md +92 -0
  111. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  112. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  113. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  114. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  115. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  116. package/packages/daemon/src/tui/chrome/notify.ts +195 -0
  117. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  118. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  119. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  120. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  121. package/packages/daemon/src/tui/chrome/updater.ts +428 -0
  122. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  123. package/packages/daemon/src/tui/compiled.ts +113 -0
  124. package/packages/daemon/src/tui/detect/classify.ts +193 -0
  125. package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
  126. package/packages/daemon/src/tui/detect/manifest.ts +184 -0
  127. package/packages/daemon/src/tui/detect/manifests.ts +226 -0
  128. package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
  129. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  130. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  131. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  132. package/packages/daemon/src/tui/main.ts +70 -0
  133. package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
  134. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  135. package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
  136. package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
  137. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  138. package/packages/daemon/src/tui/team/entry.ts +11 -0
  139. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  140. package/packages/daemon/src/tui/team/home.ts +170 -0
  141. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  142. package/packages/daemon/src/tui/team/input.ts +34 -0
  143. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  144. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  145. package/packages/daemon/src/tui/team/nav.ts +31 -0
  146. package/packages/daemon/src/tui/team/preview.ts +34 -0
  147. package/packages/daemon/src/tui/team/projects.ts +191 -0
  148. package/packages/daemon/src/tui/team/report.ts +73 -0
  149. package/packages/daemon/src/tui/team/sessions.ts +344 -0
  150. package/packages/daemon/src/tui/team/tree.ts +62 -0
  151. package/packages/daemon/src/types.ts +13 -0
  152. package/packages/daemon/src/ui/index.ts +32 -0
  153. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  154. package/packages/daemon/src/ui/types.ts +91 -0
  155. package/packages/daemon/src/ui/web/base.css +80 -0
  156. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  157. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  158. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  159. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  160. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  161. package/packages/daemon/src/ui/web/index.ts +27 -0
  162. package/packages/daemon/src/ui/web/render.ts +77 -0
  163. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  164. package/packages/daemon/src/validate.ts +217 -0
  165. package/packages/daemon/src/widgets/changes/README.md +3 -0
  166. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  167. package/packages/daemon/src/widgets/config/README.md +3 -0
  168. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  169. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  170. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  171. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  172. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  173. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  174. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  175. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  176. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  177. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  178. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  179. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  180. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  181. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  182. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  183. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  184. package/packages/daemon/src/widgets/preview/README.md +3 -0
  185. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  186. package/packages/daemon/src/widgets/resolve.ts +121 -0
  187. package/packages/daemon/src/widgets/setup/README.md +3 -0
  188. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  189. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  190. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  191. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  192. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  193. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  194. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  195. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  196. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  197. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  198. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  199. package/packages/tmux-bridge/package.json +22 -0
  200. package/packages/tmux-bridge/src/errors.ts +28 -0
  201. package/packages/tmux-bridge/src/index.ts +31 -0
  202. package/packages/tmux-bridge/src/monitor.ts +77 -0
  203. package/packages/tmux-bridge/src/panes.ts +136 -0
  204. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  205. package/packages/tmux-bridge/src/runner.ts +91 -0
  206. package/packages/tmux-bridge/src/sessions.ts +126 -0
  207. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  208. package/packages/tmux-bridge/src/targeting.ts +90 -0
  209. package/scripts/postinstall.js +26 -2
@@ -0,0 +1,1521 @@
1
+ /**
2
+ * The team TUI — a cockpit over every tmux session.
3
+ *
4
+ * An app shell: a persistent left SIDEBAR lists every registered project
5
+ * (including ones with no running session = "stopped"), and a MAIN pane shows
6
+ * the selected ("active") project's detail — its live tmux sessions plus a
7
+ * read-only preview of the active session's pane. Unregistered live sessions
8
+ * surface as ad-hoc project rows in the sidebar so nothing is hidden. Runs
9
+ * under bun (JSX via the @opentui/solid preload) and is spawned by
10
+ * `tmux-ide team`.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { parseArgs } from "node:util";
14
+ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
15
+ import { RGBA, TextAttributes, type MouseEvent } from "@opentui/core";
16
+ import { createSignal, createEffect, onMount, onCleanup, For, Show } from "solid-js";
17
+ import {
18
+ capturePane,
19
+ createDetachedSession,
20
+ hasSession,
21
+ killSession,
22
+ runTmux,
23
+ setSessionEnvironment,
24
+ splitPane,
25
+ } from "@tmux-ide/tmux-bridge";
26
+ import { createTheme } from "../../widgets/lib/theme.ts";
27
+ import { getAppConfig } from "../../lib/app-config.ts";
28
+ import { adoptSession } from "../chrome/statusline.ts";
29
+ import { previewLines } from "./preview.ts";
30
+ import { type TeamSession, type TeamWindow } from "./sessions.ts";
31
+ import { listTeamProjects, type TeamProject } from "./projects.ts";
32
+ import { registerProject, unregisterProject } from "../../lib/project-registry.ts";
33
+ import { createStatusTracker, type AgentStatus } from "../detect/classify.ts";
34
+ import { nextInput, suggestSessionName } from "./input.ts";
35
+ import { fuzzyFilter } from "./fuzzy.ts";
36
+ import { clampIndex, wrapIndex } from "./nav.ts";
37
+ import { type ActionId, loadKeymap, resolveAction } from "./keymap.ts";
38
+ import { matchGrammar } from "../../widgets/lib/grammar.ts";
39
+ import { HelpOverlay, type WidgetKey } from "../../widgets/lib/help-overlay.tsx";
40
+ import { isDoubleClick, type ClickRecord } from "./mouse.ts";
41
+ import { treeNodes, findCursor } from "./tree.ts";
42
+ import {
43
+ fleetRollup,
44
+ rollupChips,
45
+ isFleetEmpty,
46
+ emptyFleetActions,
47
+ panelForKey,
48
+ panelHints,
49
+ homeFooterHints,
50
+ pickerFooterHints,
51
+ } from "./home.ts";
52
+ import { PANEL_POPUPS, panelPopupCli, type PanelPopup } from "../chrome/panels.ts";
53
+
54
+ /**
55
+ * The team app's OWN keys, shown under the shared grammar in the `?` overlay —
56
+ * the configurable keymap actions minus the universal verbs (nav/enter/filter/
57
+ * help/quit) that the grammar already documents. Derived from the live keymap
58
+ * so a rebind in `~/.tmux-ide/team-keys.json` re-labels the overlay too.
59
+ */
60
+ const TEAM_WIDGET_ACTIONS: ActionId[] = [
61
+ "launch",
62
+ "new",
63
+ "rename",
64
+ "split",
65
+ "register",
66
+ "unregister",
67
+ "kill",
68
+ "refresh",
69
+ ];
70
+
71
+ // Theme pass-through: `bin/cli.ts` forwards the project's ide.yml `theme` as
72
+ // `--theme=<json>`. A malformed value must never crash the cockpit, so the
73
+ // parse is guarded and falls back to the default theme.
74
+ const { values: argv } = parseArgs({ options: { theme: { type: "string" } } });
75
+ function parseThemeArg(raw: string | undefined): Record<string, string> | undefined {
76
+ if (!raw) return undefined;
77
+ try {
78
+ return JSON.parse(raw);
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+ const themeConfig = parseThemeArg(argv.theme);
84
+
85
+ /**
86
+ * Resolve the tmux client the popup was invoked on, from INSIDE the popup.
87
+ *
88
+ * `display-message -p '#{client_name}'` uses the popup's inherited `$TMUX` /
89
+ * `$TMUX_PANE` to identify the invoking client — it returns the right client
90
+ * even with several attached to the same session (verified live on tmux 3.6).
91
+ * Must be called BEFORE `$TMUX` is cleared. Returns null on any failure, in
92
+ * which case the switcher falls back to a client-less `switch-client` (which
93
+ * still targets the current client when `$TMUX` is intact).
94
+ */
95
+ function resolvePopupClient(): string | null {
96
+ try {
97
+ const name = runTmux(["display-message", "-p", "#{client_name}"]).toString().trim();
98
+ return name.length > 0 ? name : null;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ // Fixed sidebar width in columns — narrow enough to leave the detail pane room
105
+ // on an 80-col terminal, wide enough for a project name + session count.
106
+ const SIDEBAR_WIDTH = 34;
107
+
108
+ function toRGBA(c: { r: number; g: number; b: number; a: number }): RGBA {
109
+ return RGBA.fromInts(c.r, c.g, c.b, c.a);
110
+ }
111
+
112
+ const STATUS: Record<AgentStatus, { glyph: string; label: string }> = {
113
+ blocked: { glyph: "●", label: "blocked" },
114
+ working: { glyph: "●", label: "working" },
115
+ done: { glyph: "●", label: "done" },
116
+ idle: { glyph: "●", label: "idle" },
117
+ unknown: { glyph: "·", label: "unknown" },
118
+ };
119
+
120
+ /** Which inline text prompt is open (they all submit an action). */
121
+ type PromptKind = "register" | "newSession" | "rename";
122
+
123
+ /** Searchable label for the sidebar fuzzy filter — the project name. */
124
+ function projectName(project: TeamProject): string {
125
+ return project.name;
126
+ }
127
+
128
+ /** Prefix shown on the inline prompt line for each prompt kind. */
129
+ function promptLabel(kind: PromptKind): string {
130
+ if (kind === "register") return "register dir:";
131
+ if (kind === "newSession") return "new session:";
132
+ return "rename to:";
133
+ }
134
+
135
+ render(() => {
136
+ const theme = createTheme(themeConfig, getAppConfig().theme);
137
+ // One tracker persists across refreshes so the cross-tick `done` state
138
+ // (working→idle without being viewed) can be inferred.
139
+ const tracker = createStatusTracker();
140
+ // The dir the user actually ran `tmux-ide` from. The CLI spawns this widget
141
+ // with cwd set to the repo root (for the bun JSX preload), so it forwards the
142
+ // real cwd via env; fall back to process.cwd() when run directly.
143
+ const invokeCwd = process.env.TMUX_IDE_CWD ?? process.cwd();
144
+ // Two ways the cockpit runs inside a tmux `display-popup` over a client:
145
+ // - PICKER mode (`M-p`): `TMUX_IDE_PICKER_CLIENT` present → the compact
146
+ // single-column switcher.
147
+ // - HOME-POPUP mode (`M-h`): `TMUX_IDE_POPUP_CLIENT` present → the FULL
148
+ // two-column home, floated over the session instead of owning the terminal.
149
+ // Both share "SWITCH mode": Enter `switch-client`s the invoking client + exits
150
+ // (closing the popup) instead of attaching in place, and a bare Esc closes.
151
+ //
152
+ // Each env var's PRESENCE flips its mode on; its VALUE is an optional explicit
153
+ // client name. When empty we resolve the invoking client ourselves from inside
154
+ // the popup — `display-message -p '#{client_name}'` returns it correctly
155
+ // (verified live on tmux 3.6), unlike a `#{client_name}` in the bind command,
156
+ // which does not format-expand. We must resolve BEFORE clearing $TMUX
157
+ // (display-message needs the popup's tmux env to know which client it's on).
158
+ // Like host mode we then clear $TMUX so fleet queries hit the DEFAULT server;
159
+ // the explicit `-c <client>` on switch-client keeps the switch correct.
160
+ const pickerClientEnv = process.env.TMUX_IDE_PICKER_CLIENT ?? null;
161
+ const popupClientEnv = process.env.TMUX_IDE_POPUP_CLIENT ?? null;
162
+ const pickerMode = pickerClientEnv !== null;
163
+ // Home-popup only when it's not also picker (picker wins if both are somehow set).
164
+ const popupHomeMode = popupClientEnv !== null && !pickerMode;
165
+ const switchMode = pickerMode || popupHomeMode;
166
+ let switchClient: string | null = null;
167
+ if (switchMode) {
168
+ const explicit = pickerClientEnv ?? popupClientEnv ?? "";
169
+ switchClient = explicit.length > 0 ? explicit : resolvePopupClient();
170
+ delete process.env.TMUX;
171
+ }
172
+ // Only the picker renders the compact single-column layout; home-popup keeps
173
+ // the full two-column app (just with switch-on-Enter behaviour).
174
+ const compactMode = pickerMode;
175
+ // Shared with the tmux chrome via the app theme tokens (blocked red, working
176
+ // amber, done blue, idle green) so the cockpit matches the status bar palette.
177
+ const statusColor: Record<AgentStatus, RGBA> = {
178
+ blocked: toRGBA(theme.statusBlocked),
179
+ working: toRGBA(theme.statusWorking),
180
+ done: toRGBA(theme.statusDone),
181
+ idle: toRGBA(theme.statusIdle),
182
+ unknown: toRGBA(theme.statusUnknown),
183
+ };
184
+
185
+ // Central keymap (defaults + optional ~/.tmux-ide/team-keys.json overrides),
186
+ // resolved once at startup — the key handler routes through it.
187
+ const keymap = loadKeymap();
188
+
189
+ // The OpenTUI renderer, captured once so the attach/launch paths can hand the
190
+ // terminal to a full-screen child and take it back afterwards.
191
+ const renderer = useRenderer();
192
+
193
+ const [projects, setProjects] = createSignal<TeamProject[]>(listTeamProjects(tracker));
194
+ // The two shell cursors: the active PROJECT (index into the visible project
195
+ // list — filtered while filtering, else all) and the active SESSION (index
196
+ // into the active project's sessions, default 0).
197
+ const [activeProject, setActiveProject] = createSignal(0);
198
+ const [activeSession, setActiveSession] = createSignal(0);
199
+ // Picker-only third cursor tier: which WINDOW (tab) of the active session is
200
+ // selected. `-1` = the session row itself (no window), `0..n-1` = a window
201
+ // row. Standalone never sets it past -1 (its window lines are read-only).
202
+ const [activeWindow, setActiveWindow] = createSignal(-1);
203
+ // Whether the `?` keybindings overlay is showing.
204
+ const [helpOpen, setHelpOpen] = createSignal(false);
205
+ // The single inline text prompt (register dir / new session / rename). null =
206
+ // no prompt open. Its submit action switches on `kind`.
207
+ const [prompt, setPrompt] = createSignal<{ kind: PromptKind; value: string } | null>(null);
208
+ // Target captured when the new-session / rename prompt opens, so the async
209
+ // submit stays correct even if the 2s refresh moves the selection.
210
+ const [newSessionDir, setNewSessionDir] = createSignal(invokeCwd);
211
+ const [renameTarget, setRenameTarget] = createSignal("");
212
+ // Pending destructive-action confirmation (e.g. kill); intercepts y/n.
213
+ const [confirm, setConfirm] = createSignal<{ message: string; onYes: () => void } | null>(null);
214
+ // Transient status line (errors / confirmations); lingers until next action.
215
+ const [message, setMessage] = createSignal("");
216
+ // Quick-jump fuzzy filter (`/`): narrows the visible SIDEBAR projects as you type.
217
+ const [filterMode, setFilterMode] = createSignal(false);
218
+ const [filterQuery, setFilterQuery] = createSignal("");
219
+ // Live preview of the active session's active pane (read-only mirror).
220
+ const dimensions = useTerminalDimensions();
221
+ const [preview, setPreview] = createSignal<string[]>([]);
222
+ const [previewTitle, setPreviewTitle] = createSignal("");
223
+
224
+ /** The sidebar's project list: fuzzy-filtered while filtering, else all. */
225
+ const visibleProjects = () =>
226
+ filterMode()
227
+ ? fuzzyFilter(filterQuery(), projects(), projectName).map((m) => m.item)
228
+ : projects();
229
+
230
+ /** The active project (into the visible list), or undefined when empty. */
231
+ const activeProj = (): TeamProject | undefined => visibleProjects()[activeProject()];
232
+ /** The active session within the active project, or undefined when none. */
233
+ const activeSess = (): TeamSession | undefined => activeProj()?.sessions[activeSession()];
234
+ /** The selected window of the active session (picker only), or undefined. */
235
+ const activeWin = (): TeamWindow | undefined => {
236
+ const wi = activeWindow();
237
+ return wi >= 0 ? activeSess()?.windowList[wi] : undefined;
238
+ };
239
+
240
+ /**
241
+ * PICKER navigation is a single flat cursor over a THREE-tier row union:
242
+ * every project row, the active project's session rows, and the active
243
+ * session's window rows. This builds that ordered node list — the cursor is
244
+ * the `(activeProject, activeSession, activeWindow)` triple, where `si:-1`
245
+ * marks a project row and `wi:-1` a session (or project) row.
246
+ */
247
+ function pickerNodes(): Array<{ pi: number; si: number; wi: number }> {
248
+ return treeNodes(visibleProjects(), activeProject(), activeSession());
249
+ }
250
+
251
+ /** Move the picker's flat cursor by `delta`, wrapping across the row union. */
252
+ function movePicker(delta: number) {
253
+ const nodes = pickerNodes();
254
+ if (nodes.length === 0) return;
255
+ const cur = findCursor(nodes, {
256
+ pi: activeProject(),
257
+ si: activeSession(),
258
+ wi: activeWindow(),
259
+ });
260
+ const next = nodes[wrapIndex(cur >= 0 ? cur : 0, delta, nodes.length)]!;
261
+ setActiveProject(next.pi);
262
+ setActiveSession(next.si);
263
+ setActiveWindow(next.wi);
264
+ }
265
+
266
+ function refresh(viewed?: string) {
267
+ const next = listTeamProjects(tracker, viewed ? { viewed } : {});
268
+ setProjects(next);
269
+ // Clamp both cursors against the freshly-loaded lists so neither dangles.
270
+ const vis = filterMode()
271
+ ? fuzzyFilter(filterQuery(), next, projectName).map((m) => m.item)
272
+ : next;
273
+ const pi = clampIndex(activeProject(), vis.length);
274
+ setActiveProject(pi);
275
+ const si = clampIndex(activeSession(), vis[pi]?.sessions.length ?? 0);
276
+ setActiveSession(activeSession() < 0 ? -1 : si);
277
+ // Keep the window cursor valid: drop to the session row (-1) when it points
278
+ // past the (possibly shrunk) window list, so it never dangles after a tick.
279
+ const winCount = vis[pi]?.sessions[si]?.windowList.length ?? 0;
280
+ setActiveWindow((w) => (w >= 0 && w < winCount ? w : -1));
281
+ }
282
+
283
+ onMount(() => {
284
+ const timer = setInterval(refresh, 2000);
285
+ onCleanup(() => clearInterval(timer));
286
+ });
287
+
288
+ /** Select a project by its index in the visible list, resetting the session cursor. */
289
+ function selectProject(index: number) {
290
+ setActiveProject(index);
291
+ setActiveSession(0);
292
+ setActiveWindow(-1);
293
+ }
294
+
295
+ /** Which tmux session (if any) the preview should mirror for the selection. */
296
+ function previewTarget(): string | null {
297
+ const proj = activeProj();
298
+ if (!proj) return null;
299
+ const sess = activeSess() ?? (proj.running ? proj.sessions[0] : undefined);
300
+ return sess?.name ?? null;
301
+ }
302
+
303
+ /** Capture the active session's active pane and shape it to the preview box. */
304
+ function updatePreview() {
305
+ // The compact picker layout has no preview box (it's a transient popup), so
306
+ // skip the capture-pane work entirely (it runs on every selection and every
307
+ // 2s refresh).
308
+ if (compactMode) return;
309
+ const target = previewTarget();
310
+ if (!target) {
311
+ setPreview([]);
312
+ setPreviewTitle("");
313
+ return;
314
+ }
315
+ const dims = dimensions();
316
+ const budget = Math.max(5, dims.height - 10);
317
+ const width = Math.max(20, dims.width - SIDEBAR_WIDTH - 6);
318
+ try {
319
+ const raw = capturePane(target, { lines: budget });
320
+ setPreview(previewLines(raw, budget, width));
321
+ setPreviewTitle(target);
322
+ } catch {
323
+ setPreview([]);
324
+ }
325
+ }
326
+
327
+ // Keep the preview live: reruns on the active project/session, filter state,
328
+ // terminal resize, and the 2s refresh (which replaces projects() each tick).
329
+ createEffect(updatePreview);
330
+
331
+ /**
332
+ * Hand the host terminal to a full-screen child process (a `tmux attach`, or
333
+ * a nested `tmux-ide` launch) and take it back when the child returns.
334
+ *
335
+ * `renderer.suspend()` / `renderer.resume()` are OpenTUI 0.1.88's built-in
336
+ * pair for exactly this: `suspend()` stops the render loop, disables
337
+ * mouse/raw mode, detaches the stdin listener and calls the native
338
+ * `suspendRenderer` (which restores the host terminal — leaves the alt-screen
339
+ * and shows the cursor); `resume()` re-enables raw mode + the stdin listener,
340
+ * calls `resumeRenderer` (re-enters the alt-screen), clears the buffer and
341
+ * restarts the render loop. So the cockpit survives the hand-off and repaints
342
+ * itself once the child exits — no `process.exit`. `finally` guarantees we
343
+ * always resume even if the child throws.
344
+ */
345
+ function withSuspendedTerminal(fn: () => void) {
346
+ renderer.suspend();
347
+ try {
348
+ fn();
349
+ } finally {
350
+ renderer.resume();
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Attach the terminal to a session by name; returns to the cockpit after the
356
+ * user detaches (prefix+d) rather than exiting. A missing session or a normal
357
+ * detach both just return from `execFileSync`, so both land back here.
358
+ */
359
+ function attachSessionName(name: string) {
360
+ withSuspendedTerminal(() => {
361
+ try {
362
+ execFileSync("tmux", ["attach", "-t", name], { stdio: "inherit" });
363
+ } catch {
364
+ // detached or session gone — fall through
365
+ }
366
+ });
367
+ // Back in the cockpit: acknowledge the session we just viewed (clears any
368
+ // pending `done` for its panes) and repaint from fresh state.
369
+ refresh(name);
370
+ }
371
+
372
+ /**
373
+ * Best-effort: adopt a cockpit-created session into the native chrome (status
374
+ * bar + switcher popup + shared updater). Chrome is optional — a failure here
375
+ * must never block session creation.
376
+ */
377
+ function bestEffortAdopt(session: string) {
378
+ try {
379
+ adoptSession(session);
380
+ } catch {
381
+ // chrome is optional
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Open a widget PANEL (explorer / changes / config) from the home screen.
387
+ *
388
+ * The panel lands on the SELECTED project's dir when a project row is active,
389
+ * else the invoke cwd — so the explorer/changes/config open on whatever you're
390
+ * looking at. Two hand-offs, chosen by whether we're inside tmux:
391
+ *
392
+ * - INSIDE tmux: float the widget as a `display-popup -E` over the cockpit
393
+ * pane — identical to the root-table panel binds (`M-e`/`M-g`/`M-,`). The
394
+ * call blocks until the popup closes, then the cockpit repaints underneath.
395
+ * - OUTSIDE tmux (bare terminal): the cockpit owns the whole screen, so we
396
+ * suspend the renderer and run the widget full-screen IN PLACE via
397
+ * `tmux-ide popup <widget>` (which bun-spawns the widget with stdio
398
+ * inherited), resuming when it exits (esc/q). Same suspend/resume the
399
+ * attach path uses.
400
+ */
401
+ function openPanel(widget: PanelPopup["widget"]) {
402
+ const panel = PANEL_POPUPS.find((p) => p.widget === widget);
403
+ if (!panel) return;
404
+ const dir = activeProj()?.dir ?? invokeCwd;
405
+ if (process.env.TMUX) {
406
+ try {
407
+ runTmux([
408
+ "display-popup",
409
+ "-E",
410
+ "-d",
411
+ dir,
412
+ "-w",
413
+ panel.width,
414
+ "-h",
415
+ panel.height,
416
+ panelPopupCli(widget),
417
+ ]);
418
+ } catch (e) {
419
+ setMessage(String((e as { message?: string })?.message ?? e));
420
+ }
421
+ return;
422
+ }
423
+ withSuspendedTerminal(() => {
424
+ try {
425
+ execFileSync("tmux-ide", ["popup", widget], { cwd: dir, stdio: "inherit" });
426
+ } catch {
427
+ // widget exited nonzero or the hand-off failed — resume regardless
428
+ }
429
+ });
430
+ refresh();
431
+ }
432
+
433
+ /**
434
+ * SWITCH MODE (picker `M-p` or home-popup `M-h`): `switch-client` the invoking
435
+ * client to `sessionName`, then exit so tmux closes the popup. We pass
436
+ * `-c <switchClient>` explicitly — the one incantation that works from the
437
+ * popup regardless of `$TMUX` state (verified live). On failure keep the popup
438
+ * open with the error on the status line rather than exiting into a broken state.
439
+ */
440
+ function pickerSwitch(sessionName: string) {
441
+ try {
442
+ const args = ["switch-client"];
443
+ if (switchClient) args.push("-c", switchClient);
444
+ args.push("-t", sessionName);
445
+ runTmux(args);
446
+ } catch (e) {
447
+ setMessage(String((e as { message?: string })?.message ?? e));
448
+ return;
449
+ }
450
+ process.exit(0);
451
+ }
452
+
453
+ /**
454
+ * PICKER MODE: bring a stopped project up detached, then switch to it and
455
+ * exit. An `ide.yml` project gets its full layout via `launch(dir, { attach:
456
+ * false })`; a plain project just needs a bare detached session. Mirrors
457
+ * ends in `pickerSwitch` (no main pane to drive). An `ide.yml` launch adopts
458
+ * itself; a bare detached session is adopted here.
459
+ */
460
+ function pickerLaunchAndSwitch(project: TeamProject) {
461
+ // const-capture so the non-null narrowing survives into the closure below
462
+ const dir = project.dir;
463
+ if (!dir) return;
464
+ if (project.hasIdeYml) {
465
+ import("../../launch.ts")
466
+ .then(({ launch }) => launch(dir, { attach: false }))
467
+ .then(() => pickerSwitch(project.name))
468
+ .catch((e) => setMessage(String((e as { message?: string })?.message ?? e)));
469
+ return;
470
+ }
471
+ try {
472
+ createDetachedSession(project.name, dir);
473
+ // Flag cockpit-created sessions so agents inside can detect tmux-ide.
474
+ try {
475
+ setSessionEnvironment(project.name, "TMUX_IDE", "1");
476
+ } catch {}
477
+ bestEffortAdopt(project.name);
478
+ } catch (e) {
479
+ setMessage(String((e as { message?: string })?.message ?? e));
480
+ return;
481
+ }
482
+ pickerSwitch(project.name);
483
+ }
484
+
485
+ /**
486
+ * PICKER MODE enter: switch to the active project's live session, or launch
487
+ * the stopped project and switch to it. No preview / main pane / suspend —
488
+ * the popup only ever ends in a `switch-client` + exit.
489
+ */
490
+ function pickerEnter() {
491
+ const proj = activeProj();
492
+ if (!proj) return;
493
+ if (proj.running) {
494
+ const sess = activeSess() ?? proj.sessions[0];
495
+ if (sess) {
496
+ // A selected WINDOW row switches to that tab (`<session>:<index>`); a
497
+ // session (or project) row switches to the session as a whole.
498
+ const win = activeWin();
499
+ pickerSwitch(win ? `${sess.name}:${win.index}` : sess.name);
500
+ return;
501
+ }
502
+ }
503
+ pickerLaunchAndSwitch(proj);
504
+ }
505
+
506
+ /**
507
+ * Launch a project (standalone cockpit). When it has an `ide.yml`, run the
508
+ * full `tmux-ide` launch (builds the layout and attaches) — this blocks until
509
+ * the user detaches, so we refresh and stay in the cockpit rather than exit.
510
+ * Otherwise spin up a bare detached session so it appears in place.
511
+ */
512
+ function launchProject(project: TeamProject) {
513
+ // const-capture so the non-null narrowing survives into the closure below
514
+ const dir = project.dir;
515
+ if (!dir) return;
516
+ if (project.hasIdeYml) {
517
+ withSuspendedTerminal(() => {
518
+ try {
519
+ execFileSync("tmux-ide", [], { cwd: dir, stdio: "inherit" });
520
+ } catch {
521
+ // launch failed or user detached — fall through to refresh
522
+ }
523
+ });
524
+ refresh();
525
+ return;
526
+ }
527
+ try {
528
+ createDetachedSession(project.name, dir);
529
+ // Flag cockpit-created sessions so agents inside can detect tmux-ide.
530
+ try {
531
+ setSessionEnvironment(project.name, "TMUX_IDE", "1");
532
+ } catch {}
533
+ bestEffortAdopt(project.name);
534
+ setMessage(`launched ${project.name}`);
535
+ } catch (e) {
536
+ setMessage(String((e as { message?: string })?.message ?? e));
537
+ }
538
+ refresh();
539
+ }
540
+
541
+ // Last click on a project / session row, tracked so a second click on the
542
+ // same row within the double-click window activates it (mirrors Enter). Plain
543
+ // closure vars — they need no reactivity, they only feed the next click.
544
+ let lastProjectClick: ClickRecord | null = null;
545
+ let lastSessionClick: ClickRecord | null = null;
546
+ let lastWindowClick: ClickRecord | null = null;
547
+
548
+ /**
549
+ * A sidebar project row's mousedown: select it, and on a same-row
550
+ * double-click within the window, activate it via `enter()` (attach / launch).
551
+ */
552
+ function clickProject(index: number) {
553
+ const now = Date.now();
554
+ selectProject(index);
555
+ if (isDoubleClick(lastProjectClick, index, now)) {
556
+ lastProjectClick = null;
557
+ enter();
558
+ return;
559
+ }
560
+ lastProjectClick = { index, at: now };
561
+ }
562
+
563
+ /**
564
+ * A main-pane session row's mousedown: make it the active session, and on a
565
+ * same-row double-click attach it.
566
+ */
567
+ function clickSession(index: number) {
568
+ const now = Date.now();
569
+ setActiveSession(index);
570
+ setActiveWindow(-1);
571
+ if (isDoubleClick(lastSessionClick, index, now)) {
572
+ lastSessionClick = null;
573
+ const sess = activeProj()?.sessions[index];
574
+ if (sess) {
575
+ if (switchMode) {
576
+ pickerSwitch(sess.name);
577
+ } else {
578
+ attachSessionName(sess.name);
579
+ }
580
+ }
581
+ return;
582
+ }
583
+ lastSessionClick = { index, at: now };
584
+ }
585
+
586
+ /**
587
+ * PICKER: a window row's mousedown selects it (its parent session too), and
588
+ * on a same-row double-click switches the client to `<session>:<index>` and
589
+ * closes the popup — the window-scoped analogue of {@link clickSession}.
590
+ */
591
+ function clickWindow(sessionIndex: number, windowIndex: number) {
592
+ const now = Date.now();
593
+ setActiveSession(sessionIndex);
594
+ setActiveWindow(windowIndex);
595
+ // Composite key so a double-click is only counted within the same window row.
596
+ const key = sessionIndex * 10000 + windowIndex;
597
+ if (isDoubleClick(lastWindowClick, key, now)) {
598
+ lastWindowClick = null;
599
+ const sess = activeProj()?.sessions[sessionIndex];
600
+ const win = sess?.windowList[windowIndex];
601
+ if (sess && win) pickerSwitch(`${sess.name}:${win.index}`);
602
+ return;
603
+ }
604
+ lastWindowClick = { index: key, at: now };
605
+ }
606
+
607
+ /** Wheel over the sidebar moves the project selection, wrapping like the arrows. */
608
+ function scrollProjects(evt: MouseEvent) {
609
+ const dir = evt.scroll?.direction;
610
+ if (dir !== "up" && dir !== "down") return;
611
+ const n = visibleProjects().length;
612
+ if (n === 0) return;
613
+ setActiveProject((p) => wrapIndex(p, dir === "up" ? -1 : 1, n));
614
+ setActiveSession(0);
615
+ setActiveWindow(-1);
616
+ }
617
+
618
+ /**
619
+ * Enter on the active project: when it's running, suspend + attach in place;
620
+ * when stopped, launch it. In picker mode this instead switch-clients + exits.
621
+ */
622
+ function enter() {
623
+ if (switchMode) {
624
+ pickerEnter();
625
+ return;
626
+ }
627
+ const proj = activeProj();
628
+ if (!proj) return;
629
+ if (proj.running) {
630
+ const sess = activeSess() ?? proj.sessions[0];
631
+ if (sess) {
632
+ attachSessionName(sess.name);
633
+ return;
634
+ }
635
+ }
636
+ launchProject(proj);
637
+ }
638
+
639
+ /** Gate a kill behind a y/n confirm: the active session, or a running project's sessions. */
640
+ function requestKill() {
641
+ const proj = activeProj();
642
+ if (!proj) return;
643
+ const sess = activeSess();
644
+ if (sess) {
645
+ setMessage("");
646
+ setConfirm({
647
+ message: `kill ${sess.name}? (y/n)`,
648
+ onYes: () => {
649
+ killSession(sess.name);
650
+ refresh();
651
+ },
652
+ });
653
+ return;
654
+ }
655
+ // No session cursor (empty project) but running — kill all its sessions.
656
+ if (proj.running) {
657
+ setMessage("");
658
+ setConfirm({
659
+ message: `kill ${proj.name}? (y/n)`,
660
+ onYes: () => {
661
+ for (const s of proj.sessions) killSession(s.name);
662
+ refresh();
663
+ },
664
+ });
665
+ }
666
+ }
667
+
668
+ /** Unregister the active project when it's registered and stopped — never orphan a live session. */
669
+ function unregister() {
670
+ const proj = activeProj();
671
+ if (!proj || !proj.registered || proj.running) return;
672
+ try {
673
+ unregisterProject(proj.name);
674
+ setMessage(`unregistered ${proj.name}`);
675
+ } catch (e) {
676
+ setMessage(String((e as { message?: string })?.message ?? e));
677
+ }
678
+ refresh();
679
+ }
680
+
681
+ /** Open the register-dir prompt seeded with the current working directory. */
682
+ function openRegister() {
683
+ setMessage("");
684
+ setPrompt({ kind: "register", value: invokeCwd });
685
+ }
686
+
687
+ /** Open the new-session prompt in the active project's dir, seeded with a unique name. */
688
+ function openNewSession() {
689
+ const proj = activeProj();
690
+ const base = proj ? proj.name : "session";
691
+ const dir = (proj ? proj.dir : null) ?? invokeCwd;
692
+ setNewSessionDir(dir);
693
+ setMessage("");
694
+ setPrompt({ kind: "newSession", value: suggestSessionName(base, hasSession) });
695
+ }
696
+
697
+ /** Open the rename prompt when the active session resolves to a live session. */
698
+ function openRename() {
699
+ const target = previewTarget();
700
+ if (!target) return;
701
+ setRenameTarget(target);
702
+ setMessage("");
703
+ setPrompt({ kind: "rename", value: target });
704
+ }
705
+
706
+ /** Split the active session's active pane (right, 50%). */
707
+ function splitSelected() {
708
+ const target = previewTarget();
709
+ if (!target) return;
710
+ const dir = activeProj()?.dir ?? invokeCwd;
711
+ try {
712
+ splitPane(target, "horizontal", dir, 50);
713
+ setMessage(`split ${target}`);
714
+ } catch (e) {
715
+ setMessage(String((e as { message?: string })?.message ?? e));
716
+ }
717
+ refresh();
718
+ }
719
+
720
+ /** Dispatch the open prompt's submit action by kind. */
721
+ function submitPrompt() {
722
+ const p = prompt();
723
+ if (!p) return;
724
+ if (p.kind === "register") {
725
+ // registerProject is async; resolve/clear after it lands.
726
+ registerProject({ dir: p.value.trim() })
727
+ .then(() => {
728
+ setPrompt(null);
729
+ setMessage("registered");
730
+ refresh();
731
+ })
732
+ .catch((e) => setMessage(String((e as { message?: string })?.message ?? e)));
733
+ return;
734
+ }
735
+ if (p.kind === "newSession") {
736
+ const name = p.value.trim();
737
+ if (!name) {
738
+ setMessage("session name required");
739
+ return;
740
+ }
741
+ try {
742
+ createDetachedSession(name, newSessionDir());
743
+ // Flag cockpit-created sessions so agents inside can detect tmux-ide.
744
+ try {
745
+ setSessionEnvironment(name, "TMUX_IDE", "1");
746
+ } catch {}
747
+ bestEffortAdopt(name);
748
+ setMessage(`created ${name}`);
749
+ } catch (e) {
750
+ setMessage(String((e as { message?: string })?.message ?? e));
751
+ }
752
+ setPrompt(null);
753
+ refresh();
754
+ return;
755
+ }
756
+ // rename
757
+ const oldName = renameTarget();
758
+ const newName = p.value.trim();
759
+ if (!oldName || !newName || newName === oldName) {
760
+ setPrompt(null);
761
+ return;
762
+ }
763
+ try {
764
+ runTmux(["rename-session", "-t", oldName, newName]);
765
+ setMessage(`renamed ${oldName} → ${newName}`);
766
+ } catch (e) {
767
+ setMessage(String((e as { message?: string })?.message ?? e));
768
+ }
769
+ setPrompt(null);
770
+ refresh();
771
+ }
772
+
773
+ useKeyboard((evt) => {
774
+ // Destructive-action confirm swallows all keys: y runs it, anything else cancels.
775
+ if (confirm()) {
776
+ const c = confirm()!;
777
+ setConfirm(null);
778
+ if (evt.name === "y") {
779
+ c.onYes();
780
+ refresh();
781
+ }
782
+ return;
783
+ }
784
+
785
+ // Inline text prompt swallows all keys while open.
786
+ if (prompt()) {
787
+ if (evt.name === "escape") {
788
+ setPrompt(null);
789
+ return;
790
+ }
791
+ if (evt.name === "return") {
792
+ submitPrompt();
793
+ return;
794
+ }
795
+ const next = nextInput(prompt()!.value, evt);
796
+ if (next !== null) setPrompt({ ...prompt()!, value: next });
797
+ return;
798
+ }
799
+
800
+ // Filter prompt intercepts navigation while open — it narrows the sidebar.
801
+ if (filterMode()) {
802
+ if (evt.name === "escape") {
803
+ setFilterMode(false);
804
+ setFilterQuery("");
805
+ setActiveProject(0);
806
+ setActiveSession(0);
807
+ setActiveWindow(-1);
808
+ return;
809
+ }
810
+ if (evt.name === "return") {
811
+ // Act on the filtered active project, then drop the filter and re-anchor
812
+ // the cursor onto that same project in the full list.
813
+ const proj = activeProj();
814
+ enter();
815
+ setFilterMode(false);
816
+ setFilterQuery("");
817
+ const idx = proj ? projects().findIndex((p) => p.name === proj.name) : -1;
818
+ setActiveProject(idx >= 0 ? idx : 0);
819
+ setActiveSession(0);
820
+ return;
821
+ }
822
+ const fn = visibleProjects().length;
823
+ if (evt.name === "up" || evt.name === "k") {
824
+ if (fn > 0) setActiveProject((s) => wrapIndex(s, -1, fn));
825
+ setActiveSession(0);
826
+ setActiveWindow(-1);
827
+ return;
828
+ }
829
+ if (evt.name === "down" || evt.name === "j") {
830
+ if (fn > 0) setActiveProject((s) => wrapIndex(s, 1, fn));
831
+ setActiveSession(0);
832
+ setActiveWindow(-1);
833
+ return;
834
+ }
835
+ const next = nextInput(filterQuery(), evt);
836
+ if (next !== null) {
837
+ setFilterQuery(next);
838
+ setActiveProject(0);
839
+ setActiveSession(0);
840
+ setActiveWindow(-1);
841
+ }
842
+ return;
843
+ }
844
+
845
+ // The help overlay swallows keys: esc / q / ? close it (grammar dismiss/quit/help).
846
+ if (helpOpen()) {
847
+ const g = matchGrammar(evt);
848
+ if (g === "dismiss" || g === "quit" || g === "help") setHelpOpen(false);
849
+ return;
850
+ }
851
+
852
+ // ctrl+c always quits, independent of the (rebindable) quit key.
853
+ if (evt.ctrl && evt.name === "c") {
854
+ process.exit(0);
855
+ }
856
+
857
+ // Switch mode (picker / home-popup): Esc just closes the popup. This sits
858
+ // AFTER the modal guards above (confirm / prompt / filter / help), so those
859
+ // still consume Esc to dismiss themselves first — only a "bare" Esc closes it.
860
+ if (switchMode && evt.name === "escape") {
861
+ process.exit(0);
862
+ }
863
+
864
+ const n = visibleProjects().length;
865
+
866
+ // The shared interaction grammar takes PRECEDENCE over the configurable
867
+ // keymap: the universal verbs (nav/enter/filter/help/quit + esc) mean the
868
+ // same here as in every widget. Their default keys agree with DEFAULT_KEYMAP,
869
+ // so this only changes behaviour for a bare `esc` (now quits the cockpit,
870
+ // matching the widgets). Custom rebinds fall through to `resolveAction`.
871
+ const grammar = matchGrammar(evt);
872
+ if (grammar === "navUp") {
873
+ if (pickerMode) movePicker(-1);
874
+ else if (n > 0) {
875
+ setActiveProject((s) => wrapIndex(s, -1, n));
876
+ setActiveSession(0);
877
+ setActiveWindow(-1);
878
+ }
879
+ return;
880
+ }
881
+ if (grammar === "navDown") {
882
+ if (pickerMode) movePicker(1);
883
+ else if (n > 0) {
884
+ setActiveProject((s) => wrapIndex(s, 1, n));
885
+ setActiveSession(0);
886
+ setActiveWindow(-1);
887
+ }
888
+ return;
889
+ }
890
+ if (grammar === "activate") {
891
+ enter();
892
+ return;
893
+ }
894
+ if (grammar === "filter") {
895
+ setMessage("");
896
+ setFilterQuery("");
897
+ setFilterMode(true);
898
+ setActiveProject(0);
899
+ setActiveSession(0);
900
+ setActiveWindow(-1);
901
+ return;
902
+ }
903
+ if (grammar === "help") {
904
+ setHelpOpen(true);
905
+ return;
906
+ }
907
+ if (grammar === "dismiss" || grammar === "quit") {
908
+ process.exit(0);
909
+ }
910
+
911
+ // Standalone-only: e/g/, open the widget panels (explorer/changes/config) —
912
+ // the in-app echo of the tmux `M-e`/`M-g`/`M-,` binds. Both popup variants
913
+ // (picker + home-popup) stay pure navigators — a nested popup over a popup is
914
+ // avoided — so panels are gated to the terminal-owning cockpit.
915
+ if (!switchMode) {
916
+ const panel = panelForKey(evt.name);
917
+ if (panel) {
918
+ openPanel(panel);
919
+ return;
920
+ }
921
+ }
922
+
923
+ // The rename default is Shift+R; single-char keys arrive lowercase with
924
+ // shift as a modifier (per the @opentui convention), so map it explicitly.
925
+ const keyName = evt.name === "r" && evt.shift ? "R" : evt.name;
926
+ const action = resolveAction(keymap, keyName);
927
+ switch (action) {
928
+ case "up":
929
+ // Picker walks the flat project→session→window row union; standalone
930
+ // just pages the project list (its window lines are read-only).
931
+ if (pickerMode) movePicker(-1);
932
+ else if (n > 0) {
933
+ setActiveProject((s) => wrapIndex(s, -1, n));
934
+ setActiveSession(0);
935
+ setActiveWindow(-1);
936
+ }
937
+ break;
938
+ case "down":
939
+ if (pickerMode) movePicker(1);
940
+ else if (n > 0) {
941
+ setActiveProject((s) => wrapIndex(s, 1, n));
942
+ setActiveSession(0);
943
+ setActiveWindow(-1);
944
+ }
945
+ break;
946
+ case "enter":
947
+ enter();
948
+ break;
949
+ case "launch": {
950
+ const proj = activeProj();
951
+ if (proj) {
952
+ if (switchMode) pickerLaunchAndSwitch(proj);
953
+ else launchProject(proj);
954
+ } else if (!switchMode) {
955
+ // Nothing selected (e.g. the empty-fleet hero): `l` is the entry point
956
+ // to get a launchable project into the fleet — open the add-dir prompt.
957
+ openRegister();
958
+ }
959
+ break;
960
+ }
961
+ case "new":
962
+ openNewSession();
963
+ break;
964
+ case "rename":
965
+ openRename();
966
+ break;
967
+ case "split":
968
+ splitSelected();
969
+ break;
970
+ case "register":
971
+ openRegister();
972
+ break;
973
+ case "unregister":
974
+ unregister();
975
+ break;
976
+ case "kill":
977
+ requestKill();
978
+ break;
979
+ case "filter":
980
+ setMessage("");
981
+ setFilterQuery("");
982
+ setFilterMode(true);
983
+ setActiveProject(0);
984
+ setActiveSession(0);
985
+ setActiveWindow(-1);
986
+ break;
987
+ case "refresh":
988
+ refresh();
989
+ break;
990
+ case "help":
991
+ setHelpOpen(true);
992
+ break;
993
+ case "quit":
994
+ process.exit(0);
995
+ break;
996
+ case null:
997
+ break;
998
+ }
999
+ });
1000
+
1001
+ // The keybindings help overlay — shared with every widget via the common
1002
+ // HelpOverlay. Replaces the middle body while `?` is open. The universal verbs
1003
+ // come from the grammar; the team's own (configurable) keys are listed below
1004
+ // them, sourced from the live keymap so a rebind re-labels the overlay.
1005
+ function helpOverlay() {
1006
+ const widgetKeys: WidgetKey[] = [
1007
+ ...TEAM_WIDGET_ACTIONS.map((action) => ({
1008
+ key: keymap[action].keys.join("/"),
1009
+ label: keymap[action].description,
1010
+ })),
1011
+ // The in-app panel keys (e/g/,) live outside the configurable keymap, so
1012
+ // list them from the shared panel registry with their readable labels.
1013
+ ...panelHints("label").map((h) => ({ key: h.keys, label: h.label })),
1014
+ ];
1015
+ return <HelpOverlay theme={theme} title="cockpit" widgetKeys={widgetKeys} />;
1016
+ }
1017
+
1018
+ // The inline text prompt (register / new session / rename) and the fuzzy
1019
+ // filter line — both shared by the two layouts, both a single line each.
1020
+ function promptRow() {
1021
+ return (
1022
+ <Show when={prompt()}>
1023
+ <box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
1024
+ <text fg={toRGBA(theme.accent)}>{promptLabel(prompt()!.kind)}</text>
1025
+ <text fg={toRGBA(theme.fg)}>{prompt()!.value}</text>
1026
+ <text fg={toRGBA(theme.fgMuted)}>_</text>
1027
+ </box>
1028
+ </Show>
1029
+ );
1030
+ }
1031
+ function filterRow() {
1032
+ return (
1033
+ <Show when={filterMode()}>
1034
+ <box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
1035
+ <text fg={toRGBA(theme.accent)}>/</text>
1036
+ <text fg={toRGBA(theme.fg)}>{filterQuery()}</text>
1037
+ <text fg={toRGBA(theme.fgMuted)}>_</text>
1038
+ <box flexGrow={1} />
1039
+ <text
1040
+ fg={toRGBA(theme.fgMuted)}
1041
+ >{`${visibleProjects().length}/${projects().length}`}</text>
1042
+ </box>
1043
+ </Show>
1044
+ );
1045
+ }
1046
+ function statusRow() {
1047
+ return (
1048
+ <Show when={confirm() || message().length > 0}>
1049
+ <box paddingLeft={1} paddingRight={1}>
1050
+ <text fg={confirm() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}>
1051
+ {confirm() ? confirm()!.message : message()}
1052
+ </text>
1053
+ </box>
1054
+ </Show>
1055
+ );
1056
+ }
1057
+
1058
+ /**
1059
+ * The empty-fleet hero — the home screen with nothing running yet. A friendly
1060
+ * centered card naming the three ways forward (new session / launch a project
1061
+ * dir / quit); the keys are live, handled by the same `n` / `l` / `q` paths.
1062
+ */
1063
+ function emptyHero() {
1064
+ return (
1065
+ <box flexDirection="column" flexGrow={1} alignItems="center" paddingTop={2}>
1066
+ <box
1067
+ flexDirection="column"
1068
+ border
1069
+ borderColor={toRGBA(theme.accent)}
1070
+ backgroundColor={toRGBA(theme.selected)}
1071
+ paddingLeft={3}
1072
+ paddingRight={3}
1073
+ paddingTop={1}
1074
+ paddingBottom={1}
1075
+ >
1076
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
1077
+ no sessions yet
1078
+ </text>
1079
+ <box paddingTop={1} flexDirection="column">
1080
+ <For each={emptyFleetActions()}>
1081
+ {(action) => (
1082
+ <box flexDirection="row" gap={1}>
1083
+ <text fg={toRGBA(theme.accent)}>{action.key}</text>
1084
+ <text fg={toRGBA(theme.fg)}>{action.label}</text>
1085
+ </box>
1086
+ )}
1087
+ </For>
1088
+ </box>
1089
+ </box>
1090
+ </box>
1091
+ );
1092
+ }
1093
+
1094
+ /**
1095
+ * PICKER layout — a compact single-column switcher for the `display-popup`
1096
+ * (bound to M-p on adopted sessions). This narrow popup is just the
1097
+ * project/session list + live status; no sidebar/preview split. The active
1098
+ * project's sessions expand inline so the user can pick one to switch to;
1099
+ * other projects collapse to their row to save vertical space. Picking ends
1100
+ * in a `switch-client` + close.
1101
+ */
1102
+ function CompactSwitcher() {
1103
+ return (
1104
+ <box flexDirection="column" flexGrow={1} backgroundColor={toRGBA(theme.bg)}>
1105
+ {/* header — one tight line */}
1106
+ <box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
1107
+ <text fg={toRGBA(theme.accent)}>tmux-ide</text>
1108
+ <box flexGrow={1} />
1109
+ <text fg={toRGBA(theme.fgMuted)}>{`${projects().length} projects`}</text>
1110
+ </box>
1111
+
1112
+ {promptRow()}
1113
+ {filterRow()}
1114
+
1115
+ {/* body: help overlay, or the single-column project/session list */}
1116
+ <Show
1117
+ when={helpOpen()}
1118
+ fallback={
1119
+ <box
1120
+ flexDirection="column"
1121
+ flexGrow={1}
1122
+ paddingLeft={1}
1123
+ paddingRight={1}
1124
+ paddingTop={1}
1125
+ onMouseScroll={scrollProjects}
1126
+ >
1127
+ <Show
1128
+ when={visibleProjects().length > 0}
1129
+ fallback={
1130
+ <text fg={toRGBA(theme.fgMuted)}>
1131
+ {filterMode() ? "no match" : "no projects — a to add"}
1132
+ </text>
1133
+ }
1134
+ >
1135
+ <For each={visibleProjects()}>
1136
+ {(project, i) => {
1137
+ const isActive = () => i() === activeProject();
1138
+ // The flat cursor sits on the project row only when no
1139
+ // session/window under it is selected (activeSession === -1).
1140
+ const isCursor = () => isActive() && activeSession() === -1;
1141
+ const running = project.running;
1142
+ return (
1143
+ <box flexDirection="column">
1144
+ {/* project row */}
1145
+ <box
1146
+ flexDirection="row"
1147
+ gap={1}
1148
+ backgroundColor={isCursor() ? toRGBA(theme.border) : undefined}
1149
+ onMouseDown={() => clickProject(i())}
1150
+ >
1151
+ <text fg={isCursor() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}>
1152
+ {isCursor() ? "▸" : " "}
1153
+ </text>
1154
+ <text fg={running ? statusColor[project.status] : toRGBA(theme.fgMuted)}>
1155
+ {running ? STATUS[project.status].glyph : "○"}
1156
+ </text>
1157
+ <text
1158
+ fg={
1159
+ isActive()
1160
+ ? toRGBA(theme.accent)
1161
+ : running
1162
+ ? toRGBA(theme.fg)
1163
+ : toRGBA(theme.fgMuted)
1164
+ }
1165
+ attributes={isActive() ? TextAttributes.BOLD : 0}
1166
+ >
1167
+ {project.name}
1168
+ </text>
1169
+ <box flexGrow={1} />
1170
+ <text fg={toRGBA(theme.fgMuted)}>
1171
+ {running ? `${project.sessions.length}` : "○ stopped"}
1172
+ </text>
1173
+ </box>
1174
+ {/* git branch — dim indented line, when present */}
1175
+ <Show when={project.gitBranch}>
1176
+ <box flexDirection="row" paddingLeft={2}>
1177
+ <text fg={toRGBA(theme.fgMuted)}>{project.gitBranch ?? ""}</text>
1178
+ </box>
1179
+ </Show>
1180
+ {/* sessions — expanded only under the active project */}
1181
+ <Show when={isActive() && project.sessions.length > 0}>
1182
+ <For each={project.sessions}>
1183
+ {(session, si) => {
1184
+ // The session row is the cursor only when no window
1185
+ // under it is selected; its windows expand whenever
1186
+ // it's the active session.
1187
+ const sExpanded = () => si() === activeSession();
1188
+ const sActive = () => sExpanded() && activeWindow() === -1;
1189
+ return (
1190
+ <box flexDirection="column">
1191
+ <box
1192
+ flexDirection="row"
1193
+ gap={1}
1194
+ paddingLeft={2}
1195
+ backgroundColor={sActive() ? toRGBA(theme.border) : undefined}
1196
+ onMouseDown={() => clickSession(si())}
1197
+ >
1198
+ <text
1199
+ fg={sActive() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}
1200
+ >
1201
+ {sActive() ? "▸" : " "}
1202
+ </text>
1203
+ <text fg={statusColor[session.status]}>
1204
+ {STATUS[session.status].glyph}
1205
+ </text>
1206
+ <text fg={toRGBA(theme.fg)}>{session.name}</text>
1207
+ <box flexGrow={1} />
1208
+ <text fg={toRGBA(theme.fgMuted)}>{`${session.panes}p`}</text>
1209
+ <text fg={toRGBA(theme.fgMuted)}>
1210
+ {session.attached ? "·a" : ""}
1211
+ </text>
1212
+ </box>
1213
+ {/* windows (tabs) — expanded under the active session */}
1214
+ <Show when={sExpanded() && session.windowList.length > 0}>
1215
+ <For each={session.windowList}>
1216
+ {(win, wi) => {
1217
+ const wActive = () => wi() === activeWindow();
1218
+ return (
1219
+ <box
1220
+ flexDirection="row"
1221
+ gap={1}
1222
+ paddingLeft={4}
1223
+ backgroundColor={
1224
+ wActive() ? toRGBA(theme.border) : undefined
1225
+ }
1226
+ onMouseDown={() => clickWindow(si(), wi())}
1227
+ >
1228
+ <text
1229
+ fg={
1230
+ wActive()
1231
+ ? toRGBA(theme.accent)
1232
+ : toRGBA(theme.fgMuted)
1233
+ }
1234
+ >
1235
+ {wActive() ? "▸" : " "}
1236
+ </text>
1237
+ <text fg={statusColor[win.status]}>
1238
+ {STATUS[win.status].glyph}
1239
+ </text>
1240
+ <text fg={toRGBA(theme.fg)}>
1241
+ {`${win.index}:${win.name}${win.active ? " *" : ""}`}
1242
+ </text>
1243
+ <box flexGrow={1} />
1244
+ <text
1245
+ fg={toRGBA(theme.fgMuted)}
1246
+ >{`${win.panes}p`}</text>
1247
+ </box>
1248
+ );
1249
+ }}
1250
+ </For>
1251
+ </Show>
1252
+ </box>
1253
+ );
1254
+ }}
1255
+ </For>
1256
+ </Show>
1257
+ </box>
1258
+ );
1259
+ }}
1260
+ </For>
1261
+ </Show>
1262
+ </box>
1263
+ }
1264
+ >
1265
+ {helpOverlay()}
1266
+ </Show>
1267
+
1268
+ {statusRow()}
1269
+
1270
+ {/* compact footer — shortened labels to fit ~34 cols. The picker ends in
1271
+ a switch-client + close, so it advertises that. */}
1272
+ <box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
1273
+ <For each={pickerFooterHints()}>
1274
+ {(hint) => <text fg={toRGBA(theme.fgMuted)}>{`${hint.keys} ${hint.label}`}</text>}
1275
+ </For>
1276
+ </box>
1277
+ </box>
1278
+ );
1279
+ }
1280
+
1281
+ /**
1282
+ * STANDALONE layout — the full two-column app: a persistent SIDEBAR project
1283
+ * list plus a MAIN detail pane (active project's sessions + a live capture
1284
+ * preview). Used when the switcher runs on its own (`bun index.tsx`), where
1285
+ * it owns the whole terminal.
1286
+ */
1287
+ function FullApp() {
1288
+ return (
1289
+ <box flexDirection="column" flexGrow={1} backgroundColor={toRGBA(theme.bg)}>
1290
+ {/* header: product name + a live fleet rollup (blocked/working/done/idle
1291
+ session counts, colored by status token) + the project total */}
1292
+ <box paddingLeft={1} paddingRight={1} flexDirection="row" gap={1}>
1293
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
1294
+ tmux-ide
1295
+ </text>
1296
+ <text fg={toRGBA(theme.fgMuted)}>· team</text>
1297
+ <box flexGrow={1} />
1298
+ <For each={rollupChips(fleetRollup(projects()))}>
1299
+ {(chip) => (
1300
+ <text fg={statusColor[chip.status]}>
1301
+ {`${STATUS[chip.status].glyph}${chip.count}`}
1302
+ </text>
1303
+ )}
1304
+ </For>
1305
+ <text fg={toRGBA(theme.fgMuted)}>{`${projects().length}p`}</text>
1306
+ </box>
1307
+
1308
+ {promptRow()}
1309
+ {filterRow()}
1310
+
1311
+ {/* middle: keybindings overlay, else the empty-fleet hero when nothing is
1312
+ running, else the sidebar (left) + main detail (right) */}
1313
+ <Show
1314
+ when={helpOpen()}
1315
+ fallback={
1316
+ <Show when={!isFleetEmpty(projects())} fallback={emptyHero()}>
1317
+ <box flexDirection="row" flexGrow={1}>
1318
+ {/* SIDEBAR — the project list */}
1319
+ <box
1320
+ flexDirection="column"
1321
+ width={SIDEBAR_WIDTH}
1322
+ paddingLeft={1}
1323
+ paddingRight={1}
1324
+ paddingTop={1}
1325
+ onMouseScroll={scrollProjects}
1326
+ >
1327
+ <text fg={toRGBA(theme.fgMuted)} attributes={TextAttributes.BOLD}>
1328
+ PROJECTS
1329
+ </text>
1330
+ <box flexDirection="column" paddingTop={1}>
1331
+ <Show
1332
+ when={visibleProjects().length > 0}
1333
+ fallback={
1334
+ <text fg={toRGBA(theme.fgMuted)}>
1335
+ {filterMode() ? "no match" : "no projects — a to add"}
1336
+ </text>
1337
+ }
1338
+ >
1339
+ <For each={visibleProjects()}>
1340
+ {(project, i) => {
1341
+ const isActive = () => i() === activeProject();
1342
+ const running = project.running;
1343
+ return (
1344
+ <box
1345
+ flexDirection="column"
1346
+ paddingLeft={1}
1347
+ paddingRight={1}
1348
+ backgroundColor={isActive() ? toRGBA(theme.border) : undefined}
1349
+ onMouseDown={() => clickProject(i())}
1350
+ >
1351
+ <box flexDirection="row" gap={1}>
1352
+ <text
1353
+ fg={isActive() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}
1354
+ >
1355
+ {isActive() ? "▸" : " "}
1356
+ </text>
1357
+ <text
1358
+ fg={running ? statusColor[project.status] : toRGBA(theme.fgMuted)}
1359
+ >
1360
+ {running ? STATUS[project.status].glyph : "○"}
1361
+ </text>
1362
+ <text
1363
+ fg={
1364
+ isActive()
1365
+ ? toRGBA(theme.accent)
1366
+ : running
1367
+ ? toRGBA(theme.fg)
1368
+ : toRGBA(theme.fgMuted)
1369
+ }
1370
+ attributes={isActive() ? TextAttributes.BOLD : 0}
1371
+ >
1372
+ {project.name.padEnd(18).slice(0, 18)}
1373
+ </text>
1374
+ <box flexGrow={1} />
1375
+ <text fg={toRGBA(theme.fgMuted)}>
1376
+ {running ? `${project.sessions.length}` : "○ stopped"}
1377
+ </text>
1378
+ </box>
1379
+ <Show when={project.gitBranch}>
1380
+ <box flexDirection="row" paddingLeft={2}>
1381
+ <text fg={toRGBA(theme.fgMuted)}>{project.gitBranch ?? ""}</text>
1382
+ </box>
1383
+ </Show>
1384
+ </box>
1385
+ );
1386
+ }}
1387
+ </For>
1388
+ </Show>
1389
+ </box>
1390
+ </box>
1391
+
1392
+ {/* vertical separator */}
1393
+ <box width={1} backgroundColor={toRGBA(theme.border)} />
1394
+
1395
+ {/* MAIN — the active project's detail: sessions + live preview */}
1396
+ <box flexDirection="column" flexGrow={1} paddingLeft={1} paddingTop={1}>
1397
+ <Show
1398
+ when={activeProj()}
1399
+ fallback={<text fg={toRGBA(theme.fgMuted)}>no project selected</text>}
1400
+ >
1401
+ {/* header: name · dir · branch · ide.yml */}
1402
+ <box flexDirection="row" gap={1} paddingRight={1}>
1403
+ <text fg={toRGBA(theme.accent)} attributes={TextAttributes.BOLD}>
1404
+ {activeProj()!.name}
1405
+ </text>
1406
+ <text fg={toRGBA(theme.fgMuted)}>{activeProj()!.dir ?? ""}</text>
1407
+ <text fg={toRGBA(theme.fgMuted)}>{activeProj()!.gitBranch ?? ""}</text>
1408
+ <Show when={activeProj()!.hasIdeYml}>
1409
+ <text fg={toRGBA(theme.fgMuted)}>ide.yml</text>
1410
+ </Show>
1411
+ </box>
1412
+
1413
+ {/* sessions sub-list */}
1414
+ <box flexDirection="column" paddingTop={1}>
1415
+ <Show
1416
+ when={(activeProj()?.sessions ?? []).length > 0}
1417
+ fallback={<text fg={toRGBA(theme.fgMuted)}>no sessions — l to launch</text>}
1418
+ >
1419
+ <For each={activeProj()?.sessions ?? []}>
1420
+ {(session, i) => {
1421
+ const isActive = () => i() === activeSession();
1422
+ return (
1423
+ <box flexDirection="column">
1424
+ <box
1425
+ flexDirection="row"
1426
+ gap={1}
1427
+ paddingLeft={1}
1428
+ paddingRight={1}
1429
+ backgroundColor={isActive() ? toRGBA(theme.border) : undefined}
1430
+ onMouseDown={() => clickSession(i())}
1431
+ >
1432
+ <text
1433
+ fg={isActive() ? toRGBA(theme.accent) : toRGBA(theme.fgMuted)}
1434
+ >
1435
+ {isActive() ? "▸" : " "}
1436
+ </text>
1437
+ <text fg={statusColor[session.status]}>
1438
+ {STATUS[session.status].glyph}
1439
+ </text>
1440
+ <text fg={toRGBA(theme.fg)}>
1441
+ {session.name.padEnd(22).slice(0, 22)}
1442
+ </text>
1443
+ <text fg={toRGBA(theme.fgMuted)}>
1444
+ {STATUS[session.status].label.padEnd(8)}
1445
+ </text>
1446
+ <text fg={toRGBA(theme.fgMuted)}>{`${session.panes}p`}</text>
1447
+ <text fg={toRGBA(theme.fgMuted)}>
1448
+ {session.attached ? "· attached" : ""}
1449
+ </text>
1450
+ </box>
1451
+ {/* windows (tabs) — read-only breakdown under each session */}
1452
+ <For each={session.windowList}>
1453
+ {(win) => (
1454
+ <box
1455
+ flexDirection="row"
1456
+ gap={1}
1457
+ paddingLeft={4}
1458
+ paddingRight={1}
1459
+ >
1460
+ <text fg={statusColor[win.status]}>
1461
+ {STATUS[win.status].glyph}
1462
+ </text>
1463
+ <text fg={toRGBA(theme.fgMuted)}>
1464
+ {`${win.index}:${win.name}${win.active ? " *" : ""}`}
1465
+ </text>
1466
+ <box flexGrow={1} />
1467
+ <text fg={toRGBA(theme.fgMuted)}>{`${win.panes}p`}</text>
1468
+ </box>
1469
+ )}
1470
+ </For>
1471
+ </box>
1472
+ );
1473
+ }}
1474
+ </For>
1475
+ </Show>
1476
+ </box>
1477
+
1478
+ {/* live preview of the active session's active pane */}
1479
+ <box flexDirection="column" flexGrow={1} paddingTop={1}>
1480
+ <Show
1481
+ when={previewTitle().length > 0}
1482
+ fallback={<text fg={toRGBA(theme.fgMuted)}>no live session</text>}
1483
+ >
1484
+ <text fg={toRGBA(theme.accent)}>{previewTitle()}</text>
1485
+ </Show>
1486
+ <box flexDirection="column" flexGrow={1} paddingTop={1}>
1487
+ <For each={preview()}>
1488
+ {(line) => <text fg={toRGBA(theme.fgMuted)}>{line}</text>}
1489
+ </For>
1490
+ </box>
1491
+ </box>
1492
+ </Show>
1493
+ </box>
1494
+ </box>
1495
+ </Show>
1496
+ }
1497
+ >
1498
+ {helpOverlay()}
1499
+ </Show>
1500
+
1501
+ {statusRow()}
1502
+
1503
+ {/* footer — grammar-sourced hint line: the universal verbs' key glyphs
1504
+ come from grammar.ts, interleaved with the cockpit's own session +
1505
+ panel keys. The full key set stays in the `?` overlay. */}
1506
+ <box paddingLeft={1} paddingRight={1} flexDirection="row" gap={2}>
1507
+ <For each={homeFooterHints()}>
1508
+ {(hint) => (
1509
+ <box flexDirection="row" gap={1}>
1510
+ <text fg={toRGBA(theme.accent)}>{hint.keys}</text>
1511
+ <text fg={toRGBA(theme.fgMuted)}>{hint.label}</text>
1512
+ </box>
1513
+ )}
1514
+ </For>
1515
+ </box>
1516
+ </box>
1517
+ );
1518
+ }
1519
+
1520
+ return compactMode ? <CompactSwitcher /> : <FullApp />;
1521
+ });