@visual-z/xeno 0.2.1 → 0.4.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 (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -1,1493 +0,0 @@
1
- /**
2
- * Minimal TUI implementation with differential rendering
3
- */
4
-
5
- import { performance } from "node:perf_hooks";
6
- import { isKeyRelease, matchesKey } from "./keys.ts";
7
- import type { Terminal } from "./terminal.ts";
8
- import {
9
- parseOscColorResponse,
10
- parseTerminalColorSchemeReport,
11
- type RgbColor,
12
- type TerminalColorScheme,
13
- type TerminalColors,
14
- } from "./terminal-colors.ts";
15
- import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts";
16
- import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts";
17
-
18
- /**
19
- * Component interface - all components must implement this
20
- */
21
- export type TuiMouseEventType = "press" | "release" | "move" | "drag" | "click" | "wheel";
22
- export type TuiMouseButton = "left" | "middle" | "right" | "none";
23
-
24
- /** Normalized cell-based mouse event. Coordinates are zero-based. */
25
- export interface TuiMouseEvent {
26
- type: TuiMouseEventType;
27
- button: TuiMouseButton;
28
- /** Coordinates local to the receiving component. */
29
- x: number;
30
- y: number;
31
- /** Absolute terminal coordinates. */
32
- screenX: number;
33
- screenY: number;
34
- /** Current component bounds. */
35
- width: number;
36
- height: number;
37
- shift: boolean;
38
- alt: boolean;
39
- ctrl: boolean;
40
- /** Logical lines. Negative values scroll up. */
41
- wheelDelta?: number;
42
- /** Consecutive click count when type is click. */
43
- clickCount?: number;
44
- }
45
-
46
- export interface TuiMouseEventResult {
47
- /** Stop propagation and suppress renderer-level fallback behavior. */
48
- handled?: boolean;
49
- /** Route subsequent drag/release events to this component. Implies handled. */
50
- capture?: boolean;
51
- /** Give keyboard focus to this component. Implies handled. */
52
- focus?: boolean;
53
- /**
54
- * Explicitly request or suppress a render. Move and release default to false;
55
- * press, click, drag, and wheel default to true.
56
- */
57
- render?: boolean;
58
- }
59
-
60
- /** Internal target metadata used by containers and alternate-screen dispatch. */
61
- export interface TuiMouseDispatchTarget {
62
- component: Component;
63
- originX: number;
64
- originY: number;
65
- width: number;
66
- height: number;
67
- }
68
-
69
- /** Result of dispatching to a concrete component. */
70
- export interface TuiMouseDispatchResult extends TuiMouseEventResult {
71
- handled: true;
72
- target: TuiMouseDispatchTarget;
73
- /** Keyboard focus target, which may be a delegating parent container. */
74
- focusTarget?: Component;
75
- }
76
-
77
- /**
78
- * Dispatch an event to a component and retain the exact target and coordinate
79
- * transform. Containers use this when forwarding events to nested children.
80
- */
81
- export function dispatchMouseEvent(component: Component, event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
82
- const result = component.handleMouse?.(event);
83
- if (!result) return undefined;
84
- if ("target" in result) {
85
- // The component forwarded the event to a child it hosts. Like a delegating container, it routes
86
- // keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave
87
- // focus on a detached component once the host removes it, e.g. a closed settings submenu.
88
- const forwarded = result as TuiMouseDispatchResult;
89
- return forwarded.focus && component.handleInput ? { ...forwarded, focusTarget: component } : forwarded;
90
- }
91
- if (!result.handled && !result.capture && !result.focus) return undefined;
92
- return {
93
- ...result,
94
- handled: true,
95
- ...(result.focus ? { focusTarget: component } : {}),
96
- target: {
97
- component,
98
- originX: event.screenX - event.x,
99
- originY: event.screenY - event.y,
100
- width: event.width,
101
- height: event.height,
102
- },
103
- };
104
- }
105
-
106
- /** Recreate local coordinates for a previously dispatched mouse target. */
107
- export function retargetMouseEvent(event: TuiMouseEvent, target: TuiMouseDispatchTarget): TuiMouseEvent {
108
- return {
109
- ...event,
110
- x: event.screenX - target.originX,
111
- y: event.screenY - target.originY,
112
- width: target.width,
113
- height: target.height,
114
- };
115
- }
116
-
117
- export interface Component {
118
- /**
119
- * Render the component to lines for the given viewport width
120
- * @param width - Current viewport width
121
- * @returns Array of strings, each representing a line
122
- */
123
- render(width: number): string[];
124
-
125
- /** Optional handler for keyboard input when component has focus. */
126
- handleInput?(data: string): void;
127
-
128
- /** Optional normalized mouse handler. */
129
- handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
130
-
131
- /**
132
- * If true, component receives key release events (Kitty protocol).
133
- * Default is false - release events are filtered out.
134
- */
135
- wantsKeyRelease?: boolean;
136
-
137
- /**
138
- * Invalidate any cached rendering state.
139
- * Called when theme changes or when component needs to re-render from scratch.
140
- */
141
- invalidate(): void;
142
- }
143
-
144
- export type TuiInputListenerResult = { consume?: boolean; data?: string } | undefined;
145
- export type TuiInputListener = (data: string) => TuiInputListenerResult;
146
- type PendingTerminalColorQuery = {
147
- foreground?: RgbColor;
148
- background?: RgbColor;
149
- palette: Array<RgbColor | undefined>;
150
- /** Targets that already replied, so duplicates do not count twice. */
151
- replied: Set<string>;
152
- /**
153
- * Receives the result: the promise's resolve until the timeout, then `onLateReply`. Unset once the
154
- * query completed (on the DA1 reply or once every color replied); later replies are ignored.
155
- */
156
- deliver: ((colors: TerminalColors) => void) | undefined;
157
- timer: NodeJS.Timeout | undefined;
158
- };
159
-
160
- const TERMINAL_PALETTE_SIZE = 16;
161
- /** OSC 10 and 11 plus OSC 4 for every palette color. */
162
- const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;
163
- /**
164
- * Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request.
165
- * Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of
166
- * the color replies, including for terminals that ignore the color queries.
167
- */
168
- const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from(
169
- { length: TERMINAL_PALETTE_SIZE },
170
- (_, index) => `\x1b]4;${index};?\x07`,
171
- ).join("")}\x1b[c`;
172
- const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;
173
-
174
- /**
175
- * Interface for components that can receive focus and display a hardware cursor.
176
- * When focused, the component should emit CURSOR_MARKER at the cursor position
177
- * in its render output. TUI will find this marker and position the hardware
178
- * cursor there for proper IME candidate window positioning.
179
- */
180
- export interface Focusable {
181
- /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
182
- focused: boolean;
183
- }
184
-
185
- /** Type guard to check if a component implements Focusable */
186
- export function isFocusable(component: Component | null): component is Component & Focusable {
187
- return component !== null && "focused" in component;
188
- }
189
-
190
- /**
191
- * Cursor position marker - APC (Application Program Command) sequence.
192
- * This is a zero-width escape sequence that terminals ignore.
193
- * Components emit this at the cursor position when focused.
194
- * TUI finds and strips this marker, then positions the hardware cursor there.
195
- */
196
- export const CURSOR_MARKER = "\x1b_pi:c\x07";
197
-
198
- export { visibleWidth };
199
-
200
- /**
201
- * Anchor position for overlays
202
- */
203
- export type OverlayAnchor =
204
- | "center"
205
- | "top-left"
206
- | "top-right"
207
- | "bottom-left"
208
- | "bottom-right"
209
- | "top-center"
210
- | "bottom-center"
211
- | "left-center"
212
- | "right-center";
213
-
214
- /**
215
- * Margin configuration for overlays
216
- */
217
- export interface OverlayMargin {
218
- top?: number;
219
- right?: number;
220
- bottom?: number;
221
- left?: number;
222
- }
223
-
224
- /** Value that can be absolute (number) or percentage (string like "50%") */
225
- export type SizeValue = number | `${number}%`;
226
-
227
- /** Parse a SizeValue into absolute value given a reference size */
228
- function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
229
- if (value === undefined) return undefined;
230
- if (typeof value === "number") return value;
231
- // Parse percentage string like "50%"
232
- const match = value.match(/^(\d+(?:\.\d+)?)%$/);
233
- if (match) {
234
- return Math.floor((referenceSize * parseFloat(match[1])) / 100);
235
- }
236
- return undefined;
237
- }
238
-
239
- /**
240
- * Options for overlay positioning and sizing.
241
- * Values can be absolute numbers or percentage strings (e.g., "50%").
242
- */
243
- export interface OverlayOptions {
244
- // === Sizing ===
245
- /** Width in columns, or percentage of terminal width (e.g., "50%") */
246
- width?: SizeValue;
247
- /** Minimum width in columns */
248
- minWidth?: number;
249
- /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
250
- maxHeight?: SizeValue;
251
-
252
- // === Positioning - anchor-based ===
253
- /** Anchor point for positioning (default: 'center') */
254
- anchor?: OverlayAnchor;
255
- /** Horizontal offset from anchor position (positive = right) */
256
- offsetX?: number;
257
- /** Vertical offset from anchor position (positive = down) */
258
- offsetY?: number;
259
-
260
- // === Positioning - percentage or absolute ===
261
- /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
262
- row?: SizeValue;
263
- /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
264
- col?: SizeValue;
265
-
266
- // === Margin from terminal edges ===
267
- /** Margin from terminal edges. Number applies to all sides. */
268
- margin?: OverlayMargin | number;
269
-
270
- // === Visibility ===
271
- /**
272
- * Control overlay visibility based on terminal dimensions.
273
- * If provided, overlay is only rendered when this returns true.
274
- * Called each render cycle with current terminal dimensions.
275
- */
276
- visible?: (termWidth: number, termHeight: number) => boolean;
277
- /** If true, don't capture keyboard focus when shown */
278
- nonCapturing?: boolean;
279
- }
280
-
281
- /** Options for {@link OverlayHandle.unfocus}. */
282
- export interface OverlayUnfocusOptions {
283
- /** Explicit target to focus after releasing this overlay. */
284
- target: Component | null;
285
- }
286
-
287
- /** Last rendered terminal-relative overlay rectangle. */
288
- export interface OverlayBounds {
289
- row: number;
290
- col: number;
291
- width: number;
292
- height: number;
293
- }
294
-
295
- /**
296
- * Handle returned by showOverlay for controlling the overlay
297
- */
298
- export interface OverlayHandle {
299
- /** Permanently remove the overlay (cannot be shown again) */
300
- hide(): void;
301
- /** Temporarily hide or show the overlay */
302
- setHidden(hidden: boolean): void;
303
- /** Check if overlay is temporarily hidden */
304
- isHidden(): boolean;
305
- /** Focus this overlay and bring it to the visual front */
306
- focus(): void;
307
- /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */
308
- unfocus(options?: OverlayUnfocusOptions): void;
309
- /** Check if this overlay currently has focus */
310
- isFocused(): boolean;
311
- /** Get the most recent rendered bounds for a visible overlay. */
312
- getBounds(): OverlayBounds | undefined;
313
- }
314
-
315
- type OverlayStackEntry = {
316
- component: Component;
317
- options?: OverlayOptions;
318
- preFocus: Component | null;
319
- hidden: boolean;
320
- focusOrder: number;
321
- bounds?: OverlayBounds;
322
- };
323
-
324
- type RenderedOverlayLayout = {
325
- entry: OverlayStackEntry;
326
- row: number;
327
- col: number;
328
- width: number;
329
- height: number;
330
- };
331
-
332
- type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null };
333
- type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry };
334
- type BlockedOverlayFocusRestoreState = {
335
- status: "blocked";
336
- overlay: OverlayStackEntry;
337
- blockedBy: Component;
338
- resume: OverlayBlockedFocusResume;
339
- };
340
- type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState;
341
- type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState;
342
- type OverlayFocusRestorePolicy = "clear" | "preserve";
343
-
344
- /**
345
- * Container - a component that contains other components
346
- */
347
- export class Container implements Component {
348
- children: Component[] = [];
349
- private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
350
-
351
- addChild(component: Component): void {
352
- this.children.push(component);
353
- }
354
-
355
- removeChild(component: Component): void {
356
- const index = this.children.indexOf(component);
357
- if (index !== -1) {
358
- this.children.splice(index, 1);
359
- }
360
- }
361
-
362
- clear(): void {
363
- this.children = [];
364
- }
365
-
366
- invalidate(): void {
367
- for (const child of this.children) {
368
- child.invalidate?.();
369
- }
370
- }
371
-
372
- handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
373
- if (event.y < 0 || event.y >= event.height) return undefined;
374
- const mouseChildren =
375
- this.mouseLayout?.width === event.width
376
- ? this.mouseLayout.children
377
- : this.children.map((component) => ({ component, height: component.render(event.width).length }));
378
- let childY = 0;
379
- for (const { component: child, height: childHeight } of mouseChildren) {
380
- if (event.y >= childY && event.y < childY + childHeight) {
381
- const result = dispatchMouseEvent(child, {
382
- ...event,
383
- y: event.y - childY,
384
- height: childHeight,
385
- });
386
- if (result?.focus && (this as Component).handleInput) return { ...result, focusTarget: this };
387
- return result;
388
- }
389
- childY += childHeight;
390
- }
391
- return undefined;
392
- }
393
-
394
- render(width: number): string[] {
395
- const lines: string[] = [];
396
- const mouseChildren: Array<{ component: Component; height: number }> = [];
397
- for (const child of this.children) {
398
- const childLines = child.render(width);
399
- mouseChildren.push({ component: child, height: childLines.length });
400
- for (const line of childLines) {
401
- lines.push(line);
402
- }
403
- }
404
- this.mouseLayout = { width, children: mouseChildren };
405
- return lines;
406
- }
407
- }
408
-
409
- /**
410
- * TUI - Main class for managing terminal UI with differential rendering
411
- */
412
- const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
413
-
414
- /** Composite overlay content into a terminal line at a fixed column. */
415
- export function compositeTuiLine(
416
- baseLine: string,
417
- overlayLine: string,
418
- startCol: number,
419
- overlayWidth: number,
420
- totalWidth: number,
421
- ): string {
422
- if (isImageLine(baseLine)) return baseLine;
423
-
424
- const afterStart = startCol + overlayWidth;
425
- const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
426
- const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
427
- const beforePad = Math.max(0, startCol - base.beforeWidth);
428
- const overlayPad = Math.max(0, overlayWidth - overlay.width);
429
- const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
430
- const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
431
- const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
432
- const afterPad = Math.max(0, afterTarget - base.afterWidth);
433
- const result =
434
- base.before +
435
- " ".repeat(beforePad) +
436
- SEGMENT_RESET +
437
- overlay.text +
438
- " ".repeat(overlayPad) +
439
- SEGMENT_RESET +
440
- base.after +
441
- " ".repeat(afterPad);
442
-
443
- return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
444
- }
445
-
446
- export type TuiMode = "regular" | "fullscreen";
447
-
448
- export interface TuiStopOptions {
449
- /** Leave renderer output in place for another TUI taking over the same terminal. */
450
- preserveScreen?: boolean;
451
- }
452
-
453
- export interface TUI extends Component {
454
- readonly mode: TuiMode;
455
- children: Component[];
456
- terminal: Terminal;
457
- onDebug?: () => void;
458
- readonly fullRedraws: number;
459
- addChild(component: Component): void;
460
- removeChild(component: Component): void;
461
- clear(): void;
462
- getShowHardwareCursor(): boolean;
463
- setShowHardwareCursor(enabled: boolean): void;
464
- getClearOnShrink(): boolean;
465
- setClearOnShrink(enabled: boolean): void;
466
- setFocus(component: Component | null): void;
467
- showOverlay(component: Component, options?: OverlayOptions): OverlayHandle;
468
- hideOverlay(): void;
469
- hasOverlay(): boolean;
470
- start(): void;
471
- stop(options?: TuiStopOptions): void;
472
- renderNow(force?: boolean): void;
473
- requestRender(force?: boolean): void;
474
- addInputListener(listener: TuiInputListener): () => void;
475
- removeInputListener(listener: TuiInputListener): void;
476
- onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void;
477
- setTerminalColorSchemeNotifications(enabled: boolean): void;
478
- queryTerminalColors(options: {
479
- timeoutMs: number;
480
- onLateReply?: (colors: TerminalColors) => void;
481
- }): Promise<TerminalColors>;
482
- }
483
-
484
- export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");
485
-
486
- export interface ViewportTUI extends TUI {
487
- readonly [VIEWPORT_TUI]: true;
488
- setLayoutRoot(component: Component | undefined): void;
489
- }
490
-
491
- export function isViewportTUI(tui: TUI): tui is ViewportTUI {
492
- return (tui as Partial<ViewportTUI>)[VIEWPORT_TUI] === true;
493
- }
494
-
495
- export abstract class TuiBase extends Container implements TUI {
496
- abstract readonly mode: TuiMode;
497
- public terminal: Terminal;
498
- private focusedComponent: Component | null = null;
499
- private inputListeners = new Set<TuiInputListener>();
500
-
501
- /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
502
- public onDebug?: () => void;
503
- private renderRequested = false;
504
- private immediateRenderScheduled = false;
505
- private renderTimer: NodeJS.Timeout | undefined;
506
- private lastRenderAt = 0;
507
- private static readonly MIN_RENDER_INTERVAL_MS = 16;
508
- private showHardwareCursor = false;
509
- private clearOnShrink = false;
510
- protected fullRedrawCount = 0;
511
- protected stopped = false;
512
- /**
513
- * Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color
514
- * replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
515
- */
516
- private pendingTerminalColorQueries: PendingTerminalColorQuery[] = [];
517
- private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>();
518
- private terminalColorSchemeNotificationsEnabled = false;
519
- /** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */
520
- protected readonly logDirectory: string | undefined;
521
-
522
- // Overlay stack for modal components rendered on top of base content
523
- private focusOrderCounter = 0;
524
- private overlayStack: OverlayStackEntry[] = [];
525
- private renderedOverlayLayouts: RenderedOverlayLayout[] = [];
526
-
527
- get hasOverlayEntries(): boolean {
528
- return this.overlayStack.length > 0;
529
- }
530
- private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };
531
-
532
- constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string) {
533
- super();
534
- this.terminal = terminal;
535
- this.logDirectory = logDirectory;
536
- if (showHardwareCursor !== undefined) {
537
- this.showHardwareCursor = showHardwareCursor;
538
- }
539
- }
540
-
541
- protected abstract doRender(): void;
542
-
543
- protected resetRenderState(): void {}
544
-
545
- protected beforeTerminalStart(): void {}
546
-
547
- protected afterTerminalStart(): void {}
548
-
549
- protected beforeTerminalStop(_options: TuiStopOptions): void {}
550
-
551
- protected afterTerminalStop(_options: TuiStopOptions): void {}
552
-
553
- get fullRedraws(): number {
554
- return this.fullRedrawCount;
555
- }
556
-
557
- getShowHardwareCursor(): boolean {
558
- return this.showHardwareCursor;
559
- }
560
-
561
- setShowHardwareCursor(enabled: boolean): void {
562
- if (this.showHardwareCursor === enabled) return;
563
- this.showHardwareCursor = enabled;
564
- if (!enabled) {
565
- this.hideTerminalCursor();
566
- }
567
- this.requestRender();
568
- }
569
-
570
- getClearOnShrink(): boolean {
571
- return this.clearOnShrink;
572
- }
573
-
574
- /**
575
- * Set whether to trigger full re-render when content shrinks.
576
- * When true, empty rows are cleared when content shrinks.
577
- * When false (default), empty rows remain (reduces redraws on slower terminals).
578
- */
579
- setClearOnShrink(enabled: boolean): void {
580
- this.clearOnShrink = enabled;
581
- }
582
-
583
- getFocusedComponent(): Component | null {
584
- return this.focusedComponent;
585
- }
586
-
587
- setFocus(component: Component | null): void {
588
- this.setFocusInternal({ component, overlayFocusRestore: "clear" });
589
- }
590
-
591
- private setFocusInternal({
592
- component,
593
- overlayFocusRestore,
594
- }: {
595
- component: Component | null;
596
- overlayFocusRestore: OverlayFocusRestorePolicy;
597
- }): void {
598
- const previousFocus = this.focusedComponent;
599
- let nextFocus = component;
600
- const previousFocusedOverlay = previousFocus
601
- ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
602
- : undefined;
603
- const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
604
- const restoreState = this.getVisibleOverlayFocusRestore();
605
- if (nextFocus && !nextFocusIsOverlay) {
606
- if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
607
- if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
608
- nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
609
- } else {
610
- this.overlayFocusRestore = {
611
- status: "blocked",
612
- overlay: restoreState.overlay,
613
- blockedBy: nextFocus,
614
- resume: restoreState.resume,
615
- };
616
- }
617
- } else if (
618
- previousFocusedOverlay &&
619
- restoreState.status !== "inactive" &&
620
- restoreState.overlay === previousFocusedOverlay &&
621
- !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)
622
- ) {
623
- this.overlayFocusRestore = {
624
- status: "blocked",
625
- overlay: previousFocusedOverlay,
626
- blockedBy: nextFocus,
627
- resume: { status: "restore-overlay" },
628
- };
629
- }
630
- } else if (nextFocus === null) {
631
- if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
632
- nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
633
- } else if (overlayFocusRestore === "clear") {
634
- this.clearOverlayFocusRestore();
635
- }
636
- }
637
-
638
- if (isFocusable(this.focusedComponent)) {
639
- this.focusedComponent.focused = false;
640
- }
641
-
642
- this.focusedComponent = nextFocus;
643
-
644
- if (isFocusable(nextFocus)) {
645
- nextFocus.focused = true;
646
- }
647
-
648
- const focusedOverlay = nextFocus
649
- ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
650
- : undefined;
651
- if (focusedOverlay) {
652
- this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
653
- }
654
- }
655
-
656
- private clearOverlayFocusRestore(): void {
657
- this.overlayFocusRestore = { status: "inactive" };
658
- }
659
-
660
- private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void {
661
- if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
662
- this.clearOverlayFocusRestore();
663
- }
664
- }
665
-
666
- private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null {
667
- if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component;
668
- this.clearOverlayFocusRestore();
669
- return restoreState.resume.target;
670
- }
671
-
672
- private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState {
673
- const restoreState = this.overlayFocusRestore;
674
- if (restoreState.status === "inactive") return restoreState;
675
- if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
676
- return { status: "inactive" };
677
- }
678
- return restoreState;
679
- }
680
-
681
- private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean {
682
- const visited = new Set<Component>();
683
- let current = entry.preFocus;
684
- while (current && !visited.has(current)) {
685
- visited.add(current);
686
- if (current === component) return true;
687
- current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;
688
- }
689
- return false;
690
- }
691
-
692
- private retargetOverlayPreFocus(removed: OverlayStackEntry): void {
693
- for (const overlay of this.overlayStack) {
694
- if (overlay !== removed && overlay.preFocus === removed.component) {
695
- overlay.preFocus = removed.preFocus;
696
- }
697
- }
698
- }
699
-
700
- protected getMountedRoots(): readonly Component[] {
701
- return this.children;
702
- }
703
-
704
- private isComponentMounted(component: Component): boolean {
705
- return this.getMountedRoots().some((child) => this.containsComponent(child, component));
706
- }
707
-
708
- private containsComponent(root: Component, target: Component): boolean {
709
- if (root === target) return true;
710
- if (!(root instanceof Container)) return false;
711
- return root.children.some((child) => this.containsComponent(child, target));
712
- }
713
-
714
- /**
715
- * Show an overlay component with configurable positioning and sizing.
716
- * Returns a handle to control the overlay's visibility.
717
- */
718
- showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
719
- const entry: OverlayStackEntry = {
720
- component,
721
- ...(options === undefined ? {} : { options }),
722
- preFocus: this.focusedComponent,
723
- hidden: false,
724
- focusOrder: ++this.focusOrderCounter,
725
- };
726
- this.overlayStack.push(entry);
727
- // Only focus if overlay is actually visible
728
- if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
729
- this.setFocus(component);
730
- }
731
- this.hideTerminalCursor();
732
- this.requestRender();
733
-
734
- // Return handle for controlling this overlay
735
- return {
736
- hide: () => {
737
- const index = this.overlayStack.indexOf(entry);
738
- if (index !== -1) {
739
- this.clearOverlayFocusRestoreFor(entry);
740
- this.retargetOverlayPreFocus(entry);
741
- this.overlayStack.splice(index, 1);
742
- // Restore focus if this overlay had focus
743
- if (this.focusedComponent === component) {
744
- const topVisible = this.getTopmostVisibleOverlay();
745
- this.setFocus(topVisible?.component ?? entry.preFocus);
746
- }
747
- if (this.overlayStack.length === 0) this.hideTerminalCursor();
748
- this.requestRender();
749
- }
750
- },
751
- setHidden: (hidden: boolean) => {
752
- if (entry.hidden === hidden) return;
753
- entry.hidden = hidden;
754
- // Update focus when hiding/showing
755
- if (hidden) {
756
- this.clearOverlayFocusRestoreFor(entry);
757
- // If this overlay had focus, move focus to next visible or preFocus
758
- if (this.focusedComponent === component) {
759
- const topVisible = this.getTopmostVisibleOverlay();
760
- this.setFocus(topVisible?.component ?? entry.preFocus);
761
- }
762
- } else {
763
- // Restore focus to this overlay when showing (if it's actually visible)
764
- if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
765
- entry.focusOrder = ++this.focusOrderCounter;
766
- this.setFocus(component);
767
- }
768
- }
769
- this.requestRender();
770
- },
771
- isHidden: () => entry.hidden,
772
- focus: () => {
773
- if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;
774
- entry.focusOrder = ++this.focusOrderCounter;
775
- this.setFocus(component);
776
- this.requestRender();
777
- },
778
- unfocus: (unfocusOptions) => {
779
- const isFocused = this.focusedComponent === component;
780
- const restoreState = this.overlayFocusRestore;
781
- const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
782
- if (!isFocused && !hasPendingRestore) return;
783
- if (
784
- restoreState.status === "blocked" &&
785
- restoreState.overlay === entry &&
786
- this.focusedComponent === restoreState.blockedBy
787
- ) {
788
- if (unfocusOptions) {
789
- this.overlayFocusRestore = {
790
- status: "blocked",
791
- overlay: entry,
792
- blockedBy: restoreState.blockedBy,
793
- resume: { status: "focus-target", target: unfocusOptions.target },
794
- };
795
- } else {
796
- this.clearOverlayFocusRestore();
797
- }
798
- this.requestRender();
799
- return;
800
- }
801
- this.clearOverlayFocusRestoreFor(entry);
802
- if (isFocused || unfocusOptions) {
803
- const topVisible = this.getTopmostVisibleOverlay();
804
- const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
805
- this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
806
- }
807
- this.requestRender();
808
- },
809
- isFocused: () => this.focusedComponent === component,
810
- getBounds: () => {
811
- if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry) || !entry.bounds) return undefined;
812
- return { ...entry.bounds };
813
- },
814
- };
815
- }
816
-
817
- /** Hide the topmost overlay and restore previous focus. */
818
- hideOverlay(): void {
819
- const overlay = this.overlayStack[this.overlayStack.length - 1];
820
- if (!overlay) return;
821
- this.clearOverlayFocusRestoreFor(overlay);
822
- this.retargetOverlayPreFocus(overlay);
823
- this.overlayStack.pop();
824
- if (this.focusedComponent === overlay.component) {
825
- // Find topmost visible overlay, or fall back to preFocus
826
- const topVisible = this.getTopmostVisibleOverlay();
827
- this.setFocus(topVisible?.component ?? overlay.preFocus);
828
- }
829
- if (this.overlayStack.length === 0) this.hideTerminalCursor();
830
- this.requestRender();
831
- }
832
-
833
- /** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */
834
- private hideTerminalCursor(): void {
835
- if (!this.stopped) this.terminal.hideCursor();
836
- }
837
-
838
- /** Check if there are any visible overlays */
839
- hasOverlay(): boolean {
840
- return this.overlayStack.some((o) => this.isOverlayVisible(o));
841
- }
842
-
843
- /** Check if the focused component is a visible overlay */
844
- protected isOverlayFocused(): boolean {
845
- return this.overlayStack.some(
846
- (entry) => entry.component === this.focusedComponent && this.isOverlayVisible(entry),
847
- );
848
- }
849
-
850
- /** Keep overlay containers as keyboard focus owners when a nested control is clicked. */
851
- protected resolveMouseFocusTarget(component: Component): Component {
852
- for (let index = this.overlayStack.length - 1; index >= 0; index--) {
853
- const overlay = this.overlayStack[index]!;
854
- if (this.isOverlayVisible(overlay) && this.containsComponent(overlay.component, component)) {
855
- return overlay.component;
856
- }
857
- }
858
- return component;
859
- }
860
-
861
- /** Dispatch to the visually topmost overlay under the pointer. */
862
- protected dispatchMouseToOverlay(event: TuiMouseEvent): { hit: boolean; result?: TuiMouseDispatchResult } {
863
- for (let index = this.renderedOverlayLayouts.length - 1; index >= 0; index--) {
864
- const layout = this.renderedOverlayLayouts[index]!;
865
- if (
866
- event.screenX < layout.col ||
867
- event.screenX >= layout.col + layout.width ||
868
- event.screenY < layout.row ||
869
- event.screenY >= layout.row + layout.height
870
- ) {
871
- continue;
872
- }
873
- const result = dispatchMouseEvent(layout.entry.component, {
874
- ...event,
875
- x: event.screenX - layout.col,
876
- y: event.screenY - layout.row,
877
- width: layout.width,
878
- height: layout.height,
879
- });
880
- return result
881
- ? {
882
- hit: true,
883
- result: result.focus ? { ...result, focusTarget: layout.entry.component } : result,
884
- }
885
- : { hit: true };
886
- }
887
- return { hit: false };
888
- }
889
-
890
- /** Check if an overlay entry is currently visible */
891
- private isOverlayVisible(entry: OverlayStackEntry): boolean {
892
- if (entry.hidden) return false;
893
- if (entry.options?.visible) {
894
- return entry.options.visible(this.terminal.columns, this.terminal.rows);
895
- }
896
- return true;
897
- }
898
-
899
- /** Find the visual-frontmost visible capturing overlay, if any */
900
- private getTopmostVisibleOverlay(): OverlayStackEntry | undefined {
901
- let topmost: OverlayStackEntry | undefined;
902
- for (const overlay of this.overlayStack) {
903
- if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue;
904
- if (!topmost || overlay.focusOrder > topmost.focusOrder) {
905
- topmost = overlay;
906
- }
907
- }
908
- return topmost;
909
- }
910
-
911
- override invalidate(): void {
912
- for (const root of this.getMountedRoots()) root.invalidate();
913
- for (const overlay of this.overlayStack) overlay.component.invalidate();
914
- }
915
-
916
- start(): void {
917
- this.stopped = false;
918
- this.beforeTerminalStart();
919
- this.terminal.start(
920
- (data) => this.handleTerminalInput(data),
921
- () => this.requestRender(),
922
- );
923
- this.afterTerminalStart();
924
- this.terminal.hideCursor();
925
- if (this.terminalColorSchemeNotificationsEnabled) {
926
- this.terminal.write("\x1b[?2031h");
927
- }
928
- this.queryCellSize();
929
- this.requestRender();
930
- }
931
-
932
- addInputListener(listener: TuiInputListener): () => void {
933
- this.inputListeners.add(listener);
934
- return () => {
935
- this.inputListeners.delete(listener);
936
- };
937
- }
938
-
939
- removeInputListener(listener: TuiInputListener): void {
940
- this.inputListeners.delete(listener);
941
- }
942
-
943
- onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void {
944
- this.terminalColorSchemeListeners.add(listener);
945
- return () => {
946
- this.terminalColorSchemeListeners.delete(listener);
947
- };
948
- }
949
-
950
- setTerminalColorSchemeNotifications(enabled: boolean): void {
951
- if (this.terminalColorSchemeNotificationsEnabled === enabled) {
952
- return;
953
- }
954
- this.terminalColorSchemeNotificationsEnabled = enabled;
955
- if (!this.stopped) {
956
- this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l");
957
- }
958
- }
959
-
960
- private queryCellSize(): void {
961
- // Only query if terminal supports images (cell size is only used for image rendering)
962
- if (!getCapabilities().images) {
963
- return;
964
- }
965
- // Query terminal for cell size in pixels: CSI 16 t
966
- // Response format: CSI 6 ; height ; width t
967
- this.terminal.write("\x1b[16t");
968
- }
969
-
970
- stop(options: TuiStopOptions = {}): void {
971
- this.stopped = true;
972
- this.cancelRenderTimer();
973
- if (this.terminalColorSchemeNotificationsEnabled) {
974
- this.terminal.write("\x1b[?2031l");
975
- }
976
- this.beforeTerminalStop(options);
977
- this.terminal.showCursor();
978
- this.terminal.stop();
979
- this.afterTerminalStop(options);
980
- }
981
-
982
- renderNow(force = false): void {
983
- if (force) this.resetRenderState();
984
- this.renderRequested = false;
985
- this.cancelRenderTimer();
986
- this.lastRenderAt = performance.now();
987
- this.doRender();
988
- }
989
-
990
- requestRender(force = false): void {
991
- if (force) {
992
- this.resetRenderState();
993
- this.requestImmediateRender();
994
- return;
995
- }
996
- if (this.renderRequested) return;
997
- this.renderRequested = true;
998
- process.nextTick(() => this.scheduleRender());
999
- }
1000
-
1001
- private requestImmediateRender(): void {
1002
- this.cancelRenderTimer();
1003
- this.renderRequested = true;
1004
- if (this.immediateRenderScheduled) return;
1005
- this.immediateRenderScheduled = true;
1006
- process.nextTick(() => {
1007
- this.immediateRenderScheduled = false;
1008
- if (this.stopped || !this.renderRequested) return;
1009
- // A previously queued scheduleRender() can create a timer before this
1010
- // callback runs. User input must preempt that throttled frame.
1011
- this.cancelRenderTimer();
1012
- this.renderRequested = false;
1013
- this.lastRenderAt = performance.now();
1014
- this.doRender();
1015
- });
1016
- }
1017
-
1018
- private cancelRenderTimer(): void {
1019
- if (!this.renderTimer) return;
1020
- clearTimeout(this.renderTimer);
1021
- this.renderTimer = undefined;
1022
- }
1023
-
1024
- private scheduleRender(): void {
1025
- if (this.stopped || this.renderTimer || !this.renderRequested) {
1026
- return;
1027
- }
1028
- const elapsed = performance.now() - this.lastRenderAt;
1029
- const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);
1030
- this.renderTimer = setTimeout(() => {
1031
- this.renderTimer = undefined;
1032
- if (this.stopped || !this.renderRequested) {
1033
- return;
1034
- }
1035
- this.renderRequested = false;
1036
- this.lastRenderAt = performance.now();
1037
- this.doRender();
1038
- if (this.renderRequested) {
1039
- this.scheduleRender();
1040
- }
1041
- }, delay);
1042
- }
1043
-
1044
- private handleTerminalInput(data: string): void {
1045
- if (this.consumeTerminalColorResponse(data)) {
1046
- return;
1047
- }
1048
- if (this.consumeTerminalColorSchemeReport(data)) {
1049
- return;
1050
- }
1051
-
1052
- if (this.inputListeners.size > 0) {
1053
- let current = data;
1054
- for (const listener of this.inputListeners) {
1055
- const result = listener(current);
1056
- if (result?.consume) {
1057
- return;
1058
- }
1059
- if (result?.data !== undefined) {
1060
- current = result.data;
1061
- }
1062
- }
1063
- if (current.length === 0) {
1064
- return;
1065
- }
1066
- data = current;
1067
- }
1068
-
1069
- // Consume terminal cell size responses without blocking unrelated input.
1070
- if (this.consumeCellSizeResponse(data)) {
1071
- return;
1072
- }
1073
-
1074
- // Global debug key handler (Shift+Ctrl+D)
1075
- if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
1076
- this.onDebug();
1077
- return;
1078
- }
1079
-
1080
- // If focused component is an overlay, verify it's still visible
1081
- // (visibility can change due to terminal resize or visible() callback)
1082
- const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);
1083
- if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
1084
- // Focused overlay is no longer visible, redirect to topmost visible overlay
1085
- const topVisible = this.getTopmostVisibleOverlay();
1086
- if (topVisible) {
1087
- this.setFocus(topVisible.component);
1088
- } else {
1089
- this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
1090
- }
1091
- }
1092
-
1093
- const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
1094
- if (!focusIsOverlay) {
1095
- const restoreState = this.getVisibleOverlayFocusRestore();
1096
- if (restoreState.status === "eligible") {
1097
- this.setFocus(restoreState.overlay.component);
1098
- } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
1099
- if (restoreState.resume.status === "restore-overlay") {
1100
- this.setFocus(restoreState.overlay.component);
1101
- } else {
1102
- this.clearOverlayFocusRestore();
1103
- this.setFocus(restoreState.resume.target);
1104
- }
1105
- }
1106
- }
1107
-
1108
- // Pass input to focused component (including Ctrl+C)
1109
- // The focused component can decide how to handle Ctrl+C
1110
- if (this.focusedComponent?.handleInput) {
1111
- // Filter out key release events unless component opts in
1112
- if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
1113
- return;
1114
- }
1115
- this.focusedComponent.handleInput(data);
1116
- // Keyboard input is latency-sensitive. Avoid the throttled timer path,
1117
- // where even setTimeout(0) can take a full 16 ms tick on Windows.
1118
- this.requestImmediateRender();
1119
- }
1120
- }
1121
-
1122
- private consumeTerminalColorResponse(data: string): boolean {
1123
- const query = this.pendingTerminalColorQueries[0];
1124
- if (!query) {
1125
- return false;
1126
- }
1127
- if (DEVICE_ATTRIBUTES_RESPONSE_PATTERN.test(data)) {
1128
- this.pendingTerminalColorQueries.shift();
1129
- this.completeTerminalColorQuery(query);
1130
- return true;
1131
- }
1132
-
1133
- const response = parseOscColorResponse(data);
1134
- if (!response) {
1135
- return false;
1136
- }
1137
- const { target, rgb } = response;
1138
- const key = String(target);
1139
- if (!query.deliver || query.replied.has(key)) {
1140
- return true;
1141
- }
1142
- query.replied.add(key);
1143
- if (target === "foreground") {
1144
- query.foreground = rgb;
1145
- } else if (target === "background") {
1146
- query.background = rgb;
1147
- } else if (target < TERMINAL_PALETTE_SIZE) {
1148
- query.palette[target] = rgb;
1149
- }
1150
- if (query.replied.size === TERMINAL_COLOR_REPLY_COUNT) {
1151
- this.completeTerminalColorQuery(query);
1152
- }
1153
- return true;
1154
- }
1155
-
1156
- private terminalColorQueryResult(query: PendingTerminalColorQuery): TerminalColors {
1157
- const palette = query.palette.every((color) => color !== undefined) ? (query.palette as RgbColor[]) : undefined;
1158
- return { foreground: query.foreground, background: query.background, palette };
1159
- }
1160
-
1161
- private completeTerminalColorQuery(query: PendingTerminalColorQuery): void {
1162
- const deliver = query.deliver;
1163
- query.deliver = undefined;
1164
- clearTimeout(query.timer);
1165
- deliver?.(this.terminalColorQueryResult(query));
1166
- }
1167
-
1168
- private consumeTerminalColorSchemeReport(data: string): boolean {
1169
- const scheme = parseTerminalColorSchemeReport(data);
1170
- if (!scheme) {
1171
- return false;
1172
- }
1173
-
1174
- for (const listener of this.terminalColorSchemeListeners) {
1175
- listener(scheme);
1176
- }
1177
- return true;
1178
- }
1179
-
1180
- private consumeCellSizeResponse(data: string): boolean {
1181
- // Response format: ESC [ 6 ; height ; width t
1182
- const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
1183
- if (!match) {
1184
- return false;
1185
- }
1186
-
1187
- const heightPx = parseInt(match[1], 10);
1188
- const widthPx = parseInt(match[2], 10);
1189
- if (heightPx <= 0 || widthPx <= 0) {
1190
- return true;
1191
- }
1192
-
1193
- setCellDimensions({ widthPx, heightPx });
1194
- // Invalidate all components so images re-render with correct dimensions.
1195
- this.invalidate();
1196
- this.requestRender();
1197
- return true;
1198
- }
1199
-
1200
- /**
1201
- * Resolve overlay layout from options.
1202
- * Returns { width, row, col, maxHeight } for rendering.
1203
- */
1204
- private resolveOverlayLayout(
1205
- options: OverlayOptions | undefined,
1206
- overlayHeight: number,
1207
- termWidth: number,
1208
- termHeight: number,
1209
- ): { width: number; row: number; col: number; maxHeight: number | undefined } {
1210
- const opt = options ?? {};
1211
-
1212
- // Parse margin (clamp to non-negative)
1213
- const margin =
1214
- typeof opt.margin === "number"
1215
- ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
1216
- : (opt.margin ?? {});
1217
- const marginTop = Math.max(0, margin.top ?? 0);
1218
- const marginRight = Math.max(0, margin.right ?? 0);
1219
- const marginBottom = Math.max(0, margin.bottom ?? 0);
1220
- const marginLeft = Math.max(0, margin.left ?? 0);
1221
-
1222
- // Available space after margins
1223
- const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
1224
- const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
1225
-
1226
- // === Resolve width ===
1227
- let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
1228
- // Apply minWidth
1229
- if (opt.minWidth !== undefined) {
1230
- width = Math.max(width, opt.minWidth);
1231
- }
1232
- // Clamp to available space
1233
- width = Math.max(1, Math.min(width, availWidth));
1234
-
1235
- // === Resolve maxHeight ===
1236
- let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
1237
- // Clamp to available space
1238
- if (maxHeight !== undefined) {
1239
- maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
1240
- }
1241
-
1242
- // Effective overlay height (may be clamped by maxHeight)
1243
- const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
1244
-
1245
- // === Resolve position ===
1246
- let row: number;
1247
- let col: number;
1248
-
1249
- if (opt.row !== undefined) {
1250
- if (typeof opt.row === "string") {
1251
- // Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
1252
- const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
1253
- if (match) {
1254
- const maxRow = Math.max(0, availHeight - effectiveHeight);
1255
- const percent = parseFloat(match[1]) / 100;
1256
- row = marginTop + Math.floor(maxRow * percent);
1257
- } else {
1258
- // Invalid format, fall back to center
1259
- row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
1260
- }
1261
- } else {
1262
- // Absolute row position
1263
- row = opt.row;
1264
- }
1265
- } else {
1266
- // Anchor-based (default: center)
1267
- const anchor = opt.anchor ?? "center";
1268
- row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
1269
- }
1270
-
1271
- if (opt.col !== undefined) {
1272
- if (typeof opt.col === "string") {
1273
- // Percentage: 0% = left, 100% = right (overlay stays within bounds)
1274
- const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
1275
- if (match) {
1276
- const maxCol = Math.max(0, availWidth - width);
1277
- const percent = parseFloat(match[1]) / 100;
1278
- col = marginLeft + Math.floor(maxCol * percent);
1279
- } else {
1280
- // Invalid format, fall back to center
1281
- col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
1282
- }
1283
- } else {
1284
- // Absolute column position
1285
- col = opt.col;
1286
- }
1287
- } else {
1288
- // Anchor-based (default: center)
1289
- const anchor = opt.anchor ?? "center";
1290
- col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);
1291
- }
1292
-
1293
- // Apply offsets
1294
- if (opt.offsetY !== undefined) row += opt.offsetY;
1295
- if (opt.offsetX !== undefined) col += opt.offsetX;
1296
-
1297
- // Clamp to terminal bounds (respecting margins)
1298
- row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
1299
- col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
1300
-
1301
- return { width, row, col, maxHeight };
1302
- }
1303
-
1304
- private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {
1305
- switch (anchor) {
1306
- case "top-left":
1307
- case "top-center":
1308
- case "top-right":
1309
- return marginTop;
1310
- case "bottom-left":
1311
- case "bottom-center":
1312
- case "bottom-right":
1313
- return marginTop + availHeight - height;
1314
- case "left-center":
1315
- case "center":
1316
- case "right-center":
1317
- return marginTop + Math.floor((availHeight - height) / 2);
1318
- }
1319
- }
1320
-
1321
- private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {
1322
- switch (anchor) {
1323
- case "top-left":
1324
- case "left-center":
1325
- case "bottom-left":
1326
- return marginLeft;
1327
- case "top-right":
1328
- case "right-center":
1329
- case "bottom-right":
1330
- return marginLeft + availWidth - width;
1331
- case "top-center":
1332
- case "center":
1333
- case "bottom-center":
1334
- return marginLeft + Math.floor((availWidth - width) / 2);
1335
- }
1336
- }
1337
-
1338
- /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
1339
- protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
1340
- if (this.overlayStack.length === 0) {
1341
- this.renderedOverlayLayouts = [];
1342
- return lines;
1343
- }
1344
- const result = [...lines];
1345
-
1346
- for (const entry of this.overlayStack) entry.bounds = undefined;
1347
-
1348
- // Pre-render all visible overlays and calculate positions
1349
- const rendered: { entry: OverlayStackEntry; overlayLines: string[]; row: number; col: number; w: number }[] = [];
1350
- let minLinesNeeded = result.length;
1351
-
1352
- const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
1353
- visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
1354
- for (const entry of visibleEntries) {
1355
- const { component, options } = entry;
1356
-
1357
- // Get layout with height=0 first to determine width and maxHeight
1358
- // (width and maxHeight don't depend on overlay height)
1359
- const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
1360
-
1361
- // Render component at calculated width
1362
- let overlayLines = component.render(width);
1363
-
1364
- // Apply maxHeight if specified
1365
- if (maxHeight !== undefined && overlayLines.length > maxHeight) {
1366
- overlayLines = overlayLines.slice(0, maxHeight);
1367
- }
1368
-
1369
- // Get final row/col with actual overlay height
1370
- const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
1371
- entry.bounds = { row, col, width, height: overlayLines.length };
1372
-
1373
- rendered.push({ entry, overlayLines, row, col, w: width });
1374
- minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
1375
- }
1376
- this.renderedOverlayLayouts = rendered.map(({ entry, row, col, w, overlayLines }) => ({
1377
- entry,
1378
- row,
1379
- col,
1380
- width: w,
1381
- height: overlayLines.length,
1382
- }));
1383
-
1384
- // Pad to at least terminal height so overlays have screen-relative positions.
1385
- // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
1386
- // inflation that pushed content into scrollback on terminal widen.
1387
- const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
1388
-
1389
- // Extend result with empty lines if content is too short for overlay placement or working area
1390
- while (result.length < workingHeight) {
1391
- result.push("");
1392
- }
1393
-
1394
- const viewportStart = Math.max(0, workingHeight - termHeight);
1395
-
1396
- // Composite each overlay
1397
- for (const { overlayLines, row, col, w } of rendered) {
1398
- for (let i = 0; i < overlayLines.length; i++) {
1399
- const idx = viewportStart + row + i;
1400
- if (idx >= 0 && idx < result.length) {
1401
- // Defensive: truncate overlay line to declared width before compositing
1402
- // (components should already respect width, but this ensures it)
1403
- const truncatedOverlayLine =
1404
- visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
1405
- result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
1406
- }
1407
- }
1408
- }
1409
-
1410
- return result;
1411
- }
1412
-
1413
- protected applyLineResets(lines: string[]): string[] {
1414
- const reset = SEGMENT_RESET;
1415
- for (let i = 0; i < lines.length; i++) {
1416
- const line = lines[i];
1417
- if (!isImageLine(line)) {
1418
- lines[i] = normalizeTerminalOutput(line) + reset;
1419
- }
1420
- }
1421
- return lines;
1422
- }
1423
-
1424
- private compositeLineAt(
1425
- baseLine: string,
1426
- overlayLine: string,
1427
- startCol: number,
1428
- overlayWidth: number,
1429
- totalWidth: number,
1430
- ): string {
1431
- return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth);
1432
- }
1433
-
1434
- /**
1435
- * Find and extract cursor position from rendered lines.
1436
- * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
1437
- * Only scans the bottom terminal height lines (visible viewport).
1438
- * @param lines - Rendered lines to search
1439
- * @param height - Terminal height (visible viewport size)
1440
- * @returns Cursor position { row, col } or null if no marker found
1441
- */
1442
- protected extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {
1443
- // Only scan the bottom `height` lines (visible viewport)
1444
- const viewportTop = Math.max(0, lines.length - height);
1445
- for (let row = lines.length - 1; row >= viewportTop; row--) {
1446
- const line = lines[row];
1447
- const markerIndex = line.indexOf(CURSOR_MARKER);
1448
- if (markerIndex !== -1) {
1449
- // Calculate visual column (width of text before marker)
1450
- const beforeMarker = line.slice(0, markerIndex);
1451
- const col = visibleWidth(beforeMarker);
1452
-
1453
- // Strip marker from the line
1454
- lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
1455
-
1456
- return { row, col };
1457
- }
1458
- }
1459
- return null;
1460
- }
1461
-
1462
- /**
1463
- * Query the terminal's theme colors: the default foreground (OSC 10), the default background
1464
- * (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
1465
- * replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires.
1466
- * Colors the terminal did not report are undefined; the palette is only set when all 16 arrived.
1467
- * @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either.
1468
- * @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links.
1469
- */
1470
- queryTerminalColors({
1471
- timeoutMs,
1472
- onLateReply,
1473
- }: {
1474
- timeoutMs: number;
1475
- onLateReply?: (colors: TerminalColors) => void;
1476
- }): Promise<TerminalColors> {
1477
- return new Promise((resolve) => {
1478
- const query: PendingTerminalColorQuery = {
1479
- palette: Array.from({ length: TERMINAL_PALETTE_SIZE }, () => undefined),
1480
- replied: new Set(),
1481
- deliver: resolve,
1482
- timer: undefined,
1483
- };
1484
- // Resolve with the replies so far, and keep collecting late replies for `onLateReply`.
1485
- query.timer = setTimeout(() => {
1486
- query.deliver = onLateReply;
1487
- resolve(this.terminalColorQueryResult(query));
1488
- }, timeoutMs);
1489
- this.pendingTerminalColorQueries.push(query);
1490
- this.terminal.write(TERMINAL_COLOR_QUERY);
1491
- });
1492
- }
1493
- }