@rlabs-inc/tui 0.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rlabs-inc/tui",
3
- "version": "0.2.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",
@@ -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
+ }
package/src/api/mount.ts CHANGED
@@ -23,12 +23,13 @@
23
23
  */
24
24
 
25
25
  import { effect } from '@rlabs-inc/signals'
26
- import type { MountOptions, ResizeEvent } from '../types'
26
+ import type { MountOptions, ResizeEvent, AppendMountResult } from '../types'
27
27
  import {
28
28
  DiffRenderer,
29
29
  InlineRenderer,
30
30
  } from '../renderer/output'
31
31
  import { AppendRegionRenderer } from '../renderer/append-region'
32
+ import { HistoryWriter, createRenderToHistory } from './history'
32
33
  import * as ansi from '../renderer/ansi'
33
34
  import { frameBufferDerived } from '../pipeline/frameBuffer'
34
35
  import { layoutDerived, terminalWidth, terminalHeight, updateTerminalSize, renderMode } from '../pipeline/layout'
@@ -50,12 +51,11 @@ import { globalKeys } from '../state/global-keys'
50
51
  export async function mount(
51
52
  root: () => void,
52
53
  options: MountOptions = {}
53
- ): Promise<() => Promise<void>> {
54
+ ): Promise<(() => Promise<void>) | AppendMountResult> {
54
55
  const {
55
56
  mode = 'fullscreen',
56
57
  mouse = true,
57
58
  kittyKeyboard = true,
58
- getStaticHeight,
59
59
  } = options
60
60
 
61
61
  // Set render mode signal BEFORE creating components
@@ -65,13 +65,21 @@ export async function mount(
65
65
  // Create renderer based on mode
66
66
  // Fullscreen uses DiffRenderer (absolute positioning)
67
67
  // Inline uses InlineRenderer (eraseLines + sequential write)
68
- // Append uses AppendRegionRenderer (two-region: static + reactive)
68
+ // Append uses AppendRegionRenderer (eraseDown + render active)
69
69
  const diffRenderer = new DiffRenderer()
70
70
  const inlineRenderer = new InlineRenderer()
71
71
  const appendRegionRenderer = new AppendRegionRenderer()
72
72
 
73
+ // For append mode: create history writer and renderToHistory function
74
+ let historyWriter: HistoryWriter | null = null
75
+ let renderToHistory: ((componentFn: () => void) => void) | null = null
76
+
77
+ if (mode === 'append') {
78
+ historyWriter = new HistoryWriter()
79
+ renderToHistory = createRenderToHistory(historyWriter, appendRegionRenderer)
80
+ }
81
+
73
82
  // Mode-specific state
74
- let previousHeight = 0 // For append mode: track last rendered height
75
83
  let isFirstRender = true
76
84
 
77
85
  // Resize handlers (keyboard module handles key/mouse)
@@ -171,10 +179,9 @@ export async function mount(
171
179
  } else if (mode === 'inline') {
172
180
  inlineRenderer.render(buffer)
173
181
  } else if (mode === 'append') {
174
- // Append mode: use two-region renderer
175
- // staticHeight determined by getStaticHeight callback or defaults to 0
176
- const staticHeight = getStaticHeight ? getStaticHeight() : 0
177
- appendRegionRenderer.render(buffer, { staticHeight })
182
+ // Append mode: render active content only
183
+ // History is written via renderToHistory() by the app
184
+ appendRegionRenderer.render(buffer)
178
185
  } else {
179
186
  // Fallback to inline for unknown modes
180
187
  inlineRenderer.render(buffer)
@@ -195,7 +202,7 @@ export async function mount(
195
202
  })
196
203
 
197
204
  // Cleanup function
198
- return async () => {
205
+ const cleanup = async () => {
199
206
  // Stop the render effect
200
207
  if (stopEffect) {
201
208
  stopEffect()
@@ -205,9 +212,12 @@ export async function mount(
205
212
  // Cleanup global input system
206
213
  globalKeys.cleanup()
207
214
 
208
- // Cleanup append region renderer if used
215
+ // Cleanup append mode resources
209
216
  if (mode === 'append') {
210
217
  appendRegionRenderer.cleanup()
218
+ if (historyWriter) {
219
+ historyWriter.end()
220
+ }
211
221
  }
212
222
 
213
223
  // Remove resize listener
@@ -241,5 +251,15 @@ export async function mount(
241
251
  // Reset registry for clean slate
242
252
  resetRegistry()
243
253
  }
254
+
255
+ // Return based on mode
256
+ if (mode === 'append' && renderToHistory) {
257
+ return {
258
+ cleanup,
259
+ renderToHistory,
260
+ }
261
+ }
262
+
263
+ return cleanup
244
264
  }
245
265
 
@@ -1,205 +1,84 @@
1
1
  /**
2
- * TUI Framework - Two-Region Append Renderer
2
+ * TUI Framework - Append Mode Renderer
3
3
  *
4
- * Implements a hybrid static/reactive rendering mode optimized for chat-like UIs:
4
+ * Simple renderer for append mode:
5
+ * - Clears active region (eraseDown from cursor)
6
+ * - Renders active content
5
7
  *
6
- * STATIC REGION (Terminal History):
7
- * - Completed messages written once via Bun.file(1).writer()
8
- * - No re-rendering, pure append-only
9
- * - Native terminal scroll, copy/paste, search
10
- * - O(1) cost after initial write
8
+ * History content is handled separately via renderToHistory().
9
+ * The app controls what goes to history vs active area.
11
10
  *
12
- * REACTIVE REGION (Active TUI):
13
- * - Last N messages + input area
14
- * - Full reactive rendering pipeline
15
- * - Interactive (focus, scroll, mouse)
16
- * - Fixed-size = O(1) render time
17
- *
18
- * This architecture enables:
19
- * - Infinite conversation length with constant performance
20
- * - Rich interactive UI for active content
21
- * - Graceful degradation to static output
22
- * - CLI-like feel with TUI interactivity
11
+ * This renderer is essentially inline mode with cursor at the
12
+ * boundary between frozen history and active content.
23
13
  */
24
14
 
25
- import type { Cell, RGBA, CellAttrs, FrameBuffer } from '../types'
15
+ import type { FrameBuffer, RGBA, CellAttrs } from '../types'
26
16
  import { Attr } from '../types'
27
17
  import { rgbaEqual } from '../types/color'
28
18
  import * as ansi from './ansi'
29
19
 
30
20
  // =============================================================================
31
- // FileSink Writer for Static Region
32
- // =============================================================================
33
-
34
- /**
35
- * Writer for appending to terminal stdout using Bun's FileSink API.
36
- * Buffers writes and flushes efficiently.
37
- */
38
- class StdoutWriter {
39
- private writer: any // FileSink type
40
- private hasContent = false
41
-
42
- constructor() {
43
- // Create writer for stdout (file descriptor 1)
44
- const stdoutFile = Bun.file(1)
45
- this.writer = stdoutFile.writer({ highWaterMark: 1024 * 1024 }) // 1MB buffer
46
- }
47
-
48
- write(content: string): void {
49
- if (content.length === 0) return
50
- this.writer.write(content)
51
- this.hasContent = true
52
- }
53
-
54
- flush(): void {
55
- if (this.hasContent) {
56
- this.writer.flush()
57
- this.hasContent = false
58
- }
59
- }
60
-
61
- end(): void {
62
- this.writer.end()
63
- }
64
- }
65
-
66
- // =============================================================================
67
- // Message Tracking
68
- // =============================================================================
69
-
70
- interface MessageMetadata {
71
- id: string
72
- lineCount: number
73
- content: string
74
- }
75
-
76
- // =============================================================================
77
- // Two-Region Append Renderer
21
+ // APPEND MODE RENDERER
78
22
  // =============================================================================
79
23
 
80
24
  /**
81
- * Renders frame buffer in two distinct regions:
82
- * 1. Static region: Frozen completed content (terminal history)
83
- * 2. Reactive region: Live updating content (last N lines)
25
+ * Simple append mode renderer.
26
+ * Erases previous active content and renders fresh.
84
27
  *
85
- * The boundary between regions shifts as content completes.
28
+ * Like InlineRenderer but only erases the active area (preserves history).
86
29
  */
87
30
  export class AppendRegionRenderer {
88
- private staticWriter = new StdoutWriter()
89
- private frozenMessages = new Set<string>()
90
- private totalStaticLines = 0
91
-
92
31
  // Cell rendering state (for ANSI optimization)
93
32
  private lastFg: RGBA | null = null
94
33
  private lastBg: RGBA | null = null
95
34
  private lastAttrs: CellAttrs = Attr.NONE
96
35
 
97
- // Previous reactive output for change detection
98
- private previousReactiveOutput = ''
36
+ // Track previous height to know how many lines to erase
37
+ private previousHeight = 0
99
38
 
100
39
  /**
101
- * Render frame buffer with two-region strategy.
102
- *
103
- * @param buffer - Full frame buffer
104
- * @param options.staticHeight - Number of lines to freeze into static region
105
- * @param options.messageIds - Optional array of message IDs for tracking
40
+ * Render frame buffer as active content.
41
+ * Erases exactly the previous content, then writes fresh.
106
42
  */
107
- render(
108
- buffer: FrameBuffer,
109
- options: {
110
- staticHeight: number
111
- messageIds?: string[]
112
- } = { staticHeight: 0 }
113
- ): void {
114
- const { staticHeight, messageIds = [] } = options
115
-
116
- // Split buffer into two regions
117
- const staticBuffer = this.extractRegion(buffer, 0, staticHeight)
118
- const reactiveBuffer = this.extractRegion(buffer, staticHeight, buffer.height - staticHeight)
43
+ render(buffer: FrameBuffer): void {
44
+ const output = this.buildOutput(buffer)
119
45
 
120
- // STATIC REGION: Freeze new content to terminal history
121
- if (staticHeight > this.totalStaticLines) {
122
- const newStaticLines = staticHeight - this.totalStaticLines
123
- const newStaticBuffer = this.extractRegion(
124
- buffer,
125
- this.totalStaticLines,
126
- newStaticLines
127
- )
128
-
129
- const staticOutput = this.buildStaticOutput(newStaticBuffer)
130
- this.staticWriter.write(staticOutput)
131
- this.staticWriter.flush()
132
-
133
- this.totalStaticLines = staticHeight
46
+ // Erase previous active content (move up and clear each line)
47
+ if (this.previousHeight > 0) {
48
+ process.stdout.write(ansi.eraseLines(this.previousHeight))
134
49
  }
135
50
 
136
- // REACTIVE REGION: Clear and re-render active content
137
- const reactiveOutput = this.buildReactiveOutput(reactiveBuffer)
138
-
139
- // Only update if changed
140
- if (reactiveOutput !== this.previousReactiveOutput) {
141
- // Clear from current position down
142
- process.stdout.write(ansi.eraseDown)
143
-
144
- // Render reactive content
145
- process.stdout.write(reactiveOutput)
51
+ // Render active content
52
+ process.stdout.write(output)
146
53
 
147
- this.previousReactiveOutput = reactiveOutput
148
- }
54
+ // Track height for next render
55
+ // +1 because buildOutput adds trailing newline which moves cursor down one line
56
+ this.previousHeight = buffer.height + 1
149
57
  }
150
58
 
151
59
  /**
152
- * Extract a region from the frame buffer.
60
+ * Erase the current active area.
61
+ * Call this BEFORE writing to history so we clear the screen first.
153
62
  */
154
- private extractRegion(buffer: FrameBuffer, startY: number, height: number): FrameBuffer {
155
- if (height <= 0) {
156
- return {
157
- width: buffer.width,
158
- height: 0,
159
- cells: []
160
- }
161
- }
162
-
163
- const endY = Math.min(startY + height, buffer.height)
164
- const actualHeight = endY - startY
165
-
166
- return {
167
- width: buffer.width,
168
- height: actualHeight,
169
- cells: buffer.cells.slice(startY, endY)
63
+ eraseActive(): void {
64
+ if (this.previousHeight > 0) {
65
+ process.stdout.write(ansi.eraseLines(this.previousHeight))
66
+ this.previousHeight = 0
170
67
  }
171
68
  }
172
69
 
173
70
  /**
174
- * Build output for static region (append once, forget).
71
+ * Call this after writing to history.
72
+ * Resets height tracking so next render doesn't try to erase history.
175
73
  */
176
- private buildStaticOutput(buffer: FrameBuffer): string {
177
- if (buffer.height === 0) return ''
178
-
179
- const chunks: string[] = []
180
-
181
- // Reset rendering state
182
- this.lastFg = null
183
- this.lastBg = null
184
- this.lastAttrs = Attr.NONE
185
-
186
- for (let y = 0; y < buffer.height; y++) {
187
- for (let x = 0; x < buffer.width; x++) {
188
- const cell = buffer.cells[y]![x]
189
- this.renderCell(chunks, cell!)
190
- }
191
- chunks.push('\n')
192
- }
193
-
194
- chunks.push(ansi.reset)
195
-
196
- return chunks.join('')
74
+ invalidate(): void {
75
+ this.previousHeight = 0
197
76
  }
198
77
 
199
78
  /**
200
- * Build output for reactive region (cleared and re-rendered each frame).
79
+ * Build output string for the buffer.
201
80
  */
202
- private buildReactiveOutput(buffer: FrameBuffer): string {
81
+ private buildOutput(buffer: FrameBuffer): string {
203
82
  if (buffer.height === 0) return ''
204
83
 
205
84
  const chunks: string[] = []
@@ -229,7 +108,7 @@ export class AppendRegionRenderer {
229
108
  /**
230
109
  * Render a single cell with ANSI optimization.
231
110
  */
232
- private renderCell(chunks: string[], cell: Cell): void {
111
+ private renderCell(chunks: string[], cell: { char: number; fg: RGBA; bg: RGBA; attrs: CellAttrs }): void {
233
112
  // Attributes changed - reset first
234
113
  if (cell.attrs !== this.lastAttrs) {
235
114
  chunks.push(ansi.reset)
@@ -262,42 +141,19 @@ export class AppendRegionRenderer {
262
141
  }
263
142
 
264
143
  /**
265
- * Mark a message as frozen (moved to static region).
266
- */
267
- freezeMessage(messageId: string): void {
268
- this.frozenMessages.add(messageId)
269
- }
270
-
271
- /**
272
- * Check if a message has been frozen.
273
- */
274
- isFrozen(messageId: string): boolean {
275
- return this.frozenMessages.has(messageId)
276
- }
277
-
278
- /**
279
- * Get total number of static lines rendered.
280
- */
281
- getStaticLineCount(): number {
282
- return this.totalStaticLines
283
- }
284
-
285
- /**
286
- * Reset the renderer state.
144
+ * Reset renderer state.
287
145
  */
288
146
  reset(): void {
289
- this.frozenMessages.clear()
290
- this.totalStaticLines = 0
291
- this.previousReactiveOutput = ''
147
+ this.previousHeight = 0
292
148
  this.lastFg = null
293
149
  this.lastBg = null
294
150
  this.lastAttrs = Attr.NONE
295
151
  }
296
152
 
297
153
  /**
298
- * Cleanup when unmounting.
154
+ * Cleanup (no-op for simplified renderer).
299
155
  */
300
156
  cleanup(): void {
301
- this.staticWriter.end()
157
+ // Nothing to cleanup - we don't own the FileSink
302
158
  }
303
159
  }
@@ -272,8 +272,8 @@ export interface MountOptions {
272
272
  /**
273
273
  * Render mode:
274
274
  * - 'fullscreen': Alternate screen buffer, full terminal control
275
- * - 'inline': Renders inline, save/restore cursor, updates in place
276
- * - 'append': Content flows down, still reactive (can update previous content)
275
+ * - 'inline': Renders inline, updates in place
276
+ * - 'append': Active content at bottom, history via renderToHistory()
277
277
  */
278
278
  mode?: RenderMode
279
279
  /** Enable mouse tracking (default: true) */
@@ -282,12 +282,27 @@ export interface MountOptions {
282
282
  kittyKeyboard?: boolean
283
283
  /** Initial cursor configuration */
284
284
  cursor?: Partial<Cursor>
285
+ }
286
+
287
+ /**
288
+ * Result of mount() for append mode.
289
+ * Includes renderToHistory for writing content to terminal history.
290
+ */
291
+ export interface AppendMountResult {
292
+ /** Cleanup function to unmount */
293
+ cleanup: () => Promise<void>
285
294
  /**
286
- * For append mode: Function to determine static region height.
287
- * Called on each render to decide where to split static/reactive regions.
288
- * Return number of lines to freeze into terminal history.
295
+ * Render components to terminal history (frozen scrollback).
296
+ * Use the same component API - components are rendered once and forgotten.
297
+ *
298
+ * @example
299
+ * ```ts
300
+ * renderToHistory(() => {
301
+ * Message({ content: 'Frozen message' })
302
+ * })
303
+ * ```
289
304
  */
290
- getStaticHeight?: () => number
305
+ renderToHistory: (componentFn: () => void) => void
291
306
  }
292
307
 
293
308
  // =============================================================================