tmux-ide 2.7.0 → 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 (101) hide show
  1. package/README.md +22 -5
  2. package/bin/cli.js +3532 -1090
  3. package/bin/cli.ts +368 -71
  4. package/package.json +2 -1
  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/control/client.d.ts +23 -0
  9. package/packages/daemon/dist/control/client.js +105 -0
  10. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  11. package/packages/daemon/dist/control/dispatch.js +83 -0
  12. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  13. package/packages/daemon/dist/control/fanout.js +37 -0
  14. package/packages/daemon/dist/control/frames.d.ts +23 -0
  15. package/packages/daemon/dist/control/frames.js +37 -0
  16. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  17. package/packages/daemon/dist/control/lifecycle.js +114 -0
  18. package/packages/daemon/dist/control/server.d.ts +16 -0
  19. package/packages/daemon/dist/control/server.js +214 -0
  20. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  21. package/packages/daemon/dist/control/verbs.js +91 -0
  22. package/packages/daemon/dist/doctor.d.ts +18 -0
  23. package/packages/daemon/dist/doctor.js +105 -15
  24. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  25. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  26. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  27. package/packages/daemon/dist/lib/app-config.js +104 -5
  28. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  29. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  30. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  31. package/packages/daemon/dist/lib/state-home.js +12 -0
  32. package/packages/daemon/dist/lib/update-check.js +5 -0
  33. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  34. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  35. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  36. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  39. package/packages/daemon/dist/restore.d.ts +35 -8
  40. package/packages/daemon/dist/restore.js +52 -15
  41. package/packages/daemon/dist/send.d.ts +33 -1
  42. package/packages/daemon/dist/send.js +32 -19
  43. package/packages/daemon/src/control/client.ts +128 -0
  44. package/packages/daemon/src/control/dispatch.ts +107 -0
  45. package/packages/daemon/src/control/fanout.ts +44 -0
  46. package/packages/daemon/src/control/frames.ts +40 -0
  47. package/packages/daemon/src/control/lifecycle.ts +151 -0
  48. package/packages/daemon/src/control/server.ts +237 -0
  49. package/packages/daemon/src/control/verbs.ts +118 -0
  50. package/packages/daemon/src/doctor.ts +113 -28
  51. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  52. package/packages/daemon/src/lib/app-config.ts +103 -5
  53. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  54. package/packages/daemon/src/lib/state-home.ts +13 -0
  55. package/packages/daemon/src/lib/update-check.ts +5 -0
  56. package/packages/daemon/src/restore.ts +53 -15
  57. package/packages/daemon/src/send.ts +55 -21
  58. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  59. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  60. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  61. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  62. package/packages/daemon/src/tui/chrome/notify.ts +582 -84
  63. package/packages/daemon/src/tui/chrome/updater.ts +268 -62
  64. package/packages/daemon/src/tui/detect/classify.ts +34 -0
  65. package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
  66. package/packages/daemon/src/tui/detect/manifest.ts +24 -3
  67. package/packages/daemon/src/tui/detect/manifests.ts +240 -6
  68. package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
  69. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  70. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  71. package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
  72. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  73. package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
  74. package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
  75. package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
  76. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  77. package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
  78. package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
  79. package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
  80. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  81. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  82. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  83. package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
  84. package/packages/daemon/src/tui/mirror/palette.ts +299 -9
  85. package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
  86. package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
  87. package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
  88. package/packages/daemon/src/tui/mirror/selection.ts +122 -8
  89. package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
  90. package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
  91. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  92. package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
  93. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  94. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  95. package/packages/daemon/src/tui/team/sessions.ts +85 -7
  96. package/packages/daemon/src/tui/team/wait.ts +144 -0
  97. package/scripts/build-macos-notifier.mjs +160 -0
  98. package/scripts/postinstall.js +8 -1
  99. package/scripts/prepublish-check.mjs +37 -1
  100. package/scripts/publish-tap.sh +55 -0
  101. package/skill/SKILL.md +88 -2
@@ -16,7 +16,7 @@
16
16
  /** The surfaces a right-click can target. Each maps to a fixed item list; the
17
17
  * concrete payload (session name, file path, pane id) rides in the app's menu
18
18
  * state, not here. */
19
- export type MenuRegion = "session" | "file" | "difffile" | "pane" | "window";
19
+ export type MenuRegion = "session" | "agent" | "file" | "difffile" | "pane" | "window";
20
20
 
21
21
  /** One menu entry. `id` is what the app dispatches on; `danger` items rearm to
22
22
  * a "confirm: y" state instead of firing immediately; `input` items open an
@@ -36,9 +36,21 @@ export interface MenuItem {
36
36
  export const MENU_ITEMS: Record<MenuRegion, MenuItem[]> = {
37
37
  session: [
38
38
  { id: "attach", label: "Attach" },
39
+ { id: "new-agent", label: "New agent…" },
39
40
  { id: "rename", label: "Rename", input: "rename to" },
40
41
  { id: "kill", label: "Kill session", danger: true },
41
42
  ],
43
+ // Sidebar AGENT rows (M23.1): a left-click jumps; the context menu carries
44
+ // the lifecycle verbs. "Stop agent" only interrupts (the pane survives);
45
+ // "Close pane" is the destructive twin that also kills the pane. restart/
46
+ // stop confirm via DialogConfirm in the app (plain-language body), so only
47
+ // close — where there is no dialog — uses the inline confirm rearm.
48
+ agent: [
49
+ { id: "jump", label: "Go to agent" },
50
+ { id: "restart", label: "Restart agent" },
51
+ { id: "stop", label: "Stop agent" },
52
+ { id: "close", label: "Close pane", danger: true },
53
+ ],
42
54
  file: [
43
55
  { id: "open", label: "Open" },
44
56
  { id: "newfile", label: "New file", input: "new file" },
@@ -81,13 +93,24 @@ export const MENU_ITEMS: Record<MenuRegion, MenuItem[]> = {
81
93
  * panes (the app turned mouse reporting on — presses are forwarded, so a drag
82
94
  * can't start a selection) get a leading "Select text…" verb that pauses
83
95
  * forwarding for that pane; while its select mode is active the entry flips to
84
- * the exit verb. Ordinary panes keep the fixed list untouched. */
85
- export function paneMenuItems(appMouse: boolean, selectModeOn: boolean): MenuItem[] {
96
+ * the exit verb. They also get the drag-default toggle (M24.2): the label is
97
+ * always the OTHER default (`dragDefault` is the pane's current one, override
98
+ * and config applied), so picking it flips the pane for the session. Ordinary
99
+ * panes keep the fixed list untouched. */
100
+ export function paneMenuItems(
101
+ appMouse: boolean,
102
+ selectModeOn: boolean,
103
+ dragDefault: "select" | "forward",
104
+ ): MenuItem[] {
86
105
  if (!appMouse) return MENU_ITEMS.pane;
87
106
  const entry: MenuItem = selectModeOn
88
107
  ? { id: "select-text-off", label: "Stop selecting" }
89
108
  : { id: "select-text", label: "Select text…" };
90
- return [entry, ...MENU_ITEMS.pane];
109
+ const dragToggle: MenuItem =
110
+ dragDefault === "select"
111
+ ? { id: "drag-forward", label: "Forward mouse drags" }
112
+ : { id: "drag-select", label: "Select on drag" };
113
+ return [entry, dragToggle, ...MENU_ITEMS.pane];
91
114
  }
92
115
 
93
116
  /** The overlay's border (1) + horizontal padding (1 each side). The header row
@@ -12,7 +12,7 @@
12
12
  * to the top when the query looks like a path.
13
13
  */
14
14
  import { fuzzyFilter } from "../team/fuzzy.ts";
15
- import type { Tab } from "./app-state.ts";
15
+ import type { PaletteUsageEntry, Tab } from "./app-state.ts";
16
16
  import type { AgentRowInput } from "./agent-rows.ts";
17
17
  import { SETTINGS_PALETTE_COMMANDS, type SettingsCommandId } from "./settings-model.ts";
18
18
 
@@ -23,10 +23,26 @@ export type PaletteAction =
23
23
  | { kind: "open-folder"; label: string }
24
24
  | { kind: "attach"; session: string; label: string }
25
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 }
26
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 }
27
42
  | { kind: "save"; label: string }
28
43
  | { kind: "refresh-diff"; label: string }
29
44
  | { kind: "paste-buffer"; label: string }
45
+ | { kind: "search-scrollback"; label: string }
30
46
  | { kind: "new-window"; label: string }
31
47
  | { kind: "rename-window"; name: string; label: string }
32
48
  | { kind: "kill-window"; label: string }
@@ -69,6 +85,27 @@ export interface PaletteContext {
69
85
  * "Select text in pane" offered (ordinary panes drag-select directly, so the
70
86
  * action would be a no-op that clutters the list). */
71
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[];
72
109
  }
73
110
 
74
111
  const TAB_LABELS: { tab: Tab; label: string }[] = [
@@ -85,14 +122,24 @@ export function staticPaletteActions(
85
122
  sessions: string[],
86
123
  ctx: PaletteContext = {},
87
124
  ): PaletteAction[] {
88
- const actions: PaletteAction[] = TAB_LABELS.map((t) => ({
89
- kind: "tab" as const,
90
- tab: t.tab,
91
- label: t.label,
92
- }));
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 });
93
133
  // The non-technicals' front door (M22.5): a filesystem picker → create-or-
94
134
  // attach a session in the chosen folder. Offered on every surface.
95
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…" });
96
143
  for (const s of sessions) {
97
144
  actions.push({ kind: "attach", session: s, label: `Attach session: ${s}` });
98
145
  }
@@ -108,6 +155,25 @@ export function staticPaletteActions(
108
155
  label: `Agent: ${a.kind} · ${a.session} (${a.state})`,
109
156
  });
110
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
+ }
111
177
  actions.push({ kind: "save", label: "Save file" });
112
178
  actions.push({ kind: "refresh-diff", label: "Refresh diff" });
113
179
  // Paste target is the focused surface (a pane, or the editor buffer), so this
@@ -120,6 +186,10 @@ export function staticPaletteActions(
120
186
  actions.push({ kind: "settings", id: c.id, label: c.label });
121
187
  }
122
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" });
123
193
  actions.push({ kind: "new-window", label: "New window" });
124
194
  actions.push({ kind: "kill-window", label: "Kill window" });
125
195
  actions.push({ kind: "zoom-pane", label: "Zoom pane" });
@@ -246,9 +316,76 @@ function looksLikePath(q: string): boolean {
246
316
  return /[/.~]/.test(q);
247
317
  }
248
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
+
249
384
  /**
250
385
  * PURE — the palette result list for `query`: the static actions fuzzy-filtered
251
- * and score-sorted, plus (when the query is non-empty) a dynamic
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
252
389
  * "Open file: <query>" action. That open-file entry is pinned FIRST when the
253
390
  * query looks like a path, otherwise appended LAST so it never buries a real
254
391
  * match. An empty query returns every static action in natural order.
@@ -261,7 +398,11 @@ export function filterPaletteActions(
261
398
  const statics = staticPaletteActions(sessions, ctx);
262
399
  const q = query.trim();
263
400
  const matched =
264
- q.length === 0 ? statics : fuzzyFilter(query, statics, (a) => a.label).map((m) => m.item);
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);
265
406
  if (q.length === 0) return matched;
266
407
  const dynamic: PaletteAction[] = [{ kind: "open-file", path: q, label: `Open file: ${q}` }];
267
408
  // On the Terminal surface a non-empty query also offers a rename-to-<query>
@@ -270,5 +411,154 @@ export function filterPaletteActions(
270
411
  if (ctx.terminal) {
271
412
  dynamic.push({ kind: "rename-window", name: q, label: `Rename window: ${q}` });
272
413
  }
273
- return looksLikePath(q) ? [...dynamic, ...matched] : [...matched, ...dynamic];
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;
274
564
  }
@@ -40,6 +40,7 @@ import {
40
40
  type GraphemeOverride,
41
41
  } from "./blit.ts";
42
42
  import { AckWriter } from "./ack-writer.ts";
43
+ import { extractSelection, type Cell } from "./selection.ts";
43
44
 
44
45
  /** OpenTUI TextAttributes bit values (kept literal to avoid the dep here). */
45
46
  const ATTR_BOLD = 1;
@@ -163,6 +164,18 @@ export class PaneMirror {
163
164
  * fast path. Counted from xterm's onScroll (which fires once per scrolled
164
165
  * line and keeps firing at the scrollback cap, unlike the saturating payload). */
165
166
  private _pendingScroll = 0;
167
+ // ── Absolute-line stability (M25.6) ────────────────────────────────────────
168
+ /** Lines TRIMMED off the top of the scrollback (monotonic): once viewportY
169
+ * saturates at the scrollback cap, each further scroll rotates one line out
170
+ * and every retained line's absolute index drops by one. An absolute-space
171
+ * consumer (the drag selection) records `lineTrim()` when it anchors and
172
+ * subtracts the delta later, so its anchor keeps naming the same content.
173
+ * Detected as "onScroll fired but viewportY did not grow". A scrollback
174
+ * clear (`clear-history` / ED3) collapses viewportY instead — that index
175
+ * shift is NOT counted; stale absolute cells then clamp into the buffer
176
+ * (the selection was over content that no longer exists anyway). */
177
+ private _trimmed = 0;
178
+ private _lastViewportY = 0;
166
179
  /** The alt/normal buffer swapped (`?1049h/l`) — the whole grid is new. */
167
180
  private _bufferSwapped = false;
168
181
  /** Shadow of the last-blitted rows' raw xterm cell data (`_line._data`,
@@ -175,12 +188,19 @@ export class PaneMirror {
175
188
  * incremental. */
176
189
  private _incremental = true;
177
190
 
178
- constructor(cols: number, rows: number) {
191
+ constructor(cols: number, rows: number, scrollback = 5000) {
179
192
  this.cols = cols;
180
193
  this.rows = rows;
181
- this.term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 5000 });
194
+ this.term = new Terminal({ cols, rows, allowProposedApi: true, scrollback });
182
195
  this.term.onWriteParsed(() => this._version++);
183
- this.term.onScroll(() => this._pendingScroll++);
196
+ this.term.onScroll(() => {
197
+ this._pendingScroll++;
198
+ // Trim detection (M25.6): below the cap each scroll grows viewportY by
199
+ // one; at the cap it stays put and the oldest line rotated out.
200
+ const vy = this.term.buffer.active.viewportY;
201
+ if (vy === this._lastViewportY) this._trimmed++;
202
+ else this._lastViewportY = vy;
203
+ });
184
204
  this.term.buffer.onBufferChange(() => {
185
205
  this._bufferSwapped = true;
186
206
  this._version++;
@@ -234,9 +254,20 @@ export class PaneMirror {
234
254
  this._shadow = null;
235
255
  this._pendingScroll = 0;
236
256
  this._bufferSwapped = false;
257
+ // Reflow moved viewportY without scrolls — re-baseline the trim detector so
258
+ // the first post-resize scroll isn't miscounted as a cap rotation. (Reflow
259
+ // also re-indexes wrapped lines; a mid-resize drag selection is approximate
260
+ // by nature and clamps safely.)
261
+ this._lastViewportY = this.term.buffer.active.viewportY;
237
262
  this._version++;
238
263
  }
239
264
 
265
+ /** Monotonic count of lines trimmed off the scrollback top (M25.6) — the
266
+ * absolute-line drift an anchored consumer must subtract. See {@link _trimmed}. */
267
+ lineTrim(): number {
268
+ return this._trimmed;
269
+ }
270
+
240
271
  /** Lines available above the live viewport (how far back scroll can go). */
241
272
  scrollbackDepth(): number {
242
273
  return this.term.buffer.active.viewportY;
@@ -420,7 +451,12 @@ export class PaneMirror {
420
451
  const forceRows = opts.forceRows && opts.forceRows.length ? opts.forceRows : null;
421
452
 
422
453
  for (let y = 0; y < height; y++) {
423
- const data = this._incremental ? this.rowData(baseY, y) : null;
454
+ const raw = this._incremental ? this.rowData(baseY, y) : null;
455
+ // Bounds guard (M23.5, D5): after a shrink xterm's reflow can leave a
456
+ // line's `_data` at a length ≠ cols×3; comparing or `set`ting it against
457
+ // the rowLen-strided shadow would read garbage / throw RangeError. Treat
458
+ // such a row as shadow-less: always repaint it, never write the shadow.
459
+ const data = raw !== null && raw.length === rowLen ? raw : null;
424
460
  let dirty = full || y >= bottomDirtyFrom || data === null; // no shadow info for this row → always repaint
425
461
  if (!dirty && this._shadow) dirty = !shadowMatches(this._shadow, y * rowLen, data!);
426
462
  if (!dirty && forceRows) {
@@ -555,6 +591,48 @@ export class PaneMirror {
555
591
  return out;
556
592
  }
557
593
 
594
+ /**
595
+ * Extract the text of an ORDERED absolute-line range (M25.6) — the release
596
+ * path of a selection that may span far beyond the viewport. Each buffer
597
+ * line reads via `translateToString(true)` — the SAME trim/collapse as
598
+ * {@link visibleRowTexts} (wide-glyph spacers collapse, trailing blanks trim)
599
+ * — and the columns/joining go through {@link extractSelection}, so a
600
+ * selection that happens to fit one screen extracts byte-identically to the
601
+ * old visible-rows path. Wrapped lines stay separate visual rows joined with
602
+ * "\n", exactly as the on-screen copy always has.
603
+ *
604
+ * `maxBytes` bounds the string BUILT here: accumulation stops once the raw
605
+ * line total strictly exceeds it, so a runaway span never materializes tens
606
+ * of MB — the caller's clipboard cap (which refuses anything over the limit)
607
+ * still sees an over-limit byte count and refuses honestly.
608
+ */
609
+ extractAbsoluteText(start: Cell, end: Cell, maxBytes: number): string {
610
+ const buf = this.term.buffer.active;
611
+ const last = buf.length - 1;
612
+ if (last < 0 || end.row < 0 || start.row > last) return "";
613
+ const lo = Math.max(0, Math.min(start.row, last));
614
+ const hi = Math.max(0, Math.min(end.row, last));
615
+ const rowTexts: string[] = [];
616
+ let bytes = 0;
617
+ for (let y = lo; y <= hi; y++) {
618
+ const line = buf.getLine(y);
619
+ const text = line ? line.translateToString(true) : "";
620
+ rowTexts.push(text);
621
+ bytes += Buffer.byteLength(text, "utf8") + 1; // +1 for the joining "\n"
622
+ if (bytes > maxBytes) break;
623
+ }
624
+ // Shift the range to the accumulated slice. A clamped start selects from
625
+ // column 0 (its true start line is gone/off-buffer); a clamped or
626
+ // byte-truncated end extends to its last line's end (Infinity clamps to
627
+ // the row length inside rowSelectionRange).
628
+ const lastRowAbs = lo + rowTexts.length - 1;
629
+ return extractSelection(
630
+ rowTexts,
631
+ { row: 0, col: start.row === lo ? start.col : 0 },
632
+ { row: rowTexts.length - 1, col: lastRowAbs === end.row ? end.col : Infinity },
633
+ );
634
+ }
635
+
558
636
  dispose(): void {
559
637
  this.term.dispose();
560
638
  }
@@ -31,7 +31,7 @@ import { appendFileSync } from "node:fs";
31
31
  import type { SessionMirror } from "./session-mirror.ts";
32
32
  import type { CursorState } from "./pane-mirror.ts";
33
33
  import { swapCells, paintBg, type GraphemeOverride } from "./blit.ts";
34
- import { rowSelectionRange, type Cell } from "./selection.ts";
34
+ import { rowSelectionRange, visibleSelRows, type Cell } from "./selection.ts";
35
35
  import type { SearchMatch } from "./search-model.ts";
36
36
 
37
37
  /** The scrollback-search highlight payload for one pane: matches keyed by
@@ -60,7 +60,10 @@ export interface PaneSurfaceOptions extends RenderableOptions<FrameBufferRendera
60
60
  paneFocused?: boolean;
61
61
  /** Bumps (coalesced, once per state tick) when this pane's content changed. */
62
62
  contentVersion?: number;
63
- /** The drag selection on THIS pane (already surface/pane-filtered), or null. */
63
+ /** The drag selection on THIS pane (already surface/pane-filtered and
64
+ * ordered), or null. ABSOLUTE buffer lines (M25.6): the walk maps them to
65
+ * visible rows per-frame against the pane's live baseY (depth − offset), so
66
+ * the highlight stays glued to its content while the view scrolls. */
64
67
  selRange?: { start: Cell; end: Cell } | null;
65
68
  search?: PaneSearchHighlight | null;
66
69
  }
@@ -234,7 +237,14 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
234
237
  this._lastScroll = this._scrollOffset;
235
238
  this._lastFocused = this._focusedPane;
236
239
 
237
- const newSelRows = this.selRows(h);
240
+ // The view's absolute→visible mapping (M25.6), re-read every walk: both the
241
+ // selection (absolute cells) and the search matches (absolute lines) resolve
242
+ // to visible rows against the CURRENT baseY, so a scroll — or new content
243
+ // pushing the depth — moves the highlights with their text. Same clamping as
244
+ // the blit's own offset math (depth here == viewportY there).
245
+ const depth = this._mirror.scrollbackDepth(this._paneId);
246
+ const baseY = depth - Math.min(Math.max(0, this._scrollOffset), depth);
247
+ const newSelRows = this.selRows(h, baseY);
238
248
  const newSearchRows = this.searchRows(h);
239
249
  // The live cursor (M21.6): the focused pane drives the hardware cursor; an
240
250
  // unfocused pane paints a quiet marker on its cursor cell. Read once.
@@ -309,7 +319,7 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
309
319
  if (sel) {
310
320
  for (let i = 0; i < newSelRows.length; i++) {
311
321
  const y = newSelRows[i]!;
312
- const r = rowSelectionRange(y, w, sel.start, sel.end);
322
+ const r = rowSelectionRange(baseY + y, w, sel.start, sel.end);
313
323
  if (r) swapCells(buffers, w, y, r.from, r.to);
314
324
  }
315
325
  }
@@ -370,15 +380,12 @@ class PaneSurfaceRenderable extends FrameBufferRenderable {
370
380
  }
371
381
 
372
382
  /** Visible rows covered by the current drag selection (clamped), or []. The
373
- * range arrives ordered (app.tsx passes `orderCells`). */
374
- private selRows(height: number): number[] {
383
+ * range arrives ordered (app.tsx passes `orderCells`) in ABSOLUTE buffer
384
+ * lines; `baseY` is the view's current absolute top (M25.6). */
385
+ private selRows(height: number, baseY: number): number[] {
375
386
  const sel = this._sel;
376
387
  if (!sel) return [];
377
- const lo = Math.max(0, sel.start.row);
378
- const hi = Math.min(height - 1, sel.end.row);
379
- const out: number[] = [];
380
- for (let y = lo; y <= hi; y++) out.push(y);
381
- return out;
388
+ return visibleSelRows(sel.start, sel.end, baseY, height);
382
389
  }
383
390
 
384
391
  /** Distinct visible rows carrying a search match (clamped), or []. */