@silvery/term 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
package/src/output.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI output constants and terminal control utilities.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: Buffer rendering is handled by pipeline/output-phase.ts.
|
|
5
|
+
* This file contains only terminal control sequences and constants.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { hostname } from "node:os"
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// ANSI Escape Codes
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
const ESC = "\x1b"
|
|
15
|
+
const CSI = `${ESC}[`
|
|
16
|
+
|
|
17
|
+
// Cursor control
|
|
18
|
+
const CURSOR_HIDE = `${CSI}?25l`
|
|
19
|
+
const CURSOR_SHOW = `${CSI}?25h`
|
|
20
|
+
const CURSOR_HOME = `${CSI}H`
|
|
21
|
+
|
|
22
|
+
// Synchronized Update Mode (DEC private mode 2026)
|
|
23
|
+
// Tells the terminal to batch output and paint atomically, preventing tearing.
|
|
24
|
+
// Supported by: Ghostty, Kitty, WezTerm, iTerm2, Foot, Alacritty 0.14+, tmux 3.2+
|
|
25
|
+
// Terminals that don't support it safely ignore these sequences.
|
|
26
|
+
const SYNC_BEGIN = `${CSI}?2026h`
|
|
27
|
+
const SYNC_END = `${CSI}?2026l`
|
|
28
|
+
|
|
29
|
+
// Style reset
|
|
30
|
+
const RESET = `${CSI}0m`
|
|
31
|
+
|
|
32
|
+
// SGR (Select Graphic Rendition) codes
|
|
33
|
+
const SGR = {
|
|
34
|
+
// Attributes
|
|
35
|
+
bold: 1,
|
|
36
|
+
dim: 2,
|
|
37
|
+
italic: 3,
|
|
38
|
+
underline: 4,
|
|
39
|
+
blink: 5,
|
|
40
|
+
inverse: 7,
|
|
41
|
+
hidden: 8,
|
|
42
|
+
strikethrough: 9,
|
|
43
|
+
|
|
44
|
+
// Attribute resets
|
|
45
|
+
boldOff: 22, // Also resets dim
|
|
46
|
+
italicOff: 23,
|
|
47
|
+
underlineOff: 24,
|
|
48
|
+
blinkOff: 25,
|
|
49
|
+
inverseOff: 27,
|
|
50
|
+
hiddenOff: 28,
|
|
51
|
+
strikethroughOff: 29,
|
|
52
|
+
|
|
53
|
+
// Colors (foreground)
|
|
54
|
+
fgDefault: 39,
|
|
55
|
+
fgBlack: 30,
|
|
56
|
+
fgRed: 31,
|
|
57
|
+
fgGreen: 32,
|
|
58
|
+
fgYellow: 33,
|
|
59
|
+
fgBlue: 34,
|
|
60
|
+
fgMagenta: 35,
|
|
61
|
+
fgCyan: 36,
|
|
62
|
+
fgWhite: 37,
|
|
63
|
+
fgBrightBlack: 90,
|
|
64
|
+
fgBrightRed: 91,
|
|
65
|
+
fgBrightGreen: 92,
|
|
66
|
+
fgBrightYellow: 93,
|
|
67
|
+
fgBrightBlue: 94,
|
|
68
|
+
fgBrightMagenta: 95,
|
|
69
|
+
fgBrightCyan: 96,
|
|
70
|
+
fgBrightWhite: 97,
|
|
71
|
+
|
|
72
|
+
// Colors (background)
|
|
73
|
+
bgDefault: 49,
|
|
74
|
+
bgBlack: 40,
|
|
75
|
+
bgRed: 41,
|
|
76
|
+
bgGreen: 42,
|
|
77
|
+
bgYellow: 43,
|
|
78
|
+
bgBlue: 44,
|
|
79
|
+
bgMagenta: 45,
|
|
80
|
+
bgCyan: 46,
|
|
81
|
+
bgWhite: 47,
|
|
82
|
+
bgBrightBlack: 100,
|
|
83
|
+
bgBrightRed: 101,
|
|
84
|
+
bgBrightGreen: 102,
|
|
85
|
+
bgBrightYellow: 103,
|
|
86
|
+
bgBrightBlue: 104,
|
|
87
|
+
bgBrightMagenta: 105,
|
|
88
|
+
bgBrightCyan: 106,
|
|
89
|
+
bgBrightWhite: 107,
|
|
90
|
+
} as const
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Cursor Movement
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generate ANSI sequence to move cursor to position.
|
|
98
|
+
* Terminal positions are 1-indexed.
|
|
99
|
+
*/
|
|
100
|
+
function moveCursor(x: number, y: number): string {
|
|
101
|
+
return `${CSI}${y + 1};${x + 1}H`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Generate ANSI sequence to move cursor up N lines.
|
|
106
|
+
*/
|
|
107
|
+
function cursorUp(n: number): string {
|
|
108
|
+
if (n <= 0) return ""
|
|
109
|
+
if (n === 1) return `${CSI}A`
|
|
110
|
+
return `${CSI}${n}A`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Generate ANSI sequence to move cursor down N lines.
|
|
115
|
+
*/
|
|
116
|
+
function cursorDown(n: number): string {
|
|
117
|
+
if (n <= 0) return ""
|
|
118
|
+
if (n === 1) return `${CSI}B`
|
|
119
|
+
return `${CSI}${n}B`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Generate ANSI sequence to move cursor right N columns.
|
|
124
|
+
*/
|
|
125
|
+
function cursorRight(n: number): string {
|
|
126
|
+
if (n <= 0) return ""
|
|
127
|
+
if (n === 1) return `${CSI}C`
|
|
128
|
+
return `${CSI}${n}C`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Generate ANSI sequence to move cursor left N columns.
|
|
133
|
+
*/
|
|
134
|
+
function cursorLeft(n: number): string {
|
|
135
|
+
if (n <= 0) return ""
|
|
136
|
+
if (n === 1) return `${CSI}D`
|
|
137
|
+
return `${CSI}${n}D`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate ANSI sequence to move cursor to column.
|
|
142
|
+
*/
|
|
143
|
+
function cursorToColumn(x: number): string {
|
|
144
|
+
return `${CSI}${x + 1}G`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// Cursor Shape (DECSCUSR)
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Terminal cursor shape. Combined with blink parameter in setCursorStyle().
|
|
153
|
+
*/
|
|
154
|
+
export type CursorShape = "block" | "underline" | "bar"
|
|
155
|
+
|
|
156
|
+
const CURSOR_SHAPE_CODES: Record<CursorShape, { blink: number; steady: number }> = {
|
|
157
|
+
block: { blink: 1, steady: 2 },
|
|
158
|
+
underline: { blink: 3, steady: 4 },
|
|
159
|
+
bar: { blink: 5, steady: 6 },
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Set the terminal cursor shape via DECSCUSR (CSI Ps SP q).
|
|
164
|
+
*
|
|
165
|
+
* Supported by: xterm, Ghostty, Kitty, WezTerm, iTerm2, Alacritty, foot.
|
|
166
|
+
* Terminals that don't support it safely ignore the sequence.
|
|
167
|
+
*
|
|
168
|
+
* @param shape - "block", "underline", or "bar"
|
|
169
|
+
* @param blink - Whether the cursor should blink (default: false)
|
|
170
|
+
*/
|
|
171
|
+
export function setCursorStyle(shape: CursorShape, blink = false): string {
|
|
172
|
+
const code = blink ? CURSOR_SHAPE_CODES[shape].blink : CURSOR_SHAPE_CODES[shape].steady
|
|
173
|
+
return `${CSI}${code} q`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Reset the terminal cursor style to the terminal's default (DECSCUSR 0).
|
|
178
|
+
*/
|
|
179
|
+
export function resetCursorStyle(): string {
|
|
180
|
+
return `${CSI}0 q`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// Terminal Control
|
|
185
|
+
// ============================================================================
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Enter alternate screen buffer, clear screen, and hide cursor.
|
|
189
|
+
* Cursor is hidden by default - applications must explicitly show it for text input.
|
|
190
|
+
*
|
|
191
|
+
* The clear screen (\x1b[2J) and cursor home (\x1b[H) are essential after entering
|
|
192
|
+
* the alternate buffer to ensure a clean slate. Without this, the terminal may have
|
|
193
|
+
* leftover content from previous sessions that causes rendering artifacts like
|
|
194
|
+
* content appearing at wrong Y positions (bug km-x7ih).
|
|
195
|
+
*/
|
|
196
|
+
export function enterAlternateScreen(): string {
|
|
197
|
+
return `${CSI}?1049h${CSI}2J${CURSOR_HOME}${CURSOR_HIDE}`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Leave alternate screen buffer and restore cursor.
|
|
202
|
+
* Includes SYNC_END as a safety belt — ensures synchronized update mode is
|
|
203
|
+
* reset even if the process was interrupted mid-render. Sending SYNC_END
|
|
204
|
+
* when not in sync mode is a harmless no-op.
|
|
205
|
+
*/
|
|
206
|
+
export function leaveAlternateScreen(): string {
|
|
207
|
+
return `${SYNC_END}${CURSOR_SHOW}${CSI}?1049l`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Enable mouse tracking.
|
|
212
|
+
*/
|
|
213
|
+
export function enableMouse(): string {
|
|
214
|
+
return `${CSI}?1000h${CSI}?1002h${CSI}?1006h`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Disable mouse tracking.
|
|
219
|
+
*/
|
|
220
|
+
export function disableMouse(): string {
|
|
221
|
+
return `${CSI}?1006l${CSI}?1002l${CSI}?1000l`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Kitty keyboard protocol flags (bitfield).
|
|
226
|
+
*
|
|
227
|
+
* | Flag | Bit | Description |
|
|
228
|
+
* | ---- | --- | ---------------------------------------------- |
|
|
229
|
+
* | 1 | 0 | Disambiguate escape codes |
|
|
230
|
+
* | 2 | 1 | Report event types (press/repeat/release) |
|
|
231
|
+
* | 4 | 2 | Report alternate keys |
|
|
232
|
+
* | 8 | 3 | Report all keys as escape codes |
|
|
233
|
+
* | 16 | 4 | Report associated text |
|
|
234
|
+
*/
|
|
235
|
+
export const KittyFlags = {
|
|
236
|
+
DISAMBIGUATE: 1,
|
|
237
|
+
REPORT_EVENTS: 2,
|
|
238
|
+
REPORT_ALTERNATE: 4,
|
|
239
|
+
REPORT_ALL_KEYS: 8,
|
|
240
|
+
REPORT_TEXT: 16,
|
|
241
|
+
} as const
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Enable Kitty keyboard protocol (push mode).
|
|
245
|
+
* Sends CSI > flags u to opt into the specified modes.
|
|
246
|
+
* Default flags=1 (disambiguate only) for maximum compatibility.
|
|
247
|
+
* Supported: Ghostty, Kitty, WezTerm, foot. Ignored by unsupported terminals.
|
|
248
|
+
*
|
|
249
|
+
* @param flags Bitfield of KittyFlags (default: DISAMBIGUATE)
|
|
250
|
+
*/
|
|
251
|
+
export function enableKittyKeyboard(flags = KittyFlags.DISAMBIGUATE): string {
|
|
252
|
+
return `${CSI}>${flags}u`
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Query Kitty keyboard protocol support.
|
|
257
|
+
* Sends CSI ? u — terminal responds with CSI ? flags u if supported.
|
|
258
|
+
* Parse the response to detect which flags the terminal supports.
|
|
259
|
+
*/
|
|
260
|
+
export function queryKittyKeyboard(): string {
|
|
261
|
+
return `${CSI}?u`
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Disable Kitty keyboard protocol (pop mode stack).
|
|
266
|
+
* Sends CSI < u to restore previous keyboard mode.
|
|
267
|
+
*/
|
|
268
|
+
export function disableKittyKeyboard(): string {
|
|
269
|
+
return `${CSI}<u`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// Terminal Notifications
|
|
274
|
+
// ============================================================================
|
|
275
|
+
|
|
276
|
+
/** BEL character — basic terminal bell/notification */
|
|
277
|
+
export const BEL = "\x07"
|
|
278
|
+
|
|
279
|
+
/** iTerm2 notification (OSC 9) */
|
|
280
|
+
export function notifyITerm2(message: string): string {
|
|
281
|
+
return `${ESC}]9;${message}${BEL}`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Kitty notification (OSC 99) with optional title */
|
|
285
|
+
export function notifyKitty(message: string, opts?: { title?: string }): string {
|
|
286
|
+
const params = opts?.title ? `;t=t;${opts.title}` : ""
|
|
287
|
+
return `${ESC}]99;i=1:d=0${params};${message}${ESC}\\`
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Send a terminal notification using the best available method.
|
|
292
|
+
*
|
|
293
|
+
* Auto-detects terminal type via TERM_PROGRAM / TERM env vars:
|
|
294
|
+
* - iTerm2 → OSC 9
|
|
295
|
+
* - Kitty → OSC 99
|
|
296
|
+
* - Others → BEL (audible/visual bell)
|
|
297
|
+
*/
|
|
298
|
+
export function notify(stdout: NodeJS.WriteStream, message: string, opts?: { title?: string }): void {
|
|
299
|
+
const termProgram = process.env.TERM_PROGRAM ?? ""
|
|
300
|
+
const term = process.env.TERM ?? ""
|
|
301
|
+
|
|
302
|
+
if (termProgram === "iTerm.app") {
|
|
303
|
+
stdout.write(notifyITerm2(message))
|
|
304
|
+
} else if (term === "xterm-kitty") {
|
|
305
|
+
stdout.write(notifyKitty(message, opts))
|
|
306
|
+
} else {
|
|
307
|
+
stdout.write(BEL)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============================================================================
|
|
312
|
+
// Window Title (OSC 0/2)
|
|
313
|
+
// ============================================================================
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Set the terminal window title using OSC 2 (window title only).
|
|
317
|
+
* Does not affect icon title (tab name in some terminals).
|
|
318
|
+
* Widely supported: xterm, Ghostty, iTerm2, Kitty, WezTerm, Alacritty, foot.
|
|
319
|
+
*/
|
|
320
|
+
export function setWindowTitle(stdout: NodeJS.WriteStream, title: string): void {
|
|
321
|
+
stdout.write(`${ESC}]2;${title}${BEL}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Set both the window title and icon title using OSC 0.
|
|
326
|
+
* Some terminals treat OSC 0 as equivalent to OSC 2; others also change the
|
|
327
|
+
* dock/taskbar icon name.
|
|
328
|
+
*/
|
|
329
|
+
export function setWindowAndIconTitle(stdout: NodeJS.WriteStream, title: string): void {
|
|
330
|
+
stdout.write(`${ESC}]0;${title}${BEL}`)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Reset the terminal window title by sending an empty OSC 2 sequence.
|
|
335
|
+
* The terminal typically reverts to its default title (shell command, etc.).
|
|
336
|
+
*/
|
|
337
|
+
export function resetWindowTitle(stdout: NodeJS.WriteStream): void {
|
|
338
|
+
stdout.write(`${ESC}]2;${BEL}`)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ============================================================================
|
|
342
|
+
// Directory Reporting
|
|
343
|
+
// ============================================================================
|
|
344
|
+
|
|
345
|
+
/** Report current working directory to the terminal via OSC 7.
|
|
346
|
+
* Used by terminals (iTerm2, Ghostty, WezTerm) for tab/split directory inheritance.
|
|
347
|
+
*/
|
|
348
|
+
export function reportDirectory(stdout: NodeJS.WriteStream, path: string): void {
|
|
349
|
+
// OSC 7 format: ESC ] 7 ; file://hostname/path BEL
|
|
350
|
+
const host = hostname()
|
|
351
|
+
const encoded = encodeURI(path).replace(/#/g, "%23")
|
|
352
|
+
stdout.write(`${ESC}]7;file://${host}${encoded}${BEL}`)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ============================================================================
|
|
356
|
+
// Mouse Cursor Shape (OSC 22)
|
|
357
|
+
// ============================================================================
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Mouse cursor shape names for OSC 22.
|
|
361
|
+
*
|
|
362
|
+
* Uses X11/CSS cursor names. Supported by: Ghostty, Kitty (>=0.33), foot,
|
|
363
|
+
* WezTerm (partial). Terminals that don't support OSC 22 safely ignore it.
|
|
364
|
+
*/
|
|
365
|
+
export type MouseCursorShape = "default" | "text" | "pointer" | "crosshair" | "move" | "not-allowed" | "wait" | "help"
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Generate OSC 22 sequence to set the mouse cursor shape.
|
|
369
|
+
*
|
|
370
|
+
* @param shape - X11/CSS cursor name
|
|
371
|
+
* @returns ANSI escape sequence string
|
|
372
|
+
*/
|
|
373
|
+
export function setMouseCursorShape(shape: MouseCursorShape): string {
|
|
374
|
+
return `${ESC}]22;${shape}${BEL}`
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Generate OSC 22 sequence to reset mouse cursor to default.
|
|
379
|
+
*
|
|
380
|
+
* @returns ANSI escape sequence string
|
|
381
|
+
*/
|
|
382
|
+
export function resetMouseCursorShape(): string {
|
|
383
|
+
return `${ESC}]22;default${BEL}`
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ============================================================================
|
|
387
|
+
// Export Constants
|
|
388
|
+
// ============================================================================
|
|
389
|
+
|
|
390
|
+
export const ANSI = {
|
|
391
|
+
ESC,
|
|
392
|
+
CSI,
|
|
393
|
+
CURSOR_HIDE,
|
|
394
|
+
CURSOR_SHOW,
|
|
395
|
+
CURSOR_HOME,
|
|
396
|
+
SYNC_BEGIN,
|
|
397
|
+
SYNC_END,
|
|
398
|
+
RESET,
|
|
399
|
+
SGR,
|
|
400
|
+
moveCursor,
|
|
401
|
+
cursorUp,
|
|
402
|
+
cursorDown,
|
|
403
|
+
cursorLeft,
|
|
404
|
+
cursorRight,
|
|
405
|
+
cursorToColumn,
|
|
406
|
+
} as const
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pane Manager - Pure functions for manipulating split layout trees.
|
|
3
|
+
*
|
|
4
|
+
* All functions are pure: they return new layout trees, never mutate.
|
|
5
|
+
* The layout tree is a binary tree where leaves are panes and internal
|
|
6
|
+
* nodes are splits (horizontal or vertical) with a ratio.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Types
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
export type LayoutNode =
|
|
14
|
+
| { type: "leaf"; id: string }
|
|
15
|
+
| {
|
|
16
|
+
type: "split"
|
|
17
|
+
direction: "horizontal" | "vertical"
|
|
18
|
+
ratio: number
|
|
19
|
+
first: LayoutNode
|
|
20
|
+
second: LayoutNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Construction
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/** Create a single-pane layout */
|
|
28
|
+
export function createLeaf(id: string): LayoutNode {
|
|
29
|
+
return { type: "leaf", id }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Tree Queries
|
|
34
|
+
// ============================================================================
|
|
35
|
+
|
|
36
|
+
/** Get all leaf pane IDs in depth-first left-to-right order */
|
|
37
|
+
export function getPaneIds(layout: LayoutNode): string[] {
|
|
38
|
+
if (layout.type === "leaf") return [layout.id]
|
|
39
|
+
return [...getPaneIds(layout.first), ...getPaneIds(layout.second)]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Find the next/previous pane in tab order (depth-first left-to-right) */
|
|
43
|
+
export function getTabOrder(layout: LayoutNode): string[] {
|
|
44
|
+
return getPaneIds(layout)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Tree Mutations (Pure)
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split a pane into two. Returns new layout tree with the target pane split.
|
|
53
|
+
* The original pane becomes the first child; the new pane becomes the second.
|
|
54
|
+
*/
|
|
55
|
+
export function splitPane(
|
|
56
|
+
layout: LayoutNode,
|
|
57
|
+
targetPaneId: string,
|
|
58
|
+
direction: "horizontal" | "vertical",
|
|
59
|
+
newPaneId: string,
|
|
60
|
+
ratio = 0.5,
|
|
61
|
+
): LayoutNode {
|
|
62
|
+
const clampedRatio = clampRatio(ratio)
|
|
63
|
+
|
|
64
|
+
if (layout.type === "leaf") {
|
|
65
|
+
if (layout.id === targetPaneId) {
|
|
66
|
+
return {
|
|
67
|
+
type: "split",
|
|
68
|
+
direction,
|
|
69
|
+
ratio: clampedRatio,
|
|
70
|
+
first: { type: "leaf", id: targetPaneId },
|
|
71
|
+
second: { type: "leaf", id: newPaneId },
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return layout
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Recurse into children
|
|
78
|
+
const newFirst = splitPane(layout.first, targetPaneId, direction, newPaneId, ratio)
|
|
79
|
+
const newSecond = splitPane(layout.second, targetPaneId, direction, newPaneId, ratio)
|
|
80
|
+
|
|
81
|
+
if (newFirst === layout.first && newSecond === layout.second) return layout
|
|
82
|
+
|
|
83
|
+
return { ...layout, first: newFirst, second: newSecond }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove a pane from the layout. The sibling takes the full space.
|
|
88
|
+
* Returns null if the removed pane was the last one.
|
|
89
|
+
*/
|
|
90
|
+
export function removePane(layout: LayoutNode, paneId: string): LayoutNode | null {
|
|
91
|
+
if (layout.type === "leaf") {
|
|
92
|
+
return layout.id === paneId ? null : layout
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check if either direct child is the target leaf
|
|
96
|
+
if (layout.first.type === "leaf" && layout.first.id === paneId) {
|
|
97
|
+
return layout.second
|
|
98
|
+
}
|
|
99
|
+
if (layout.second.type === "leaf" && layout.second.id === paneId) {
|
|
100
|
+
return layout.first
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Recurse
|
|
104
|
+
const newFirst = removePane(layout.first, paneId)
|
|
105
|
+
const newSecond = removePane(layout.second, paneId)
|
|
106
|
+
|
|
107
|
+
// If a subtree collapsed, promote the survivor
|
|
108
|
+
if (newFirst === null) return newSecond
|
|
109
|
+
if (newSecond === null) return newFirst
|
|
110
|
+
|
|
111
|
+
if (newFirst === layout.first && newSecond === layout.second) return layout
|
|
112
|
+
|
|
113
|
+
return { ...layout, first: newFirst, second: newSecond }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Swap two panes' positions in the layout */
|
|
117
|
+
export function swapPanes(layout: LayoutNode, paneId1: string, paneId2: string): LayoutNode {
|
|
118
|
+
if (layout.type === "leaf") {
|
|
119
|
+
if (layout.id === paneId1) return { type: "leaf", id: paneId2 }
|
|
120
|
+
if (layout.id === paneId2) return { type: "leaf", id: paneId1 }
|
|
121
|
+
return layout
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const newFirst = swapPanes(layout.first, paneId1, paneId2)
|
|
125
|
+
const newSecond = swapPanes(layout.second, paneId1, paneId2)
|
|
126
|
+
|
|
127
|
+
if (newFirst === layout.first && newSecond === layout.second) return layout
|
|
128
|
+
|
|
129
|
+
return { ...layout, first: newFirst, second: newSecond }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resize a split: adjust the ratio of the nearest ancestor split
|
|
134
|
+
* that contains the target pane as its first child.
|
|
135
|
+
* Positive delta = grow (first child gets more), negative = shrink.
|
|
136
|
+
*/
|
|
137
|
+
export function resizeSplit(layout: LayoutNode, paneId: string, delta: number): LayoutNode {
|
|
138
|
+
if (layout.type === "leaf") return layout
|
|
139
|
+
|
|
140
|
+
const firstIds = getPaneIds(layout.first)
|
|
141
|
+
|
|
142
|
+
if (firstIds.includes(paneId)) {
|
|
143
|
+
// Pane is in the first child — adjust this split's ratio
|
|
144
|
+
const newRatio = clampRatio(layout.ratio + delta)
|
|
145
|
+
if (newRatio === layout.ratio) return layout
|
|
146
|
+
|
|
147
|
+
return { ...layout, ratio: newRatio }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const secondIds = getPaneIds(layout.second)
|
|
151
|
+
|
|
152
|
+
if (secondIds.includes(paneId)) {
|
|
153
|
+
// Pane is in the second child — shrink ratio (give less to first)
|
|
154
|
+
const newRatio = clampRatio(layout.ratio - delta)
|
|
155
|
+
if (newRatio === layout.ratio) return layout
|
|
156
|
+
|
|
157
|
+
return { ...layout, ratio: newRatio }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return layout
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// Navigation
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Find the pane adjacent to the given pane in a direction.
|
|
169
|
+
*
|
|
170
|
+
* For left/right: looks for siblings in horizontal splits.
|
|
171
|
+
* For up/down: looks for siblings in vertical splits.
|
|
172
|
+
*
|
|
173
|
+
* Returns null if no adjacent pane exists in that direction.
|
|
174
|
+
*/
|
|
175
|
+
export function findAdjacentPane(
|
|
176
|
+
layout: LayoutNode,
|
|
177
|
+
paneId: string,
|
|
178
|
+
direction: "left" | "right" | "up" | "down",
|
|
179
|
+
): string | null {
|
|
180
|
+
const path = findPath(layout, paneId)
|
|
181
|
+
if (!path) return null
|
|
182
|
+
|
|
183
|
+
const splitDirection = direction === "left" || direction === "right" ? "horizontal" : "vertical"
|
|
184
|
+
const goToSecond = direction === "right" || direction === "down"
|
|
185
|
+
|
|
186
|
+
// Walk up the path looking for a relevant split
|
|
187
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
188
|
+
const step = path[i]!
|
|
189
|
+
if (step.node.type !== "split") continue
|
|
190
|
+
if (step.node.direction !== splitDirection) continue
|
|
191
|
+
|
|
192
|
+
// We came from 'first' and want to go to second (right/down)
|
|
193
|
+
if (step.side === "first" && goToSecond) {
|
|
194
|
+
return firstLeaf(step.node.second)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// We came from 'second' and want to go to first (left/up)
|
|
198
|
+
if (step.side === "second" && !goToSecond) {
|
|
199
|
+
return lastLeaf(step.node.first)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// Internal Helpers
|
|
208
|
+
// ============================================================================
|
|
209
|
+
|
|
210
|
+
function clampRatio(ratio: number): number {
|
|
211
|
+
return Math.max(0.1, Math.min(0.9, ratio))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Get the first (leftmost/topmost) leaf ID */
|
|
215
|
+
function firstLeaf(node: LayoutNode): string {
|
|
216
|
+
if (node.type === "leaf") return node.id
|
|
217
|
+
return firstLeaf(node.first)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Get the last (rightmost/bottommost) leaf ID */
|
|
221
|
+
function lastLeaf(node: LayoutNode): string {
|
|
222
|
+
if (node.type === "leaf") return node.id
|
|
223
|
+
return lastLeaf(node.second)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface PathStep {
|
|
227
|
+
node: LayoutNode
|
|
228
|
+
side: "first" | "second"
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Find the path from root to a leaf, recording which side we took at each split */
|
|
232
|
+
function findPath(layout: LayoutNode, paneId: string): PathStep[] | null {
|
|
233
|
+
if (layout.type === "leaf") {
|
|
234
|
+
return layout.id === paneId ? [] : null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const firstPath = findPath(layout.first, paneId)
|
|
238
|
+
if (firstPath !== null) {
|
|
239
|
+
return [{ node: layout, side: "first" }, ...firstPath]
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const secondPath = findPath(layout.second, paneId)
|
|
243
|
+
if (secondPath !== null) {
|
|
244
|
+
return [{ node: layout, side: "second" }, ...secondPath]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return null
|
|
248
|
+
}
|