tmux-ide 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +36 -14
  2. package/bin/cli.js +4174 -1235
  3. package/bin/cli.ts +426 -72
  4. package/package.json +10 -6
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  9. package/packages/daemon/dist/agent-explain.js +19 -3
  10. package/packages/daemon/dist/control/client.d.ts +23 -0
  11. package/packages/daemon/dist/control/client.js +105 -0
  12. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  13. package/packages/daemon/dist/control/dispatch.js +83 -0
  14. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  15. package/packages/daemon/dist/control/fanout.js +37 -0
  16. package/packages/daemon/dist/control/frames.d.ts +23 -0
  17. package/packages/daemon/dist/control/frames.js +37 -0
  18. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  19. package/packages/daemon/dist/control/lifecycle.js +114 -0
  20. package/packages/daemon/dist/control/server.d.ts +16 -0
  21. package/packages/daemon/dist/control/server.js +214 -0
  22. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  23. package/packages/daemon/dist/control/verbs.js +91 -0
  24. package/packages/daemon/dist/doctor.d.ts +18 -0
  25. package/packages/daemon/dist/doctor.js +105 -15
  26. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  27. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  28. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  29. package/packages/daemon/dist/lib/app-config.js +104 -5
  30. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  31. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  32. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  33. package/packages/daemon/dist/lib/state-home.js +12 -0
  34. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  35. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  36. package/packages/daemon/dist/lib/update-check.js +5 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  43. package/packages/daemon/dist/restore.d.ts +35 -8
  44. package/packages/daemon/dist/restore.js +52 -15
  45. package/packages/daemon/dist/send.d.ts +33 -1
  46. package/packages/daemon/dist/send.js +32 -19
  47. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  48. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  49. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  50. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  51. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  52. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  53. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  54. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  55. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  56. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  57. package/packages/daemon/src/agent-explain.ts +34 -6
  58. package/packages/daemon/src/control/client.ts +128 -0
  59. package/packages/daemon/src/control/dispatch.ts +107 -0
  60. package/packages/daemon/src/control/fanout.ts +44 -0
  61. package/packages/daemon/src/control/frames.ts +40 -0
  62. package/packages/daemon/src/control/lifecycle.ts +151 -0
  63. package/packages/daemon/src/control/server.ts +237 -0
  64. package/packages/daemon/src/control/verbs.ts +118 -0
  65. package/packages/daemon/src/doctor.ts +113 -28
  66. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  67. package/packages/daemon/src/lib/app-config.ts +194 -5
  68. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  69. package/packages/daemon/src/lib/state-home.ts +13 -0
  70. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  71. package/packages/daemon/src/lib/update-check.ts +5 -0
  72. package/packages/daemon/src/restore.ts +53 -15
  73. package/packages/daemon/src/send.ts +55 -21
  74. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  75. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  76. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  77. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  78. package/packages/daemon/src/tui/chrome/notify.ts +737 -52
  79. package/packages/daemon/src/tui/chrome/updater.ts +318 -30
  80. package/packages/daemon/src/tui/compiled.ts +11 -3
  81. package/packages/daemon/src/tui/detect/classify.ts +49 -0
  82. package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
  83. package/packages/daemon/src/tui/detect/manifest.ts +39 -3
  84. package/packages/daemon/src/tui/detect/manifests.ts +395 -33
  85. package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
  86. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  87. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  88. package/packages/daemon/src/tui/main.ts +13 -1
  89. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  90. package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
  91. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  92. package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
  93. package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
  94. package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
  95. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  96. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  97. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  98. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  99. package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
  100. package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
  101. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  102. package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
  103. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  104. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  105. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  106. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  107. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  108. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  109. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  110. package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
  111. package/packages/daemon/src/tui/mirror/palette.ts +564 -0
  112. package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
  113. package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
  114. package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
  115. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  116. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  117. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  118. package/packages/daemon/src/tui/mirror/selection.ts +376 -0
  119. package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
  120. package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
  121. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  122. package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
  123. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  124. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  125. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  126. package/packages/daemon/src/tui/team/entry.ts +34 -7
  127. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  128. package/packages/daemon/src/tui/team/report.ts +11 -1
  129. package/packages/daemon/src/tui/team/sessions.ts +184 -17
  130. package/packages/daemon/src/tui/team/wait.ts +144 -0
  131. package/scripts/build-macos-notifier.mjs +160 -0
  132. package/scripts/build-tui.mjs +11 -4
  133. package/scripts/perf-mirror.mjs +313 -0
  134. package/scripts/postinstall.js +8 -1
  135. package/scripts/prepublish-check.mjs +37 -1
  136. package/scripts/publish-tap.sh +55 -0
  137. package/skill/SKILL.md +110 -2
  138. package/templates/AGENTS.md +14 -7
  139. package/templates/agent-team-monorepo.yml +8 -0
  140. package/templates/agent-team-nextjs.yml +8 -0
  141. package/templates/agent-team.yml +10 -0
  142. package/templates/convex.yml +2 -0
  143. package/templates/default.yml +11 -5
  144. package/templates/go.yml +4 -0
  145. package/templates/missions.yml +6 -0
  146. package/templates/nextjs.yml +4 -0
  147. package/templates/python.yml +4 -0
  148. package/templates/skills/backend.md +5 -12
  149. package/templates/skills/frontend.md +5 -12
  150. package/templates/skills/general-worker.md +5 -12
  151. package/templates/skills/researcher.md +7 -12
  152. package/templates/skills/reviewer.md +7 -16
  153. package/templates/vite.yml +4 -0
  154. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -0,0 +1,564 @@
1
+ /**
2
+ * The command palette's action model (M18.4) — PURE so it unit-tests without
3
+ * OpenTUI. app.tsx opens a centered overlay (F5), feeds keystrokes to a query,
4
+ * and runs the selected {@link PaletteAction}; the DESCRIPTORS and the fuzzy
5
+ * filtering live here, the closures that actually attach/save/quit stay in the
6
+ * app (a `dispatch` switch over `action.kind`).
7
+ *
8
+ * Reuses {@link ../team/fuzzy.ts} for the same subsequence match the fleet
9
+ * quick-jump uses, so typing "ses" ranks "Attach session: …" the way a human
10
+ * expects. The dynamic "Open file: <query>" action is always offered when the
11
+ * query is non-empty (you can't fuzzy-match a path you're still typing), pinned
12
+ * to the top when the query looks like a path.
13
+ */
14
+ import { fuzzyFilter } from "../team/fuzzy.ts";
15
+ import type { PaletteUsageEntry, Tab } from "./app-state.ts";
16
+ import type { AgentRowInput } from "./agent-rows.ts";
17
+ import { SETTINGS_PALETTE_COMMANDS, type SettingsCommandId } from "./settings-model.ts";
18
+
19
+ /** One runnable palette entry. `label` is what the list shows and what the
20
+ * fuzzy filter scores; `kind` (+ payload) is what the app dispatches on. */
21
+ export type PaletteAction =
22
+ | { kind: "tab"; tab: Tab; label: string }
23
+ | { kind: "open-folder"; label: string }
24
+ | { kind: "attach"; session: string; label: string }
25
+ | { kind: "jump-agent"; paneId: string; session: string; windowIndex: number; label: string }
26
+ // The lifecycle verbs (M23.1) — `agentKind` (not `kind`, taken by the action
27
+ // discriminant) is the manifest id the restart flow resolves a launch
28
+ // command from. Each fleet agent gets one restart + one stop row; "New
29
+ // agent…" opens the spawn flow (kind → placement → run) on the dialog stack.
30
+ | { kind: "new-agent"; label: string }
31
+ // Repeat the current context's remembered spawn directly — no dialog (M24.1).
32
+ | { kind: "new-agent-again"; label: string }
33
+ // The one-surface team console (M24.1): jump/restart/stop + "+ new agent".
34
+ | { kind: "manage-team"; label: string }
35
+ | { kind: "restart-agent"; paneId: string; agentKind: string; session: string; label: string }
36
+ | { kind: "stop-agent"; paneId: string; agentKind: string; session: string; label: string }
37
+ | { kind: "open-file"; path: string; label: string }
38
+ // "Go to file:" (M24.6) — one row per fuzzy-matched REPO file (workspace-
39
+ // relative, ignore-respecting, fed by the app via ctx.repoFiles), unlike
40
+ // open-file whose path is whatever the user typed.
41
+ | { kind: "go-file"; path: string; label: string }
42
+ | { kind: "save"; label: string }
43
+ | { kind: "refresh-diff"; label: string }
44
+ | { kind: "paste-buffer"; label: string }
45
+ | { kind: "search-scrollback"; label: string }
46
+ | { kind: "new-window"; label: string }
47
+ | { kind: "rename-window"; name: string; label: string }
48
+ | { kind: "kill-window"; label: string }
49
+ | { kind: "zoom-pane"; label: string }
50
+ | { kind: "swap-pane"; label: string }
51
+ | { kind: "break-pane"; label: string }
52
+ | { kind: "rotate-window"; label: string }
53
+ | { kind: "select-layout"; layout: string; label: string }
54
+ | { kind: "sync-toggle"; label: string }
55
+ | { kind: "select-text"; label: string }
56
+ | { kind: "resize-window"; label: string }
57
+ | { kind: "settings"; id: SettingsCommandId; label: string }
58
+ | { kind: "quit"; label: string };
59
+
60
+ /** The five tmux `select-layout` presets, offered one palette action each so the
61
+ * fuzzy filter finds "tiled" / "main-vertical" directly. Shared with the pane
62
+ * context menu's Layouts submenu (menu-model). */
63
+ export const LAYOUT_PRESETS = [
64
+ "even-horizontal",
65
+ "even-vertical",
66
+ "main-horizontal",
67
+ "main-vertical",
68
+ "tiled",
69
+ ] as const;
70
+
71
+ /** Context the palette needs beyond the fuzzy query. `terminal` gates the
72
+ * window/pane verbs (New/Rename/Kill window, Zoom pane) to the Terminal surface
73
+ * — they are no-ops on Home/Files/Diff, so they only clutter the list there. */
74
+ export interface PaletteContext {
75
+ terminal?: boolean;
76
+ /** The fleet's agents (M22.2) — the palette offers one "Agent: …" jump action
77
+ * each, the keyboard twin of the sidebar's clickable agent rows. Already
78
+ * sorted attention-first by the caller (fleetAgents). */
79
+ agents?: AgentRowInput[];
80
+ /** A co-attached terminal has sized the tmux window away from our canvas
81
+ * (M22.8). Only then is the "Resize to fit this window" reclaim action worth
82
+ * offering — otherwise it is a no-op that clutters the list. */
83
+ sizeMismatch?: boolean;
84
+ /** The focused pane's app turned mouse reporting on (M22.9) — only then is
85
+ * "Select text in pane" offered (ordinary panes drag-select directly, so the
86
+ * action would be a no-op that clutters the list). */
87
+ appMousePane?: boolean;
88
+ /** What the current context's remembered spawn is called (`claude`, a custom
89
+ * argv, …) — when set, a direct "New agent: <name> (again)" action is PINNED
90
+ * FIRST, so a repeat spawn is F5 → Enter (M24.1's ≤2-Enters bar). */
91
+ againName?: string | null;
92
+ /** Palette usage history (M24.4) keyed by {@link paletteActionKey}: drives
93
+ * the empty-query "recent" group and the frequency/recency tie-break on a
94
+ * typed query. Persisted in app-state. */
95
+ usage?: Readonly<Record<string, PaletteUsageEntry>>;
96
+ /** The active surface tab (M24.4) — steers the empty-query "suggested"
97
+ * group (Terminal → window/pane verbs + the again spawn; Files → save +
98
+ * open-folder). `terminal` above stays THE gate for the window/pane verbs
99
+ * so existing callers/tests are untouched. */
100
+ surface?: Tab;
101
+ /** Action key → keycap ({@link ../mirror/settings-model.ts}'s
102
+ * PALETTE_KEYCAPS) — rows whose action has one right-align it (M24.4).
103
+ * app.tsx drops `quit` in HOSTED mode, where ^q detaches instead. */
104
+ keycaps?: Readonly<Record<string, string>>;
105
+ /** The workspace's file list (M24.6) — repo-relative paths, gitignore-
106
+ * respecting, capped by the caller. A non-empty query offers fuzzy-matched
107
+ * "Go to file: <path>" rows via {@link goToFileActions}. */
108
+ repoFiles?: readonly string[];
109
+ }
110
+
111
+ const TAB_LABELS: { tab: Tab; label: string }[] = [
112
+ { tab: "home", label: "Switch tab: Home" },
113
+ { tab: "terminal", label: "Switch tab: Terminal" },
114
+ { tab: "files", label: "Switch tab: Files" },
115
+ { tab: "diff", label: "Switch tab: Diff" },
116
+ ];
117
+
118
+ /** PURE — the always-available static actions: the four tab switches, one
119
+ * attach-session per fleet session, then Save / Refresh diff, the Terminal-only
120
+ * window/pane verbs, and Quit. */
121
+ export function staticPaletteActions(
122
+ sessions: string[],
123
+ ctx: PaletteContext = {},
124
+ ): PaletteAction[] {
125
+ const actions: PaletteAction[] = [];
126
+ // The repeat spawn (M24.1) — FIRST, above everything: with memory for the
127
+ // current context, an empty-query Enter repeats the last spawn, making
128
+ // F5 → Enter the whole gesture (the card's ≤2-Enters acceptance bar).
129
+ if (ctx.againName) {
130
+ actions.push({ kind: "new-agent-again", label: `New agent: ${ctx.againName} (again)` });
131
+ }
132
+ for (const t of TAB_LABELS) actions.push({ kind: "tab", tab: t.tab, label: t.label });
133
+ // The non-technicals' front door (M22.5): a filesystem picker → create-or-
134
+ // attach a session in the chosen folder. Offered on every surface.
135
+ actions.push({ kind: "open-folder", label: "Open folder…" });
136
+ // Spawn an agent (M23.1/M24.1) — offered on every surface; ONE dialog whose
137
+ // default placement is contextual (split beside a focused pane, else a new
138
+ // window in the session, else a fresh session).
139
+ actions.push({ kind: "new-agent", label: "New agent…" });
140
+ // The team console (M24.1): every fleet agent in one dialog — jump, restart,
141
+ // stop, spawn. The sidebar agents-header click's keyboard twin.
142
+ actions.push({ kind: "manage-team", label: "Manage team…" });
143
+ for (const s of sessions) {
144
+ actions.push({ kind: "attach", session: s, label: `Attach session: ${s}` });
145
+ }
146
+ // One jump per fleet agent (M22.2) — the sidebar rows' keyboard twin. Labeled
147
+ // "Agent: <kind> · <session> (<state>)" so the fuzzy filter finds it by kind,
148
+ // session, or state (typing "agent", "claude", or "blocked" all narrow to it).
149
+ for (const a of ctx.agents ?? []) {
150
+ actions.push({
151
+ kind: "jump-agent",
152
+ paneId: a.paneId,
153
+ session: a.session,
154
+ windowIndex: a.windowIndex,
155
+ label: `Agent: ${a.kind} · ${a.session} (${a.state})`,
156
+ });
157
+ }
158
+ // The per-agent lifecycle verbs (M23.1), grouped after the jumps so an empty
159
+ // query reads jump-first; the fuzzy filter finds "restart"/"stop" directly.
160
+ for (const a of ctx.agents ?? []) {
161
+ const who = `${a.kind} · ${a.session}`;
162
+ actions.push({
163
+ kind: "restart-agent",
164
+ paneId: a.paneId,
165
+ agentKind: a.kind,
166
+ session: a.session,
167
+ label: `Restart agent: ${who}`,
168
+ });
169
+ actions.push({
170
+ kind: "stop-agent",
171
+ paneId: a.paneId,
172
+ agentKind: a.kind,
173
+ session: a.session,
174
+ label: `Stop agent: ${who}`,
175
+ });
176
+ }
177
+ actions.push({ kind: "save", label: "Save file" });
178
+ actions.push({ kind: "refresh-diff", label: "Refresh diff" });
179
+ // Paste target is the focused surface (a pane, or the editor buffer), so this
180
+ // is offered on every surface — selecting it opens a second-level list of the
181
+ // tmux paste buffers rather than dispatching directly.
182
+ actions.push({ kind: "paste-buffer", label: "Paste buffer…" });
183
+ // The SETTINGS category (M22.4) — every setting is a command; "Settings…" is
184
+ // the categorized umbrella over the same dialogs. Offered on every surface.
185
+ for (const c of SETTINGS_PALETTE_COMMANDS) {
186
+ actions.push({ kind: "settings", id: c.id, label: c.label });
187
+ }
188
+ if (ctx.terminal) {
189
+ // `/` only opens search while scrolled into history (at the live prompt it
190
+ // belongs to the pane — agents' slash commands); this is the always-there
191
+ // entry.
192
+ actions.push({ kind: "search-scrollback", label: "Search scrollback" });
193
+ actions.push({ kind: "new-window", label: "New window" });
194
+ actions.push({ kind: "kill-window", label: "Kill window" });
195
+ actions.push({ kind: "zoom-pane", label: "Zoom pane" });
196
+ actions.push({ kind: "swap-pane", label: "Swap pane with next" });
197
+ actions.push({ kind: "break-pane", label: "Break pane to window" });
198
+ actions.push({ kind: "rotate-window", label: "Rotate panes" });
199
+ actions.push({ kind: "sync-toggle", label: "Synchronize panes (toggle)" });
200
+ // Selection entry on app-mouse panes (M22.9) — the pane menu verb's
201
+ // keyboard twin, offered only where forwarding blocks a direct drag.
202
+ if (ctx.appMousePane) {
203
+ actions.push({ kind: "select-text", label: "Select text in pane" });
204
+ }
205
+ for (const layout of LAYOUT_PRESETS) {
206
+ actions.push({ kind: "select-layout", layout, label: `Layout: ${layout}` });
207
+ }
208
+ // Only when a co-attached terminal is dictating the window size — the reclaim
209
+ // is a no-op otherwise, and we never fight the other terminal unasked.
210
+ if (ctx.sizeMismatch) {
211
+ actions.push({ kind: "resize-window", label: "Resize to fit this window" });
212
+ }
213
+ }
214
+ actions.push({ kind: "quit", label: "Quit" });
215
+ return actions;
216
+ }
217
+
218
+ /** One tmux paste buffer for the picker: its `name` (e.g. `buffer0`) and a
219
+ * short, control-char-sanitized `preview` of its content. */
220
+ export interface TmuxBuffer {
221
+ name: string;
222
+ preview: string;
223
+ }
224
+
225
+ /**
226
+ * PURE — parse `list-buffers -F "#{buffer_name}\t#{buffer_sample}"` reply lines
227
+ * into pickable buffers. The sample is truncated to `previewLen` chars and any
228
+ * control characters (tabs/newlines that survived a sample, C0 bytes) collapse
229
+ * to a middle-dot so one buffer stays one clean row. Nameless/blank lines drop.
230
+ */
231
+ export function parseBufferList(lines: readonly string[], previewLen = 40): TmuxBuffer[] {
232
+ const out: TmuxBuffer[] = [];
233
+ for (const raw of lines) {
234
+ if (!raw) continue;
235
+ const tab = raw.indexOf("\t");
236
+ const name = (tab === -1 ? raw : raw.slice(0, tab)).trim();
237
+ if (!name) continue;
238
+ const sample = tab === -1 ? "" : raw.slice(tab + 1);
239
+ const preview = sample
240
+ .slice(0, previewLen)
241
+ // eslint-disable-next-line no-control-regex
242
+ .replace(/[\x00-\x1f\x7f]/g, "·");
243
+ out.push({ name, preview });
244
+ }
245
+ return out;
246
+ }
247
+
248
+ // ── Overlay geometry (M21.9 — palette mouse support) ────────────────────────
249
+ // The palette overlay is app-rendered (a centered <box border paddingLeft=1>),
250
+ // so its full geometry is knowable by pure math. These helpers are shared by
251
+ // the RENDER (placement) and the central mouse ROUTER (row hit-test / inside-
252
+ // vs-outside), so a click can never land where a row isn't drawn. Layout, in
253
+ // screen rows from `top`: border (top+0) · input/header line (top+1) · rule
254
+ // (top+2) · result rows (top+3 …) · an optional "no matches" row when empty ·
255
+ // border. Rows span the box interior in x: [left+1, left+width-1).
256
+
257
+ /** Rows of chrome above the first result row: top border + input line + rule. */
258
+ export const PALETTE_HEADER_ROWS = 3;
259
+
260
+ /** The palette box geometry the router hit-tests: the placed box plus how many
261
+ * result rows are currently visible (min(count - top, pageRows)). */
262
+ export interface PaletteGeom {
263
+ left: number;
264
+ top: number;
265
+ width: number;
266
+ /** Result rows on screen right now (0 when the list is empty). */
267
+ visibleRows: number;
268
+ }
269
+
270
+ /** PURE — the overlay's placement for a terminal size: horizontally centered,
271
+ * top at a sixth of the height (min 1 — below the surface tab bar). MUST match
272
+ * the render's `left`/`top` props (the render calls this). */
273
+ export function palettePos(
274
+ termW: number,
275
+ termH: number,
276
+ width: number,
277
+ ): { left: number; top: number } {
278
+ return {
279
+ left: Math.max(0, Math.floor((termW - width) / 2)),
280
+ top: Math.max(1, Math.floor(termH / 6)),
281
+ };
282
+ }
283
+
284
+ /** PURE — total box height for `visibleRows` results: header chrome + the rows
285
+ * (an empty list still shows one "no matches"/"no buffers" row) + the bottom
286
+ * border. Used for inside/outside containment. */
287
+ export function paletteHeight(visibleRows: number): number {
288
+ return PALETTE_HEADER_ROWS + Math.max(1, visibleRows) + 1;
289
+ }
290
+
291
+ /** PURE — the VISIBLE result-row index under (x, y), or -1. Only real rows hit
292
+ * (the "no matches" placeholder row is not clickable); x must be inside the
293
+ * box interior (borders excluded). */
294
+ export function paletteRowAt(g: PaletteGeom, x: number, y: number): number {
295
+ if (x < g.left + 1 || x >= g.left + g.width - 1) return -1;
296
+ const row = y - (g.top + PALETTE_HEADER_ROWS);
297
+ return row >= 0 && row < g.visibleRows ? row : -1;
298
+ }
299
+
300
+ /** PURE — whether (x, y) falls anywhere on the palette box (border included).
301
+ * A press outside dismisses the overlay; inside-but-not-a-row is a no-op. */
302
+ export function paletteContains(g: PaletteGeom, x: number, y: number): boolean {
303
+ return (
304
+ x >= g.left && x < g.left + g.width && y >= g.top && y < g.top + paletteHeight(g.visibleRows)
305
+ );
306
+ }
307
+
308
+ /** PURE — clamp a wheel-scrolled list top into [0, count - pageRows]. */
309
+ export function clampPaletteTop(top: number, count: number, pageRows: number): number {
310
+ return Math.max(0, Math.min(top, count - pageRows));
311
+ }
312
+
313
+ /** A typed query "looks like a path" when it carries a separator or dot — then
314
+ * the Open-file action is worth pinning to the top of the results. */
315
+ function looksLikePath(q: string): boolean {
316
+ return /[/.~]/.test(q);
317
+ }
318
+
319
+ /** Cap on "Go to file:" rows so a big repo never floods the palette. */
320
+ export const GO_FILE_CAP = 8;
321
+
322
+ /**
323
+ * PURE (M24.6) — the dynamic "Go to file:" rows for `query`: the repo's
324
+ * (ignore-respecting) file list fuzzy-filtered against the query, one open
325
+ * action per match, capped at `cap`. An empty query offers none — the static
326
+ * list stays uncluttered. Strictly ADDITIVE to the palette: this only CALLS
327
+ * the shared fuzzy filter, and its rows are appended after the existing
328
+ * results (see {@link filterPaletteActions}).
329
+ */
330
+ export function goToFileActions(
331
+ query: string,
332
+ repoFiles: readonly string[],
333
+ cap: number = GO_FILE_CAP,
334
+ ): PaletteAction[] {
335
+ const q = query.trim();
336
+ if (q.length === 0 || repoFiles.length === 0) return [];
337
+ return fuzzyFilter(q, [...repoFiles], (p) => p)
338
+ .slice(0, cap)
339
+ .map((m) => ({ kind: "go-file" as const, path: m.item, label: `Go to file: ${m.item}` }));
340
+ }
341
+
342
+ /**
343
+ * PURE — the STABLE identity of an action for usage history (M24.4): the kind
344
+ * plus the payload fields that survive restarts (session/layout/settings id/
345
+ * path — NOT the label, so relabeling never orphans history, and NOT pane ids,
346
+ * which tmux renumbers). Kinds without a stable payload are keyed by kind
347
+ * alone (rename-window's typed name is query-dependent, not identity).
348
+ */
349
+ export function paletteActionKey(a: PaletteAction): string {
350
+ switch (a.kind) {
351
+ case "tab":
352
+ return `tab:${a.tab}`;
353
+ case "attach":
354
+ return `attach:${a.session}`;
355
+ case "jump-agent":
356
+ return `jump-agent:${a.session}`;
357
+ case "restart-agent":
358
+ return `restart-agent:${a.session}:${a.agentKind}`;
359
+ case "stop-agent":
360
+ return `stop-agent:${a.session}:${a.agentKind}`;
361
+ case "select-layout":
362
+ return `select-layout:${a.layout}`;
363
+ case "settings":
364
+ return `settings:${a.id}`;
365
+ case "open-file":
366
+ return `open-file:${a.path}`;
367
+ default:
368
+ return a.kind;
369
+ }
370
+ }
371
+
372
+ /** PURE — the usage comparator (frequency first, then recency): negative when
373
+ * `a` should rank before `b`. Unused actions rank last, mutually tied. */
374
+ function compareUsage(
375
+ a: PaletteAction,
376
+ b: PaletteAction,
377
+ usage: Readonly<Record<string, PaletteUsageEntry>> | undefined,
378
+ ): number {
379
+ const ua = usage?.[paletteActionKey(a)];
380
+ const ub = usage?.[paletteActionKey(b)];
381
+ return (ub?.count ?? 0) - (ua?.count ?? 0) || (ub?.lastUsed ?? 0) - (ua?.lastUsed ?? 0);
382
+ }
383
+
384
+ /**
385
+ * PURE — the palette result list for `query`: the static actions fuzzy-filtered
386
+ * and score-sorted (exact/prefix matches outrank mid-word via the scorer's
387
+ * label-start weighting; score TIES break by usage — frequency then recency —
388
+ * M24.4), plus (when the query is non-empty) a dynamic
389
+ * "Open file: <query>" action. That open-file entry is pinned FIRST when the
390
+ * query looks like a path, otherwise appended LAST so it never buries a real
391
+ * match. An empty query returns every static action in natural order.
392
+ */
393
+ export function filterPaletteActions(
394
+ query: string,
395
+ sessions: string[],
396
+ ctx: PaletteContext = {},
397
+ ): PaletteAction[] {
398
+ const statics = staticPaletteActions(sessions, ctx);
399
+ const q = query.trim();
400
+ const matched =
401
+ q.length === 0
402
+ ? statics
403
+ : fuzzyFilter(query, statics, (a) => a.label)
404
+ .sort((x, y) => y.score - x.score || compareUsage(x.item, y.item, ctx.usage))
405
+ .map((m) => m.item);
406
+ if (q.length === 0) return matched;
407
+ const dynamic: PaletteAction[] = [{ kind: "open-file", path: q, label: `Open file: ${q}` }];
408
+ // On the Terminal surface a non-empty query also offers a rename-to-<query>
409
+ // window verb (there is no way to fuzzy-match a name you are still typing —
410
+ // same reasoning as the open-file entry).
411
+ if (ctx.terminal) {
412
+ dynamic.push({ kind: "rename-window", name: q, label: `Rename window: ${q}` });
413
+ }
414
+ // "Go to file:" rows (M24.6) are APPENDED after everything so this addition
415
+ // cannot disturb the existing result ranking.
416
+ const goFiles = goToFileActions(q, ctx.repoFiles ?? []);
417
+ return looksLikePath(q)
418
+ ? [...dynamic, ...matched, ...goFiles]
419
+ : [...matched, ...dynamic, ...goFiles];
420
+ }
421
+
422
+ // ── Grouped rows (M24.4 — the palette's list model) ─────────────────────────
423
+ // The overlay renders ROWS, not bare actions: on an EMPTY query the list opens
424
+ // with a "recent" group (top used actions), a contextual "suggested" group,
425
+ // then everything else under "commands"; a typed query is one flat ranked list
426
+ // (no headers). Headers are real rows — they occupy a screen line, scroll with
427
+ // the list, and are NOT selectable/clickable — so the selection helpers below
428
+ // are what the keyboard/router use to move between action rows.
429
+
430
+ /** One rendered palette line: a group header, or an action with its optional
431
+ * right-aligned keycap (from ctx.keycaps). */
432
+ export type PaletteRow =
433
+ | { type: "header"; label: string }
434
+ | { type: "action"; action: PaletteAction; shortcut: string | null };
435
+
436
+ /** How many actions the empty-query "recent" group shows at most. */
437
+ export const PALETTE_RECENT_LIMIT = 5;
438
+
439
+ /** The Terminal surface's suggested window/pane verbs, in offer order. */
440
+ const TERMINAL_SUGGESTED_KINDS: ReadonlyArray<PaletteAction["kind"]> = [
441
+ "new-window",
442
+ "kill-window",
443
+ "zoom-pane",
444
+ "swap-pane",
445
+ "break-pane",
446
+ "rotate-window",
447
+ ];
448
+
449
+ /**
450
+ * PURE — the palette rows for `query` (M24.4). A non-empty query is the flat
451
+ * {@link filterPaletteActions} ranking. An empty query groups:
452
+ * 1. "recent" — up to {@link PALETTE_RECENT_LIMIT} previously-run actions
453
+ * still offerable now (usage keys matched against the current statics),
454
+ * most-frequently-used first, recency breaking ties;
455
+ * 2. "suggested" — BLOCKED agents' jump actions first (ctx.agents arrives
456
+ * attention-sorted, so their relative order is the sidebar's), then the
457
+ * surface's verbs: Terminal → the again-spawn + window/pane verbs, Files
458
+ * → save + open-folder;
459
+ * 3. "commands" — every remaining action in today's natural order.
460
+ * With nothing to group (no usage, no suggestions) the list is exactly the
461
+ * ungrouped statics — no headers, same as before this card.
462
+ */
463
+ export function paletteRows(
464
+ query: string,
465
+ sessions: string[],
466
+ ctx: PaletteContext = {},
467
+ ): PaletteRow[] {
468
+ const row = (action: PaletteAction): PaletteRow => ({
469
+ type: "action",
470
+ action,
471
+ shortcut: ctx.keycaps?.[paletteActionKey(action)] ?? null,
472
+ });
473
+ if (query.trim().length > 0) return filterPaletteActions(query, sessions, ctx).map(row);
474
+
475
+ const statics = staticPaletteActions(sessions, ctx);
476
+ // First static per key — recents resolve through this so a persisted key
477
+ // finds the action's CURRENT incarnation (relabels don't orphan history).
478
+ const byKey = new Map<string, PaletteAction>();
479
+ for (const a of statics) {
480
+ const k = paletteActionKey(a);
481
+ if (!byKey.has(k)) byKey.set(k, a);
482
+ }
483
+ const taken = new Set<PaletteAction>();
484
+
485
+ const recent: PaletteAction[] = [];
486
+ const usedKeys = Object.entries(ctx.usage ?? {})
487
+ .sort(([, a], [, b]) => b.count - a.count || b.lastUsed - a.lastUsed)
488
+ .map(([k]) => k);
489
+ for (const k of usedKeys) {
490
+ if (recent.length >= PALETTE_RECENT_LIMIT) break;
491
+ const a = byKey.get(k);
492
+ if (a && !taken.has(a)) {
493
+ recent.push(a);
494
+ taken.add(a);
495
+ }
496
+ }
497
+
498
+ const suggested: PaletteAction[] = [];
499
+ const suggest = (pred: (a: PaletteAction) => boolean): void => {
500
+ for (const a of statics) {
501
+ if (!taken.has(a) && pred(a)) {
502
+ suggested.push(a);
503
+ taken.add(a);
504
+ }
505
+ }
506
+ };
507
+ // Blocked agents outrank every other suggestion — they are why the user is
508
+ // opening the palette (the sidebar's attention-first law, applied here).
509
+ const blockedPanes = new Set(
510
+ (ctx.agents ?? []).filter((x) => x.state === "blocked").map((x) => x.paneId),
511
+ );
512
+ suggest((a) => a.kind === "jump-agent" && blockedPanes.has(a.paneId));
513
+ if (ctx.surface === "terminal") {
514
+ suggest((a) => a.kind === "new-agent-again");
515
+ suggest((a) => TERMINAL_SUGGESTED_KINDS.includes(a.kind));
516
+ } else if (ctx.surface === "files") {
517
+ suggest((a) => a.kind === "save" || a.kind === "open-folder");
518
+ }
519
+
520
+ if (recent.length === 0 && suggested.length === 0) return statics.map(row);
521
+ const rows: PaletteRow[] = [];
522
+ if (recent.length > 0) {
523
+ rows.push({ type: "header", label: "recent" });
524
+ for (const a of recent) rows.push(row(a));
525
+ }
526
+ if (suggested.length > 0) {
527
+ rows.push({ type: "header", label: "suggested" });
528
+ for (const a of suggested) rows.push(row(a));
529
+ }
530
+ rows.push({ type: "header", label: "commands" });
531
+ for (const a of statics) if (!taken.has(a)) rows.push(row(a));
532
+ return rows;
533
+ }
534
+
535
+ /** PURE — the first selectable (action) row index, or -1 when none. Where the
536
+ * selection starts on open/query edits (headers are never selected). */
537
+ export function firstPaletteAction(rows: readonly PaletteRow[]): number {
538
+ return rows.findIndex((r) => r.type === "action");
539
+ }
540
+
541
+ /** PURE — the next action row from `cur` in `dir` (±1), skipping headers;
542
+ * stays put at the list's ends. The keyboard's up/down. */
543
+ export function stepPaletteRow(rows: readonly PaletteRow[], cur: number, dir: 1 | -1): number {
544
+ for (let i = cur + dir; i >= 0 && i < rows.length; i += dir) {
545
+ if (rows[i]!.type === "action") return i;
546
+ }
547
+ return cur;
548
+ }
549
+
550
+ /**
551
+ * PURE — one action row's text after the selection prefix: the label, then —
552
+ * when the row has a keycap — right-aligned padding + the keycap inside
553
+ * `width` cells. The label truncates with an ellipsis so the keycap ALWAYS
554
+ * fits (min one space between); without a keycap the label just truncates.
555
+ */
556
+ export function paletteRowText(label: string, shortcut: string | null, width: number): string {
557
+ if (width <= 0) return "";
558
+ if (!shortcut || shortcut.length + 2 > width) {
559
+ return label.length <= width ? label : label.slice(0, Math.max(0, width - 1)) + "…";
560
+ }
561
+ const labelMax = width - shortcut.length - 1;
562
+ const shown = label.length <= labelMax ? label : label.slice(0, Math.max(0, labelMax - 1)) + "…";
563
+ return shown + " ".repeat(width - shown.length - shortcut.length) + shortcut;
564
+ }