@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.
Files changed (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. 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
+ }