@rlabs-inc/tui 0.1.0 → 0.2.1

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 (39) hide show
  1. package/README.md +126 -13
  2. package/index.ts +11 -5
  3. package/package.json +2 -2
  4. package/src/api/history.ts +451 -0
  5. package/src/api/mount.ts +66 -31
  6. package/src/engine/arrays/core.ts +13 -21
  7. package/src/engine/arrays/dimensions.ts +22 -32
  8. package/src/engine/arrays/index.ts +88 -86
  9. package/src/engine/arrays/interaction.ts +34 -48
  10. package/src/engine/arrays/layout.ts +67 -92
  11. package/src/engine/arrays/spacing.ts +37 -52
  12. package/src/engine/arrays/text.ts +23 -31
  13. package/src/engine/arrays/visual.ts +56 -75
  14. package/src/engine/inheritance.ts +18 -18
  15. package/src/engine/registry.ts +15 -0
  16. package/src/pipeline/frameBuffer.ts +26 -26
  17. package/src/pipeline/layout/index.ts +2 -2
  18. package/src/pipeline/layout/titan-engine.ts +112 -84
  19. package/src/primitives/animation.ts +194 -0
  20. package/src/primitives/box.ts +74 -86
  21. package/src/primitives/each.ts +87 -0
  22. package/src/primitives/index.ts +7 -0
  23. package/src/primitives/scope.ts +215 -0
  24. package/src/primitives/show.ts +77 -0
  25. package/src/primitives/text.ts +63 -59
  26. package/src/primitives/types.ts +1 -1
  27. package/src/primitives/when.ts +102 -0
  28. package/src/renderer/append-region.ts +159 -0
  29. package/src/renderer/index.ts +4 -2
  30. package/src/renderer/output.ts +11 -34
  31. package/src/state/focus.ts +16 -5
  32. package/src/state/global-keys.ts +184 -0
  33. package/src/state/index.ts +44 -8
  34. package/src/state/input.ts +534 -0
  35. package/src/state/keyboard.ts +98 -674
  36. package/src/state/mouse.ts +163 -340
  37. package/src/state/scroll.ts +7 -9
  38. package/src/types/index.ts +23 -2
  39. package/src/renderer/input.ts +0 -518
package/README.md CHANGED
@@ -57,17 +57,6 @@ setInterval(() => count.value++, 1000)
57
57
  - Bindings for prop connections
58
58
  - Zero reconciliation - reactivity IS the update mechanism
59
59
 
60
- ### Svelte-like .tui Files (Compiler)
61
- ```html
62
- <script>
63
- const name = signal('World')
64
- </script>
65
-
66
- <box width={40} height={3}>
67
- <text content={`Hello, ${name.value}!`} />
68
- </box>
69
- ```
70
-
71
60
  ### State Modules
72
61
  - **keyboard** - Key events, shortcuts, input buffering
73
62
  - **mouse** - Click, hover, drag, wheel events
@@ -110,6 +99,8 @@ bun run examples/tests/03-layout-flex.ts
110
99
 
111
100
  ## Primitives
112
101
 
102
+ ### UI Primitives
103
+
113
104
  | Primitive | Status | Description |
114
105
  |-----------|--------|-------------|
115
106
  | `box` | Complete | Container with flexbox layout |
@@ -118,13 +109,135 @@ bun run examples/tests/03-layout-flex.ts
118
109
  | `select` | Planned | Dropdown selection |
119
110
  | `progress` | Planned | Progress bar |
120
111
 
112
+ ### Template Primitives
113
+
114
+ Reactive control flow for dynamic UIs - no manual effects needed!
115
+
116
+ | Primitive | Purpose | Description |
117
+ |-----------|---------|-------------|
118
+ | `each()` | Lists | Reactive list rendering with keyed reconciliation |
119
+ | `show()` | Conditionals | Show/hide components based on condition |
120
+ | `when()` | Async | Suspense-like pending/success/error states |
121
+
122
+ #### `each()` - Reactive Lists
123
+
124
+ Renders a list of components that automatically updates when the array changes.
125
+
126
+ ```typescript
127
+ import { each, box, text, signal } from '@rlabs-inc/tui'
128
+
129
+ const todos = signal([
130
+ { id: '1', text: 'Learn TUI', done: false },
131
+ { id: '2', text: 'Build app', done: false },
132
+ ])
133
+
134
+ box({
135
+ children: () => {
136
+ each(
137
+ () => todos.value, // Reactive array getter
138
+ (todo) => box({ // Render function per item
139
+ id: `todo-${todo.id}`, // Stable ID for reconciliation
140
+ children: () => {
141
+ text({ content: () => todo.text }) // Props can be reactive too!
142
+ }
143
+ }),
144
+ { key: (todo) => todo.id } // Key function for efficient updates
145
+ )
146
+ }
147
+ })
148
+
149
+ // Add item - UI updates automatically
150
+ todos.value = [...todos.value, { id: '3', text: 'Deploy', done: false }]
151
+
152
+ // Remove item - component is cleaned up automatically
153
+ todos.value = todos.value.filter(t => t.id !== '1')
154
+ ```
155
+
156
+ #### `show()` - Conditional Rendering
157
+
158
+ Shows or hides components based on a reactive condition.
159
+
160
+ ```typescript
161
+ import { show, box, text, signal } from '@rlabs-inc/tui'
162
+
163
+ const isLoggedIn = signal(false)
164
+
165
+ box({
166
+ children: () => {
167
+ show(
168
+ () => isLoggedIn.value, // Condition getter
169
+ () => box({ // Render when true
170
+ children: () => {
171
+ text({ content: 'Welcome back!' })
172
+ }
173
+ }),
174
+ () => text({ content: 'Please log in' }) // Optional: render when false
175
+ )
176
+ }
177
+ })
178
+
179
+ // Toggle - UI switches automatically
180
+ isLoggedIn.value = true
181
+ ```
182
+
183
+ #### `when()` - Async/Suspense
184
+
185
+ Handles async operations with loading, success, and error states.
186
+
187
+ ```typescript
188
+ import { when, box, text, signal } from '@rlabs-inc/tui'
189
+
190
+ const userId = signal('123')
191
+
192
+ // Fetch function that returns a promise
193
+ const fetchUser = (id: string) =>
194
+ fetch(`/api/users/${id}`).then(r => r.json())
195
+
196
+ box({
197
+ children: () => {
198
+ when(
199
+ () => fetchUser(userId.value), // Promise getter (re-runs on userId change)
200
+ {
201
+ pending: () => text({ content: 'Loading...' }),
202
+ then: (user) => box({
203
+ children: () => {
204
+ text({ content: `Name: ${user.name}` })
205
+ text({ content: `Email: ${user.email}` })
206
+ }
207
+ }),
208
+ catch: (error) => text({
209
+ content: `Error: ${error.message}`,
210
+ fg: 'red'
211
+ })
212
+ }
213
+ )
214
+ }
215
+ })
216
+
217
+ // Change userId - triggers new fetch, shows loading, then result
218
+ userId.value = '456'
219
+ ```
220
+
221
+ ### How Template Primitives Work
222
+
223
+ All template primitives follow the same elegant pattern:
224
+
225
+ 1. **Capture parent context** at creation time
226
+ 2. **Initial render synchronously** (correct parent hierarchy)
227
+ 3. **Internal effect** tracks reactive dependencies
228
+ 4. **Reconcile on change** (create new, cleanup removed)
229
+
230
+ This means:
231
+ - User code stays clean - no manual effects
232
+ - Props inside templates are fully reactive
233
+ - Cleanup is automatic
234
+ - Performance is optimal (only affected components update)
235
+
121
236
  ## Test Coverage
122
237
 
123
- - **130 tests** passing
124
238
  - TITAN layout engine: 48 tests
125
239
  - Parallel arrays: 17 tests
126
240
  - Focus manager: 29 tests
127
- - Compiler: 36 tests (unit + integration)
128
241
 
129
242
  ## Requirements
130
243
 
package/index.ts CHANGED
@@ -9,21 +9,24 @@
9
9
  export { mount } from './src/api'
10
10
 
11
11
  // Primitives - UI building blocks
12
- export { box, text } from './src/primitives'
13
- export type { BoxProps, TextProps, Cleanup } from './src/primitives'
12
+ export { box, text, each, show, when, scoped, onCleanup, useAnimation, AnimationFrames } from './src/primitives'
13
+ export type { BoxProps, TextProps, Cleanup, AnimationOptions } from './src/primitives'
14
14
 
15
15
  // State modules - Input handling
16
- export { keyboard } from './src/state/keyboard'
16
+ export { keyboard, lastKey, lastEvent } from './src/state/keyboard'
17
+ export { mouse, hitGrid, lastMouseEvent, mouseX, mouseY, isMouseDown } from './src/state/mouse'
17
18
  export { focusManager, focusedIndex } from './src/state/focus'
18
19
  export { scroll } from './src/state/scroll'
19
- export { mouse, hitGrid } from './src/state/mouse'
20
+ export { globalKeys } from './src/state/global-keys'
21
+ export { cursor } from './src/state/cursor'
20
22
 
21
23
  // Types
22
24
  export * from './src/types'
23
25
  export * from './src/types/color'
24
26
 
25
27
  // Signals re-export for convenience
26
- export { signal, state, derived, effect, bind, signals } from '@rlabs-inc/signals'
28
+ export { signal, state, derived, effect, bind, signals, batch, reactiveProps } from '@rlabs-inc/signals'
29
+ export type { PropInput, PropsInput, ReactiveProps } from '@rlabs-inc/signals'
27
30
 
28
31
  // Theme
29
32
  export {
@@ -43,3 +46,6 @@ export * from './src/renderer'
43
46
 
44
47
  // Engine (advanced)
45
48
  export * from './src/engine'
49
+
50
+ // Layout (advanced - for debugging)
51
+ export { layoutDerived } from './src/pipeline/layout'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlabs-inc/tui",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "The Terminal UI Framework for TypeScript/Bun - Blazing-fast, fine-grained reactive terminal UI with complete flexbox layout",
5
5
  "module": "index.ts",
6
6
  "main": "index.ts",
@@ -54,6 +54,6 @@
54
54
  "typescript": "^5.0.0"
55
55
  },
56
56
  "dependencies": {
57
- "@rlabs-inc/signals": "^1.6.0"
57
+ "@rlabs-inc/signals": "^1.8.2"
58
58
  }
59
59
  }
@@ -0,0 +1,451 @@
1
+ /**
2
+ * TUI Framework - History Rendering for Append Mode
3
+ *
4
+ * Provides utilities for rendering content to terminal history (scrollback).
5
+ * Used in append mode where completed content is frozen to history while
6
+ * active content remains reactive.
7
+ *
8
+ * Key concepts:
9
+ * - History content is written once via FileSink (Bun's efficient stdout writer)
10
+ * - Uses the same component API as active rendering
11
+ * - Isolated rendering: creates temporary components, renders, cleans up
12
+ */
13
+
14
+ import { batch } from '@rlabs-inc/signals'
15
+ import type { FrameBuffer, RGBA } from '../types'
16
+ import { ComponentType } from '../types'
17
+ import { Colors, TERMINAL_DEFAULT } from '../types/color'
18
+ import {
19
+ createBuffer,
20
+ fillRect,
21
+ drawBorder,
22
+ drawText,
23
+ drawTextCentered,
24
+ drawTextRight,
25
+ createClipRect,
26
+ intersectClipRects,
27
+ type ClipRect,
28
+ type BorderConfig,
29
+ } from '../renderer/buffer'
30
+ import {
31
+ getAllocatedIndices,
32
+ getCapacity,
33
+ releaseIndex,
34
+ } from '../engine/registry'
35
+ import { wrapText, truncateText } from '../utils/text'
36
+ import {
37
+ getInheritedFg,
38
+ getInheritedBg,
39
+ getBorderColors,
40
+ getBorderStyles,
41
+ hasBorder,
42
+ getEffectiveOpacity,
43
+ } from '../engine/inheritance'
44
+ import { computeLayoutTitan } from '../pipeline/layout/titan-engine'
45
+ import { terminalWidth, terminalHeight } from '../pipeline/layout'
46
+ import * as ansi from '../renderer/ansi'
47
+
48
+ // Import arrays
49
+ import * as core from '../engine/arrays/core'
50
+ import * as visual from '../engine/arrays/visual'
51
+ import * as text from '../engine/arrays/text'
52
+ import * as spacing from '../engine/arrays/spacing'
53
+ import * as layout from '../engine/arrays/layout'
54
+ import * as interaction from '../engine/arrays/interaction'
55
+ import { Attr } from '../types'
56
+ import { rgbaEqual } from '../types/color'
57
+
58
+ // =============================================================================
59
+ // FILESINK WRITER
60
+ // =============================================================================
61
+
62
+ /**
63
+ * Writer for appending to terminal stdout using Bun's FileSink API.
64
+ * Buffers writes and flushes efficiently.
65
+ */
66
+ export class HistoryWriter {
67
+ private writer: ReturnType<ReturnType<typeof Bun.file>['writer']>
68
+ private hasContent = false
69
+
70
+ constructor() {
71
+ // Create writer for stdout (file descriptor 1)
72
+ const stdoutFile = Bun.file(1)
73
+ this.writer = stdoutFile.writer({ highWaterMark: 1024 * 1024 }) // 1MB buffer
74
+ }
75
+
76
+ write(content: string): void {
77
+ if (content.length === 0) return
78
+ this.writer.write(content)
79
+ this.hasContent = true
80
+ }
81
+
82
+ flush(): void {
83
+ if (this.hasContent) {
84
+ this.writer.flush()
85
+ this.hasContent = false
86
+ }
87
+ }
88
+
89
+ end(): void {
90
+ this.writer.end()
91
+ }
92
+ }
93
+
94
+ // =============================================================================
95
+ // BUFFER TO ANSI CONVERSION
96
+ // =============================================================================
97
+
98
+ /**
99
+ * Convert a FrameBuffer to ANSI escape sequence string.
100
+ */
101
+ function bufferToAnsi(buffer: FrameBuffer): string {
102
+ if (buffer.height === 0) return ''
103
+
104
+ const chunks: string[] = []
105
+
106
+ // Track last colors/attrs for optimization
107
+ let lastFg: RGBA | null = null
108
+ let lastBg: RGBA | null = null
109
+ let lastAttrs: number = Attr.NONE
110
+
111
+ for (let y = 0; y < buffer.height; y++) {
112
+ for (let x = 0; x < buffer.width; x++) {
113
+ const cell = buffer.cells[y]![x]!
114
+
115
+ // Attributes changed - reset first
116
+ if (cell.attrs !== lastAttrs) {
117
+ chunks.push(ansi.reset)
118
+ if (cell.attrs !== Attr.NONE) {
119
+ chunks.push(ansi.attrs(cell.attrs))
120
+ }
121
+ lastFg = null
122
+ lastBg = null
123
+ lastAttrs = cell.attrs
124
+ }
125
+
126
+ // Foreground color changed
127
+ if (!lastFg || !rgbaEqual(cell.fg, lastFg)) {
128
+ chunks.push(ansi.fg(cell.fg))
129
+ lastFg = cell.fg
130
+ }
131
+
132
+ // Background color changed
133
+ if (!lastBg || !rgbaEqual(cell.bg, lastBg)) {
134
+ chunks.push(ansi.bg(cell.bg))
135
+ lastBg = cell.bg
136
+ }
137
+
138
+ // Output character
139
+ if (cell.char === 0) {
140
+ chunks.push(' ')
141
+ } else {
142
+ chunks.push(String.fromCodePoint(cell.char))
143
+ }
144
+ }
145
+ chunks.push('\n')
146
+ }
147
+
148
+ chunks.push(ansi.reset)
149
+
150
+ return chunks.join('')
151
+ }
152
+
153
+ // =============================================================================
154
+ // ISOLATED FRAME BUFFER COMPUTATION
155
+ // =============================================================================
156
+
157
+ /**
158
+ * Compute a frame buffer for a specific set of component indices.
159
+ * Used for isolated history rendering.
160
+ */
161
+ function computeBufferForIndices(
162
+ indices: Set<number>,
163
+ layoutResult: ReturnType<typeof computeLayoutTitan>,
164
+ tw: number
165
+ ): FrameBuffer {
166
+ const bufferWidth = tw
167
+ const bufferHeight = Math.max(1, layoutResult.contentHeight)
168
+
169
+ // Create fresh buffer
170
+ const buffer = createBuffer(bufferWidth, bufferHeight, TERMINAL_DEFAULT)
171
+
172
+ if (indices.size === 0) {
173
+ return buffer
174
+ }
175
+
176
+ // Find root components and build child index map (only for our indices)
177
+ const rootIndices: number[] = []
178
+ const childMap = new Map<number, number[]>()
179
+
180
+ for (const i of indices) {
181
+ if (core.componentType[i] === ComponentType.NONE) continue
182
+ const vis = core.visible[i]
183
+ if (vis === 0 || vis === false) continue
184
+
185
+ const parent = core.parentIndex[i] ?? -1
186
+ if (parent === -1 || !indices.has(parent)) {
187
+ // Root if no parent or parent not in our set
188
+ rootIndices.push(i)
189
+ } else {
190
+ const children = childMap.get(parent)
191
+ if (children) {
192
+ children.push(i)
193
+ } else {
194
+ childMap.set(parent, [i])
195
+ }
196
+ }
197
+ }
198
+
199
+ // Sort by zIndex
200
+ rootIndices.sort((a, b) => (layout.zIndex[a] || 0) - (layout.zIndex[b] || 0))
201
+ for (const children of childMap.values()) {
202
+ children.sort((a, b) => (layout.zIndex[a] || 0) - (layout.zIndex[b] || 0))
203
+ }
204
+
205
+ // Render tree recursively
206
+ for (const rootIdx of rootIndices) {
207
+ renderComponentToBuffer(
208
+ buffer,
209
+ rootIdx,
210
+ layoutResult,
211
+ childMap,
212
+ undefined,
213
+ 0,
214
+ 0
215
+ )
216
+ }
217
+
218
+ return buffer
219
+ }
220
+
221
+ /**
222
+ * Render a component and its children to a buffer.
223
+ */
224
+ function renderComponentToBuffer(
225
+ buffer: FrameBuffer,
226
+ index: number,
227
+ computedLayout: { x: number[]; y: number[]; width: number[]; height: number[]; scrollable: number[] },
228
+ childMap: Map<number, number[]>,
229
+ parentClip: ClipRect | undefined,
230
+ parentScrollY: number,
231
+ parentScrollX: number
232
+ ): void {
233
+ const vis = core.visible[index]
234
+ if (vis === 0 || vis === false) return
235
+ if (core.componentType[index] === ComponentType.NONE) return
236
+
237
+ const x = Math.floor((computedLayout.x[index] || 0) - parentScrollX)
238
+ const y = Math.floor((computedLayout.y[index] || 0) - parentScrollY)
239
+ const w = Math.floor(computedLayout.width[index] || 0)
240
+ const h = Math.floor(computedLayout.height[index] || 0)
241
+
242
+ if (w <= 0 || h <= 0) return
243
+
244
+ const componentBounds = createClipRect(x, y, w, h)
245
+
246
+ if (parentClip) {
247
+ const intersection = intersectClipRects(componentBounds, parentClip)
248
+ if (!intersection) return
249
+ }
250
+
251
+ // Get colors
252
+ const fg = getInheritedFg(index)
253
+ const bg = getInheritedBg(index)
254
+ const opacity = getEffectiveOpacity(index)
255
+
256
+ const effectiveFg = opacity < 1 ? { ...fg, a: Math.round(fg.a * opacity) } : fg
257
+ const effectiveBg = opacity < 1 ? { ...bg, a: Math.round(bg.a * opacity) } : bg
258
+
259
+ // Fill background
260
+ if (effectiveBg.a > 0 && effectiveBg.r !== -1) {
261
+ fillRect(buffer, x, y, w, h, effectiveBg, parentClip)
262
+ }
263
+
264
+ // Borders
265
+ const borderStyles = getBorderStyles(index)
266
+ const borderColors = getBorderColors(index)
267
+ const hasAnyBorder = hasBorder(index)
268
+
269
+ if (hasAnyBorder && w >= 2 && h >= 2) {
270
+ const config: BorderConfig = {
271
+ styles: borderStyles,
272
+ colors: {
273
+ top: opacity < 1 ? { ...borderColors.top, a: Math.round(borderColors.top.a * opacity) } : borderColors.top,
274
+ right: opacity < 1 ? { ...borderColors.right, a: Math.round(borderColors.right.a * opacity) } : borderColors.right,
275
+ bottom: opacity < 1 ? { ...borderColors.bottom, a: Math.round(borderColors.bottom.a * opacity) } : borderColors.bottom,
276
+ left: opacity < 1 ? { ...borderColors.left, a: Math.round(borderColors.left.a * opacity) } : borderColors.left,
277
+ },
278
+ }
279
+ drawBorder(buffer, x, y, w, h, config, undefined, parentClip)
280
+ }
281
+
282
+ // Content area
283
+ const padTop = (spacing.paddingTop[index] || 0) + (hasAnyBorder && borderStyles.top > 0 ? 1 : 0)
284
+ const padRight = (spacing.paddingRight[index] || 0) + (hasAnyBorder && borderStyles.right > 0 ? 1 : 0)
285
+ const padBottom = (spacing.paddingBottom[index] || 0) + (hasAnyBorder && borderStyles.bottom > 0 ? 1 : 0)
286
+ const padLeft = (spacing.paddingLeft[index] || 0) + (hasAnyBorder && borderStyles.left > 0 ? 1 : 0)
287
+
288
+ const contentX = x + padLeft
289
+ const contentY = y + padTop
290
+ const contentW = w - padLeft - padRight
291
+ const contentH = h - padTop - padBottom
292
+
293
+ const contentBounds = createClipRect(contentX, contentY, contentW, contentH)
294
+ const contentClip = parentClip
295
+ ? intersectClipRects(contentBounds, parentClip)
296
+ : contentBounds
297
+
298
+ if (!contentClip || contentW <= 0 || contentH <= 0) {
299
+ return
300
+ }
301
+
302
+ // Render by type
303
+ switch (core.componentType[index]) {
304
+ case ComponentType.BOX:
305
+ break
306
+
307
+ case ComponentType.TEXT:
308
+ renderTextToBuffer(buffer, index, contentX, contentY, contentW, contentH, effectiveFg, contentClip)
309
+ break
310
+
311
+ // Other types can be added as needed
312
+ }
313
+
314
+ // Render children
315
+ if (core.componentType[index] === ComponentType.BOX) {
316
+ const children = childMap.get(index) || []
317
+ const isScrollable = (computedLayout.scrollable[index] ?? 0) === 1
318
+ const scrollY = isScrollable ? (interaction.scrollOffsetY[index] || 0) : 0
319
+ const scrollX = isScrollable ? (interaction.scrollOffsetX[index] || 0) : 0
320
+
321
+ for (const childIdx of children) {
322
+ renderComponentToBuffer(
323
+ buffer,
324
+ childIdx,
325
+ computedLayout,
326
+ childMap,
327
+ contentClip,
328
+ parentScrollY + scrollY,
329
+ parentScrollX + scrollX
330
+ )
331
+ }
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Render text component to buffer.
337
+ */
338
+ function renderTextToBuffer(
339
+ buffer: FrameBuffer,
340
+ index: number,
341
+ x: number,
342
+ y: number,
343
+ w: number,
344
+ h: number,
345
+ fg: RGBA,
346
+ clip: ClipRect
347
+ ): void {
348
+ const rawValue = text.textContent[index]
349
+ const content = rawValue == null ? '' : String(rawValue)
350
+ if (!content) return
351
+
352
+ const attrs = text.textAttrs[index] || 0
353
+ const align = text.textAlign[index] || 0
354
+
355
+ const lines = wrapText(content, w)
356
+
357
+ for (let lineIdx = 0; lineIdx < lines.length && lineIdx < h; lineIdx++) {
358
+ const line = lines[lineIdx] ?? ''
359
+ const lineY = y + lineIdx
360
+
361
+ if (lineY < clip.y || lineY >= clip.y + clip.height) continue
362
+
363
+ switch (align) {
364
+ case 0:
365
+ drawText(buffer, x, lineY, line, fg, undefined, attrs, clip)
366
+ break
367
+ case 1:
368
+ drawTextCentered(buffer, x, lineY, w, line, fg, undefined, attrs, clip)
369
+ break
370
+ case 2:
371
+ drawTextRight(buffer, x, lineY, w, line, fg, undefined, attrs, clip)
372
+ break
373
+ }
374
+ }
375
+ }
376
+
377
+ // =============================================================================
378
+ // RENDER TO HISTORY
379
+ // =============================================================================
380
+
381
+ /**
382
+ * Create a renderToHistory function bound to a HistoryWriter and AppendRegionRenderer.
383
+ *
384
+ * The renderer is needed to coordinate:
385
+ * 1. Erase the active area BEFORE writing history
386
+ * 2. History is written (becomes permanent scrollback)
387
+ * 3. Next render will start fresh below the history
388
+ *
389
+ * Usage:
390
+ * ```ts
391
+ * const renderToHistory = createRenderToHistory(historyWriter, appendRegionRenderer)
392
+ *
393
+ * // When freezing content:
394
+ * renderToHistory(() => {
395
+ * Message({ content: 'Hello!' })
396
+ * })
397
+ * ```
398
+ */
399
+ export function createRenderToHistory(
400
+ historyWriter: HistoryWriter,
401
+ appendRegionRenderer: { eraseActive: () => void }
402
+ ) {
403
+ return function renderToHistory(componentFn: () => void): void {
404
+ // CRITICAL: Wrap in batch() to prevent reactive updates during this operation.
405
+ // Without batch, the ReactiveSet triggers updates when we allocate/release indices,
406
+ // causing the render effect to run mid-operation and duplicate content.
407
+ batch(() => {
408
+ // Save current allocated indices BEFORE creating history components
409
+ const beforeIndices = new Set(getAllocatedIndices())
410
+
411
+ // Run component function - creates new components
412
+ componentFn()
413
+
414
+ // Find NEW indices (ones that didn't exist before)
415
+ const historyIndices = new Set<number>()
416
+ for (const idx of getAllocatedIndices()) {
417
+ if (!beforeIndices.has(idx)) {
418
+ historyIndices.add(idx)
419
+ }
420
+ }
421
+
422
+ if (historyIndices.size === 0) {
423
+ return
424
+ }
425
+
426
+ // Get terminal width for layout
427
+ const tw = terminalWidth.value
428
+ const th = terminalHeight.value
429
+
430
+ // Compute layout for just history components
431
+ const layoutResult = computeLayoutTitan(tw, th, historyIndices, false)
432
+
433
+ // Build frame buffer for history components
434
+ const buffer = computeBufferForIndices(historyIndices, layoutResult, tw)
435
+
436
+ // STEP 2: Convert to ANSI and write to history
437
+ // This becomes permanent terminal scrollback
438
+ const output = bufferToAnsi(buffer)
439
+ historyWriter.write(output)
440
+ historyWriter.flush()
441
+
442
+ // Cleanup: release all history components
443
+ // Batched, so render effect won't run until after release completes.
444
+ // The renderer's previousHeight is already 0 from eraseActive(), so next render
445
+ // will simply render the active area fresh below our history output
446
+ for (const idx of historyIndices) {
447
+ releaseIndex(idx)
448
+ }
449
+ })
450
+ }
451
+ }