@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 +1 -1
- package/src/api/history.ts +451 -0
- package/src/api/mount.ts +31 -11
- package/src/renderer/append-region.ts +44 -188
- package/src/types/index.ts +21 -6
package/package.json
CHANGED
|
@@ -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 (
|
|
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:
|
|
175
|
-
//
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
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 -
|
|
2
|
+
* TUI Framework - Append Mode Renderer
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Simple renderer for append mode:
|
|
5
|
+
* - Clears active region (eraseDown from cursor)
|
|
6
|
+
* - Renders active content
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
*
|
|
82
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
98
|
-
private
|
|
36
|
+
// Track previous height to know how many lines to erase
|
|
37
|
+
private previousHeight = 0
|
|
99
38
|
|
|
100
39
|
/**
|
|
101
|
-
* Render frame buffer
|
|
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
|
|
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
|
-
//
|
|
121
|
-
if (
|
|
122
|
-
|
|
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
|
-
//
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
60
|
+
* Erase the current active area.
|
|
61
|
+
* Call this BEFORE writing to history so we clear the screen first.
|
|
153
62
|
*/
|
|
154
|
-
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
*
|
|
71
|
+
* Call this after writing to history.
|
|
72
|
+
* Resets height tracking so next render doesn't try to erase history.
|
|
175
73
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
79
|
+
* Build output string for the buffer.
|
|
201
80
|
*/
|
|
202
|
-
private
|
|
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:
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
154
|
+
* Cleanup (no-op for simplified renderer).
|
|
299
155
|
*/
|
|
300
156
|
cleanup(): void {
|
|
301
|
-
|
|
157
|
+
// Nothing to cleanup - we don't own the FileSink
|
|
302
158
|
}
|
|
303
159
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -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,
|
|
276
|
-
* - 'append':
|
|
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
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
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
|
-
|
|
305
|
+
renderToHistory: (componentFn: () => void) => void
|
|
291
306
|
}
|
|
292
307
|
|
|
293
308
|
// =============================================================================
|