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,88 @@
1
+ /**
2
+ * Pure scrollbar geometry (M19.5) — shared by the editor viewport, the diff
3
+ * pane, and any scrolled mirror pane.
4
+ *
5
+ * A 1-col track at the right edge of an always-present container; the thumb's
6
+ * position and size are pure functions of (viewportTop, contentLen, viewH).
7
+ * Both the RENDER (draws the track cells) and the ROUTER (classifies a press as
8
+ * thumb vs track, maps a thumb drag to an absolute scroll top, pages a track
9
+ * click) read the SAME math from here — the surface-bar discipline applied to a
10
+ * vertical strip. Deliberately opentui-free so it unit-tests under Node.
11
+ */
12
+
13
+ export interface ScrollThumb {
14
+ /** Whether the content overflows the viewport (nothing to scroll → hidden). */
15
+ overflow: boolean;
16
+ /** First track row (0-based, within [0, viewH)) the thumb occupies. */
17
+ start: number;
18
+ /** Thumb height in rows (≥1, ≤viewH). */
19
+ size: number;
20
+ }
21
+
22
+ /** The thumb size for a content/viewport pair — a viewport-fraction of the
23
+ * track, floored at one cell so a thumb always exists. Shared by every
24
+ * function below so their maths never drift. */
25
+ function thumbSize(contentLen: number, viewH: number): number {
26
+ return Math.max(1, Math.min(viewH, Math.round((viewH * viewH) / contentLen)));
27
+ }
28
+
29
+ /** The thumb geometry for a scroll position. `viewportTop` is the first visible
30
+ * content line; `contentLen` the total lines; `viewH` the visible rows. When
31
+ * the content fits, `overflow` is false and the caller draws no track. */
32
+ export function scrollThumb(viewportTop: number, contentLen: number, viewH: number): ScrollThumb {
33
+ if (viewH <= 0 || contentLen <= viewH) {
34
+ return { overflow: false, start: 0, size: Math.max(1, viewH) };
35
+ }
36
+ const size = thumbSize(contentLen, viewH);
37
+ const maxTop = contentLen - viewH;
38
+ const maxStart = viewH - size;
39
+ const top = Math.max(0, Math.min(maxTop, viewportTop));
40
+ const start = maxStart <= 0 ? 0 : Math.round((top / maxTop) * maxStart);
41
+ return { overflow: true, start: Math.max(0, Math.min(maxStart, start)), size };
42
+ }
43
+
44
+ /** Where a track row falls relative to the thumb — the fourth drag-origin's
45
+ * press classifier and the page-click direction source. */
46
+ export type TrackZone = "above" | "thumb" | "below";
47
+
48
+ export function trackZone(row: number, thumb: ScrollThumb): TrackZone {
49
+ if (row < thumb.start) return "above";
50
+ if (row >= thumb.start + thumb.size) return "below";
51
+ return "thumb";
52
+ }
53
+
54
+ /** Page the viewport toward a click at track `row` (above the thumb → up one
55
+ * viewport, below → down one; on the thumb → unchanged), clamped to range. */
56
+ export function pageTop(
57
+ row: number,
58
+ viewportTop: number,
59
+ contentLen: number,
60
+ viewH: number,
61
+ ): number {
62
+ const thumb = scrollThumb(viewportTop, contentLen, viewH);
63
+ if (!thumb.overflow) return viewportTop;
64
+ const maxTop = contentLen - viewH;
65
+ const zone = trackZone(row, thumb);
66
+ const next =
67
+ zone === "above" ? viewportTop - viewH : zone === "below" ? viewportTop + viewH : viewportTop;
68
+ return Math.max(0, Math.min(maxTop, next));
69
+ }
70
+
71
+ /** Map a dragged thumb to an absolute scroll top. `trackRow` is the current
72
+ * pointer row within the track; `grabOffset` is the row within the thumb where
73
+ * the drag began (so the thumb doesn't jump under the cursor). Returns the new
74
+ * first-visible line, clamped. */
75
+ export function dragTop(
76
+ trackRow: number,
77
+ grabOffset: number,
78
+ contentLen: number,
79
+ viewH: number,
80
+ ): number {
81
+ if (viewH <= 0 || contentLen <= viewH) return 0;
82
+ const size = thumbSize(contentLen, viewH);
83
+ const maxStart = viewH - size;
84
+ const maxTop = contentLen - viewH;
85
+ if (maxStart <= 0) return 0;
86
+ const start = Math.max(0, Math.min(maxStart, trackRow - grabOffset));
87
+ return Math.round((start / maxStart) * maxTop);
88
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pure, io-free math for scrollback search (M20.3) — the copy-mode `/` finder,
3
+ * app-native. app.tsx owns the search-session state machine + the io (reading a
4
+ * pane's `bufferLines`, driving scrollOffset); this module owns the arithmetic,
5
+ * unit-tested without a terminal exactly like selection.ts / diff-model.ts.
6
+ *
7
+ * A match is a (line, col) into the pane's full buffer (0 = oldest scrollback
8
+ * line). The render maps a match line to a visible row and inverse/accent-tints
9
+ * its [col, col+len) span; a jump converts a match line to a scrollOffset.
10
+ */
11
+
12
+ /** One substring hit: `line` is the absolute buffer line, `col` the 0-based
13
+ * character column of the match start on that line. */
14
+ export interface SearchMatch {
15
+ line: number;
16
+ col: number;
17
+ }
18
+
19
+ /**
20
+ * Every case-insensitive substring occurrence of `query` across `lines`, in
21
+ * buffer order (top→bottom, left→right), one entry per occurrence (multiple per
22
+ * line allowed; non-overlapping — the scan advances past each hit). An empty
23
+ * query yields no matches.
24
+ */
25
+ export function findMatches(lines: readonly string[], query: string): SearchMatch[] {
26
+ const out: SearchMatch[] = [];
27
+ if (query.length === 0) return out;
28
+ const needle = query.toLowerCase();
29
+ for (let line = 0; line < lines.length; line++) {
30
+ const hay = (lines[line] ?? "").toLowerCase();
31
+ let from = 0;
32
+ for (;;) {
33
+ const idx = hay.indexOf(needle, from);
34
+ if (idx === -1) break;
35
+ out.push({ line, col: idx });
36
+ from = idx + needle.length;
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Matches in VISIT order — bottom-up (nearest the live viewport first), the
44
+ * order `/`-then-Enter lands on and n/N cycle through. So visit index 0 is the
45
+ * bottom-most match (shown as "1/N"), and `n` walks upward through the buffer.
46
+ * A fresh copy (does not mutate the input).
47
+ */
48
+ export function visitOrder(matches: readonly SearchMatch[]): SearchMatch[] {
49
+ return [...matches].reverse();
50
+ }
51
+
52
+ /** Cycle a match index by `dir` (+1 next / -1 prev) with wraparound; -1 stays
53
+ * -1 when there are no matches. */
54
+ export function stepMatch(current: number, dir: number, total: number): number {
55
+ if (total <= 0) return -1;
56
+ return (((current + dir) % total) + total) % total;
57
+ }
58
+
59
+ /**
60
+ * The scrollOffset (lines above the live viewport) that brings buffer `line`
61
+ * into view, positioned roughly at the middle of a `viewH`-row viewport, clamped
62
+ * to [0, depth]. `depth` is the scrollback budget (lines above the live top);
63
+ * the pane snapshot renders visible row `r` from buffer line `depth - offset + r`,
64
+ * so putting `line` at row `target` needs `offset = depth + target - line`.
65
+ */
66
+ export function offsetForMatch(line: number, depth: number, viewH: number): number {
67
+ const target = Math.floor(Math.max(0, viewH) / 2);
68
+ const raw = depth + target - line;
69
+ return Math.max(0, Math.min(depth, raw));
70
+ }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Pure, io-free geometry + text math for mouse selection & clipboard (M19.4).
3
+ *
4
+ * Two surfaces select the same way — the session MIRROR (pane snapshot rows)
5
+ * and the built-in EDITOR (buffer lines) — so the coordinate math lives here,
6
+ * unit-tested without a terminal, exactly like spans.ts / diff-model.ts. app.tsx
7
+ * owns the mouse-gesture state machine and the io (OSC52 to stdout, tmux
8
+ * passthrough, send-keys); this module owns the arithmetic.
9
+ *
10
+ * The selection is LINEAR (terminal-style, reading order), not rectangular: a
11
+ * cell is selected if it falls between the anchor and head in row-major order.
12
+ */
13
+
14
+ /** A cell in surface-local coordinates. Mirror (M25.6): ABSOLUTE xterm buffer
15
+ * line (0 = oldest scrollback line) + pane column — anchored to CONTENT, so
16
+ * the selection survives scrolling mid-drag and can span many screens. Editor:
17
+ * buffer line + buffer column (already absolute by nature). A visible pane row
18
+ * `r` at scroll offset `off` maps to absolute line `baseY + r` where
19
+ * `baseY = scrollbackDepth − off` (the search path's existing arithmetic —
20
+ * see PaneMirror.bufferLines). */
21
+ export interface Cell {
22
+ row: number;
23
+ col: number;
24
+ }
25
+
26
+ /** An active selection on one surface. `anchor` is where the drag began, `head`
27
+ * the current/last pointer cell; either may precede the other. Mirror cells
28
+ * are in ABSOLUTE buffer coordinates (see {@link Cell}). */
29
+ export type Selection =
30
+ | { surface: "mirror"; paneId: string; anchor: Cell; head: Cell }
31
+ | { surface: "editor"; anchor: Cell; head: Cell };
32
+
33
+ /** OpenTUI TextAttributes bit for inverse video (matches pane-mirror.ts). */
34
+ export const ATTR_INVERSE = 32;
35
+
36
+ /** Order two cells into reading order (row-major); both endpoints inclusive. */
37
+ export function orderCells(a: Cell, b: Cell): { start: Cell; end: Cell } {
38
+ if (a.row < b.row || (a.row === b.row && a.col <= b.col)) return { start: a, end: b };
39
+ return { start: b, end: a };
40
+ }
41
+
42
+ /**
43
+ * The inclusive [from,to] column interval selected on `row` for a linear
44
+ * selection between ordered endpoints, or null when the row is outside the
45
+ * selection or has no cells. `rowLen` is the row's character count.
46
+ */
47
+ export function rowSelectionRange(
48
+ row: number,
49
+ rowLen: number,
50
+ start: Cell,
51
+ end: Cell,
52
+ ): { from: number; to: number } | null {
53
+ if (row < start.row || row > end.row) return null;
54
+ if (rowLen <= 0) return null;
55
+ const rawFrom = row === start.row ? start.col : 0;
56
+ const rawTo = row === end.row ? end.col : rowLen - 1;
57
+ const from = Math.max(0, Math.min(rawFrom, rowLen - 1));
58
+ const to = Math.max(0, Math.min(rawTo, rowLen - 1));
59
+ if (to < from) return null;
60
+ return { from, to };
61
+ }
62
+
63
+ /**
64
+ * Extract the selected text from per-row plain strings between ordered
65
+ * endpoints. Rows join with "\n". When `trimTrailing` (mirror snapshot rows are
66
+ * space-padded to the pane width), trailing whitespace on each visual row is
67
+ * stripped — the terminal-copy convention; the editor passes false to keep
68
+ * buffer text exact.
69
+ */
70
+ export function extractSelection(
71
+ rowTexts: string[],
72
+ start: Cell,
73
+ end: Cell,
74
+ trimTrailing = true,
75
+ ): string {
76
+ const out: string[] = [];
77
+ for (let r = start.row; r <= end.row; r++) {
78
+ const text = rowTexts[r] ?? "";
79
+ const range = rowSelectionRange(r, text.length, start, end);
80
+ let seg = range ? text.slice(range.from, range.to + 1) : "";
81
+ if (trimTrailing) seg = seg.replace(/\s+$/u, "");
82
+ out.push(seg);
83
+ }
84
+ return out.join("\n");
85
+ }
86
+
87
+ // ── Absolute-space mapping (M25.6) ──────────────────────────────────────────
88
+ // The selection model is anchored to ABSOLUTE buffer lines; the render works in
89
+ // visible pane rows. These mappers are the one bridge: given the pane's current
90
+ // baseY (scrollbackDepth − scrollOffset) and height, an absolute range resolves
91
+ // to the visible rows it covers and the per-row column spans to highlight.
92
+
93
+ /** A highlighted span on one VISIBLE pane row (row is viewport-relative). */
94
+ export interface VisibleSpan {
95
+ row: number;
96
+ from: number;
97
+ to: number;
98
+ }
99
+
100
+ /** Visible pane rows [0,height) covered by an ORDERED absolute range at the
101
+ * current view (`baseY` = scrollbackDepth − scrollOffset). Empty when the
102
+ * whole range is scrolled off either edge. */
103
+ export function visibleSelRows(start: Cell, end: Cell, baseY: number, height: number): number[] {
104
+ const lo = Math.max(0, start.row - baseY);
105
+ const hi = Math.min(height - 1, end.row - baseY);
106
+ const out: number[] = [];
107
+ for (let r = lo; r <= hi; r++) out.push(r);
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Map an ORDERED absolute range to the visible per-row column spans at the
113
+ * current view. `rowLen(viewRow)` supplies each visible row's selectable cell
114
+ * count (grid width for the framebuffer swap; the row's char count for run
115
+ * tinting — wide glyphs collapse to one char there, so the two callers pass
116
+ * different lengths and each gets spans clamped to ITS space). Rows whose
117
+ * span is empty (zero-length row, or a first/last-row column interval that
118
+ * falls outside it) are omitted.
119
+ */
120
+ export function visibleSelectionSpans(
121
+ start: Cell,
122
+ end: Cell,
123
+ baseY: number,
124
+ height: number,
125
+ rowLen: (viewRow: number) => number,
126
+ ): VisibleSpan[] {
127
+ const out: VisibleSpan[] = [];
128
+ for (const r of visibleSelRows(start, end, baseY, height)) {
129
+ const range = rowSelectionRange(baseY + r, rowLen(r), start, end);
130
+ if (range) out.push({ row: r, from: range.from, to: range.to });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Re-anchor an absolute cell after the buffer trimmed `trimDelta` lines off its
137
+ * top (the scrollback cap rotating mid-drag): every retained line's index
138
+ * dropped by the trim, so the cell follows its content down. A cell whose line
139
+ * was trimmed away clamps to the oldest retained line (row 0), col 0 — the
140
+ * selection top pins to the oldest text still in the buffer.
141
+ */
142
+ export function trimAdjustCell(cell: Cell, trimDelta: number): Cell {
143
+ if (trimDelta <= 0) return cell;
144
+ const row = cell.row - trimDelta;
145
+ if (row < 0) return { row: 0, col: 0 };
146
+ return { row, col: cell.col };
147
+ }
148
+
149
+ /**
150
+ * The inclusive [from,to] char interval of the word at `col` in `text`,
151
+ * splitting on /\W/ (word chars are [A-Za-z0-9_]). A click on a non-word char
152
+ * selects just that char; an empty line selects {0,0}.
153
+ */
154
+ export function wordRangeAt(text: string, col: number): { from: number; to: number } {
155
+ const n = text.length;
156
+ if (n === 0) return { from: 0, to: 0 };
157
+ const c = Math.max(0, Math.min(col, n - 1));
158
+ const isWord = (ch: string): boolean => /\w/u.test(ch);
159
+ if (!isWord(text[c]!)) return { from: c, to: c };
160
+ let from = c;
161
+ let to = c;
162
+ while (from > 0 && isWord(text[from - 1]!)) from--;
163
+ while (to < n - 1 && isWord(text[to + 1]!)) to++;
164
+ return { from, to };
165
+ }
166
+
167
+ /** The inclusive [from,to] interval covering the line up to its last non-blank
168
+ * char (a blank line → {0,0}). */
169
+ export function lineRangeAt(text: string): { from: number; to: number } {
170
+ const trimmed = text.replace(/\s+$/u, "");
171
+ if (trimmed.length === 0) return { from: 0, to: 0 };
172
+ return { from: 0, to: trimmed.length - 1 };
173
+ }
174
+
175
+ /**
176
+ * The resulting click count (1/2/3, cycling to 1 after 3) for double/triple
177
+ * detection: a click at the same cell within `windowMs` of the previous one
178
+ * increments; anything else resets to 1.
179
+ */
180
+ export function clickCount(
181
+ prev: { row: number; col: number; ts: number; count: number } | null,
182
+ cell: Cell,
183
+ ts: number,
184
+ windowMs: number,
185
+ ): number {
186
+ if (prev && prev.row === cell.row && prev.col === cell.col && ts - prev.ts <= windowMs) {
187
+ return prev.count >= 3 ? 1 : prev.count + 1;
188
+ }
189
+ return 1;
190
+ }
191
+
192
+ /**
193
+ * Toggle the inverse attribute across chars [from,to] (inclusive, in row-char
194
+ * columns) of a row's styled runs, splitting runs at the boundaries so the
195
+ * selected span renders highlighted while every other run keeps its colors.
196
+ * XOR (not OR) so an already-inverse cell — e.g. the painted cursor — flips
197
+ * back, matching pane-mirror's cursor treatment.
198
+ */
199
+ export function tintRunsInverse<T extends { text: string; attributes: number }>(
200
+ runs: T[],
201
+ from: number,
202
+ to: number,
203
+ ): T[] {
204
+ if (to < from) return runs;
205
+ const out: T[] = [];
206
+ let col = 0;
207
+ for (const run of runs) {
208
+ const len = run.text.length;
209
+ const runStart = col;
210
+ const runEnd = col + len - 1;
211
+ col += len;
212
+ if (len === 0 || runEnd < from || runStart > to) {
213
+ out.push(run);
214
+ continue;
215
+ }
216
+ const a = Math.max(from, runStart) - runStart;
217
+ const b = Math.min(to, runEnd) - runStart;
218
+ if (a > 0) out.push({ ...run, text: run.text.slice(0, a) });
219
+ out.push({ ...run, text: run.text.slice(a, b + 1), attributes: run.attributes ^ ATTR_INVERSE });
220
+ if (b + 1 < len) out.push({ ...run, text: run.text.slice(b + 1) });
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * Set the background color across chars [from,to] (inclusive, in row-char
227
+ * columns) of a row's styled runs, splitting runs at the boundaries so the
228
+ * spanned cells render with `bg` while every other run keeps its colors. The
229
+ * twin of {@link tintRunsInverse} — search-match highlighting paints an accent
230
+ * background (a distinct treatment from the inverse-video selection) so the two
231
+ * can coexist on one row. `bg` is packed 0xRRGGBB (the run bg convention).
232
+ */
233
+ export function tintRunsBg<T extends { text: string; bg: number | null }>(
234
+ runs: T[],
235
+ from: number,
236
+ to: number,
237
+ bg: number,
238
+ ): T[] {
239
+ if (to < from) return runs;
240
+ const out: T[] = [];
241
+ let col = 0;
242
+ for (const run of runs) {
243
+ const len = run.text.length;
244
+ const runStart = col;
245
+ const runEnd = col + len - 1;
246
+ col += len;
247
+ if (len === 0 || runEnd < from || runStart > to) {
248
+ out.push(run);
249
+ continue;
250
+ }
251
+ const a = Math.max(from, runStart) - runStart;
252
+ const b = Math.min(to, runEnd) - runStart;
253
+ if (a > 0) out.push({ ...run, text: run.text.slice(0, a) });
254
+ out.push({ ...run, text: run.text.slice(a, b + 1), bg });
255
+ if (b + 1 < len) out.push({ ...run, text: run.text.slice(b + 1) });
256
+ }
257
+ return out;
258
+ }
259
+
260
+ // ── Select mode on app-mouse panes (M22.9) ──────────────────────────────────
261
+ // Panes whose app turned mouse reporting on (`appMouse`) normally get every
262
+ // press FORWARDED, so a drag can never start a local selection there. Two
263
+ // entries reopen selection: an explicit per-pane SELECT MODE (right-click →
264
+ // "Select text…", paused forwarding until esc / a completed copy / focus
265
+ // leaving the pane) and a SHIFT-modified press when the terminal passes shift
266
+ // through (SGR encodes it as +4 on the button code; many terminals keep
267
+ // shift+drag for native selection instead — then only select mode applies).
268
+
269
+ // ── Implicit drag-select default (M24.2) ─────────────────────────────────────
270
+ // Every agent pane (claude/codex) is app-mouse, so "forwarding wins" meant a
271
+ // user's whole fleet couldn't drag-select. The DEFAULT now follows the pane:
272
+ // where our own detection says an agent runs there, a plain drag selects and
273
+ // only a genuine click (press+release in one cell) is forwarded — deferred
274
+ // until motion or release decides. Shift INVERTS the pane's default (so on an
275
+ // agent pane shift+drag forwards; on a vim pane it selects, as in M22.9), the
276
+ // right-click toggle overrides per pane for the session, and `app.dragSelect`
277
+ // ("agents"|"always"|"never") sets the policy.
278
+
279
+ /** Where a plain left drag on a pane goes by default. */
280
+ export type PaneDragDefault = "select" | "forward";
281
+ /** The `app.dragSelect` policy values (app-config). */
282
+ export type DragSelectSetting = "agents" | "always" | "never";
283
+
284
+ /** PURE — a pane's drag default. Precedence: the session's per-pane override
285
+ * (the right-click toggle) > the config policy ("always"/"never") > the agent
286
+ * join ("agents": a pane matching a fleet agent entry selects; every other
287
+ * app-mouse pane forwards). */
288
+ export function paneDragDefault(
289
+ agentEntry: { paneId: string } | undefined,
290
+ config: DragSelectSetting,
291
+ override: PaneDragDefault | null,
292
+ ): PaneDragDefault {
293
+ if (override !== null) return override;
294
+ if (config === "always") return "select";
295
+ if (config === "never") return "forward";
296
+ return agentEntry !== undefined ? "select" : "forward";
297
+ }
298
+
299
+ /** What a left press on a pane does: run the selection machine NOW, forward
300
+ * the SGR press NOW, or DEFER (withhold the press until motion starts a
301
+ * selection or a release-in-place forwards the owed click pair). */
302
+ export type PressRouting = "select" | "forward" | "defer";
303
+
304
+ /** PURE — route a left press. Plain panes and select mode keep the immediate
305
+ * selection machine. Otherwise the pane's drag default decides, with shift
306
+ * inverting it: a shift press that lands on "select" stays IMMEDIATE (the
307
+ * M22.9 behavior — a shift-click was never forwarded), while an unshifted
308
+ * "select" defers so the pane's app still gets genuine clicks. */
309
+ export function routePanePress(
310
+ appMouse: boolean,
311
+ selectModeOn: boolean,
312
+ shift: boolean,
313
+ dragDefault: PaneDragDefault,
314
+ ): PressRouting {
315
+ if (!appMouse || selectModeOn) return "select";
316
+ const effective: PaneDragDefault = shift
317
+ ? dragDefault === "select"
318
+ ? "forward"
319
+ : "select"
320
+ : dragDefault;
321
+ if (effective === "forward") return "forward";
322
+ return shift ? "select" : "defer";
323
+ }
324
+
325
+ /** PURE — whether the wheel scrolls the LOCAL mirror scrollback (vs. being
326
+ * forwarded as SGR wheel events). Select mode reclaims the wheel so older
327
+ * output can be scrolled into view and selected. */
328
+ export function wheelScrollsLocal(appMouse: boolean, selectModeOn: boolean): boolean {
329
+ return !appMouse || selectModeOn;
330
+ }
331
+
332
+ /** PURE — the select-mode badge text for a pane width (rendered in the pane's
333
+ * top-right badge family, next to the scroll badge), degrading label → glyph
334
+ * → hidden on narrow panes exactly like the agent chip's budget. */
335
+ export function selectBadgeLabel(paneWidth: number): string | null {
336
+ if (paneWidth >= 16) return " ⧉ select ";
337
+ if (paneWidth >= 5) return " ⧉ ";
338
+ return null;
339
+ }
340
+
341
+ /** The raw OSC52 clipboard-set escape for a base64 payload (BEL-terminated). */
342
+ export function osc52Sequence(base64: string): string {
343
+ return `\x1b]52;c;${base64}\x07`;
344
+ }
345
+
346
+ /**
347
+ * Wrap a sequence in the tmux passthrough envelope (DCS `tmux;` … ST) with inner
348
+ * ESC bytes doubled, so it rides through an outer tmux (with allow-passthrough
349
+ * on) verbatim to the real terminal. Kept tested + available; the app prefers
350
+ * raw OSC52 so `set-clipboard on` captures it into tmux's paste buffer.
351
+ */
352
+ export function tmuxPassthrough(seq: string): string {
353
+ const doubled = seq.split("\x1b").join("\x1b\x1b");
354
+ return `\x1bPtmux;${doubled}\x1b\\`;
355
+ }
356
+
357
+ /** Split `text` into chunks each at most `maxBytes` UTF-8 bytes, never breaking
358
+ * a code point — for send-keys -H, whose per-command length tmux caps. */
359
+ export function chunkByBytes(text: string, maxBytes: number): string[] {
360
+ const enc = new TextEncoder();
361
+ const chunks: string[] = [];
362
+ let cur = "";
363
+ let curBytes = 0;
364
+ for (const ch of text) {
365
+ const b = enc.encode(ch).length;
366
+ if (curBytes + b > maxBytes && cur.length > 0) {
367
+ chunks.push(cur);
368
+ cur = "";
369
+ curBytes = 0;
370
+ }
371
+ cur += ch;
372
+ curBytes += b;
373
+ }
374
+ if (cur.length > 0) chunks.push(cur);
375
+ return chunks;
376
+ }