@jmoyers/harness 0.1.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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/native/ptyd/Cargo.lock +16 -0
  4. package/native/ptyd/Cargo.toml +7 -0
  5. package/native/ptyd/src/main.rs +257 -0
  6. package/package.json +90 -0
  7. package/scripts/build-ptyd.sh +73 -0
  8. package/scripts/control-plane-daemon.ts +277 -0
  9. package/scripts/cursor-hook-relay.ts +82 -0
  10. package/scripts/harness-animate.ts +469 -0
  11. package/scripts/harness-bin.js +77 -0
  12. package/scripts/harness-core.ts +1 -0
  13. package/scripts/harness-inspector.ts +439 -0
  14. package/scripts/harness.ts +2493 -0
  15. package/src/adapters/agent-session-state.ts +390 -0
  16. package/src/cli/gateway-record.ts +173 -0
  17. package/src/codex/live-session.ts +872 -0
  18. package/src/config/config-core.ts +1359 -0
  19. package/src/config/secrets-core.ts +170 -0
  20. package/src/control-plane/agent-realtime-api.ts +2441 -0
  21. package/src/control-plane/codex-session-stream.ts +392 -0
  22. package/src/control-plane/codex-telemetry.ts +1325 -0
  23. package/src/control-plane/lifecycle-hooks.ts +706 -0
  24. package/src/control-plane/session-summary.ts +380 -0
  25. package/src/control-plane/status/agent-status-reducer.ts +21 -0
  26. package/src/control-plane/status/reducer-base.ts +170 -0
  27. package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
  28. package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
  29. package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
  30. package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
  31. package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
  32. package/src/control-plane/status/session-status-engine.ts +76 -0
  33. package/src/control-plane/stream-client.ts +396 -0
  34. package/src/control-plane/stream-command-parser.ts +1673 -0
  35. package/src/control-plane/stream-protocol.ts +1808 -0
  36. package/src/control-plane/stream-server-background.ts +486 -0
  37. package/src/control-plane/stream-server-command.ts +2557 -0
  38. package/src/control-plane/stream-server-connection.ts +234 -0
  39. package/src/control-plane/stream-server-observed-filter.ts +112 -0
  40. package/src/control-plane/stream-server-session-runtime.ts +566 -0
  41. package/src/control-plane/stream-server-state-store.ts +15 -0
  42. package/src/control-plane/stream-server.ts +3192 -0
  43. package/src/cursor/managed-hooks.ts +282 -0
  44. package/src/domain/conversations.ts +414 -0
  45. package/src/domain/directories.ts +78 -0
  46. package/src/domain/repositories.ts +123 -0
  47. package/src/domain/tasks.ts +148 -0
  48. package/src/domain/workspace.ts +156 -0
  49. package/src/events/normalized-events.ts +124 -0
  50. package/src/mux/ansi-integrity.ts +103 -0
  51. package/src/mux/control-plane-op-queue.ts +212 -0
  52. package/src/mux/conversation-rail.ts +339 -0
  53. package/src/mux/double-click.ts +78 -0
  54. package/src/mux/dual-pane-core.ts +435 -0
  55. package/src/mux/harness-core-ui.ts +817 -0
  56. package/src/mux/input-shortcuts.ts +667 -0
  57. package/src/mux/live-mux/actions-conversation.ts +344 -0
  58. package/src/mux/live-mux/actions-repository.ts +246 -0
  59. package/src/mux/live-mux/actions-task.ts +115 -0
  60. package/src/mux/live-mux/args.ts +142 -0
  61. package/src/mux/live-mux/command-menu.ts +298 -0
  62. package/src/mux/live-mux/control-plane-records.ts +546 -0
  63. package/src/mux/live-mux/conversation-state.ts +188 -0
  64. package/src/mux/live-mux/directory-resolution.ts +34 -0
  65. package/src/mux/live-mux/event-mapping.ts +96 -0
  66. package/src/mux/live-mux/gateway-profiler.ts +152 -0
  67. package/src/mux/live-mux/gateway-render-trace.ts +177 -0
  68. package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
  69. package/src/mux/live-mux/git-parsing.ts +131 -0
  70. package/src/mux/live-mux/git-snapshot.ts +263 -0
  71. package/src/mux/live-mux/git-state.ts +136 -0
  72. package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
  73. package/src/mux/live-mux/home-pane-actions.ts +58 -0
  74. package/src/mux/live-mux/home-pane-drop.ts +44 -0
  75. package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
  76. package/src/mux/live-mux/home-pane-pointer.ts +96 -0
  77. package/src/mux/live-mux/input-forwarding.ts +112 -0
  78. package/src/mux/live-mux/layout.ts +30 -0
  79. package/src/mux/live-mux/left-nav-activation.ts +103 -0
  80. package/src/mux/live-mux/left-nav.ts +85 -0
  81. package/src/mux/live-mux/left-rail-actions.ts +118 -0
  82. package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
  83. package/src/mux/live-mux/left-rail-pointer.ts +74 -0
  84. package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
  85. package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
  86. package/src/mux/live-mux/modal-input-reducers.ts +94 -0
  87. package/src/mux/live-mux/modal-overlays.ts +287 -0
  88. package/src/mux/live-mux/modal-pointer.ts +70 -0
  89. package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
  90. package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
  91. package/src/mux/live-mux/observed-stream.ts +87 -0
  92. package/src/mux/live-mux/palette-parsing.ts +128 -0
  93. package/src/mux/live-mux/pointer-routing.ts +108 -0
  94. package/src/mux/live-mux/process-usage.ts +53 -0
  95. package/src/mux/live-mux/project-pane-pointer.ts +44 -0
  96. package/src/mux/live-mux/rail-layout.ts +244 -0
  97. package/src/mux/live-mux/render-trace-analysis.ts +213 -0
  98. package/src/mux/live-mux/render-trace-state.ts +84 -0
  99. package/src/mux/live-mux/repository-folding.ts +207 -0
  100. package/src/mux/live-mux/runtime-shutdown.ts +51 -0
  101. package/src/mux/live-mux/selection.ts +411 -0
  102. package/src/mux/live-mux/startup-utils.ts +187 -0
  103. package/src/mux/live-mux/status-timeline-state.ts +82 -0
  104. package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
  105. package/src/mux/live-mux/terminal-palette.ts +79 -0
  106. package/src/mux/new-thread-prompt.ts +165 -0
  107. package/src/mux/project-tree.ts +295 -0
  108. package/src/mux/render-frame.ts +113 -0
  109. package/src/mux/runtime-wiring.ts +185 -0
  110. package/src/mux/selector-index.ts +160 -0
  111. package/src/mux/startup-sequencer.ts +238 -0
  112. package/src/mux/task-composer.ts +289 -0
  113. package/src/mux/task-focused-pane.ts +417 -0
  114. package/src/mux/task-screen-keybindings.ts +539 -0
  115. package/src/mux/terminal-input-modes.ts +35 -0
  116. package/src/mux/workspace-path.ts +55 -0
  117. package/src/mux/workspace-rail-model.ts +701 -0
  118. package/src/mux/workspace-rail.ts +247 -0
  119. package/src/perf/perf-core.ts +307 -0
  120. package/src/pty/pty_host.ts +217 -0
  121. package/src/pty/session-broker.ts +158 -0
  122. package/src/recording/terminal-recording.ts +383 -0
  123. package/src/services/control-plane.ts +567 -0
  124. package/src/services/conversation-lifecycle.ts +176 -0
  125. package/src/services/conversation-startup-hydration.ts +47 -0
  126. package/src/services/directory-hydration.ts +49 -0
  127. package/src/services/event-persistence.ts +104 -0
  128. package/src/services/mux-ui-state-persistence.ts +82 -0
  129. package/src/services/output-load-sampler.ts +231 -0
  130. package/src/services/process-usage-refresh.ts +88 -0
  131. package/src/services/recording.ts +75 -0
  132. package/src/services/render-trace-recorder.ts +177 -0
  133. package/src/services/runtime-control-actions.ts +123 -0
  134. package/src/services/runtime-control-plane-ops.ts +131 -0
  135. package/src/services/runtime-conversation-actions.ts +113 -0
  136. package/src/services/runtime-conversation-activation.ts +78 -0
  137. package/src/services/runtime-conversation-starter.ts +171 -0
  138. package/src/services/runtime-conversation-title-edit.ts +149 -0
  139. package/src/services/runtime-directory-actions.ts +164 -0
  140. package/src/services/runtime-envelope-handler.ts +198 -0
  141. package/src/services/runtime-git-state.ts +92 -0
  142. package/src/services/runtime-input-pipeline.ts +50 -0
  143. package/src/services/runtime-input-router.ts +202 -0
  144. package/src/services/runtime-layout-resize.ts +236 -0
  145. package/src/services/runtime-left-rail-render.ts +159 -0
  146. package/src/services/runtime-main-pane-input.ts +230 -0
  147. package/src/services/runtime-modal-input.ts +119 -0
  148. package/src/services/runtime-navigation-input.ts +207 -0
  149. package/src/services/runtime-process-wiring.ts +68 -0
  150. package/src/services/runtime-rail-input.ts +287 -0
  151. package/src/services/runtime-render-flush.ts +146 -0
  152. package/src/services/runtime-render-lifecycle.ts +104 -0
  153. package/src/services/runtime-render-orchestrator.ts +108 -0
  154. package/src/services/runtime-render-pipeline.ts +167 -0
  155. package/src/services/runtime-render-state.ts +72 -0
  156. package/src/services/runtime-repository-actions.ts +197 -0
  157. package/src/services/runtime-right-pane-render.ts +132 -0
  158. package/src/services/runtime-shutdown.ts +79 -0
  159. package/src/services/runtime-stream-subscriptions.ts +56 -0
  160. package/src/services/runtime-task-composer-persistence.ts +139 -0
  161. package/src/services/runtime-task-editor-actions.ts +83 -0
  162. package/src/services/runtime-task-pane-actions.ts +198 -0
  163. package/src/services/runtime-task-pane-shortcuts.ts +189 -0
  164. package/src/services/runtime-task-pane.ts +62 -0
  165. package/src/services/runtime-workspace-actions.ts +153 -0
  166. package/src/services/runtime-workspace-observed-events.ts +190 -0
  167. package/src/services/session-projection-instrumentation.ts +190 -0
  168. package/src/services/startup-background-probe.ts +91 -0
  169. package/src/services/startup-background-resume.ts +65 -0
  170. package/src/services/startup-orchestrator.ts +166 -0
  171. package/src/services/startup-output-tracker.ts +54 -0
  172. package/src/services/startup-paint-tracker.ts +115 -0
  173. package/src/services/startup-persisted-conversation-queue.ts +45 -0
  174. package/src/services/startup-settled-gate.ts +67 -0
  175. package/src/services/startup-shutdown.ts +53 -0
  176. package/src/services/startup-span-tracker.ts +77 -0
  177. package/src/services/startup-state-hydration.ts +94 -0
  178. package/src/services/startup-visibility.ts +35 -0
  179. package/src/services/status-timeline-recorder.ts +144 -0
  180. package/src/services/task-pane-selection-actions.ts +153 -0
  181. package/src/services/task-planning-hydration.ts +58 -0
  182. package/src/services/task-planning-observed-events.ts +89 -0
  183. package/src/services/workspace-observed-events.ts +113 -0
  184. package/src/store/control-plane-store-normalize.ts +760 -0
  185. package/src/store/control-plane-store-types.ts +224 -0
  186. package/src/store/control-plane-store.ts +2951 -0
  187. package/src/store/event-store.ts +253 -0
  188. package/src/store/sqlite.ts +81 -0
  189. package/src/terminal/compat-matrix.ts +345 -0
  190. package/src/terminal/differential-checkpoints.ts +132 -0
  191. package/src/terminal/parity-suite.ts +441 -0
  192. package/src/terminal/snapshot-oracle.ts +1840 -0
  193. package/src/ui/conversation-input-forwarder.ts +114 -0
  194. package/src/ui/conversation-selection-input.ts +103 -0
  195. package/src/ui/debug-footer-notice.ts +39 -0
  196. package/src/ui/global-shortcut-input.ts +126 -0
  197. package/src/ui/input-preflight.ts +68 -0
  198. package/src/ui/input-token-router.ts +312 -0
  199. package/src/ui/input.ts +238 -0
  200. package/src/ui/kit.ts +509 -0
  201. package/src/ui/left-nav-input.ts +80 -0
  202. package/src/ui/left-rail-pointer-input.ts +148 -0
  203. package/src/ui/main-pane-pointer-input.ts +150 -0
  204. package/src/ui/modals/manager.ts +192 -0
  205. package/src/ui/mux-theme.ts +529 -0
  206. package/src/ui/panes/conversation.ts +19 -0
  207. package/src/ui/panes/home-gridfire.ts +302 -0
  208. package/src/ui/panes/home.ts +109 -0
  209. package/src/ui/panes/left-rail.ts +12 -0
  210. package/src/ui/panes/project.ts +44 -0
  211. package/src/ui/pointer-routing-input.ts +158 -0
  212. package/src/ui/repository-fold-input.ts +91 -0
  213. package/src/ui/screen.ts +210 -0
  214. package/src/ui/surface.ts +224 -0
@@ -0,0 +1,1840 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+
4
+ type ParserMode = 'normal' | 'esc' | 'csi' | 'osc' | 'osc-esc' | 'dcs' | 'dcs-esc';
5
+ type ActiveScreen = 'primary' | 'alternate';
6
+ type TerminalCursorShape = 'block' | 'underline' | 'bar';
7
+
8
+ interface TerminalCursorStyle {
9
+ shape: TerminalCursorShape;
10
+ blinking: boolean;
11
+ }
12
+
13
+ type TerminalColor =
14
+ | { kind: 'default' }
15
+ | { kind: 'indexed'; index: number }
16
+ | { kind: 'rgb'; r: number; g: number; b: number };
17
+
18
+ interface TerminalCellStyle {
19
+ bold: boolean;
20
+ dim: boolean;
21
+ italic: boolean;
22
+ underline: boolean;
23
+ inverse: boolean;
24
+ fg: TerminalColor;
25
+ bg: TerminalColor;
26
+ }
27
+
28
+ interface TerminalCell {
29
+ glyph: string;
30
+ width: number;
31
+ continued: boolean;
32
+ style: TerminalCellStyle;
33
+ }
34
+
35
+ interface TerminalSnapshotLine {
36
+ wrapped: boolean;
37
+ text: string;
38
+ cells: TerminalCell[];
39
+ }
40
+
41
+ export interface TerminalSnapshotFrameCore {
42
+ rows: number;
43
+ cols: number;
44
+ activeScreen: ActiveScreen;
45
+ modes: {
46
+ bracketedPaste: boolean;
47
+ };
48
+ cursor: {
49
+ row: number;
50
+ col: number;
51
+ visible: boolean;
52
+ style: TerminalCursorStyle;
53
+ };
54
+ viewport: {
55
+ top: number;
56
+ totalRows: number;
57
+ followOutput: boolean;
58
+ };
59
+ lines: string[];
60
+ richLines: TerminalSnapshotLine[];
61
+ }
62
+
63
+ export interface TerminalSnapshotFrame extends TerminalSnapshotFrameCore {
64
+ frameHash: string;
65
+ }
66
+
67
+ export interface TerminalBufferTail {
68
+ totalRows: number;
69
+ startRow: number;
70
+ lines: string[];
71
+ }
72
+
73
+ interface TerminalSelectionPoint {
74
+ rowAbs: number;
75
+ col: number;
76
+ }
77
+
78
+ export interface TerminalQueryState {
79
+ rows: number;
80
+ cols: number;
81
+ cursor: {
82
+ row: number;
83
+ col: number;
84
+ };
85
+ }
86
+
87
+ interface TerminalQueryHooks {
88
+ onCsiQuery?: (payload: string, readState: () => TerminalQueryState) => void;
89
+ onOscQuery?: (payload: string, useBellTerminator: boolean) => void;
90
+ onDcsQuery?: (payload: string) => void;
91
+ }
92
+
93
+ interface ScreenCursor {
94
+ row: number;
95
+ col: number;
96
+ }
97
+
98
+ interface InternalLine {
99
+ wrapped: boolean;
100
+ cells: TerminalCell[];
101
+ revision: number;
102
+ snapshotCache: TerminalSnapshotLine | null;
103
+ snapshotCacheRevision: number;
104
+ snapshotCacheWrapped: boolean;
105
+ }
106
+
107
+ const DEFAULT_COLOR: TerminalColor = { kind: 'default' };
108
+ const DEFAULT_CURSOR_STYLE: TerminalCursorStyle = {
109
+ shape: 'block',
110
+ blinking: true,
111
+ };
112
+
113
+ function cloneCursorStyle(style: TerminalCursorStyle): TerminalCursorStyle {
114
+ return {
115
+ shape: style.shape,
116
+ blinking: style.blinking,
117
+ };
118
+ }
119
+
120
+ function cursorStyleEqual(left: TerminalCursorStyle, right: TerminalCursorStyle): boolean {
121
+ return left.shape === right.shape && left.blinking === right.blinking;
122
+ }
123
+
124
+ function cloneColor(color: TerminalColor): TerminalColor {
125
+ if (color.kind === 'default') {
126
+ return DEFAULT_COLOR;
127
+ }
128
+ if (color.kind === 'indexed') {
129
+ return {
130
+ kind: 'indexed',
131
+ index: color.index,
132
+ };
133
+ }
134
+ return {
135
+ kind: 'rgb',
136
+ r: color.r,
137
+ g: color.g,
138
+ b: color.b,
139
+ };
140
+ }
141
+
142
+ function defaultCellStyle(): TerminalCellStyle {
143
+ return {
144
+ bold: false,
145
+ dim: false,
146
+ italic: false,
147
+ underline: false,
148
+ inverse: false,
149
+ fg: DEFAULT_COLOR,
150
+ bg: DEFAULT_COLOR,
151
+ };
152
+ }
153
+
154
+ function cloneStyle(style: TerminalCellStyle): TerminalCellStyle {
155
+ return {
156
+ bold: style.bold,
157
+ dim: style.dim,
158
+ italic: style.italic,
159
+ underline: style.underline,
160
+ inverse: style.inverse,
161
+ fg: cloneColor(style.fg),
162
+ bg: cloneColor(style.bg),
163
+ };
164
+ }
165
+
166
+ function styleEqual(left: TerminalCellStyle, right: TerminalCellStyle): boolean {
167
+ return (
168
+ left.bold === right.bold &&
169
+ left.dim === right.dim &&
170
+ left.italic === right.italic &&
171
+ left.underline === right.underline &&
172
+ left.inverse === right.inverse &&
173
+ colorEqual(left.fg, right.fg) &&
174
+ colorEqual(left.bg, right.bg)
175
+ );
176
+ }
177
+
178
+ function colorEqual(left: TerminalColor, right: TerminalColor): boolean {
179
+ if (left.kind !== right.kind) {
180
+ return false;
181
+ }
182
+
183
+ switch (left.kind) {
184
+ case 'default':
185
+ return true;
186
+ case 'indexed':
187
+ return left.index === (right as Extract<TerminalColor, { kind: 'indexed' }>).index;
188
+ case 'rgb': {
189
+ const typedRight = right as Extract<TerminalColor, { kind: 'rgb' }>;
190
+ return left.r === typedRight.r && left.g === typedRight.g && left.b === typedRight.b;
191
+ }
192
+ }
193
+ }
194
+
195
+ function blankCell(style: TerminalCellStyle): TerminalCell {
196
+ return {
197
+ glyph: ' ',
198
+ width: 1,
199
+ continued: false,
200
+ style: cloneStyle(style),
201
+ };
202
+ }
203
+
204
+ function continuationCell(style: TerminalCellStyle): TerminalCell {
205
+ return {
206
+ glyph: '',
207
+ width: 0,
208
+ continued: true,
209
+ style: cloneStyle(style),
210
+ };
211
+ }
212
+
213
+ function cloneCell(cell: TerminalCell): TerminalCell {
214
+ return {
215
+ glyph: cell.glyph,
216
+ width: cell.width,
217
+ continued: cell.continued,
218
+ style: cloneStyle(cell.style),
219
+ };
220
+ }
221
+
222
+ function trimRightCells(cells: readonly TerminalCell[]): readonly TerminalCell[] {
223
+ let end = cells.length;
224
+ while (end > 0) {
225
+ const cell = cells[end - 1]!;
226
+ if (cell.continued) {
227
+ end -= 1;
228
+ continue;
229
+ }
230
+ if (cell.glyph === ' ' && styleEqual(cell.style, defaultCellStyle())) {
231
+ end -= 1;
232
+ continue;
233
+ }
234
+ break;
235
+ }
236
+ return cells.slice(0, end);
237
+ }
238
+
239
+ function cellsToText(cells: readonly TerminalCell[]): string {
240
+ let value = '';
241
+ for (const cell of cells) {
242
+ if (cell.continued) {
243
+ continue;
244
+ }
245
+ value += cell.glyph;
246
+ }
247
+ return value;
248
+ }
249
+
250
+ function createLine(cols: number, style: TerminalCellStyle, revision: number): InternalLine {
251
+ return {
252
+ wrapped: false,
253
+ cells: Array.from({ length: cols }, () => blankCell(style)),
254
+ revision,
255
+ snapshotCache: null,
256
+ snapshotCacheRevision: -1,
257
+ snapshotCacheWrapped: false,
258
+ };
259
+ }
260
+
261
+ function compareBufferPoints(left: TerminalSelectionPoint, right: TerminalSelectionPoint): number {
262
+ if (left.rowAbs !== right.rowAbs) {
263
+ return left.rowAbs - right.rowAbs;
264
+ }
265
+ return left.col - right.col;
266
+ }
267
+
268
+ class ScreenBuffer {
269
+ cols: number;
270
+ rows: number;
271
+ private readonly includeScrollback: boolean;
272
+ private readonly scrollbackLimit: number;
273
+ private lines: InternalLine[];
274
+ private scrollback: InternalLine[] = [];
275
+ private followOutput = true;
276
+ private viewportTop = 0;
277
+ private scrollRegionTop = 0;
278
+ private scrollRegionBottom: number;
279
+ private nextLineRevision = 1;
280
+
281
+ constructor(cols: number, rows: number, includeScrollback: boolean, scrollbackLimit: number) {
282
+ this.cols = cols;
283
+ this.rows = rows;
284
+ this.includeScrollback = includeScrollback;
285
+ this.scrollbackLimit = scrollbackLimit;
286
+ this.lines = Array.from({ length: rows }, () => this.createBlankLine(defaultCellStyle()));
287
+ this.scrollRegionBottom = Math.max(0, rows - 1);
288
+ }
289
+
290
+ resize(cols: number, rows: number, fillStyle: TerminalCellStyle): void {
291
+ const nextLines = Array.from({ length: rows }, (_, rowIdx) => {
292
+ const nextLine = createLine(cols, fillStyle, this.nextLineRevision);
293
+ this.nextLineRevision += 1;
294
+ if (rowIdx < this.lines.length) {
295
+ const previousLine = this.lines[rowIdx]!;
296
+ nextLine.wrapped = previousLine.wrapped;
297
+ for (let colIdx = 0; colIdx < Math.min(cols, previousLine.cells.length); colIdx += 1) {
298
+ nextLine.cells[colIdx] = previousLine.cells[colIdx]!;
299
+ }
300
+ }
301
+ return nextLine;
302
+ });
303
+
304
+ this.cols = cols;
305
+ this.rows = rows;
306
+ this.lines = nextLines;
307
+ if (
308
+ this.scrollRegionTop < 0 ||
309
+ this.scrollRegionTop >= rows ||
310
+ this.scrollRegionBottom < 0 ||
311
+ this.scrollRegionBottom >= rows ||
312
+ this.scrollRegionTop >= this.scrollRegionBottom
313
+ ) {
314
+ this.resetScrollRegion();
315
+ } else {
316
+ this.scrollRegionTop = Math.max(0, Math.min(this.scrollRegionTop, rows - 1));
317
+ this.scrollRegionBottom = Math.max(0, Math.min(this.scrollRegionBottom, rows - 1));
318
+ }
319
+ this.ensureViewportInRange();
320
+ }
321
+
322
+ clear(fillStyle: TerminalCellStyle): void {
323
+ this.lines = Array.from({ length: this.rows }, () => this.createBlankLine(fillStyle));
324
+ this.scrollback = [];
325
+ this.recomputeViewport();
326
+ }
327
+
328
+ resetScrollRegion(): void {
329
+ this.scrollRegionTop = 0;
330
+ this.scrollRegionBottom = Math.max(0, this.rows - 1);
331
+ }
332
+
333
+ setScrollRegion(topOneBased: number, bottomOneBased: number): boolean {
334
+ const top = Math.max(1, Math.min(this.rows, topOneBased)) - 1;
335
+ const bottom = Math.max(1, Math.min(this.rows, bottomOneBased)) - 1;
336
+ if (top >= bottom) {
337
+ return false;
338
+ }
339
+ this.scrollRegionTop = top;
340
+ this.scrollRegionBottom = bottom;
341
+ return true;
342
+ }
343
+
344
+ scrollRegion(): { top: number; bottom: number } {
345
+ return {
346
+ top: this.scrollRegionTop,
347
+ bottom: this.scrollRegionBottom,
348
+ };
349
+ }
350
+
351
+ setFollowOutput(followOutput: boolean): void {
352
+ this.followOutput = followOutput;
353
+ this.recomputeViewport();
354
+ }
355
+
356
+ scrollViewport(deltaRows: number): void {
357
+ if (deltaRows === 0) {
358
+ return;
359
+ }
360
+
361
+ const maxTop = this.maxViewportTop();
362
+ const nextTop = Math.max(0, Math.min(maxTop, this.viewportTop + deltaRows));
363
+ this.viewportTop = nextTop;
364
+ this.followOutput = nextTop === maxTop;
365
+ }
366
+
367
+ putGlyph(cursor: ScreenCursor, glyph: string, width: number, style: TerminalCellStyle): boolean {
368
+ const normalizedWidth = Math.max(1, Math.min(2, width));
369
+ const line = this.currentLine(cursor);
370
+
371
+ if (line.cells[cursor.col]?.continued === true && cursor.col > 0) {
372
+ line.cells[cursor.col - 1] = blankCell(defaultCellStyle());
373
+ this.touchLine(line);
374
+ }
375
+
376
+ if (normalizedWidth === 2 && cursor.col === this.cols - 1) {
377
+ this.advanceLine(cursor, true, style);
378
+ }
379
+
380
+ const targetLine = this.currentLine(cursor);
381
+ targetLine.cells[cursor.col] = {
382
+ glyph,
383
+ width: normalizedWidth,
384
+ continued: false,
385
+ style: cloneStyle(style),
386
+ };
387
+ this.touchLine(targetLine);
388
+
389
+ if (normalizedWidth === 2 && cursor.col + 1 < this.cols) {
390
+ targetLine.cells[cursor.col + 1] = continuationCell(style);
391
+ this.touchLine(targetLine);
392
+ }
393
+
394
+ if (normalizedWidth === 1 && cursor.col === this.cols - 1) {
395
+ return true;
396
+ }
397
+
398
+ cursor.col += normalizedWidth;
399
+ if (cursor.col >= this.cols) {
400
+ this.advanceLine(cursor, true, style);
401
+ return false;
402
+ }
403
+ return false;
404
+ }
405
+
406
+ lineFeed(cursor: ScreenCursor, fillStyle: TerminalCellStyle): void {
407
+ if (cursor.row === this.scrollRegionBottom) {
408
+ this.scrollUp(1, fillStyle, this.scrollRegionTop, this.scrollRegionBottom);
409
+ return;
410
+ }
411
+ cursor.row = Math.min(this.rows - 1, cursor.row + 1);
412
+ }
413
+
414
+ reverseLineFeed(cursor: ScreenCursor, fillStyle: TerminalCellStyle): void {
415
+ if (cursor.row === this.scrollRegionTop) {
416
+ this.scrollDown(1, fillStyle, this.scrollRegionTop, this.scrollRegionBottom);
417
+ return;
418
+ }
419
+ cursor.row = Math.max(0, cursor.row - 1);
420
+ }
421
+
422
+ appendCombining(cursor: ScreenCursor, combiningChar: string): void {
423
+ const line = this.currentLine(cursor);
424
+ const targetCol = cursor.col > 0 ? cursor.col - 1 : 0;
425
+ const targetCell = line.cells[targetCol];
426
+ if (targetCell === undefined || targetCell.continued) {
427
+ return;
428
+ }
429
+ targetCell.glyph += combiningChar;
430
+ this.touchLine(line);
431
+ }
432
+
433
+ clearScreen(cursor: ScreenCursor, mode: number, fillStyle: TerminalCellStyle): void {
434
+ if (mode === 2 || mode === 3) {
435
+ this.lines = Array.from({ length: this.rows }, () => this.createBlankLine(fillStyle));
436
+ if (mode === 3) {
437
+ this.scrollback = [];
438
+ }
439
+ cursor.row = 0;
440
+ cursor.col = 0;
441
+ this.recomputeViewport();
442
+ return;
443
+ }
444
+
445
+ if (mode === 1) {
446
+ for (let row = 0; row <= cursor.row; row += 1) {
447
+ const end = row === cursor.row ? cursor.col : this.cols;
448
+ const line = this.lines[row]!;
449
+ for (let col = 0; col < end; col += 1) {
450
+ line.cells[col] = blankCell(fillStyle);
451
+ }
452
+ this.touchLine(line);
453
+ }
454
+ this.recomputeViewport();
455
+ return;
456
+ }
457
+
458
+ for (let row = cursor.row; row < this.rows; row += 1) {
459
+ const start = row === cursor.row ? cursor.col : 0;
460
+ const line = this.lines[row]!;
461
+ for (let col = start; col < this.cols; col += 1) {
462
+ line.cells[col] = blankCell(fillStyle);
463
+ }
464
+ this.touchLine(line);
465
+ }
466
+ this.recomputeViewport();
467
+ }
468
+
469
+ clearLine(cursor: ScreenCursor, mode: number, fillStyle: TerminalCellStyle): void {
470
+ if (mode === 2) {
471
+ this.lines[cursor.row] = this.createBlankLine(fillStyle);
472
+ return;
473
+ }
474
+
475
+ const line = this.lines[cursor.row]!;
476
+ if (mode === 1) {
477
+ for (let col = 0; col <= cursor.col; col += 1) {
478
+ line.cells[col] = blankCell(fillStyle);
479
+ }
480
+ this.touchLine(line);
481
+ return;
482
+ }
483
+
484
+ for (let col = cursor.col; col < this.cols; col += 1) {
485
+ line.cells[col] = blankCell(fillStyle);
486
+ }
487
+ this.touchLine(line);
488
+ }
489
+
490
+ scrollUp(lines: number, fillStyle: TerminalCellStyle, top = 0, bottom = this.rows - 1): void {
491
+ const clampedTop = Math.max(0, Math.min(this.rows - 1, top));
492
+ const clampedBottom = Math.max(0, Math.min(this.rows - 1, bottom));
493
+ if (clampedTop >= clampedBottom) {
494
+ return;
495
+ }
496
+ const count = Math.max(1, lines);
497
+ for (let idx = 0; idx < count; idx += 1) {
498
+ const shifted = this.lines.splice(clampedTop, 1)[0];
499
+ if (shifted !== undefined && this.includeScrollback && clampedTop === 0) {
500
+ this.scrollback.push(shifted);
501
+ while (this.scrollback.length > this.scrollbackLimit) {
502
+ this.scrollback.shift();
503
+ }
504
+ }
505
+ this.lines.splice(clampedBottom, 0, this.createBlankLine(fillStyle));
506
+ }
507
+ this.recomputeViewport();
508
+ }
509
+
510
+ scrollDown(lines: number, fillStyle: TerminalCellStyle, top = 0, bottom = this.rows - 1): void {
511
+ const clampedTop = Math.max(0, Math.min(this.rows - 1, top));
512
+ const clampedBottom = Math.max(0, Math.min(this.rows - 1, bottom));
513
+ if (clampedTop >= clampedBottom) {
514
+ return;
515
+ }
516
+ const count = Math.max(1, lines);
517
+ for (let idx = 0; idx < count; idx += 1) {
518
+ this.lines.splice(clampedBottom, 1);
519
+ this.lines.splice(clampedTop, 0, this.createBlankLine(fillStyle));
520
+ }
521
+ this.recomputeViewport();
522
+ }
523
+
524
+ insertLines(cursor: ScreenCursor, lines: number, fillStyle: TerminalCellStyle): void {
525
+ if (cursor.row < this.scrollRegionTop || cursor.row > this.scrollRegionBottom) {
526
+ return;
527
+ }
528
+
529
+ const maxCount = this.scrollRegionBottom - cursor.row + 1;
530
+ const count = Math.max(1, Math.min(lines, maxCount));
531
+ for (let idx = 0; idx < count; idx += 1) {
532
+ this.lines.splice(this.scrollRegionBottom, 1);
533
+ this.lines.splice(cursor.row, 0, this.createBlankLine(fillStyle));
534
+ }
535
+ }
536
+
537
+ deleteLines(cursor: ScreenCursor, lines: number, fillStyle: TerminalCellStyle): void {
538
+ if (cursor.row < this.scrollRegionTop || cursor.row > this.scrollRegionBottom) {
539
+ return;
540
+ }
541
+
542
+ const maxCount = this.scrollRegionBottom - cursor.row + 1;
543
+ const count = Math.max(1, Math.min(lines, maxCount));
544
+ for (let idx = 0; idx < count; idx += 1) {
545
+ this.lines.splice(cursor.row, 1);
546
+ this.lines.splice(this.scrollRegionBottom, 0, this.createBlankLine(fillStyle));
547
+ }
548
+ }
549
+
550
+ insertChars(cursor: ScreenCursor, chars: number, fillStyle: TerminalCellStyle): void {
551
+ const line = this.lines[cursor.row]!;
552
+ const maxCount = this.cols - cursor.col;
553
+ const count = Math.max(1, Math.min(chars, maxCount));
554
+ for (let col = this.cols - 1; col >= cursor.col + count; col -= 1) {
555
+ line.cells[col] = cloneCell(line.cells[col - count]!);
556
+ }
557
+ for (let col = cursor.col; col < cursor.col + count; col += 1) {
558
+ line.cells[col] = blankCell(fillStyle);
559
+ }
560
+ this.touchLine(line);
561
+ }
562
+
563
+ deleteChars(cursor: ScreenCursor, chars: number, fillStyle: TerminalCellStyle): void {
564
+ const line = this.lines[cursor.row]!;
565
+ const maxCount = this.cols - cursor.col;
566
+ const count = Math.max(1, Math.min(chars, maxCount));
567
+ for (let col = cursor.col; col < this.cols - count; col += 1) {
568
+ line.cells[col] = cloneCell(line.cells[col + count]!);
569
+ }
570
+ for (let col = this.cols - count; col < this.cols; col += 1) {
571
+ line.cells[col] = blankCell(fillStyle);
572
+ }
573
+ this.touchLine(line);
574
+ }
575
+
576
+ snapshot(
577
+ cursor: ScreenCursor,
578
+ cursorVisible: boolean,
579
+ cursorStyle: TerminalCursorStyle,
580
+ activeScreen: ActiveScreen,
581
+ bracketedPasteMode: boolean,
582
+ includeHash: true,
583
+ ): TerminalSnapshotFrame;
584
+ snapshot(
585
+ cursor: ScreenCursor,
586
+ cursorVisible: boolean,
587
+ cursorStyle: TerminalCursorStyle,
588
+ activeScreen: ActiveScreen,
589
+ bracketedPasteMode: boolean,
590
+ includeHash: false,
591
+ ): TerminalSnapshotFrameCore;
592
+ snapshot(
593
+ cursor: ScreenCursor,
594
+ cursorVisible: boolean,
595
+ cursorStyle: TerminalCursorStyle,
596
+ activeScreen: ActiveScreen,
597
+ bracketedPasteMode: boolean,
598
+ includeHash: boolean,
599
+ ): TerminalSnapshotFrame | TerminalSnapshotFrameCore {
600
+ const combined = [...this.scrollback, ...this.lines];
601
+ const totalRows = combined.length;
602
+ const viewportTop = Math.max(0, Math.min(this.viewportTop, Math.max(0, totalRows - this.rows)));
603
+ const visible = combined.slice(viewportTop, viewportTop + this.rows);
604
+
605
+ const richLines = Array.from({ length: this.rows }, (_, rowIdx) => {
606
+ const line = visible[rowIdx]!;
607
+ return this.materializeSnapshotLine(line);
608
+ });
609
+
610
+ const simpleLines = richLines.map((line) => line.text);
611
+
612
+ const frameWithoutHash: TerminalSnapshotFrameCore = {
613
+ rows: this.rows,
614
+ cols: this.cols,
615
+ activeScreen,
616
+ modes: {
617
+ bracketedPaste: bracketedPasteMode,
618
+ },
619
+ cursor: {
620
+ row: cursor.row,
621
+ col: cursor.col,
622
+ visible: cursorVisible,
623
+ style: cloneCursorStyle(cursorStyle),
624
+ },
625
+ viewport: {
626
+ top: viewportTop,
627
+ totalRows,
628
+ followOutput: this.followOutput,
629
+ },
630
+ lines: simpleLines,
631
+ richLines,
632
+ };
633
+
634
+ if (!includeHash) {
635
+ return frameWithoutHash;
636
+ }
637
+
638
+ return {
639
+ ...frameWithoutHash,
640
+ frameHash: createHash('sha256').update(JSON.stringify(frameWithoutHash)).digest('hex'),
641
+ };
642
+ }
643
+
644
+ bufferTail(tailLines: number | null): TerminalBufferTail {
645
+ const combined = [...this.scrollback, ...this.lines];
646
+ const totalRows = combined.length;
647
+ const maxTail = tailLines === null ? totalRows : Math.max(1, Math.floor(tailLines));
648
+ const rowCount = Math.min(totalRows, maxTail);
649
+ const startRow = Math.max(0, totalRows - rowCount);
650
+ const visible = combined.slice(startRow);
651
+ return {
652
+ totalRows,
653
+ startRow,
654
+ lines: visible.map((line) => this.materializeSnapshotLine(line).text),
655
+ };
656
+ }
657
+
658
+ selectionText(start: TerminalSelectionPoint, end: TerminalSelectionPoint): string {
659
+ const combined = [...this.scrollback, ...this.lines];
660
+ const totalRows = combined.length;
661
+ if (totalRows === 0) {
662
+ return '';
663
+ }
664
+ const maxRowAbs = totalRows - 1;
665
+ const maxCol = Math.max(0, this.cols - 1);
666
+ const boundedStart: TerminalSelectionPoint = {
667
+ rowAbs: Math.max(0, Math.min(maxRowAbs, start.rowAbs)),
668
+ col: Math.max(0, Math.min(maxCol, start.col)),
669
+ };
670
+ const boundedEnd: TerminalSelectionPoint = {
671
+ rowAbs: Math.max(0, Math.min(maxRowAbs, end.rowAbs)),
672
+ col: Math.max(0, Math.min(maxCol, end.col)),
673
+ };
674
+ const normalized =
675
+ compareBufferPoints(boundedStart, boundedEnd) <= 0
676
+ ? { start: boundedStart, end: boundedEnd }
677
+ : { start: boundedEnd, end: boundedStart };
678
+
679
+ const rows: string[] = [];
680
+ for (let rowAbs = normalized.start.rowAbs; rowAbs <= normalized.end.rowAbs; rowAbs += 1) {
681
+ const line = combined[rowAbs];
682
+ if (line === undefined) {
683
+ rows.push('');
684
+ continue;
685
+ }
686
+ const rowStartCol = rowAbs === normalized.start.rowAbs ? normalized.start.col : 0;
687
+ const rowEndCol = rowAbs === normalized.end.rowAbs ? normalized.end.col : maxCol;
688
+ if (rowEndCol < rowStartCol) {
689
+ rows.push('');
690
+ continue;
691
+ }
692
+ let text = '';
693
+ for (let col = rowStartCol; col <= rowEndCol; col += 1) {
694
+ const cell = line.cells[col];
695
+ if (cell === undefined || cell.continued) {
696
+ continue;
697
+ }
698
+ text += cell.glyph;
699
+ }
700
+ rows.push(text);
701
+ }
702
+ return rows.join('\n');
703
+ }
704
+
705
+ private advanceLine(cursor: ScreenCursor, wrapped: boolean, fillStyle: TerminalCellStyle): void {
706
+ cursor.col = 0;
707
+ this.lineFeed(cursor, fillStyle);
708
+ if (cursor.row >= 0 && cursor.row < this.rows) {
709
+ const line = this.lines[cursor.row]!;
710
+ if (line.wrapped !== wrapped) {
711
+ line.wrapped = wrapped;
712
+ this.touchLine(line);
713
+ }
714
+ }
715
+ }
716
+
717
+ private currentLine(cursor: ScreenCursor): InternalLine {
718
+ return this.lines[cursor.row]!;
719
+ }
720
+
721
+ private createBlankLine(style: TerminalCellStyle): InternalLine {
722
+ const line = createLine(this.cols, style, this.nextLineRevision);
723
+ this.nextLineRevision += 1;
724
+ return line;
725
+ }
726
+
727
+ private touchLine(line: InternalLine): void {
728
+ line.revision += 1;
729
+ line.snapshotCache = null;
730
+ line.snapshotCacheRevision = -1;
731
+ }
732
+
733
+ private materializeSnapshotLine(line: InternalLine): TerminalSnapshotLine {
734
+ if (
735
+ line.snapshotCache !== null &&
736
+ line.snapshotCacheRevision === line.revision &&
737
+ line.snapshotCacheWrapped === line.wrapped
738
+ ) {
739
+ return line.snapshotCache;
740
+ }
741
+ const cells = line.cells.map((cell) => ({
742
+ glyph: cell.glyph,
743
+ width: cell.width,
744
+ continued: cell.continued,
745
+ style: cloneStyle(cell.style),
746
+ }));
747
+ const trimmedText = cellsToText(trimRightCells(cells));
748
+ const snapshotLine: TerminalSnapshotLine = {
749
+ wrapped: line.wrapped,
750
+ text: trimmedText,
751
+ cells,
752
+ };
753
+ line.snapshotCache = snapshotLine;
754
+ line.snapshotCacheRevision = line.revision;
755
+ line.snapshotCacheWrapped = line.wrapped;
756
+ return snapshotLine;
757
+ }
758
+
759
+ private maxViewportTop(): number {
760
+ const totalRows = this.scrollback.length + this.rows;
761
+ return Math.max(0, totalRows - this.rows);
762
+ }
763
+
764
+ private ensureViewportInRange(): void {
765
+ const maxTop = this.maxViewportTop();
766
+ this.viewportTop = Math.max(0, Math.min(maxTop, this.viewportTop));
767
+ }
768
+
769
+ private recomputeViewport(): void {
770
+ if (this.followOutput) {
771
+ this.viewportTop = this.maxViewportTop();
772
+ return;
773
+ }
774
+ this.ensureViewportInRange();
775
+ }
776
+ }
777
+
778
+ function colorToParams(color: TerminalColor, isBackground: boolean): number[] {
779
+ if (color.kind === 'default') {
780
+ return [isBackground ? 49 : 39];
781
+ }
782
+
783
+ if (color.kind === 'indexed') {
784
+ if (color.index >= 0 && color.index <= 7) {
785
+ return [(isBackground ? 40 : 30) + color.index];
786
+ }
787
+ if (color.index >= 8 && color.index <= 15) {
788
+ return [(isBackground ? 100 : 90) + (color.index - 8)];
789
+ }
790
+ return [isBackground ? 48 : 38, 5, color.index];
791
+ }
792
+
793
+ return [isBackground ? 48 : 38, 2, color.r, color.g, color.b];
794
+ }
795
+
796
+ function styleToAnsi(style: TerminalCellStyle): string {
797
+ const params: number[] = [0];
798
+ if (style.bold) {
799
+ params.push(1);
800
+ }
801
+ if (style.dim) {
802
+ params.push(2);
803
+ }
804
+ if (style.italic) {
805
+ params.push(3);
806
+ }
807
+ if (style.underline) {
808
+ params.push(4);
809
+ }
810
+ if (style.inverse) {
811
+ params.push(7);
812
+ }
813
+ params.push(...colorToParams(style.fg, false));
814
+ params.push(...colorToParams(style.bg, true));
815
+ return `\u001b[${params.join(';')}m`;
816
+ }
817
+
818
+ function clampColor(value: number): number {
819
+ return Math.max(0, Math.min(255, Math.trunc(value)));
820
+ }
821
+
822
+ function isWideCodePoint(codePoint: number): boolean {
823
+ const ranges: ReadonlyArray<readonly [number, number]> = [
824
+ [0x1100, 0x115f],
825
+ [0x2329, 0x232a],
826
+ [0x2e80, 0xa4cf],
827
+ [0xac00, 0xd7a3],
828
+ [0xf900, 0xfaff],
829
+ [0xfe10, 0xfe19],
830
+ [0xfe30, 0xfe6f],
831
+ [0xff00, 0xff60],
832
+ [0xffe0, 0xffe6],
833
+ [0x1f300, 0x1faff],
834
+ ];
835
+
836
+ for (const [start, end] of ranges) {
837
+ if (codePoint >= start && codePoint <= end) {
838
+ return true;
839
+ }
840
+ }
841
+ return false;
842
+ }
843
+
844
+ export function measureDisplayWidth(text: string): number {
845
+ let width = 0;
846
+ for (const char of text) {
847
+ const codePoint = char.codePointAt(0)!;
848
+
849
+ if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
850
+ continue;
851
+ }
852
+
853
+ if (/\p{Mark}/u.test(char)) {
854
+ continue;
855
+ }
856
+
857
+ width += isWideCodePoint(codePoint) ? 2 : 1;
858
+ }
859
+ return width;
860
+ }
861
+
862
+ export function wrapTextForColumns(text: string, cols: number): string[] {
863
+ if (cols <= 0) {
864
+ return [''];
865
+ }
866
+
867
+ const lines: string[] = [];
868
+ let current = '';
869
+ let currentWidth = 0;
870
+
871
+ for (const char of text) {
872
+ if (char === '\n') {
873
+ lines.push(current);
874
+ current = '';
875
+ currentWidth = 0;
876
+ continue;
877
+ }
878
+
879
+ const charWidth = Math.max(1, measureDisplayWidth(char));
880
+ if (currentWidth + charWidth > cols) {
881
+ lines.push(current);
882
+ current = '';
883
+ currentWidth = 0;
884
+ }
885
+
886
+ current += char;
887
+ currentWidth += charWidth;
888
+ }
889
+
890
+ lines.push(current);
891
+ return lines;
892
+ }
893
+
894
+ function applySgrParams(style: TerminalCellStyle, params: number[]): TerminalCellStyle {
895
+ let nextStyle = cloneStyle(style);
896
+ const queue = params.length === 0 ? [0] : [...params];
897
+
898
+ for (let idx = 0; idx < queue.length; idx += 1) {
899
+ const param = queue[idx]!;
900
+
901
+ if (param === 0) {
902
+ nextStyle = defaultCellStyle();
903
+ continue;
904
+ }
905
+ if (param === 1) {
906
+ nextStyle.bold = true;
907
+ continue;
908
+ }
909
+ if (param === 2) {
910
+ nextStyle.dim = true;
911
+ continue;
912
+ }
913
+ if (param === 3) {
914
+ nextStyle.italic = true;
915
+ continue;
916
+ }
917
+ if (param === 4) {
918
+ nextStyle.underline = true;
919
+ continue;
920
+ }
921
+ if (param === 7) {
922
+ nextStyle.inverse = true;
923
+ continue;
924
+ }
925
+ if (param === 21 || param === 22) {
926
+ nextStyle.bold = false;
927
+ nextStyle.dim = false;
928
+ continue;
929
+ }
930
+ if (param === 23) {
931
+ nextStyle.italic = false;
932
+ continue;
933
+ }
934
+ if (param === 24) {
935
+ nextStyle.underline = false;
936
+ continue;
937
+ }
938
+ if (param === 27) {
939
+ nextStyle.inverse = false;
940
+ continue;
941
+ }
942
+ if (param >= 30 && param <= 37) {
943
+ nextStyle.fg = { kind: 'indexed', index: param - 30 };
944
+ continue;
945
+ }
946
+ if (param >= 90 && param <= 97) {
947
+ nextStyle.fg = { kind: 'indexed', index: 8 + (param - 90) };
948
+ continue;
949
+ }
950
+ if (param === 39) {
951
+ nextStyle.fg = DEFAULT_COLOR;
952
+ continue;
953
+ }
954
+ if (param >= 40 && param <= 47) {
955
+ nextStyle.bg = { kind: 'indexed', index: param - 40 };
956
+ continue;
957
+ }
958
+ if (param >= 100 && param <= 107) {
959
+ nextStyle.bg = { kind: 'indexed', index: 8 + (param - 100) };
960
+ continue;
961
+ }
962
+ if (param === 49) {
963
+ nextStyle.bg = DEFAULT_COLOR;
964
+ continue;
965
+ }
966
+
967
+ if (param !== 38 && param !== 48) {
968
+ continue;
969
+ }
970
+
971
+ const isBackground = param === 48;
972
+ const mode = queue[idx + 1];
973
+ if (mode === 5) {
974
+ const value = queue[idx + 2];
975
+ if (typeof value === 'number' && Number.isFinite(value)) {
976
+ const parsedColor: TerminalColor = {
977
+ kind: 'indexed',
978
+ index: clampColor(value),
979
+ };
980
+ if (isBackground) {
981
+ nextStyle.bg = parsedColor;
982
+ } else {
983
+ nextStyle.fg = parsedColor;
984
+ }
985
+ }
986
+ idx += 2;
987
+ continue;
988
+ }
989
+
990
+ if (mode === 2) {
991
+ const red = queue[idx + 2];
992
+ const green = queue[idx + 3];
993
+ const blue = queue[idx + 4];
994
+ if (
995
+ typeof red === 'number' &&
996
+ typeof green === 'number' &&
997
+ typeof blue === 'number' &&
998
+ Number.isFinite(red) &&
999
+ Number.isFinite(green) &&
1000
+ Number.isFinite(blue)
1001
+ ) {
1002
+ const parsedColor: TerminalColor = {
1003
+ kind: 'rgb',
1004
+ r: clampColor(red),
1005
+ g: clampColor(green),
1006
+ b: clampColor(blue),
1007
+ };
1008
+ if (isBackground) {
1009
+ nextStyle.bg = parsedColor;
1010
+ } else {
1011
+ nextStyle.fg = parsedColor;
1012
+ }
1013
+ }
1014
+ idx += 4;
1015
+ }
1016
+ }
1017
+
1018
+ return nextStyle;
1019
+ }
1020
+
1021
+ export class TerminalSnapshotOracle {
1022
+ private readonly decoder = new StringDecoder('utf8');
1023
+ private readonly primary: ScreenBuffer;
1024
+ private readonly alternate: ScreenBuffer;
1025
+ private queryHooks: TerminalQueryHooks | null;
1026
+ private activeScreen: ActiveScreen = 'primary';
1027
+ private cursor: ScreenCursor = { row: 0, col: 0 };
1028
+ private savedCursor: ScreenCursor | null = null;
1029
+ private mode: ParserMode = 'normal';
1030
+ private csiBuffer = '';
1031
+ private oscBuffer = '';
1032
+ private dcsBuffer = '';
1033
+ private cursorVisible = true;
1034
+ private cursorStyle: TerminalCursorStyle = cloneCursorStyle(DEFAULT_CURSOR_STYLE);
1035
+ private bracketedPasteMode = false;
1036
+ private decMouseX10Mode = false;
1037
+ private decMouseButtonEventMode = false;
1038
+ private decMouseAnyEventMode = false;
1039
+ private style: TerminalCellStyle = defaultCellStyle();
1040
+ private originMode = false;
1041
+ private pendingWrap = false;
1042
+ private tabStops = new Set<number>();
1043
+
1044
+ constructor(
1045
+ cols: number,
1046
+ rows: number,
1047
+ scrollbackLimit = 5000,
1048
+ queryHooks: TerminalQueryHooks | null = null,
1049
+ ) {
1050
+ this.primary = new ScreenBuffer(cols, rows, true, scrollbackLimit);
1051
+ this.alternate = new ScreenBuffer(cols, rows, false, 0);
1052
+ this.queryHooks = queryHooks;
1053
+ this.resetTabStops(cols);
1054
+ }
1055
+
1056
+ ingest(chunk: string | Uint8Array): void {
1057
+ const text = typeof chunk === 'string' ? chunk : this.decoder.write(Buffer.from(chunk));
1058
+ for (const char of text) {
1059
+ this.processChar(char);
1060
+ }
1061
+ }
1062
+
1063
+ resize(cols: number, rows: number): void {
1064
+ if (cols <= 0 || rows <= 0) {
1065
+ return;
1066
+ }
1067
+ this.primary.resize(cols, rows, this.style);
1068
+ this.alternate.resize(cols, rows, this.style);
1069
+ this.cursor.row = Math.max(0, Math.min(rows - 1, this.cursor.row));
1070
+ this.cursor.col = Math.max(0, Math.min(cols - 1, this.cursor.col));
1071
+ this.resetTabStops(cols);
1072
+ if (this.pendingWrap && this.cursor.col !== cols - 1) {
1073
+ this.pendingWrap = false;
1074
+ }
1075
+ }
1076
+
1077
+ setFollowOutput(followOutput: boolean): void {
1078
+ this.currentScreen().setFollowOutput(followOutput);
1079
+ }
1080
+
1081
+ scrollViewport(deltaRows: number): void {
1082
+ this.currentScreen().scrollViewport(deltaRows);
1083
+ }
1084
+
1085
+ setQueryHooks(queryHooks: TerminalQueryHooks | null): void {
1086
+ this.queryHooks = queryHooks;
1087
+ }
1088
+
1089
+ queryState(): TerminalQueryState {
1090
+ const screen = this.currentScreen();
1091
+ return {
1092
+ rows: screen.rows,
1093
+ cols: screen.cols,
1094
+ cursor: {
1095
+ row: this.cursor.row,
1096
+ col: this.cursor.col,
1097
+ },
1098
+ };
1099
+ }
1100
+
1101
+ snapshot(): TerminalSnapshotFrame {
1102
+ return this.currentScreen().snapshot(
1103
+ this.cursor,
1104
+ this.cursorVisible,
1105
+ this.cursorStyle,
1106
+ this.activeScreen,
1107
+ this.bracketedPasteMode,
1108
+ true,
1109
+ );
1110
+ }
1111
+
1112
+ snapshotWithoutHash(): TerminalSnapshotFrameCore {
1113
+ return this.currentScreen().snapshot(
1114
+ this.cursor,
1115
+ this.cursorVisible,
1116
+ this.cursorStyle,
1117
+ this.activeScreen,
1118
+ this.bracketedPasteMode,
1119
+ false,
1120
+ );
1121
+ }
1122
+
1123
+ isMouseTrackingEnabled(): boolean {
1124
+ return this.decMouseX10Mode || this.decMouseButtonEventMode || this.decMouseAnyEventMode;
1125
+ }
1126
+
1127
+ bufferTail(tailLines?: number): TerminalBufferTail {
1128
+ const normalizedTail =
1129
+ typeof tailLines === 'number' && Number.isFinite(tailLines)
1130
+ ? Math.max(1, Math.floor(tailLines))
1131
+ : null;
1132
+ return this.currentScreen().bufferTail(normalizedTail);
1133
+ }
1134
+
1135
+ selectionText(start: TerminalSelectionPoint, end: TerminalSelectionPoint): string {
1136
+ return this.currentScreen().selectionText(start, end);
1137
+ }
1138
+
1139
+ private currentScreen(): ScreenBuffer {
1140
+ return this.activeScreen === 'primary' ? this.primary : this.alternate;
1141
+ }
1142
+
1143
+ private processChar(char: string): void {
1144
+ if (this.mode === 'normal') {
1145
+ this.processNormal(char);
1146
+ return;
1147
+ }
1148
+ if (this.mode === 'esc') {
1149
+ this.processEsc(char);
1150
+ return;
1151
+ }
1152
+ if (this.mode === 'csi') {
1153
+ this.processCsi(char);
1154
+ return;
1155
+ }
1156
+ if (this.mode === 'osc') {
1157
+ this.processOsc(char);
1158
+ return;
1159
+ }
1160
+ if (this.mode === 'osc-esc') {
1161
+ this.processOscEsc(char);
1162
+ return;
1163
+ }
1164
+ if (this.mode === 'dcs') {
1165
+ this.processDcs(char);
1166
+ return;
1167
+ }
1168
+ this.processDcsEsc(char);
1169
+ }
1170
+
1171
+ private processNormal(char: string): void {
1172
+ const codePoint = char.codePointAt(0)!;
1173
+ if (char === '\u001b') {
1174
+ this.mode = 'esc';
1175
+ return;
1176
+ }
1177
+ if (char === '\r') {
1178
+ this.cursor.col = 0;
1179
+ this.pendingWrap = false;
1180
+ return;
1181
+ }
1182
+ if (char === '\n') {
1183
+ this.currentScreen().lineFeed(this.cursor, this.style);
1184
+ this.pendingWrap = false;
1185
+ return;
1186
+ }
1187
+ if (char === '\t') {
1188
+ if (this.pendingWrap) {
1189
+ this.currentScreen().lineFeed(this.cursor, this.style);
1190
+ this.cursor.col = 0;
1191
+ this.pendingWrap = false;
1192
+ }
1193
+ this.cursor.col = this.nextTabStop(this.cursor.col, this.currentScreen().cols);
1194
+ return;
1195
+ }
1196
+ if (char === '\b') {
1197
+ this.cursor.col = Math.max(0, this.cursor.col - 1);
1198
+ this.pendingWrap = false;
1199
+ return;
1200
+ }
1201
+ if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
1202
+ return;
1203
+ }
1204
+
1205
+ if (this.pendingWrap) {
1206
+ this.currentScreen().lineFeed(this.cursor, this.style);
1207
+ this.cursor.col = 0;
1208
+ this.pendingWrap = false;
1209
+ }
1210
+
1211
+ const width = measureDisplayWidth(char);
1212
+ if (width === 0) {
1213
+ this.currentScreen().appendCombining(this.cursor, char);
1214
+ return;
1215
+ }
1216
+
1217
+ this.pendingWrap = this.currentScreen().putGlyph(this.cursor, char, width, this.style);
1218
+ }
1219
+
1220
+ private processEsc(char: string): void {
1221
+ if (char === '[') {
1222
+ this.mode = 'csi';
1223
+ this.csiBuffer = '';
1224
+ return;
1225
+ }
1226
+ if (char === ']') {
1227
+ this.mode = 'osc';
1228
+ this.oscBuffer = '';
1229
+ return;
1230
+ }
1231
+ if (char === 'P') {
1232
+ this.mode = 'dcs';
1233
+ this.dcsBuffer = '';
1234
+ return;
1235
+ }
1236
+ if (char === '7') {
1237
+ this.savedCursor = { row: this.cursor.row, col: this.cursor.col };
1238
+ this.mode = 'normal';
1239
+ return;
1240
+ }
1241
+ if (char === '8') {
1242
+ if (this.savedCursor !== null) {
1243
+ this.cursor = { row: this.savedCursor.row, col: this.savedCursor.col };
1244
+ }
1245
+ this.mode = 'normal';
1246
+ return;
1247
+ }
1248
+ if (char === 'D') {
1249
+ this.currentScreen().lineFeed(this.cursor, this.style);
1250
+ this.pendingWrap = false;
1251
+ this.mode = 'normal';
1252
+ return;
1253
+ }
1254
+ if (char === 'E') {
1255
+ this.cursor.col = 0;
1256
+ this.currentScreen().lineFeed(this.cursor, this.style);
1257
+ this.pendingWrap = false;
1258
+ this.mode = 'normal';
1259
+ return;
1260
+ }
1261
+ if (char === 'M') {
1262
+ this.currentScreen().reverseLineFeed(this.cursor, this.style);
1263
+ this.pendingWrap = false;
1264
+ this.mode = 'normal';
1265
+ return;
1266
+ }
1267
+ if (char === 'H') {
1268
+ this.tabStops.add(this.cursor.col);
1269
+ this.mode = 'normal';
1270
+ return;
1271
+ }
1272
+ if (char === 'c') {
1273
+ this.hardReset();
1274
+ this.mode = 'normal';
1275
+ return;
1276
+ }
1277
+ this.mode = 'normal';
1278
+ }
1279
+
1280
+ private processCsi(char: string): void {
1281
+ const code = char.charCodeAt(0);
1282
+ if (code >= 0x40 && code <= 0x7e) {
1283
+ const finalByte = char;
1284
+ const rawParams = this.csiBuffer;
1285
+ this.mode = 'normal';
1286
+ this.csiBuffer = '';
1287
+ this.emitCsiQuery(`${rawParams}${finalByte}`);
1288
+ this.applyCsi(rawParams, finalByte);
1289
+ return;
1290
+ }
1291
+ if (char === '\u001b') {
1292
+ this.mode = 'esc';
1293
+ this.csiBuffer = '';
1294
+ return;
1295
+ }
1296
+
1297
+ this.csiBuffer += char;
1298
+ }
1299
+
1300
+ private processOsc(char: string): void {
1301
+ if (char === '\u0007') {
1302
+ this.emitOscQuery(true);
1303
+ this.mode = 'normal';
1304
+ return;
1305
+ }
1306
+ if (char === '\u001b') {
1307
+ this.mode = 'osc-esc';
1308
+ return;
1309
+ }
1310
+ this.oscBuffer += char;
1311
+ }
1312
+
1313
+ private processOscEsc(char: string): void {
1314
+ if (char === '\\') {
1315
+ this.emitOscQuery(false);
1316
+ this.mode = 'normal';
1317
+ return;
1318
+ }
1319
+ this.oscBuffer += '\u001b';
1320
+ this.oscBuffer += char;
1321
+ this.mode = 'osc';
1322
+ }
1323
+
1324
+ private processDcs(char: string): void {
1325
+ if (char === '\u001b') {
1326
+ this.mode = 'dcs-esc';
1327
+ return;
1328
+ }
1329
+ this.dcsBuffer += char;
1330
+ }
1331
+
1332
+ private processDcsEsc(char: string): void {
1333
+ if (char === '\\') {
1334
+ this.emitDcsQuery();
1335
+ this.mode = 'normal';
1336
+ return;
1337
+ }
1338
+ this.dcsBuffer += '\u001b';
1339
+ this.dcsBuffer += char;
1340
+ this.mode = 'dcs';
1341
+ }
1342
+
1343
+ private applyCsi(rawParams: string, finalByte: string): void {
1344
+ const privateMode = rawParams.startsWith('?');
1345
+ const privateKeyboardMode = rawParams.startsWith('>');
1346
+ const params = (privateMode ? rawParams.slice(1) : rawParams).split(';').map((part) => {
1347
+ if (part.length === 0) {
1348
+ return NaN;
1349
+ }
1350
+ return Number(part);
1351
+ });
1352
+ const first = Number.isFinite(params[0]) ? (params[0] as number) : 1;
1353
+
1354
+ if (privateMode) {
1355
+ if (finalByte === 'h') {
1356
+ this.applyPrivateMode(params, true);
1357
+ return;
1358
+ }
1359
+ if (finalByte === 'l') {
1360
+ this.applyPrivateMode(params, false);
1361
+ return;
1362
+ }
1363
+ }
1364
+
1365
+ if (finalByte === 'q' && rawParams.endsWith(' ')) {
1366
+ const trimmed = rawParams.slice(0, -1).trim();
1367
+ const value = trimmed.length === 0 ? 0 : Number(trimmed);
1368
+ if (Number.isFinite(value)) {
1369
+ this.applyCursorStyleParam(value);
1370
+ }
1371
+ return;
1372
+ }
1373
+
1374
+ if (privateKeyboardMode && (finalByte === 'm' || finalByte === 'u')) {
1375
+ return;
1376
+ }
1377
+
1378
+ if (finalByte === 'm') {
1379
+ const cleaned = params.filter((value) => Number.isFinite(value));
1380
+ this.style = applySgrParams(this.style, cleaned);
1381
+ return;
1382
+ }
1383
+
1384
+ if (finalByte === 'A') {
1385
+ const bounds = this.activeRowBounds();
1386
+ this.cursor.row = Math.max(bounds.top, this.cursor.row - first);
1387
+ this.pendingWrap = false;
1388
+ return;
1389
+ }
1390
+ if (finalByte === 'B') {
1391
+ const bounds = this.activeRowBounds();
1392
+ this.cursor.row = Math.min(bounds.bottom, this.cursor.row + first);
1393
+ this.pendingWrap = false;
1394
+ return;
1395
+ }
1396
+ if (finalByte === 'C') {
1397
+ this.cursor.col = Math.min(this.currentScreen().cols - 1, this.cursor.col + first);
1398
+ this.pendingWrap = false;
1399
+ return;
1400
+ }
1401
+ if (finalByte === 'D') {
1402
+ this.cursor.col = Math.max(0, this.cursor.col - first);
1403
+ this.pendingWrap = false;
1404
+ return;
1405
+ }
1406
+ if (finalByte === 'G') {
1407
+ this.cursor.col = Math.max(0, Math.min(this.currentScreen().cols - 1, first - 1));
1408
+ this.pendingWrap = false;
1409
+ return;
1410
+ }
1411
+ if (finalByte === 'H' || finalByte === 'f') {
1412
+ const row = Number.isFinite(params[0]) ? (params[0] as number) : 1;
1413
+ const col = Number.isFinite(params[1]) ? (params[1] as number) : 1;
1414
+ const bounds = this.activeRowBounds();
1415
+ const targetRow = this.originMode ? bounds.top + row - 1 : row - 1;
1416
+ this.cursor.row = Math.max(bounds.top, Math.min(bounds.bottom, targetRow));
1417
+ this.cursor.col = Math.max(0, Math.min(this.currentScreen().cols - 1, col - 1));
1418
+ this.pendingWrap = false;
1419
+ return;
1420
+ }
1421
+ if (finalByte === 'J') {
1422
+ const mode = Number.isFinite(params[0]) ? (params[0] as number) : 0;
1423
+ this.currentScreen().clearScreen(this.cursor, mode, this.style);
1424
+ return;
1425
+ }
1426
+ if (finalByte === 'K') {
1427
+ const mode = Number.isFinite(params[0]) ? (params[0] as number) : 0;
1428
+ this.currentScreen().clearLine(this.cursor, mode, this.style);
1429
+ return;
1430
+ }
1431
+ if (finalByte === 'S') {
1432
+ const region = this.currentScreen().scrollRegion();
1433
+ this.currentScreen().scrollUp(first, this.style, region.top, region.bottom);
1434
+ return;
1435
+ }
1436
+ if (finalByte === 'T') {
1437
+ const region = this.currentScreen().scrollRegion();
1438
+ this.currentScreen().scrollDown(first, this.style, region.top, region.bottom);
1439
+ return;
1440
+ }
1441
+ if (finalByte === 'L') {
1442
+ this.currentScreen().insertLines(this.cursor, first, this.style);
1443
+ this.pendingWrap = false;
1444
+ return;
1445
+ }
1446
+ if (finalByte === 'M') {
1447
+ this.currentScreen().deleteLines(this.cursor, first, this.style);
1448
+ this.pendingWrap = false;
1449
+ return;
1450
+ }
1451
+ if (finalByte === '@') {
1452
+ this.currentScreen().insertChars(this.cursor, first, this.style);
1453
+ this.pendingWrap = false;
1454
+ return;
1455
+ }
1456
+ if (finalByte === 'P') {
1457
+ this.currentScreen().deleteChars(this.cursor, first, this.style);
1458
+ this.pendingWrap = false;
1459
+ return;
1460
+ }
1461
+ if (finalByte === 'g') {
1462
+ const mode = Number.isFinite(params[0]) ? (params[0] as number) : 0;
1463
+ if (mode === 0) {
1464
+ this.tabStops.delete(this.cursor.col);
1465
+ } else if (mode === 3) {
1466
+ this.tabStops.clear();
1467
+ }
1468
+ return;
1469
+ }
1470
+ if (finalByte === 'r') {
1471
+ const top = Number.isFinite(params[0]) ? (params[0] as number) : 1;
1472
+ const bottom = Number.isFinite(params[1]) ? (params[1] as number) : this.currentScreen().rows;
1473
+ if (this.currentScreen().setScrollRegion(top, bottom)) {
1474
+ this.homeCursor();
1475
+ }
1476
+ this.pendingWrap = false;
1477
+ return;
1478
+ }
1479
+ if (finalByte === 's') {
1480
+ this.savedCursor = { row: this.cursor.row, col: this.cursor.col };
1481
+ return;
1482
+ }
1483
+ if (finalByte === 'u') {
1484
+ if (rawParams.length > 0 && rawParams !== '0') {
1485
+ return;
1486
+ }
1487
+ if (this.savedCursor !== null) {
1488
+ this.cursor = { row: this.savedCursor.row, col: this.savedCursor.col };
1489
+ this.pendingWrap = false;
1490
+ }
1491
+ }
1492
+ }
1493
+
1494
+ private emitCsiQuery(payload: string): void {
1495
+ this.queryHooks?.onCsiQuery?.(payload, () => this.queryState());
1496
+ }
1497
+
1498
+ private emitOscQuery(useBellTerminator: boolean): void {
1499
+ const payload = this.oscBuffer;
1500
+ this.oscBuffer = '';
1501
+ this.queryHooks?.onOscQuery?.(payload, useBellTerminator);
1502
+ }
1503
+
1504
+ private emitDcsQuery(): void {
1505
+ const payload = this.dcsBuffer;
1506
+ this.dcsBuffer = '';
1507
+ this.queryHooks?.onDcsQuery?.(payload);
1508
+ }
1509
+
1510
+ private applyPrivateMode(params: number[], enabled: boolean): void {
1511
+ for (const value of params) {
1512
+ if (!Number.isFinite(value)) {
1513
+ continue;
1514
+ }
1515
+
1516
+ if (value === 25) {
1517
+ this.cursorVisible = enabled;
1518
+ continue;
1519
+ }
1520
+ if (value === 2004) {
1521
+ this.bracketedPasteMode = enabled;
1522
+ continue;
1523
+ }
1524
+ if (value === 1000) {
1525
+ this.decMouseX10Mode = enabled;
1526
+ continue;
1527
+ }
1528
+ if (value === 1002) {
1529
+ this.decMouseButtonEventMode = enabled;
1530
+ continue;
1531
+ }
1532
+ if (value === 1003) {
1533
+ this.decMouseAnyEventMode = enabled;
1534
+ continue;
1535
+ }
1536
+
1537
+ if (value === 1047) {
1538
+ this.activeScreen = enabled ? 'alternate' : 'primary';
1539
+ if (enabled) {
1540
+ this.originMode = false;
1541
+ this.alternate.clear(this.style);
1542
+ this.alternate.resetScrollRegion();
1543
+ this.cursor = { row: 0, col: 0 };
1544
+ }
1545
+ this.pendingWrap = false;
1546
+ continue;
1547
+ }
1548
+
1549
+ if (value === 1048) {
1550
+ if (enabled) {
1551
+ this.savedCursor = { row: this.cursor.row, col: this.cursor.col };
1552
+ } else if (this.savedCursor !== null) {
1553
+ this.cursor = { row: this.savedCursor.row, col: this.savedCursor.col };
1554
+ }
1555
+ this.pendingWrap = false;
1556
+ continue;
1557
+ }
1558
+
1559
+ if (value === 1049) {
1560
+ if (enabled) {
1561
+ this.savedCursor = { row: this.cursor.row, col: this.cursor.col };
1562
+ this.originMode = false;
1563
+ this.activeScreen = 'alternate';
1564
+ this.alternate.clear(this.style);
1565
+ this.alternate.resetScrollRegion();
1566
+ this.cursor = { row: 0, col: 0 };
1567
+ } else {
1568
+ this.activeScreen = 'primary';
1569
+ if (this.savedCursor !== null) {
1570
+ this.cursor = { row: this.savedCursor.row, col: this.savedCursor.col };
1571
+ }
1572
+ }
1573
+ this.pendingWrap = false;
1574
+ continue;
1575
+ }
1576
+
1577
+ if (value === 6) {
1578
+ this.originMode = enabled;
1579
+ this.homeCursor();
1580
+ this.pendingWrap = false;
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ private applyCursorStyleParam(value: number): void {
1586
+ if (value === 0 || value === 1) {
1587
+ this.cursorStyle = {
1588
+ shape: 'block',
1589
+ blinking: true,
1590
+ };
1591
+ return;
1592
+ }
1593
+ if (value === 2) {
1594
+ this.cursorStyle = {
1595
+ shape: 'block',
1596
+ blinking: false,
1597
+ };
1598
+ return;
1599
+ }
1600
+ if (value === 3) {
1601
+ this.cursorStyle = {
1602
+ shape: 'underline',
1603
+ blinking: true,
1604
+ };
1605
+ return;
1606
+ }
1607
+ if (value === 4) {
1608
+ this.cursorStyle = {
1609
+ shape: 'underline',
1610
+ blinking: false,
1611
+ };
1612
+ return;
1613
+ }
1614
+ if (value === 5) {
1615
+ this.cursorStyle = {
1616
+ shape: 'bar',
1617
+ blinking: true,
1618
+ };
1619
+ return;
1620
+ }
1621
+ if (value === 6) {
1622
+ this.cursorStyle = {
1623
+ shape: 'bar',
1624
+ blinking: false,
1625
+ };
1626
+ }
1627
+ }
1628
+
1629
+ private hardReset(): void {
1630
+ const style = defaultCellStyle();
1631
+ this.mode = 'normal';
1632
+ this.csiBuffer = '';
1633
+ this.oscBuffer = '';
1634
+ this.dcsBuffer = '';
1635
+ this.activeScreen = 'primary';
1636
+ this.cursor = { row: 0, col: 0 };
1637
+ this.savedCursor = null;
1638
+ this.cursorVisible = true;
1639
+ this.cursorStyle = cloneCursorStyle(DEFAULT_CURSOR_STYLE);
1640
+ this.bracketedPasteMode = false;
1641
+ this.decMouseX10Mode = false;
1642
+ this.decMouseButtonEventMode = false;
1643
+ this.decMouseAnyEventMode = false;
1644
+ this.style = style;
1645
+ this.originMode = false;
1646
+ this.pendingWrap = false;
1647
+
1648
+ this.primary.clear(style);
1649
+ this.primary.resetScrollRegion();
1650
+ this.primary.setFollowOutput(true);
1651
+ this.alternate.clear(style);
1652
+ this.alternate.resetScrollRegion();
1653
+ this.alternate.setFollowOutput(true);
1654
+
1655
+ this.resetTabStops(this.primary.cols);
1656
+ }
1657
+
1658
+ private activeRowBounds(): { top: number; bottom: number } {
1659
+ if (!this.originMode) {
1660
+ return {
1661
+ top: 0,
1662
+ bottom: this.currentScreen().rows - 1,
1663
+ };
1664
+ }
1665
+ return this.currentScreen().scrollRegion();
1666
+ }
1667
+
1668
+ private homeCursor(): void {
1669
+ const bounds = this.activeRowBounds();
1670
+ this.cursor.row = bounds.top;
1671
+ this.cursor.col = 0;
1672
+ this.pendingWrap = false;
1673
+ }
1674
+
1675
+ private resetTabStops(cols: number): void {
1676
+ this.tabStops.clear();
1677
+ for (let col = 8; col < cols; col += 8) {
1678
+ this.tabStops.add(col);
1679
+ }
1680
+ }
1681
+
1682
+ private nextTabStop(currentCol: number, cols: number): number {
1683
+ const sortedStops = [...this.tabStops].sort((left, right) => left - right);
1684
+ for (const stop of sortedStops) {
1685
+ if (stop > currentCol) {
1686
+ return Math.min(cols - 1, stop);
1687
+ }
1688
+ }
1689
+ return cols - 1;
1690
+ }
1691
+ }
1692
+
1693
+ export function renderSnapshotAnsiRow(
1694
+ frame: TerminalSnapshotFrameCore,
1695
+ rowIndex: number,
1696
+ cols: number,
1697
+ ): string {
1698
+ const line = frame.richLines[rowIndex];
1699
+ const defaultStyle = defaultCellStyle();
1700
+
1701
+ if (line === undefined) {
1702
+ return `${styleToAnsi(defaultStyle)}${' '.repeat(cols)}\u001b[0m`;
1703
+ }
1704
+
1705
+ let output = '';
1706
+ let previousStyle: TerminalCellStyle | null = null;
1707
+
1708
+ for (let col = 0; col < cols; col += 1) {
1709
+ const cell = line.cells[col] ?? {
1710
+ glyph: ' ',
1711
+ width: 1,
1712
+ continued: false,
1713
+ style: defaultStyle,
1714
+ };
1715
+ if (cell.continued) {
1716
+ continue;
1717
+ }
1718
+
1719
+ if (previousStyle === null || !styleEqual(previousStyle, cell.style)) {
1720
+ output += styleToAnsi(cell.style);
1721
+ previousStyle = cell.style;
1722
+ }
1723
+
1724
+ output += cell.glyph;
1725
+ if (cell.width === 2) {
1726
+ col += 1;
1727
+ }
1728
+ }
1729
+
1730
+ output += '\u001b[0m';
1731
+ return output;
1732
+ }
1733
+
1734
+ export function renderSnapshotText(frame: TerminalSnapshotFrame): string {
1735
+ return frame.lines.join('\n');
1736
+ }
1737
+
1738
+ interface TerminalReplayStep {
1739
+ kind: 'output' | 'resize';
1740
+ chunk?: string;
1741
+ cols?: number;
1742
+ rows?: number;
1743
+ }
1744
+
1745
+ export function replayTerminalSteps(
1746
+ steps: readonly TerminalReplayStep[],
1747
+ initialCols: number,
1748
+ initialRows: number,
1749
+ ): TerminalSnapshotFrame[] {
1750
+ const oracle = new TerminalSnapshotOracle(initialCols, initialRows);
1751
+ const snapshots: TerminalSnapshotFrame[] = [];
1752
+
1753
+ for (const step of steps) {
1754
+ if (step.kind === 'output') {
1755
+ oracle.ingest(step.chunk ?? '');
1756
+ snapshots.push(oracle.snapshot());
1757
+ continue;
1758
+ }
1759
+
1760
+ const cols = step.cols ?? initialCols;
1761
+ const rows = step.rows ?? initialRows;
1762
+ oracle.resize(cols, rows);
1763
+ snapshots.push(oracle.snapshot());
1764
+ }
1765
+
1766
+ return snapshots;
1767
+ }
1768
+
1769
+ interface TerminalFrameDiff {
1770
+ equal: boolean;
1771
+ reasons: string[];
1772
+ }
1773
+
1774
+ export function diffTerminalFrames(
1775
+ expected: TerminalSnapshotFrame,
1776
+ actual: TerminalSnapshotFrame,
1777
+ ): TerminalFrameDiff {
1778
+ const reasons: string[] = [];
1779
+
1780
+ if (expected.rows !== actual.rows || expected.cols !== actual.cols) {
1781
+ reasons.push('dimensions-mismatch');
1782
+ }
1783
+
1784
+ if (expected.activeScreen !== actual.activeScreen) {
1785
+ reasons.push('active-screen-mismatch');
1786
+ }
1787
+
1788
+ if (expected.modes.bracketedPaste !== actual.modes.bracketedPaste) {
1789
+ reasons.push('bracketed-paste-mode-mismatch');
1790
+ }
1791
+
1792
+ if (expected.cursor.row !== actual.cursor.row || expected.cursor.col !== actual.cursor.col) {
1793
+ reasons.push('cursor-position-mismatch');
1794
+ }
1795
+
1796
+ if (expected.cursor.visible !== actual.cursor.visible) {
1797
+ reasons.push('cursor-visibility-mismatch');
1798
+ }
1799
+
1800
+ if (!cursorStyleEqual(expected.cursor.style, actual.cursor.style)) {
1801
+ reasons.push('cursor-style-mismatch');
1802
+ }
1803
+
1804
+ const rowCount = Math.max(expected.richLines.length, actual.richLines.length);
1805
+ for (let row = 0; row < rowCount; row += 1) {
1806
+ const expectedLine = expected.richLines[row];
1807
+ const actualLine = actual.richLines[row];
1808
+ if (expectedLine === undefined || actualLine === undefined) {
1809
+ reasons.push(`line-${String(row)}-missing`);
1810
+ continue;
1811
+ }
1812
+
1813
+ if (expectedLine.text !== actualLine.text || expectedLine.wrapped !== actualLine.wrapped) {
1814
+ reasons.push(`line-${String(row)}-text-mismatch`);
1815
+ }
1816
+
1817
+ const cellCount = Math.max(expectedLine.cells.length, actualLine.cells.length);
1818
+ for (let col = 0; col < cellCount; col += 1) {
1819
+ const expectedCell = expectedLine.cells[col];
1820
+ const actualCell = actualLine.cells[col];
1821
+ if (expectedCell === undefined || actualCell === undefined) {
1822
+ reasons.push(`cell-${String(row)}-${String(col)}-missing`);
1823
+ continue;
1824
+ }
1825
+ if (
1826
+ expectedCell.glyph !== actualCell.glyph ||
1827
+ expectedCell.width !== actualCell.width ||
1828
+ expectedCell.continued !== actualCell.continued ||
1829
+ !styleEqual(expectedCell.style, actualCell.style)
1830
+ ) {
1831
+ reasons.push(`cell-${String(row)}-${String(col)}-mismatch`);
1832
+ }
1833
+ }
1834
+ }
1835
+
1836
+ return {
1837
+ equal: reasons.length === 0,
1838
+ reasons,
1839
+ };
1840
+ }