@visual-z/xeno 0.2.1 → 0.3.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 (258) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +106 -26
  3. package/docs/install.md +51 -13
  4. package/docs/providers.md +11 -0
  5. package/package.json +5 -4
  6. package/src/apps/approve.ts +3 -3
  7. package/src/apps/commands.ts +38 -5
  8. package/src/apps/completions.ts +73 -19
  9. package/src/apps/image-draft.ts +46 -0
  10. package/src/apps/project-trust.ts +2 -42
  11. package/src/apps/provider-connect.ts +346 -0
  12. package/src/apps/render.ts +22 -47
  13. package/src/apps/resources.ts +5 -2
  14. package/src/apps/shell.ts +298 -1526
  15. package/src/apps/startup-input.ts +45 -9
  16. package/src/apps/startup.ts +128 -86
  17. package/src/apps/ui/app.ts +1383 -0
  18. package/src/apps/ui/extension-host.ts +194 -0
  19. package/src/apps/ui/image-cache.ts +78 -0
  20. package/src/apps/ui/palette.ts +88 -0
  21. package/src/apps/ui/project.ts +127 -0
  22. package/src/apps/ui/standalone.ts +146 -0
  23. package/src/apps/ui/status-model.ts +68 -0
  24. package/src/apps/ui/surface.ts +84 -0
  25. package/src/apps/ui/theme.ts +86 -0
  26. package/src/core/acceptance.ts +107 -0
  27. package/src/core/action-defaults.ts +47 -295
  28. package/src/core/benchmark.ts +2 -2
  29. package/src/core/branch-receipt.ts +24 -0
  30. package/src/core/checkpoints.ts +64 -0
  31. package/src/core/churn.ts +2 -0
  32. package/src/core/command-artifacts.ts +94 -0
  33. package/src/core/command-contract.ts +35 -0
  34. package/src/core/completion-review.ts +21 -5
  35. package/src/core/context-detail.ts +37 -0
  36. package/src/core/context-policy.ts +74 -1
  37. package/src/core/continuation.ts +17 -2
  38. package/src/core/derive.ts +29 -3
  39. package/src/core/draft-images.ts +56 -0
  40. package/src/core/environment.ts +145 -0
  41. package/src/core/evaluation-coverage.ts +74 -0
  42. package/src/core/event-validation.ts +80 -5
  43. package/src/core/events.ts +97 -3
  44. package/src/core/evidence-links.ts +7 -0
  45. package/src/core/evidence.ts +13 -3
  46. package/src/core/execution.ts +8 -2
  47. package/src/core/experiment.ts +133 -0
  48. package/src/core/export.ts +9 -0
  49. package/src/core/failure-recovery.ts +173 -0
  50. package/src/core/file-precondition.ts +72 -0
  51. package/src/core/handoff.ts +65 -4
  52. package/src/core/history-query.ts +38 -2
  53. package/src/core/history.ts +38 -1
  54. package/src/core/integration-health.ts +34 -0
  55. package/src/core/keymap.ts +8 -20
  56. package/src/core/model-picker.ts +64 -0
  57. package/src/core/native-eval.ts +25 -0
  58. package/src/core/note-guidance.ts +34 -0
  59. package/src/core/observation-view.ts +49 -8
  60. package/src/core/permissions.ts +24 -2
  61. package/src/core/process-receipt.ts +105 -0
  62. package/src/core/process-state.ts +37 -0
  63. package/src/core/project.ts +45 -18
  64. package/src/core/prompt-policy.ts +37 -0
  65. package/src/core/query-eval-contract.ts +14 -0
  66. package/src/core/read-observation.ts +82 -0
  67. package/src/core/recovery-corpus.ts +17 -0
  68. package/src/core/recovery-evaluation.ts +60 -0
  69. package/src/core/recovery-sources.ts +74 -0
  70. package/src/core/runtime-target.ts +94 -0
  71. package/src/core/search-observation.ts +97 -0
  72. package/src/core/session-tree.ts +147 -0
  73. package/src/core/settings.ts +26 -10
  74. package/src/core/skills.ts +44 -0
  75. package/src/core/sql-query.ts +211 -0
  76. package/src/core/theme.ts +36 -58
  77. package/src/core/tool-contract.ts +51 -2
  78. package/src/core/tool-discovery.ts +87 -0
  79. package/src/core/tool-failure.ts +60 -0
  80. package/src/core/tool-mode-protocol.ts +25 -0
  81. package/src/core/transcript.ts +19 -4
  82. package/src/core/trial-lifecycle.ts +14 -0
  83. package/src/core/tui-mode.ts +29 -0
  84. package/src/core/update-check.ts +97 -0
  85. package/src/core/version.ts +55 -0
  86. package/src/core/work-state.ts +49 -3
  87. package/src/core/working-set.ts +91 -0
  88. package/src/runtime/auth.ts +2 -2
  89. package/src/runtime/branch-jobs.ts +36 -0
  90. package/src/runtime/clipboard.ts +15 -4
  91. package/src/runtime/command-artifacts.ts +64 -0
  92. package/src/runtime/environment-tools.ts +60 -0
  93. package/src/runtime/evidence-notes.ts +4 -2
  94. package/src/runtime/exec-sessions.ts +206 -61
  95. package/src/runtime/execution-control.ts +1 -0
  96. package/src/runtime/extension-ui.ts +22 -30
  97. package/src/runtime/extensions.ts +95 -16
  98. package/src/runtime/harness-comparison.ts +27 -7
  99. package/src/runtime/harness-eval.ts +460 -0
  100. package/src/runtime/history-cache.ts +57 -0
  101. package/src/runtime/history-tools.ts +62 -12
  102. package/src/runtime/integrations.ts +210 -0
  103. package/src/runtime/loop.ts +1021 -268
  104. package/src/runtime/model-discovery.ts +208 -0
  105. package/src/runtime/native-eval-fixtures.ts +33 -0
  106. package/src/runtime/native-eval.ts +143 -0
  107. package/src/runtime/oauth.ts +1 -1
  108. package/src/runtime/provider-connect.ts +109 -0
  109. package/src/runtime/provider-presets.ts +87 -0
  110. package/src/runtime/query-eval-verifier.ts +28 -0
  111. package/src/runtime/recovery-coverage.ts +71 -0
  112. package/src/runtime/recovery-evidence.ts +76 -0
  113. package/src/runtime/recovery-fixtures.ts +116 -0
  114. package/src/runtime/runtime-target.ts +149 -0
  115. package/src/runtime/tool-broker.ts +137 -0
  116. package/src/runtime/tool-discovery.ts +57 -0
  117. package/src/runtime/tool-mode-eval.ts +146 -0
  118. package/src/runtime/tool-queue.ts +77 -0
  119. package/src/runtime/tools.ts +628 -183
  120. package/src/runtime/trial-control.ts +45 -0
  121. package/src/runtime/update-check.ts +47 -0
  122. package/src/runtime/window-trial.ts +2 -1
  123. package/src/runtime/working-notes.ts +33 -3
  124. package/src/runtime/workspace-lifecycle.ts +97 -0
  125. package/src/store/acceptance.ts +15 -0
  126. package/src/store/checkpoint-history.ts +43 -0
  127. package/src/store/command-artifacts.ts +96 -0
  128. package/src/store/command.ts +65 -37
  129. package/src/store/file-checkpoints.ts +104 -0
  130. package/src/store/file-index.ts +13 -1
  131. package/src/store/git-checkpoints.ts +67 -0
  132. package/src/store/observations.ts +61 -20
  133. package/src/store/process-receipts.ts +34 -0
  134. package/src/store/query-source.ts +13 -8
  135. package/src/store/recovery.ts +2 -3
  136. package/src/store/replay.ts +16 -4
  137. package/src/store/runtime-target.ts +64 -0
  138. package/src/store/search.ts +171 -132
  139. package/src/store/session-archive.ts +4 -0
  140. package/src/store/source-cancellation.ts +6 -0
  141. package/src/store/update-cache.ts +36 -0
  142. package/src/store/workspace-observation.ts +113 -0
  143. package/src/tui/ansi-spans.ts +126 -0
  144. package/src/tui/style.ts +19 -16
  145. package/src/tui/ui/atomic-draft.ts +121 -0
  146. package/src/tui/ui/canvas.ts +96 -0
  147. package/src/tui/ui/colors.ts +70 -0
  148. package/src/tui/ui/composer.ts +199 -0
  149. package/src/tui/ui/dialog.ts +379 -0
  150. package/src/tui/ui/draft.ts +23 -0
  151. package/src/tui/ui/edge.ts +186 -0
  152. package/src/tui/ui/image-size.ts +40 -0
  153. package/src/tui/ui/ink.ts +210 -0
  154. package/src/tui/ui/items.ts +40 -0
  155. package/src/tui/ui/keys.ts +40 -0
  156. package/src/tui/ui/layout.ts +74 -0
  157. package/src/tui/ui/list-model.ts +86 -0
  158. package/src/tui/ui/list-view.ts +172 -0
  159. package/src/tui/ui/mascot.ts +67 -0
  160. package/src/tui/ui/messages.ts +518 -0
  161. package/src/tui/ui/motion.ts +28 -0
  162. package/src/tui/ui/paths.ts +10 -0
  163. package/src/tui/ui/pictures.ts +78 -0
  164. package/src/tui/ui/screen.ts +421 -0
  165. package/src/tui/ui/status.ts +271 -0
  166. package/src/tui/ui/styled.ts +85 -0
  167. package/src/tui/ui/testing.ts +5 -0
  168. package/src/tui/ui/tool-card.ts +150 -0
  169. package/src/apps/extension-ui.ts +0 -330
  170. package/src/apps/resource-browser.ts +0 -75
  171. package/src/apps/session-browser.ts +0 -113
  172. package/src/apps/session-picker.ts +0 -55
  173. package/src/tui/ansi.ts +0 -67
  174. package/src/tui/autocomplete.ts +0 -29
  175. package/src/tui/chat-viewport.ts +0 -47
  176. package/src/tui/complete.ts +0 -87
  177. package/src/tui/editor.ts +0 -940
  178. package/src/tui/host.ts +0 -264
  179. package/src/tui/input.ts +0 -75
  180. package/src/tui/keys.ts +0 -378
  181. package/src/tui/markdown.ts +0 -17
  182. package/src/tui/render.ts +0 -153
  183. package/src/tui/select.ts +0 -258
  184. package/src/tui/settings.ts +0 -44
  185. package/src/tui/statusline.ts +0 -162
  186. package/src/tui/system-theme.ts +0 -700
  187. package/src/tui/terminal.ts +0 -30
  188. package/src/tui/text.ts +0 -52
  189. package/src/tui/toolview.ts +0 -386
  190. package/src/tui/transcript.ts +0 -123
  191. package/src/tui/tree.ts +0 -471
  192. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  193. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  194. package/src/vendor/pi-tui/VENDOR.md +0 -25
  195. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  196. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  197. package/src/vendor/pi-tui/colors.ts +0 -367
  198. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  199. package/src/vendor/pi-tui/components/box.ts +0 -166
  200. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  201. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  202. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  203. package/src/vendor/pi-tui/components/image.ts +0 -127
  204. package/src/vendor/pi-tui/components/input.ts +0 -494
  205. package/src/vendor/pi-tui/components/loader.ts +0 -101
  206. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  207. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  208. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  209. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  210. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  211. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  212. package/src/vendor/pi-tui/components/stack.ts +0 -154
  213. package/src/vendor/pi-tui/components/text.ts +0 -107
  214. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  215. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  216. package/src/vendor/pi-tui/editor-component.ts +0 -74
  217. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  218. package/src/vendor/pi-tui/index.ts +0 -186
  219. package/src/vendor/pi-tui/keybindings.ts +0 -320
  220. package/src/vendor/pi-tui/keys.ts +0 -1401
  221. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  222. package/src/vendor/pi-tui/latex.ts +0 -1506
  223. package/src/vendor/pi-tui/layout-node.ts +0 -51
  224. package/src/vendor/pi-tui/layout.ts +0 -449
  225. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  226. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  227. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  228. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  229. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  230. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  231. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  232. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  233. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  234. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  235. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  236. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  237. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  238. package/src/vendor/pi-tui/native/napi.h +0 -91
  239. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  240. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  241. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  242. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  243. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  244. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  245. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  246. package/src/vendor/pi-tui/native-platform.ts +0 -65
  247. package/src/vendor/pi-tui/oklab.ts +0 -233
  248. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  249. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  250. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  251. package/src/vendor/pi-tui/terminal.ts +0 -554
  252. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  253. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  254. package/src/vendor/pi-tui/tui.ts +0 -1493
  255. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  256. package/src/vendor/pi-tui/utils.ts +0 -1385
  257. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  258. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -1,1750 +0,0 @@
1
- import {
2
- AltScreenSearchComponent,
3
- AltScreenSearchIndex,
4
- type AltScreenSearchMatch,
5
- getAltScreenSearchMatchKey,
6
- } from "./alt-screen-search.ts";
7
- import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts";
8
- import { ScrollView } from "./components/scroll-view.ts";
9
- import { getKeybindings } from "./keybindings.ts";
10
- import { isKeyRelease } from "./keys.ts";
11
- import {
12
- getLayoutBoxesAt,
13
- getScrollbarGeometry,
14
- getScrollViewBox,
15
- getScrollViewsAt,
16
- type LayoutFrame,
17
- renderLayoutFrame,
18
- type ScrollbarGeometry,
19
- } from "./layout.ts";
20
- import { getLayoutNode } from "./layout-node.ts";
21
- import type { Terminal } from "./terminal.ts";
22
- import {
23
- deleteAllKittyImages,
24
- deleteAllKittyPlacements,
25
- deleteKittyImage,
26
- getCapabilities,
27
- getKittyImagePlacement,
28
- type ImageProtocol,
29
- isImageLine,
30
- setCapabilities,
31
- type TerminalCapabilities,
32
- } from "./terminal-image.ts";
33
- import {
34
- type Component,
35
- Container,
36
- CURSOR_MARKER,
37
- compositeTuiLine,
38
- dispatchMouseEvent,
39
- type OverlayHandle,
40
- retargetMouseEvent,
41
- TuiBase,
42
- type TuiMouseButton,
43
- type TuiMouseDispatchResult,
44
- type TuiMouseDispatchTarget,
45
- type TuiMouseEvent,
46
- type TuiStopOptions,
47
- VIEWPORT_TUI,
48
- type ViewportTUI,
49
- } from "./tui.ts";
50
- import {
51
- extractAnsiCode,
52
- getGraphemeCellRange,
53
- getOsc8LinkAtColumn,
54
- getWordSegmenter,
55
- sliceByColumn,
56
- stripTerminalSequences,
57
- truncateToWidth,
58
- visibleWidth,
59
- } from "./utils.ts";
60
- import { WheelScrollAccelerator, type WheelScrollLines } from "./wheel-scroll.ts";
61
-
62
- const ENTER_ALT_SCREEN = "\x1b[?1049h";
63
- const EXIT_ALT_SCREEN = "\x1b[?1049l";
64
- const DISABLE_AUTOWRAP = "\x1b[?7l";
65
- const ENABLE_AUTOWRAP = "\x1b[?7h";
66
- const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h";
67
- const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h";
68
- const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
69
- const FOCUS_IN = "\x1b[I";
70
- const FOCUS_OUT = "\x1b[O";
71
- const BEGIN_SYNCHRONIZED_OUTPUT = "\x1b[?2026h";
72
- const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l";
73
- const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
74
- const OSC133_PROMPT_START = /^\x1b\]133;A(?:\x07|\x1b\\)/;
75
- const PAGE_SCROLL_OVERLAP = 4;
76
- const ALT_WHEEL_SCROLL_MULTIPLIER = 5;
77
- const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
78
- const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
79
- const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
80
- const DOUBLE_CLICK_INTERVAL_MS = 500;
81
- const COPY_ERROR_FLASH_DURATION_MS = 5000;
82
- // Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection,
83
- // so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole.
84
- const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]);
85
- const wordSegmenter = getWordSegmenter();
86
-
87
- interface CachedKittyImage {
88
- transmissionGeneration: number;
89
- transmissionBytes: number;
90
- estimatedDecodedBytes: number;
91
- }
92
-
93
- interface SelectionPoint {
94
- row: number;
95
- col: number;
96
- scrollView?: ScrollView;
97
- /** Whether this point lies between terminal cells rather than on a cell. */
98
- boundary?: boolean;
99
- }
100
-
101
- interface SelectionRange {
102
- start: SelectionPoint;
103
- end: SelectionPoint;
104
- }
105
-
106
- type SelectionGranularity = "character" | "word" | "line";
107
-
108
- interface ClickTarget {
109
- timestamp: number;
110
- count: number;
111
- row: number;
112
- scrollView?: ScrollView;
113
- wordStart: number;
114
- wordEnd: number;
115
- }
116
-
117
- interface SgrMouseEvent {
118
- button: number;
119
- x: number;
120
- y: number;
121
- release: boolean;
122
- }
123
-
124
- interface WheelEvent {
125
- direction: -1 | 1;
126
- x: number;
127
- y: number;
128
- button: number;
129
- }
130
-
131
- interface ScrollbarDrag {
132
- scrollView: ScrollView;
133
- grabOffset: number;
134
- }
135
-
136
- interface ScrollbarTarget {
137
- scrollView: ScrollView;
138
- geometry: ScrollbarGeometry;
139
- }
140
-
141
- interface ScrollToEndIndicatorRect {
142
- row: number;
143
- column: number;
144
- width: number;
145
- }
146
-
147
- type SearchSelectionMode = "query" | "retain" | "next" | "previous";
148
-
149
- interface ActiveSearch {
150
- component: AltScreenSearchComponent;
151
- index: AltScreenSearchIndex;
152
- overlay?: OverlayHandle;
153
- query: string;
154
- matches: AltScreenSearchMatch[];
155
- selectedIndex: number;
156
- selectedKey?: string;
157
- anchorRow: number;
158
- selectionMode: SearchSelectionMode;
159
- }
160
-
161
- interface SearchHighlightRange {
162
- startCol: number;
163
- endCol: number;
164
- current: boolean;
165
- }
166
-
167
- export interface TuiAltScreenOptions {
168
- /**
169
- * Logical lines moved for each mouse-wheel event (default: 1). `"auto"` accelerates fast wheel
170
- * spins on terminals that send one event per notch. Alt+wheel moves five times as far.
171
- */
172
- wheelScrollLines?: WheelScrollLines;
173
- /** Capture mouse events for viewport scrolling and application-owned text selection. */
174
- mouse?: boolean;
175
- /** Style a non-current transcript search match. */
176
- searchMatchStyle?: (text: string) => string;
177
- /** Style the current transcript search match. */
178
- searchCurrentMatchStyle?: (text: string) => string;
179
- /** Style a transcript search navigation button. */
180
- searchNavigationButtonStyle?: (text: string, hovered: boolean) => string;
181
- /**
182
- * Render a clickable jump-to-end label. It is centered on the last row of a follow-end
183
- * primary scroll view while that view is scrolled away from its end.
184
- */
185
- scrollToEndIndicator?: () => string;
186
- /** Open an OSC 8 hyperlink activated with a primary-button click. */
187
- openUrl?: (url: string) => void;
188
- /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */
189
- onRightClickPaste?: () => void;
190
- /** Automatically copy selected text to the clipboard on mouse release (default: true). */
191
- copyOnSelect?: boolean;
192
- /**
193
- * Copy selected text to the system clipboard. Return `true` on success, an error message to
194
- * display on failure, or `false` for a generic error. When omitted, the selection is copied
195
- * via an OSC 52 write.
196
- */
197
- copySelection?: (text: string) => Promise<boolean | string>;
198
- }
199
-
200
- /** Alternate-screen TUI with a scrollable, application-owned viewport. */
201
- export class TuiAltScreen extends TuiBase implements ViewportTUI {
202
- readonly mode = "fullscreen" as const;
203
- readonly [VIEWPORT_TUI] = true as const;
204
- private previousScreen: string[] = [];
205
- private lastDocument: string[] = [];
206
- private previousScreenWidth = 0;
207
- private previousScreenHeight = 0;
208
- private layoutRoot: Component | undefined;
209
- private currentLayout: LayoutFrame | undefined;
210
- private readonly implicitDocument: Component;
211
- private readonly implicitScrollView: ScrollView;
212
- private readonly flashes: AltScreenFlashContainer;
213
- private altScreenActive = false;
214
- private imageProtocol: ImageProtocol = null;
215
- private savedCapabilities?: TerminalCapabilities;
216
- private readonly uploadedKittyImages = new Map<number, CachedKittyImage>();
217
- private selectionAnchor?: SelectionPoint;
218
- private selectionFocus?: SelectionPoint;
219
- private selectionGranularity: SelectionGranularity = "character";
220
- private selectionInitialRange?: SelectionRange;
221
- private lastClick?: ClickTarget;
222
- private selectionDragPointer?: { x: number; y: number };
223
- private selectionAutoScrollDirection: -1 | 0 | 1 = 0;
224
- private selectionAutoScrollTimer?: NodeJS.Timeout;
225
- private selectionPressActive = false;
226
- private scrollbarDrag?: ScrollbarDrag;
227
- private scrollbarHover?: ScrollView;
228
- private scrollToEndIndicatorRect?: ScrollToEndIndicatorRect;
229
- private activeSearch?: ActiveSearch;
230
- private pressedUrl?: string;
231
- private selectionDragged = false;
232
- private mouseCapture?: TuiMouseDispatchTarget;
233
- private mousePressTarget?: TuiMouseDispatchTarget;
234
- private mousePressPoint?: { x: number; y: number };
235
- private mousePressMoved = false;
236
- private lastComponentClick?: {
237
- timestamp: number;
238
- count: number;
239
- component: Component;
240
- x: number;
241
- y: number;
242
- };
243
- private readonly wheelScroll: WheelScrollAccelerator;
244
- private readonly mouseEnabled: boolean;
245
- private readonly searchMatchStyle: (text: string) => string;
246
- private readonly searchCurrentMatchStyle: (text: string) => string;
247
- private readonly searchNavigationButtonStyle: (text: string, hovered: boolean) => string;
248
- private readonly scrollToEndIndicator?: () => string;
249
- private readonly openUrl?: (url: string) => void;
250
- private readonly onRightClickPaste?: () => void;
251
- private copyOnSelect: boolean;
252
- private readonly copySelection?: (text: string) => Promise<boolean | string>;
253
-
254
- constructor(
255
- terminal: Terminal,
256
- showHardwareCursor?: boolean,
257
- logDirectory?: string,
258
- options: TuiAltScreenOptions = {},
259
- ) {
260
- super(terminal, showHardwareCursor, logDirectory);
261
- this.implicitDocument = {
262
- render: (width) => super.render(width),
263
- handleMouse: (event) => super.handleMouse(event),
264
- invalidate: () => {
265
- for (const child of this.children) child.invalidate();
266
- },
267
- };
268
- this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true });
269
- this.flashes = new AltScreenFlashContainer(() => this.requestRender());
270
- this.wheelScroll = new WheelScrollAccelerator(options.wheelScrollLines ?? 1);
271
- this.mouseEnabled = options.mouse ?? true;
272
- this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`);
273
- this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
274
- this.searchNavigationButtonStyle = options.searchNavigationButtonStyle ?? ((text) => text);
275
- this.scrollToEndIndicator = options.scrollToEndIndicator;
276
- this.openUrl = options.openUrl;
277
- this.onRightClickPaste = options.onRightClickPaste;
278
- this.copyOnSelect = options.copyOnSelect ?? true;
279
- this.copySelection = options.copySelection;
280
- this.addInputListener((data) => this.handleViewportInput(data));
281
- }
282
-
283
- get viewportTop(): number {
284
- return this.getPrimaryScrollView().scrollTop;
285
- }
286
-
287
- get isFollowingOutput(): boolean {
288
- return this.getPrimaryScrollView().isFollowingEnd;
289
- }
290
-
291
- setWheelScrollLines(lines: WheelScrollLines): void {
292
- this.wheelScroll.setLines(lines);
293
- }
294
-
295
- getCopyOnSelect(): boolean {
296
- return this.copyOnSelect;
297
- }
298
-
299
- setCopyOnSelect(enabled: boolean): void {
300
- this.copyOnSelect = enabled;
301
- }
302
-
303
- /** Whether the fullscreen viewport has a non-empty active text selection. */
304
- hasActiveSelection(): boolean {
305
- return this.getActiveSelectionText() !== undefined;
306
- }
307
-
308
- /** Copy the active fullscreen text selection, if any, using the configured selection clipboard path. */
309
- async copyActiveSelectionToClipboard(): Promise<boolean> {
310
- const text = this.getActiveSelectionText();
311
- if (!text) return false;
312
- return this.copyTextToClipboard(text);
313
- }
314
-
315
- setLayoutRoot(component: Component | undefined): void {
316
- if (this.layoutRoot === component) return;
317
- this.layoutRoot = component;
318
- this.currentLayout = undefined;
319
- this.requestRender();
320
- }
321
-
322
- override render(width: number): string[] {
323
- return this.layoutRoot?.render(width) ?? super.render(width);
324
- }
325
-
326
- protected override getMountedRoots(): readonly Component[] {
327
- return this.layoutRoot ? [this.layoutRoot] : this.children;
328
- }
329
-
330
- private getPrimaryScrollView(): ScrollView {
331
- return this.currentLayout?.primaryScrollView ?? this.implicitScrollView;
332
- }
333
-
334
- protected override beforeTerminalStart(): void {
335
- this.stopSelectionAutoScroll();
336
- this.selectionPressActive = false;
337
- this.stopScrollbarHover();
338
- this.stopScrollbarDrag();
339
- this.flashes.dispose();
340
- this.altScreenActive = true;
341
- const capabilities = getCapabilities();
342
- this.imageProtocol = capabilities.images;
343
- this.uploadedKittyImages.clear();
344
- if (capabilities.images === "iterm2") {
345
- this.savedCapabilities = capabilities;
346
- setCapabilities({ ...capabilities, images: null });
347
- this.invalidate();
348
- }
349
- this.lastDocument = [];
350
- this.selectionAnchor = undefined;
351
- this.selectionFocus = undefined;
352
- this.selectionGranularity = "character";
353
- this.selectionInitialRange = undefined;
354
- this.lastClick = undefined;
355
- this.pressedUrl = undefined;
356
- this.selectionDragged = false;
357
- this.clearComponentMouseGesture();
358
- this.lastComponentClick = undefined;
359
- this.resetRenderState();
360
- const term = process.env.TERM?.toLowerCase() ?? "";
361
- // Multiplexers can lag when every pointer movement is forwarded. Button-motion
362
- // tracking preserves clicks, wheel events, selections, and scrollbar dragging.
363
- const mouseSequence =
364
- process.env.TMUX !== undefined ||
365
- process.env.ZELLIJ !== undefined ||
366
- process.env.STY !== undefined ||
367
- term.startsWith("tmux") ||
368
- term.startsWith("screen")
369
- ? ENABLE_BUTTON_MOTION_MOUSE
370
- : ENABLE_ALL_MOTION_MOUSE;
371
- this.terminal.write(
372
- `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`,
373
- );
374
- }
375
-
376
- protected override beforeTerminalStop(_options: TuiStopOptions): void {
377
- this.closeSearch();
378
- this.stopSelectionAutoScroll();
379
- this.selectionPressActive = false;
380
- this.stopScrollbarHover();
381
- this.stopScrollbarDrag();
382
- this.clearComponentMouseGesture();
383
- this.flashes.dispose();
384
- if (!this.altScreenActive) return;
385
- this.terminal.write(
386
- `${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`,
387
- );
388
- this.uploadedKittyImages.clear();
389
- }
390
-
391
- protected override afterTerminalStop(options: TuiStopOptions): void {
392
- if (!this.altScreenActive) return;
393
- this.altScreenActive = false;
394
- if (options.preserveScreen) {
395
- this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`);
396
- } else {
397
- const width = Math.max(1, this.terminal.columns);
398
- const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
399
- this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(
400
- (line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),
401
- );
402
- let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`;
403
- for (let row = 0; row < this.lastDocument.length; row++) {
404
- if (row > 0) buffer += "\r\n";
405
- buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`;
406
- }
407
- buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`;
408
- this.terminal.write(buffer);
409
- }
410
- if (this.savedCapabilities) {
411
- setCapabilities(this.savedCapabilities);
412
- this.savedCapabilities = undefined;
413
- }
414
- }
415
-
416
- private deleteKittyImages(): string {
417
- return this.imageProtocol === "kitty" ? deleteAllKittyImages() : "";
418
- }
419
-
420
- private prepareKittyScreen(screen: string[]): { lines: string[]; evictedImageDeletion: string } {
421
- const visibleImageIds = new Set<number>();
422
- const lines = screen.map((line) => {
423
- const placement = getKittyImagePlacement(line);
424
- if (!placement) return line;
425
- visibleImageIds.add(placement.imageId);
426
-
427
- const cachedImage = this.uploadedKittyImages.get(placement.imageId);
428
- const nextCachedImage = {
429
- transmissionGeneration: placement.transmissionGeneration,
430
- transmissionBytes: placement.transmissionBytes,
431
- estimatedDecodedBytes: placement.estimatedDecodedBytes,
432
- };
433
- if (cachedImage) this.uploadedKittyImages.delete(placement.imageId);
434
- this.uploadedKittyImages.set(placement.imageId, nextCachedImage);
435
-
436
- return cachedImage?.transmissionGeneration === placement.transmissionGeneration
437
- ? placement.replacementLine
438
- : line;
439
- });
440
-
441
- let cachedOffscreenImageCount = 0;
442
- let cachedOffscreenTransmissionBytes = 0;
443
- let cachedOffscreenDecodedBytes = 0;
444
- for (const [imageId, cachedImage] of this.uploadedKittyImages) {
445
- if (visibleImageIds.has(imageId)) continue;
446
- cachedOffscreenImageCount += 1;
447
- cachedOffscreenTransmissionBytes += cachedImage.transmissionBytes;
448
- cachedOffscreenDecodedBytes += cachedImage.estimatedDecodedBytes;
449
- }
450
-
451
- let evictedImageDeletion = "";
452
- for (const [imageId, cachedImage] of this.uploadedKittyImages) {
453
- if (
454
- cachedOffscreenImageCount <= MAX_CACHED_OFFSCREEN_KITTY_IMAGES &&
455
- cachedOffscreenTransmissionBytes <= MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES &&
456
- cachedOffscreenDecodedBytes <= MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES
457
- ) {
458
- break;
459
- }
460
- if (visibleImageIds.has(imageId)) continue;
461
- evictedImageDeletion += deleteKittyImage(imageId);
462
- this.uploadedKittyImages.delete(imageId);
463
- cachedOffscreenImageCount -= 1;
464
- cachedOffscreenTransmissionBytes -= cachedImage.transmissionBytes;
465
- cachedOffscreenDecodedBytes -= cachedImage.estimatedDecodedBytes;
466
- }
467
- return { lines, evictedImageDeletion };
468
- }
469
-
470
- protected override resetRenderState(): void {
471
- this.previousScreen = [];
472
- this.previousScreenWidth = 0;
473
- this.previousScreenHeight = 0;
474
- this.currentLayout = undefined;
475
- }
476
-
477
- scrollBy(lines: number): void {
478
- this.getPrimaryScrollView().scrollBy(lines);
479
- this.requestRender();
480
- }
481
-
482
- scrollToTop(): void {
483
- this.getPrimaryScrollView().scrollToStart();
484
- this.requestRender();
485
- }
486
-
487
- scrollToBottom(): void {
488
- this.getPrimaryScrollView().scrollToEnd();
489
- this.requestRender();
490
- }
491
-
492
- private scrollToPrompt(direction: -1 | 1): void {
493
- if (!this.currentLayout) return;
494
- const scrollView = this.getPrimaryScrollView();
495
- const lines = getScrollViewBox(this.currentLayout, scrollView)?.scrollContentLines;
496
- if (!lines) return;
497
-
498
- for (let row = scrollView.scrollTop + direction; row >= 0 && row < lines.length; row += direction) {
499
- if (!OSC133_PROMPT_START.test(lines[row] ?? "")) continue;
500
- scrollView.scrollTo(row);
501
- this.requestRender();
502
- return;
503
- }
504
- }
505
-
506
- private toggleSearch(): void {
507
- if (this.activeSearch) {
508
- this.closeSearch();
509
- return;
510
- }
511
- const component = new AltScreenSearchComponent(
512
- (query) => this.updateSearchQuery(query),
513
- this.searchNavigationButtonStyle,
514
- );
515
- const search: ActiveSearch = {
516
- component,
517
- index: new AltScreenSearchIndex(),
518
- query: "",
519
- matches: [],
520
- selectedIndex: -1,
521
- anchorRow: this.getPrimaryScrollView().scrollTop,
522
- selectionMode: "query",
523
- };
524
- this.activeSearch = search;
525
- search.overlay = this.showOverlay(component, {
526
- anchor: "top-right",
527
- width: "40%",
528
- minWidth: 32,
529
- margin: 1,
530
- });
531
- }
532
-
533
- private closeSearch(): void {
534
- const search = this.activeSearch;
535
- if (!search) return;
536
- this.activeSearch = undefined;
537
- search.overlay?.hide();
538
- this.requestRender();
539
- }
540
-
541
- private updateSearchQuery(query: string): void {
542
- const search = this.activeSearch;
543
- if (!search || query === search.query) return;
544
- const selected = search.matches[search.selectedIndex];
545
- search.anchorRow = selected?.segments[0]?.row ?? this.getPrimaryScrollView().scrollTop;
546
- search.query = query;
547
- search.selectionMode = "query";
548
- search.component.setResult(-1, 0);
549
- this.requestRender();
550
- }
551
-
552
- private navigateSearch(direction: -1 | 1): void {
553
- const search = this.activeSearch;
554
- if (!search?.query) return;
555
- search.selectionMode = direction < 0 ? "previous" : "next";
556
- this.requestRender();
557
- }
558
-
559
- private getSearchNavigationDirectionAt(x: number, y: number): -1 | 1 | undefined {
560
- const search = this.activeSearch;
561
- const bounds = search?.overlay?.getBounds();
562
- if (!search || !bounds) return undefined;
563
- if (x < bounds.col || x >= bounds.col + bounds.width || y < bounds.row || y >= bounds.row + bounds.height) {
564
- return undefined;
565
- }
566
- return search.component.getNavigationDirectionAt(y - bounds.row, x - bounds.col);
567
- }
568
-
569
- private handleSearchMouseEvent(event: SgrMouseEvent): boolean {
570
- const search = this.activeSearch;
571
- if (!search) return false;
572
- const direction = this.getSearchNavigationDirectionAt(event.x, event.y);
573
- if (search.component.setHoveredNavigationDirection(direction)) this.requestRender();
574
- if (direction === undefined || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) {
575
- return false;
576
- }
577
- this.navigateSearch(direction);
578
- return true;
579
- }
580
-
581
- private refreshSearch(layout: LayoutFrame): boolean {
582
- const search = this.activeSearch;
583
- if (!search) return false;
584
- const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
585
- const box = getScrollViewBox(layout, scrollView);
586
- const lines = box?.scrollContentLines;
587
- if (!lines || !search.query.trim()) {
588
- search.matches = [];
589
- search.selectedIndex = -1;
590
- search.selectedKey = undefined;
591
- search.selectionMode = "retain";
592
- search.component.setResult(-1, 0);
593
- return false;
594
- }
595
-
596
- const shouldRevealSelection = search.selectionMode !== "retain";
597
- const result = search.index.search(lines, search.query);
598
- const matches = result.matches;
599
- search.matches = matches;
600
- if (!result.changed && search.selectionMode === "retain") return false;
601
-
602
- const exactIndex = result.changed
603
- ? search.selectedKey
604
- ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
605
- : -1
606
- : search.selectedIndex;
607
- let selectedIndex = -1;
608
- if (matches.length > 0) {
609
- if (search.selectionMode === "query") {
610
- let low = 0;
611
- let high = matches.length;
612
- while (low < high) {
613
- const middle = low + Math.floor((high - low) / 2);
614
- if ((matches[middle]!.segments[0]?.row ?? 0) < search.anchorRow) low = middle + 1;
615
- else high = middle;
616
- }
617
- selectedIndex = low < matches.length ? low : 0;
618
- } else if (search.selectionMode === "next") {
619
- const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
620
- selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length;
621
- } else if (search.selectionMode === "previous") {
622
- const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
623
- selectedIndex = baseIndex < 0 ? matches.length - 1 : (baseIndex - 1 + matches.length) % matches.length;
624
- } else {
625
- selectedIndex =
626
- exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1);
627
- }
628
- }
629
-
630
- search.selectedIndex = selectedIndex;
631
- search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]!) : undefined;
632
- search.selectionMode = "retain";
633
- search.component.setResult(selectedIndex, matches.length);
634
- if (!shouldRevealSelection) return false;
635
-
636
- const selected = matches[selectedIndex];
637
- const firstSegment = selected?.segments[0];
638
- const lastSegment = selected?.segments[selected.segments.length - 1];
639
- if (!box || !firstSegment || !lastSegment || scrollView.viewportHeight <= 0) return false;
640
- const before = scrollView.scrollTop;
641
- const visibleBottom = before + scrollView.viewportHeight - 1;
642
- let target = before;
643
- if (firstSegment.row < before || lastSegment.row > visibleBottom) {
644
- target = firstSegment.row - Math.floor(scrollView.viewportHeight / 3);
645
- }
646
- scrollView.scrollTo(target, { disableFollow: true });
647
- return scrollView.scrollTop !== before;
648
- }
649
-
650
- /** Show a transient message in the alternate-screen flash stack. */
651
- flash(message: string, durationMs?: number): void {
652
- this.flashes.flash(message, durationMs);
653
- }
654
-
655
- private shouldDeferViewportInputToOverlay(): boolean {
656
- return this.isOverlayFocused() && this.activeSearch?.overlay?.isFocused() !== true;
657
- }
658
-
659
- private clearComponentMouseGesture(): void {
660
- this.mouseCapture = undefined;
661
- this.mousePressTarget = undefined;
662
- this.mousePressPoint = undefined;
663
- this.mousePressMoved = false;
664
- }
665
-
666
- private handleViewportInput(data: string): { consume?: boolean } | undefined {
667
- if (data === FOCUS_OUT) {
668
- const hadActiveSelection = this.selectionPressActive;
669
- const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined;
670
- this.selectionPressActive = false;
671
- this.stopSelectionAutoScroll();
672
- this.stopScrollbarHover();
673
- if (this.activeSearch?.component.setHoveredNavigationDirection(undefined)) this.requestRender();
674
- this.stopScrollbarDrag();
675
- this.pressedUrl = undefined;
676
- this.selectionDragged = false;
677
- this.clearComponentMouseGesture();
678
- this.lastComponentClick = undefined;
679
- if (hadActiveSelection) {
680
- this.selectionAnchor = undefined;
681
- this.selectionFocus = undefined;
682
- this.selectionGranularity = "character";
683
- this.selectionInitialRange = undefined;
684
- if (hadNonEmptyActiveSelection) this.requestRender();
685
- }
686
- this.lastClick = undefined;
687
- return { consume: true };
688
- }
689
- if (data === FOCUS_IN) return { consume: true };
690
-
691
- const wheelEvent = this.parseWheelEvent(data);
692
- if (wheelEvent) {
693
- const lines = this.wheelScroll.next(wheelEvent.direction, performance.now());
694
- // SGR mouse button codes use bit 3 (value 8) for the Alt modifier.
695
- const wheelDelta =
696
- wheelEvent.direction * ((wheelEvent.button & 8) !== 0 ? lines * ALT_WHEEL_SCROLL_MULTIPLIER : lines);
697
- const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, { wheelDelta });
698
- const overlay = this.dispatchMouseToOverlay(event);
699
- const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
700
- if (result) {
701
- if (this.applyMouseDispatchResult(event, result)) this.requestRender();
702
- return { consume: true };
703
- }
704
- if (this.shouldDeferViewportInputToOverlay()) return undefined;
705
- this.routeWheel(wheelEvent, wheelDelta);
706
- return { consume: true };
707
- }
708
- const mouseEvent = this.parseSgrMouseEvent(data);
709
- if (mouseEvent) {
710
- this.handleMouseEvent(mouseEvent);
711
- return { consume: true };
712
- }
713
- if (this.isMouseSequence(data)) return { consume: true };
714
-
715
- const keybindings = getKeybindings();
716
- const isRelease = isKeyRelease(data);
717
- if (keybindings.matches(data, "tui.altScreen.search")) {
718
- if (!isRelease) this.toggleSearch();
719
- return { consume: true };
720
- }
721
- if (this.activeSearch?.overlay?.isFocused()) {
722
- if (keybindings.matches(data, "tui.altScreen.searchNext")) {
723
- if (!isRelease) this.navigateSearch(1);
724
- return { consume: true };
725
- }
726
- if (keybindings.matches(data, "tui.altScreen.searchPrevious")) {
727
- if (!isRelease) this.navigateSearch(-1);
728
- return { consume: true };
729
- }
730
- if (keybindings.matches(data, "tui.altScreen.searchClose")) {
731
- if (!isRelease) this.closeSearch();
732
- return { consume: true };
733
- }
734
- }
735
- if (this.shouldDeferViewportInputToOverlay()) return undefined;
736
- if (keybindings.matches(data, "tui.altScreen.pageUp")) {
737
- if (!isRelease) {
738
- this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP));
739
- }
740
- return { consume: true };
741
- }
742
- if (keybindings.matches(data, "tui.altScreen.pageDown")) {
743
- if (!isRelease) {
744
- this.scrollBy(Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP));
745
- }
746
- return { consume: true };
747
- }
748
- if (keybindings.matches(data, "tui.altScreen.halfPageUp")) {
749
- if (!isRelease) this.scrollBy(-Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
750
- return { consume: true };
751
- }
752
- if (keybindings.matches(data, "tui.altScreen.halfPageDown")) {
753
- if (!isRelease) this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2)));
754
- return { consume: true };
755
- }
756
- if (keybindings.matches(data, "tui.altScreen.lineUp")) {
757
- if (!isRelease) this.scrollBy(-1);
758
- return { consume: true };
759
- }
760
- if (keybindings.matches(data, "tui.altScreen.lineDown")) {
761
- if (!isRelease) this.scrollBy(1);
762
- return { consume: true };
763
- }
764
- if (keybindings.matches(data, "tui.altScreen.previousPrompt")) {
765
- if (!isRelease) this.scrollToPrompt(-1);
766
- return { consume: true };
767
- }
768
- if (keybindings.matches(data, "tui.altScreen.nextPrompt")) {
769
- if (!isRelease) this.scrollToPrompt(1);
770
- return { consume: true };
771
- }
772
- if (keybindings.matches(data, "tui.altScreen.top")) {
773
- if (!isRelease) this.scrollToTop();
774
- return { consume: true };
775
- }
776
- if (keybindings.matches(data, "tui.altScreen.bottom")) {
777
- if (!isRelease) this.scrollToBottom();
778
- return { consume: true };
779
- }
780
- return undefined;
781
- }
782
-
783
- private decodeMouseButton(button: number): TuiMouseButton {
784
- switch (button & 3) {
785
- case 0:
786
- return "left";
787
- case 1:
788
- return "middle";
789
- case 2:
790
- return "right";
791
- default:
792
- return "none";
793
- }
794
- }
795
-
796
- private createMouseEvent(
797
- type: TuiMouseEvent["type"],
798
- button: number,
799
- x: number,
800
- y: number,
801
- extra: Partial<Pick<TuiMouseEvent, "wheelDelta" | "clickCount">> = {},
802
- ): TuiMouseEvent {
803
- return {
804
- type,
805
- button: type === "wheel" ? "none" : this.decodeMouseButton(button),
806
- x,
807
- y,
808
- screenX: x,
809
- screenY: y,
810
- width: Math.max(1, this.terminal.columns),
811
- height: Math.max(1, this.terminal.rows),
812
- shift: (button & 4) !== 0,
813
- alt: (button & 8) !== 0,
814
- ctrl: (button & 16) !== 0,
815
- ...(extra.wheelDelta === undefined ? {} : { wheelDelta: extra.wheelDelta }),
816
- ...(extra.clickCount === undefined ? {} : { clickCount: extra.clickCount }),
817
- };
818
- }
819
-
820
- private dispatchMouseToLayout(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
821
- if (!this.currentLayout) return undefined;
822
- const visited = new Set<Component>();
823
- const boxes = getLayoutBoxesAt(this.currentLayout, event.screenX, event.screenY);
824
- for (const box of boxes) {
825
- if (visited.has(box.component)) continue;
826
- if (getLayoutNode(box.component) && box.component.handleMouse === Container.prototype.handleMouse) continue;
827
- visited.add(box.component);
828
- const result = dispatchMouseEvent(box.component, {
829
- ...event,
830
- x: event.screenX - box.rect.x,
831
- y: event.screenY - box.rect.y,
832
- width: box.rect.width,
833
- height: box.rect.height,
834
- });
835
- if (result) return result;
836
- }
837
- return undefined;
838
- }
839
-
840
- private applyMouseDispatchResult(event: TuiMouseEvent, result: TuiMouseDispatchResult): boolean {
841
- const focusTarget = this.resolveMouseFocusTarget(result.focusTarget ?? result.target.component);
842
- const focusChanged = result.focus === true && this.getFocusedComponent() !== focusTarget;
843
- if (result.focus) this.setFocus(focusTarget);
844
- if (result.capture) this.mouseCapture = result.target;
845
- return (
846
- result.render ??
847
- (focusChanged ||
848
- event.type === "press" ||
849
- event.type === "click" ||
850
- event.type === "drag" ||
851
- event.type === "wheel")
852
- );
853
- }
854
-
855
- private dispatchMouseToTarget(
856
- event: TuiMouseEvent,
857
- target: TuiMouseDispatchTarget,
858
- ): TuiMouseDispatchResult | undefined {
859
- return dispatchMouseEvent(target.component, retargetMouseEvent(event, target));
860
- }
861
-
862
- private getComponentClickCount(target: TuiMouseDispatchTarget, x: number, y: number): number {
863
- const now = Date.now();
864
- const previous = this.lastComponentClick;
865
- const count =
866
- previous &&
867
- now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
868
- previous.component === target.component &&
869
- previous.x === x &&
870
- previous.y === y
871
- ? (previous.count % 3) + 1
872
- : 1;
873
- this.lastComponentClick = { timestamp: now, count, component: target.component, x, y };
874
- return count;
875
- }
876
-
877
- private clearTextSelection(): void {
878
- this.stopSelectionAutoScroll();
879
- this.selectionPressActive = false;
880
- this.selectionAnchor = undefined;
881
- this.selectionFocus = undefined;
882
- this.selectionGranularity = "character";
883
- this.selectionInitialRange = undefined;
884
- this.pressedUrl = undefined;
885
- this.selectionDragged = false;
886
- }
887
-
888
- private handleMouseEvent(raw: SgrMouseEvent): void {
889
- const isMotion = (raw.button & 32) !== 0;
890
- const type: TuiMouseEvent["type"] = raw.release
891
- ? "release"
892
- : isMotion
893
- ? this.decodeMouseButton(raw.button) === "none"
894
- ? "move"
895
- : "drag"
896
- : "press";
897
- const event = this.createMouseEvent(type, raw.button, raw.x, raw.y);
898
-
899
- if (this.mouseCapture || this.mousePressTarget) {
900
- const target = this.mouseCapture ?? this.mousePressTarget!;
901
- if (this.mousePressPoint && (raw.x !== this.mousePressPoint.x || raw.y !== this.mousePressPoint.y)) {
902
- this.mousePressMoved = true;
903
- this.lastComponentClick = undefined;
904
- }
905
- let render = false;
906
- const targetResult = this.dispatchMouseToTarget(event, target);
907
- if (targetResult) render = this.applyMouseDispatchResult(event, targetResult);
908
- if (raw.release) {
909
- if (!this.mousePressMoved && this.mousePressPoint?.x === raw.x && this.mousePressPoint.y === raw.y) {
910
- const clickEvent = this.createMouseEvent("click", raw.button, raw.x, raw.y, {
911
- clickCount: this.getComponentClickCount(target, raw.x, raw.y),
912
- });
913
- const clickResult = this.dispatchMouseToTarget(clickEvent, target);
914
- if (clickResult) render = this.applyMouseDispatchResult(clickEvent, clickResult) || render;
915
- }
916
- this.clearComponentMouseGesture();
917
- }
918
- if (render) this.requestRender();
919
- return;
920
- }
921
-
922
- if (this.handleSearchMouseEvent(raw)) return;
923
-
924
- const overlay = this.dispatchMouseToOverlay(event);
925
- if (!overlay.hit) {
926
- if (this.handleScrollToEndIndicatorMouseEvent(raw)) return;
927
- const scrollbarHandled = this.handleScrollbarMouseEvent(raw);
928
- if (!this.scrollbarDrag) this.updateScrollbarHover(raw.x, raw.y);
929
- if (scrollbarHandled) return;
930
- } else {
931
- this.stopScrollbarHover();
932
- }
933
-
934
- const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
935
- if (result) {
936
- const render = this.applyMouseDispatchResult(event, result);
937
- if (type === "press") {
938
- this.clearTextSelection();
939
- this.mousePressTarget = result.target;
940
- this.mousePressPoint = { x: raw.x, y: raw.y };
941
- this.mousePressMoved = false;
942
- }
943
- if (render) this.requestRender();
944
- return;
945
- }
946
-
947
- if (this.handleRightClickPaste(raw)) return;
948
- this.handleSelectionMouseEvent(raw);
949
- }
950
-
951
- private parseWheelEvent(data: string): WheelEvent | undefined {
952
- const sgr = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
953
- if (sgr) {
954
- const button = Number.parseInt(sgr[1], 10);
955
- if ((button & 64) === 0) return undefined;
956
- const direction = button & 3;
957
- if (direction !== 0 && direction !== 1) return undefined;
958
- return {
959
- direction: direction === 0 ? -1 : 1,
960
- x: Number.parseInt(sgr[2], 10) - 1,
961
- y: Number.parseInt(sgr[3], 10) - 1,
962
- button,
963
- };
964
- }
965
- if (data.length === 6 && data.startsWith("\x1b[M")) {
966
- const button = data.charCodeAt(3) - 32;
967
- if ((button & 64) === 0) return undefined;
968
- const direction = button & 3;
969
- if (direction !== 0 && direction !== 1) return undefined;
970
- return {
971
- direction: direction === 0 ? -1 : 1,
972
- x: data.charCodeAt(4) - 33,
973
- y: data.charCodeAt(5) - 33,
974
- button,
975
- };
976
- }
977
- return undefined;
978
- }
979
-
980
- private routeWheel(event: WheelEvent, delta: number): void {
981
- let remaining = delta;
982
- const seen = new Set<ScrollView>();
983
- for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) {
984
- seen.add(scrollView);
985
- remaining = scrollView.scrollBy(remaining);
986
- if (remaining === 0 || scrollView.overscroll === "contain") break;
987
- }
988
- const primary = this.getPrimaryScrollView();
989
- if (remaining !== 0 && !seen.has(primary)) primary.scrollBy(remaining);
990
- this.updateScrollbarHover(event.x, event.y);
991
- this.requestRender();
992
- }
993
-
994
- private parseSgrMouseEvent(data: string): SgrMouseEvent | undefined {
995
- const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
996
- if (!match) return undefined;
997
- return {
998
- button: Number.parseInt(match[1], 10),
999
- x: Number.parseInt(match[2], 10) - 1,
1000
- y: Number.parseInt(match[3], 10) - 1,
1001
- release: match[4] === "m",
1002
- };
1003
- }
1004
-
1005
- private handleRightClickPaste(event: SgrMouseEvent): boolean {
1006
- if (
1007
- !this.onRightClickPaste ||
1008
- process.platform !== "win32" ||
1009
- process.env.TERM_PROGRAM?.toLowerCase() === "vscode" ||
1010
- event.release ||
1011
- event.button !== 2
1012
- ) {
1013
- return false;
1014
- }
1015
- try {
1016
- this.onRightClickPaste();
1017
- } catch {
1018
- // Clipboard paste is best-effort.
1019
- }
1020
- return true;
1021
- }
1022
-
1023
- private handleScrollToEndIndicatorMouseEvent(event: SgrMouseEvent): boolean {
1024
- const rect = this.scrollToEndIndicatorRect;
1025
- if (!rect || event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) return false;
1026
- if (event.y !== rect.row || event.x < rect.column || event.x >= rect.column + rect.width) return false;
1027
- this.scrollToBottom();
1028
- return true;
1029
- }
1030
-
1031
- private getScrollbarTargetAt(x: number, y: number, includeHiddenAuto = false): ScrollbarTarget | undefined {
1032
- if (this.hasOverlay() || !this.currentLayout) return undefined;
1033
- for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) {
1034
- const box = getScrollViewBox(this.currentLayout, scrollView);
1035
- const geometry = box ? getScrollbarGeometry(box, includeHiddenAuto) : undefined;
1036
- if (
1037
- geometry &&
1038
- x === geometry.column &&
1039
- y >= geometry.trackTop &&
1040
- y < geometry.trackTop + geometry.trackHeight
1041
- ) {
1042
- return { scrollView, geometry };
1043
- }
1044
- }
1045
- return undefined;
1046
- }
1047
-
1048
- private setScrollbarHover(scrollView: ScrollView | undefined): void {
1049
- if (scrollView === this.scrollbarHover) return;
1050
- this.scrollbarHover?.setScrollbarActive(false);
1051
- this.scrollbarHover = scrollView;
1052
- this.scrollbarHover?.setScrollbarActive(true);
1053
- }
1054
-
1055
- private updateScrollbarHover(x: number, y: number): void {
1056
- this.setScrollbarHover(this.getScrollbarTargetAt(x, y, true)?.scrollView);
1057
- }
1058
-
1059
- private stopScrollbarHover(): void {
1060
- this.setScrollbarHover(undefined);
1061
- }
1062
-
1063
- private scrollScrollbarToPointer(
1064
- scrollView: ScrollView,
1065
- geometry: ScrollbarGeometry,
1066
- pointerY: number,
1067
- grabOffset: number,
1068
- ): void {
1069
- const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
1070
- const thumbOffset = Math.max(0, Math.min(maxThumbOffset, pointerY - geometry.trackTop - grabOffset));
1071
- const scrollTop = maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
1072
- scrollView.scrollTo(scrollTop);
1073
- }
1074
-
1075
- private handleScrollbarMouseEvent(event: SgrMouseEvent): boolean {
1076
- if (this.scrollbarDrag) {
1077
- if (event.release) {
1078
- this.stopScrollbarDrag();
1079
- return true;
1080
- }
1081
- const box = this.currentLayout
1082
- ? getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView)
1083
- : undefined;
1084
- const geometry = box ? getScrollbarGeometry(box) : undefined;
1085
- if (geometry) {
1086
- this.scrollScrollbarToPointer(
1087
- this.scrollbarDrag.scrollView,
1088
- geometry,
1089
- event.y,
1090
- this.scrollbarDrag.grabOffset,
1091
- );
1092
- }
1093
- return true;
1094
- }
1095
-
1096
- if (event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) return false;
1097
- const target = this.getScrollbarTargetAt(event.x, event.y);
1098
- if (!target) return false;
1099
- this.stopSelectionAutoScroll();
1100
- this.selectionPressActive = false;
1101
- this.selectionAnchor = undefined;
1102
- this.selectionFocus = undefined;
1103
- this.selectionGranularity = "character";
1104
- this.selectionInitialRange = undefined;
1105
- this.lastClick = undefined;
1106
- this.pressedUrl = undefined;
1107
- this.selectionDragged = false;
1108
- this.setScrollbarHover(target.scrollView);
1109
- const onThumb =
1110
- event.y >= target.geometry.thumbTop && event.y < target.geometry.thumbTop + target.geometry.thumbHeight;
1111
- const grabOffset = onThumb ? event.y - target.geometry.thumbTop : Math.floor(target.geometry.thumbHeight / 2);
1112
- if (!onThumb) this.scrollScrollbarToPointer(target.scrollView, target.geometry, event.y, grabOffset);
1113
- this.scrollbarDrag = {
1114
- scrollView: target.scrollView,
1115
- grabOffset,
1116
- };
1117
- return true;
1118
- }
1119
-
1120
- private stopScrollbarDrag(): void {
1121
- this.scrollbarDrag = undefined;
1122
- }
1123
-
1124
- private getScrollSelectionPoint(scrollView: ScrollView, x: number, y: number): SelectionPoint | undefined {
1125
- if (!this.currentLayout) return undefined;
1126
- const box = getScrollViewBox(this.currentLayout, scrollView);
1127
- if (!box || box.rect.height <= 0 || box.clip.height <= 0) return undefined;
1128
- const visibleTop = Math.max(0, box.rect.y, box.clip.y);
1129
- const visibleBottom = Math.min(
1130
- this.terminal.rows - 1,
1131
- box.rect.y + box.rect.height - 1,
1132
- box.clip.y + box.clip.height - 1,
1133
- );
1134
- if (visibleBottom < visibleTop) return undefined;
1135
- const pointerRow = Math.max(visibleTop, Math.min(visibleBottom, y));
1136
- const maxContentRow = Math.max(0, (box.scrollContentLines?.length ?? 1) - 1);
1137
- return {
1138
- row: Math.max(0, Math.min(maxContentRow, scrollView.scrollTop + pointerRow - box.rect.y)),
1139
- col: Math.max(0, Math.min(box.rect.width - 1, x - box.rect.x)),
1140
- scrollView,
1141
- };
1142
- }
1143
-
1144
- private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint {
1145
- if (scrollView) {
1146
- const point = this.getScrollSelectionPoint(scrollView, event.x, event.y);
1147
- if (point) return point;
1148
- }
1149
- return {
1150
- row: Math.max(0, Math.min(this.terminal.rows - 1, event.y)),
1151
- col: Math.max(0, Math.min(this.terminal.columns - 1, event.x)),
1152
- };
1153
- }
1154
-
1155
- private getSelectionSourceLine(point: SelectionPoint): string {
1156
- if (point.scrollView && this.currentLayout) {
1157
- const lines = getScrollViewBox(this.currentLayout, point.scrollView)?.scrollContentLines;
1158
- if (lines) return lines[point.row] ?? "";
1159
- }
1160
- return this.previousScreen[point.row] ?? "";
1161
- }
1162
-
1163
- private getWordSelection(point: SelectionPoint): SelectionRange | undefined {
1164
- const line = stripTerminalSequences(this.getSelectionSourceLine(point));
1165
- const segments: Array<{ start: number; end: number; selectable: boolean; joiner: boolean }> = [];
1166
- let start = 0;
1167
- for (const segment of wordSegmenter.segment(line)) {
1168
- const end = start + visibleWidth(segment.segment);
1169
- const joiner = TERMINAL_WORD_SELECTION_JOINERS.has(segment.segment);
1170
- segments.push({ start, end, selectable: segment.isWordLike === true || joiner, joiner });
1171
- start = end;
1172
- }
1173
- const clickedSegmentIndex = segments.findIndex(
1174
- (segment) => point.col >= segment.start && point.col < segment.end,
1175
- );
1176
- if (clickedSegmentIndex < 0) return undefined;
1177
-
1178
- const canJoin = (
1179
- left: { selectable: boolean; joiner: boolean },
1180
- right: { selectable: boolean; joiner: boolean },
1181
- ): boolean => left.selectable && right.selectable && (left.joiner || right.joiner);
1182
- let selectionStart = segments[clickedSegmentIndex].start;
1183
- let selectionEnd = segments[clickedSegmentIndex].end;
1184
- for (let index = clickedSegmentIndex; index > 0 && canJoin(segments[index - 1], segments[index]); index--) {
1185
- selectionStart = segments[index - 1].start;
1186
- }
1187
- for (
1188
- let index = clickedSegmentIndex;
1189
- index < segments.length - 1 && canJoin(segments[index], segments[index + 1]);
1190
- index++
1191
- ) {
1192
- selectionEnd = segments[index + 1].end;
1193
- }
1194
- return {
1195
- start: { ...point, col: selectionStart },
1196
- end: { ...point, col: selectionEnd, boundary: true },
1197
- };
1198
- }
1199
-
1200
- private getLineSelection(point: SelectionPoint): SelectionRange {
1201
- return {
1202
- start: { ...point, col: 0 },
1203
- end: { ...point, col: visibleWidth(this.getSelectionSourceLine(point)), boundary: true },
1204
- };
1205
- }
1206
-
1207
- private updateSelectionFocus(point: SelectionPoint): void {
1208
- if (this.selectionGranularity === "character" || !this.selectionInitialRange) {
1209
- this.selectionFocus = point;
1210
- return;
1211
- }
1212
- const range = this.selectionGranularity === "word" ? this.getWordSelection(point) : this.getLineSelection(point);
1213
- if (!range) return;
1214
- const initial = this.selectionInitialRange;
1215
- const targetBeforeInitial =
1216
- range.start.row < initial.start.row ||
1217
- (range.start.row === initial.start.row && range.start.col < initial.start.col);
1218
- if (targetBeforeInitial) {
1219
- this.selectionAnchor = initial.end;
1220
- this.selectionFocus = range.start;
1221
- } else {
1222
- this.selectionAnchor = initial.start;
1223
- this.selectionFocus = range.end;
1224
- }
1225
- }
1226
-
1227
- private getClickCount(point: SelectionPoint, word: SelectionRange | undefined): number {
1228
- const now = Date.now();
1229
- const previous = this.lastClick;
1230
- const count =
1231
- word &&
1232
- previous &&
1233
- now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS &&
1234
- previous.row === point.row &&
1235
- previous.scrollView === point.scrollView &&
1236
- previous.wordStart === word.start.col &&
1237
- previous.wordEnd === word.end.col
1238
- ? (previous.count % 3) + 1
1239
- : 1;
1240
- this.lastClick = word
1241
- ? {
1242
- timestamp: now,
1243
- count,
1244
- row: point.row,
1245
- scrollView: point.scrollView,
1246
- wordStart: word.start.col,
1247
- wordEnd: word.end.col,
1248
- }
1249
- : undefined;
1250
- return count;
1251
- }
1252
-
1253
- private updateSelectionAutoScroll(event: SgrMouseEvent): void {
1254
- const scrollView = this.selectionAnchor?.scrollView;
1255
- if (!scrollView || !this.currentLayout) {
1256
- this.stopSelectionAutoScroll();
1257
- return;
1258
- }
1259
- const box = getScrollViewBox(this.currentLayout, scrollView);
1260
- if (!box || box.rect.height <= 0 || box.clip.height <= 0) {
1261
- this.stopSelectionAutoScroll();
1262
- return;
1263
- }
1264
- const visibleTop = Math.max(0, box.rect.y, box.clip.y);
1265
- const visibleBottom = Math.min(
1266
- this.terminal.rows - 1,
1267
- box.rect.y + box.rect.height - 1,
1268
- box.clip.y + box.clip.height - 1,
1269
- );
1270
- this.selectionDragPointer = { x: event.x, y: event.y };
1271
- this.selectionAutoScrollDirection = event.y <= visibleTop ? -1 : event.y >= visibleBottom ? 1 : 0;
1272
- if (this.selectionAutoScrollDirection === 0) {
1273
- this.stopSelectionAutoScroll();
1274
- return;
1275
- }
1276
- if (this.selectionAutoScrollTimer) return;
1277
- this.selectionAutoScrollTimer = setInterval(() => this.autoScrollSelection(), 50);
1278
- this.selectionAutoScrollTimer.unref();
1279
- }
1280
-
1281
- private autoScrollSelection(): void {
1282
- const scrollView = this.selectionAnchor?.scrollView;
1283
- const pointer = this.selectionDragPointer;
1284
- const direction = this.selectionAutoScrollDirection;
1285
- if (!scrollView || !pointer || direction === 0) {
1286
- this.stopSelectionAutoScroll();
1287
- return;
1288
- }
1289
- const remaining = scrollView.scrollBy(direction);
1290
- if (remaining === direction) {
1291
- this.stopSelectionAutoScroll();
1292
- return;
1293
- }
1294
- const point = this.getScrollSelectionPoint(scrollView, pointer.x, pointer.y);
1295
- if (point) this.updateSelectionFocus(point);
1296
- this.requestRender();
1297
- }
1298
-
1299
- private stopSelectionAutoScroll(): void {
1300
- if (this.selectionAutoScrollTimer) {
1301
- clearInterval(this.selectionAutoScrollTimer);
1302
- this.selectionAutoScrollTimer = undefined;
1303
- }
1304
- this.selectionAutoScrollDirection = 0;
1305
- this.selectionDragPointer = undefined;
1306
- }
1307
-
1308
- private handleSelectionMouseEvent(event: SgrMouseEvent): void {
1309
- const button = event.button & 3;
1310
- if (button !== 0 && !(event.release && button === 3)) return;
1311
- const anchorScrollView = this.selectionAnchor?.scrollView;
1312
- const point = this.getSelectionPoint(event, anchorScrollView);
1313
- if (event.release) {
1314
- if (!this.selectionPressActive) return;
1315
- this.selectionPressActive = false;
1316
- this.stopSelectionAutoScroll();
1317
- if (!this.selectionAnchor) return;
1318
- this.updateSelectionFocus(point);
1319
- const isClick =
1320
- !this.selectionDragged &&
1321
- this.selectionAnchor.scrollView === point.scrollView &&
1322
- this.selectionAnchor.row === point.row &&
1323
- this.selectionAnchor.col === point.col;
1324
- const clickedUrl = isClick ? this.pressedUrl : undefined;
1325
- this.pressedUrl = undefined;
1326
- if (clickedUrl && this.openUrl) {
1327
- this.selectionAnchor = undefined;
1328
- this.selectionFocus = undefined;
1329
- try {
1330
- this.openUrl(clickedUrl);
1331
- } catch {
1332
- // URL activation is best-effort.
1333
- }
1334
- this.requestRender();
1335
- return;
1336
- }
1337
- if (isClick) {
1338
- const clickEvent = this.createMouseEvent("click", event.button, event.x, event.y, {
1339
- clickCount: this.lastClick?.count ?? 1,
1340
- });
1341
- const overlay = this.dispatchMouseToOverlay(clickEvent);
1342
- const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(clickEvent));
1343
- if (result) {
1344
- const render = this.applyMouseDispatchResult(clickEvent, result);
1345
- this.clearTextSelection();
1346
- if (render) this.requestRender();
1347
- return;
1348
- }
1349
- }
1350
- if (this.copyOnSelect) void this.copySelectionToClipboard();
1351
- this.requestRender();
1352
- return;
1353
- }
1354
- if ((event.button & 32) !== 0) {
1355
- if (!this.selectionPressActive || !this.selectionAnchor) return;
1356
- this.selectionDragged = true;
1357
- this.lastClick = undefined;
1358
- this.pressedUrl = undefined;
1359
- this.updateSelectionFocus(point);
1360
- this.updateSelectionAutoScroll(event);
1361
- this.requestRender();
1362
- return;
1363
- }
1364
- this.stopSelectionAutoScroll();
1365
- this.selectionPressActive = true;
1366
- const scrollView =
1367
- !this.hasOverlay() && this.currentLayout
1368
- ? getScrollViewsAt(this.currentLayout, event.x, event.y)[0]
1369
- : undefined;
1370
- const anchor = this.getSelectionPoint(event, scrollView);
1371
- const word = this.getWordSelection(anchor);
1372
- const clickCount = this.getClickCount(anchor, word);
1373
- const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined;
1374
- this.selectionGranularity = range ? (clickCount === 2 ? "word" : "line") : "character";
1375
- this.selectionInitialRange = range;
1376
- this.selectionAnchor = range?.start ?? anchor;
1377
- this.selectionFocus = range?.end ?? anchor;
1378
- this.selectionDragged = false;
1379
- this.pressedUrl = range
1380
- ? undefined
1381
- : getOsc8LinkAtColumn(
1382
- this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "",
1383
- Math.max(0, Math.min(this.terminal.columns - 1, event.x)),
1384
- );
1385
- this.requestRender();
1386
- }
1387
-
1388
- private getSelectionBounds(): { start: SelectionPoint; end: SelectionPoint } | undefined {
1389
- if (!this.selectionAnchor || !this.selectionFocus) return undefined;
1390
- if (this.selectionAnchor.scrollView !== this.selectionFocus.scrollView) return undefined;
1391
- const anchorBeforeFocus =
1392
- this.selectionAnchor.row < this.selectionFocus.row ||
1393
- (this.selectionAnchor.row === this.selectionFocus.row && this.selectionAnchor.col < this.selectionFocus.col);
1394
- if (
1395
- this.selectionAnchor.row === this.selectionFocus.row &&
1396
- this.selectionAnchor.col === this.selectionFocus.col
1397
- ) {
1398
- return undefined;
1399
- }
1400
- return anchorBeforeFocus
1401
- ? { start: this.selectionAnchor, end: this.selectionFocus }
1402
- : { start: this.selectionFocus, end: this.selectionAnchor };
1403
- }
1404
-
1405
- private getSelectionColumns(
1406
- line: string,
1407
- row: number,
1408
- selection: { start: SelectionPoint; end: SelectionPoint },
1409
- minColumn = 0,
1410
- maxColumn = visibleWidth(line),
1411
- ): { start: number; end: number } {
1412
- const lineWidth = visibleWidth(line);
1413
- let start = Math.max(0, minColumn);
1414
- let end = Math.min(lineWidth, maxColumn);
1415
- if (row === selection.start.row) {
1416
- start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth);
1417
- }
1418
- if (row === selection.end.row) {
1419
- end = selection.end.boundary
1420
- ? Math.min(selection.end.col, lineWidth)
1421
- : (getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth));
1422
- }
1423
- return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
1424
- }
1425
-
1426
- private getActiveSelectionText(): string | undefined {
1427
- const selection = this.getSelectionBounds();
1428
- if (!selection) return undefined;
1429
- let sourceLines: readonly string[] = this.previousScreen;
1430
- if (selection.start.scrollView) {
1431
- if (!this.currentLayout) return undefined;
1432
- const box = getScrollViewBox(this.currentLayout, selection.start.scrollView);
1433
- if (!box?.scrollContentLines) return undefined;
1434
- sourceLines = box.scrollContentLines;
1435
- }
1436
- const lines: string[] = [];
1437
- for (let row = selection.start.row; row <= selection.end.row; row++) {
1438
- const line = sourceLines[row] ?? "";
1439
- const columns = this.getSelectionColumns(line, row, selection);
1440
- lines.push(
1441
- stripTerminalSequences(
1442
- sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true),
1443
- ).trimEnd(),
1444
- );
1445
- }
1446
- const text = lines.join("\n");
1447
- return text.length === 0 ? undefined : text;
1448
- }
1449
-
1450
- private async copySelectionToClipboard(): Promise<boolean> {
1451
- const text = this.getActiveSelectionText();
1452
- if (!text) return false;
1453
- return this.copyTextToClipboard(text);
1454
- }
1455
-
1456
- private async copyTextToClipboard(text: string): Promise<boolean> {
1457
- // Prefer an injected clipboard implementation (native clipboard + platform tools with a
1458
- // verified success path) when the host app provides one. A bare OSC 52 write can show
1459
- // "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux
1460
- // without OSC 52 clipboard passthrough), so only report success when it actually copies.
1461
- if (this.copySelection) {
1462
- const result = await this.copySelection(text);
1463
- const ok = result === true;
1464
- this.flash(
1465
- ok ? "Copied!" : typeof result === "string" ? result : "Copy failed",
1466
- ok ? undefined : COPY_ERROR_FLASH_DURATION_MS,
1467
- );
1468
- return ok;
1469
- }
1470
- this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
1471
- this.flash("Copied!");
1472
- return true;
1473
- }
1474
-
1475
- private applySearchTextHighlight(text: string, current: boolean): string {
1476
- const style = current ? this.searchCurrentMatchStyle : this.searchMatchStyle;
1477
- let result = "";
1478
- let plainStart = 0;
1479
- let index = 0;
1480
- while (index < text.length) {
1481
- const ansi = extractAnsiCode(text, index);
1482
- if (!ansi) {
1483
- index += 1;
1484
- continue;
1485
- }
1486
- if (index > plainStart) result += style(text.slice(plainStart, index));
1487
- result += ansi.code;
1488
- index += ansi.length;
1489
- plainStart = index;
1490
- }
1491
- if (plainStart < text.length) result += style(text.slice(plainStart));
1492
- return result;
1493
- }
1494
-
1495
- private applySearchHighlights(screen: string[], layout: LayoutFrame): string[] {
1496
- const search = this.activeSearch;
1497
- if (!search || search.selectedIndex < 0 || search.matches.length === 0) return screen;
1498
- const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
1499
- const box = getScrollViewBox(layout, scrollView);
1500
- if (!box) return screen;
1501
-
1502
- const rangesByRow = new Map<number, SearchHighlightRange[]>();
1503
- const scrollbarColumn = getScrollbarGeometry(box)?.column;
1504
- const minRow = Math.max(0, box.rect.y, box.clip.y);
1505
- const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height);
1506
- const minColumn = Math.max(0, box.rect.x, box.clip.x);
1507
- const maxColumn = Math.min(
1508
- this.terminal.columns,
1509
- box.rect.x + box.rect.width,
1510
- box.clip.x + box.clip.width,
1511
- scrollbarColumn ?? Number.POSITIVE_INFINITY,
1512
- );
1513
- const minContentRow = scrollView.scrollTop + minRow - box.rect.y;
1514
- const maxContentRow = scrollView.scrollTop + maxRow - box.rect.y - 1;
1515
- let low = 0;
1516
- let high = search.matches.length;
1517
- while (low < high) {
1518
- const middle = low + Math.floor((high - low) / 2);
1519
- const match = search.matches[middle]!;
1520
- const lastRow = match.segments[match.segments.length - 1]?.row ?? -1;
1521
- if (lastRow < minContentRow) low = middle + 1;
1522
- else high = middle;
1523
- }
1524
- for (let matchIndex = low; matchIndex < search.matches.length; matchIndex++) {
1525
- const match = search.matches[matchIndex]!;
1526
- if ((match.segments[0]?.row ?? 0) > maxContentRow) break;
1527
- for (const segment of match.segments) {
1528
- const row = box.rect.y + segment.row - scrollView.scrollTop;
1529
- if (row < minRow || row >= maxRow) continue;
1530
- const startCol = Math.max(minColumn, box.rect.x + segment.startCol);
1531
- const endCol = Math.min(maxColumn, box.rect.x + segment.endCol);
1532
- if (endCol <= startCol) continue;
1533
- const ranges = rangesByRow.get(row) ?? [];
1534
- ranges.push({ startCol, endCol, current: matchIndex === search.selectedIndex });
1535
- rangesByRow.set(row, ranges);
1536
- }
1537
- }
1538
-
1539
- const result = [...screen];
1540
- for (const [row, ranges] of rangesByRow) {
1541
- let line = result[row] ?? "";
1542
- if (isImageLine(line)) continue;
1543
- const lineWidth = visibleWidth(line);
1544
- for (const range of ranges.sort((a, b) => b.startCol - a.startCol)) {
1545
- const startCol = Math.min(range.startCol, lineWidth);
1546
- const endCol = Math.min(range.endCol, lineWidth);
1547
- if (endCol <= startCol) continue;
1548
- const before = sliceByColumn(line, 0, startCol, true);
1549
- const highlighted = sliceByColumn(line, startCol, endCol - startCol, true);
1550
- const after = sliceByColumn(line, endCol, Math.max(0, lineWidth - endCol), true);
1551
- line = `${before}${this.applySearchTextHighlight(highlighted, range.current)}${after}`;
1552
- }
1553
- result[row] = line;
1554
- }
1555
- return result;
1556
- }
1557
-
1558
- private applySelectionHighlight(text: string): string {
1559
- let result = "\x1b[7m";
1560
- let index = 0;
1561
- while (index < text.length) {
1562
- const ansi = extractAnsiCode(text, index);
1563
- if (!ansi) {
1564
- result += text[index];
1565
- index += 1;
1566
- continue;
1567
- }
1568
- result += ansi.code;
1569
- if (ansi.code.endsWith("m")) result += "\x1b[7m";
1570
- index += ansi.length;
1571
- }
1572
- return `${result}\x1b[27m`;
1573
- }
1574
-
1575
- private applySelection(screen: string[], layout = this.currentLayout): string[] {
1576
- const selection = this.getSelectionBounds();
1577
- if (!selection) return screen;
1578
- let screenSelection = selection;
1579
- let minRow = 0;
1580
- let maxRow = screen.length - 1;
1581
- let minColumn = 0;
1582
- let maxColumn = this.terminal.columns;
1583
- if (selection.start.scrollView) {
1584
- if (!layout) return screen;
1585
- const box = getScrollViewBox(layout, selection.start.scrollView);
1586
- if (!box) return screen;
1587
- minRow = Math.max(0, box.rect.y, box.clip.y);
1588
- maxRow = Math.min(screen.length - 1, box.rect.y + box.rect.height - 1, box.clip.y + box.clip.height - 1);
1589
- minColumn = Math.max(0, box.rect.x, box.clip.x);
1590
- maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width);
1591
- screenSelection = {
1592
- start: {
1593
- ...selection.start,
1594
- row: box.rect.y + selection.start.row - selection.start.scrollView.scrollTop,
1595
- col: box.rect.x + selection.start.col,
1596
- },
1597
- end: {
1598
- ...selection.end,
1599
- row: box.rect.y + selection.end.row - selection.start.scrollView.scrollTop,
1600
- col: box.rect.x + selection.end.col,
1601
- },
1602
- };
1603
- }
1604
- return screen.map((line, row) => {
1605
- if (
1606
- row < minRow ||
1607
- row > maxRow ||
1608
- row < screenSelection.start.row ||
1609
- row > screenSelection.end.row ||
1610
- isImageLine(line)
1611
- ) {
1612
- return line;
1613
- }
1614
- const lineWidth = visibleWidth(line);
1615
- const columns = this.getSelectionColumns(line, row, screenSelection, minColumn, maxColumn);
1616
- if (columns.end <= columns.start) return line;
1617
- const before = sliceByColumn(line, 0, columns.start, true);
1618
- const selected = sliceByColumn(line, columns.start, columns.end - columns.start, true);
1619
- const after = sliceByColumn(line, columns.end, Math.max(0, lineWidth - columns.end), true);
1620
- return `${before}${this.applySelectionHighlight(selected)}${after}`;
1621
- });
1622
- }
1623
-
1624
- private isMouseSequence(data: string): boolean {
1625
- return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data) || (data.length === 6 && data.startsWith("\x1b[M"));
1626
- }
1627
-
1628
- private compositeScrollToEndIndicator(screen: string[], layout: LayoutFrame, width: number): string[] {
1629
- this.scrollToEndIndicatorRect = undefined;
1630
- const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
1631
- if (!this.scrollToEndIndicator || !scrollView.followEnd || scrollView.isFollowingEnd) return screen;
1632
- const box = getScrollViewBox(layout, scrollView);
1633
- const clip = box?.clip;
1634
- if (!clip || clip.width <= 0 || clip.height <= 0) return screen;
1635
- const row = clip.y + clip.height - 1;
1636
- if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen;
1637
- const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
1638
- const label = truncateToWidth(this.scrollToEndIndicator(), clip.width, "");
1639
- const labelWidth = visibleWidth(label);
1640
- const column = clip.x + Math.floor((clip.width - labelWidth) / 2);
1641
- const rightEdge = scrollbarColumn ?? clip.x + clip.width;
1642
- const availableWidth = Math.max(0, rightEdge - column);
1643
- const text = truncateToWidth(label, availableWidth, "");
1644
- const textWidth = visibleWidth(text);
1645
- if (textWidth === 0) return screen;
1646
- const result = [...screen];
1647
- result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
1648
- this.scrollToEndIndicatorRect = { row, column, width: textWidth };
1649
- return result;
1650
- }
1651
-
1652
- private compositeFlashes(screen: string[], width: number, height: number): string[] {
1653
- const flashLines = this.flashes.render(width).slice(-height);
1654
- if (flashLines.length === 0) return screen;
1655
- const result = [...screen];
1656
- while (result.length < height) result.push("");
1657
- for (let row = 0; row < flashLines.length; row++) {
1658
- const line = flashLines[row]!;
1659
- const flashWidth = visibleWidth(line);
1660
- if (flashWidth === 0) continue;
1661
- result[row] = compositeTuiLine(result[row] ?? "", line, width - flashWidth, flashWidth, width);
1662
- }
1663
- return result;
1664
- }
1665
-
1666
- protected override doRender(): void {
1667
- if (this.stopped || !this.altScreenActive) return;
1668
- const width = Math.max(1, this.terminal.columns);
1669
- const height = Math.max(1, this.terminal.rows);
1670
- const root = this.layoutRoot ?? this.implicitScrollView;
1671
- let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
1672
- if (this.refreshSearch(nextLayout)) {
1673
- nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
1674
- }
1675
- let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
1676
- screen = this.applySearchHighlights(screen, nextLayout);
1677
- screen = this.compositeScrollToEndIndicator(screen, nextLayout, width);
1678
- screen = this.compositeOverlays(screen, width, height);
1679
- if (screen.length > height) screen = screen.slice(screen.length - height);
1680
- screen = this.applySelection(screen, nextLayout);
1681
- screen = this.compositeFlashes(screen, width, height);
1682
-
1683
- const cursorPos = this.extractCursorPosition(screen, height);
1684
- screen = this.applyLineResets(screen).map((line) => {
1685
- if (isImageLine(line) || visibleWidth(line) <= width) return line;
1686
- return sliceByColumn(line, 0, width, true);
1687
- });
1688
-
1689
- const fullRedraw =
1690
- this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height;
1691
- const imagesNeedRedraw = screen.some(
1692
- (line, row) =>
1693
- line !== this.previousScreen[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")),
1694
- );
1695
- const redrawImages = fullRedraw || imagesNeedRedraw;
1696
- const hadUploadedKittyImages = this.uploadedKittyImages.size > 0;
1697
- const preparedKittyScreen =
1698
- redrawImages && this.imageProtocol === "kitty"
1699
- ? this.prepareKittyScreen(screen)
1700
- : { lines: screen, evictedImageDeletion: "" };
1701
-
1702
- let buffer = BEGIN_SYNCHRONIZED_OUTPUT;
1703
- if (fullRedraw) {
1704
- this.fullRedrawCount += 1;
1705
- const clearImages =
1706
- this.imageProtocol === "kitty" && hadUploadedKittyImages
1707
- ? deleteAllKittyPlacements()
1708
- : this.deleteKittyImages();
1709
- buffer += `${clearImages}\x1b[2J`;
1710
- } else if (imagesNeedRedraw) {
1711
- if (this.imageProtocol === "iterm2") buffer += "\x1b[2J";
1712
- else if (this.imageProtocol === "kitty") buffer += deleteAllKittyPlacements();
1713
- }
1714
- buffer += preparedKittyScreen.evictedImageDeletion;
1715
-
1716
- // WezTerm erases intersecting Kitty image cells when a later EL clears a covered row.
1717
- // Only separate clearing from drawing for WezTerm frames that place images; preserve the
1718
- // existing interleaved output for text-only frames and every other terminal.
1719
- const clearRowsBeforeKittyImages =
1720
- redrawImages &&
1721
- this.imageProtocol === "kitty" &&
1722
- screen.some(isImageLine) &&
1723
- (Boolean(process.env.WEZTERM_PANE) || process.env.TERM_PROGRAM?.toLowerCase() === "wezterm");
1724
- if (clearRowsBeforeKittyImages) {
1725
- for (let row = 0; row < height; row++) {
1726
- if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
1727
- buffer += `\x1b[${row + 1};1H\x1b[2K`;
1728
- }
1729
- }
1730
-
1731
- for (let row = 0; row < height; row++) {
1732
- if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
1733
- buffer += `\x1b[${row + 1};1H${clearRowsBeforeKittyImages ? "" : "\x1b[2K"}${preparedKittyScreen.lines[row] ?? ""}`;
1734
- }
1735
-
1736
- if (cursorPos) {
1737
- buffer += `\x1b[${cursorPos.row + 1};${Math.min(width, cursorPos.col) + 1}H`;
1738
- buffer += this.getShowHardwareCursor() ? "\x1b[?25h" : "\x1b[?25l";
1739
- } else {
1740
- buffer += "\x1b[?25l";
1741
- }
1742
- buffer += END_SYNCHRONIZED_OUTPUT;
1743
- this.terminal.write(buffer);
1744
-
1745
- this.previousScreen = screen;
1746
- this.previousScreenWidth = width;
1747
- this.previousScreenHeight = height;
1748
- this.currentLayout = nextLayout;
1749
- }
1750
- }