@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
@@ -23,11 +23,12 @@
23
23
  * ```
24
24
  */
25
25
 
26
- import { bind, BINDING_SYMBOL } from '@rlabs-inc/signals'
26
+ // Using slotArray APIs - no direct signal imports needed
27
27
  import { ComponentType, Attr } from '../types'
28
28
  import { allocateIndex, releaseIndex, getCurrentParentIndex } from '../engine/registry'
29
29
  import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
30
30
  import { getVariantStyle } from '../state/theme'
31
+ import { getActiveScope } from './scope'
31
32
 
32
33
  // Import arrays
33
34
  import * as core from '../engine/arrays/core'
@@ -61,31 +62,33 @@ function wrapToNum(wrap: string | undefined): number {
61
62
  default: return 1 // wrap
62
63
  }
63
64
  }
65
+ /** Override alignItems for this item: 0=auto, 1=stretch, 2=flex-start, 3=center, 4=flex-end */
66
+ function alignSelfToNum(alignSelf: string | undefined): number {
67
+ switch (alignSelf) {
68
+ case 'auto': return 0
69
+ case 'stretch': return 1
70
+ case 'flex-start': return 2
71
+ case 'center': return 3
72
+ case 'flex-end': return 4
73
+ default: return 0
74
+ }
75
+ }
76
+
77
+ // bindEnumProp removed - using enumSource instead
64
78
 
65
79
  /**
66
- * Create a binding for enum props that converts at read time.
67
- * No derived needed - reads signal directly and converts inline.
68
- * This creates dependency directly on user's signal, no intermediate objects.
80
+ * Create a slot source for enum props - returns getter for reactive, value for static.
81
+ * For use with slotArray.setSource()
69
82
  */
70
- function bindEnumProp<T extends string>(
83
+ function enumSource<T extends string>(
71
84
  prop: T | { value: T } | undefined,
72
85
  converter: (val: T | undefined) => number
73
- ): ReturnType<typeof bind<number>> {
74
- // If it's reactive (has .value), create binding that converts at read time
86
+ ): number | (() => number) {
75
87
  if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
76
88
  const reactiveSource = prop as { value: T }
77
- return {
78
- [BINDING_SYMBOL]: true,
79
- get value(): number {
80
- return converter(reactiveSource.value)
81
- },
82
- set value(_: number) {
83
- // Enum props are read-only from number side
84
- },
85
- } as unknown as ReturnType<typeof bind<number>>
89
+ return () => converter(reactiveSource.value)
86
90
  }
87
- // Static value - just convert
88
- return bind(converter(prop as T | undefined))
91
+ return converter(prop as T | undefined)
89
92
  }
90
93
 
91
94
  // =============================================================================
@@ -110,55 +113,56 @@ export function text(props: TextProps): Cleanup {
110
113
  // CORE - Always needed
111
114
  // ==========================================================================
112
115
  core.componentType[index] = ComponentType.TEXT
113
- core.parentIndex[index] = bind(getCurrentParentIndex())
116
+ core.parentIndex.setSource(index, getCurrentParentIndex())
114
117
 
115
118
  // Visible - only bind if passed
116
119
  if (props.visible !== undefined) {
117
- core.visible[index] = bind(props.visible)
120
+ core.visible.setSource(index, props.visible)
118
121
  }
119
122
 
120
123
  // ==========================================================================
121
124
  // TEXT CONTENT - Always needed (this is a text component!)
125
+ // Uses setSource() for stable slot tracking (fixes bind() replacement bug)
122
126
  // ==========================================================================
123
- textArrays.textContent[index] = bind(props.content)
127
+ textArrays.textContent.setSource(index, props.content)
124
128
 
125
- // Text styling - only bind if passed
126
- if (props.attrs !== undefined) textArrays.textAttrs[index] = bind(props.attrs)
127
- if (props.align !== undefined) textArrays.textAlign[index] = bindEnumProp(props.align, alignToNum)
128
- if (props.wrap !== undefined) textArrays.textWrap[index] = bindEnumProp(props.wrap, wrapToNum)
129
+ // Text styling - only set if passed
130
+ if (props.attrs !== undefined) textArrays.textAttrs.setSource(index, props.attrs)
131
+ if (props.align !== undefined) textArrays.textAlign.setSource(index, enumSource(props.align, alignToNum))
132
+ if (props.wrap !== undefined) textArrays.textWrap.setSource(index, enumSource(props.wrap, wrapToNum))
129
133
 
130
134
  // ==========================================================================
131
135
  // DIMENSIONS - Only bind what's passed (TITAN uses ?? 0 for undefined)
132
136
  // ==========================================================================
133
- if (props.width !== undefined) dimensions.width[index] = bind(props.width)
134
- if (props.height !== undefined) dimensions.height[index] = bind(props.height)
135
- if (props.minWidth !== undefined) dimensions.minWidth[index] = bind(props.minWidth)
136
- if (props.maxWidth !== undefined) dimensions.maxWidth[index] = bind(props.maxWidth)
137
- if (props.minHeight !== undefined) dimensions.minHeight[index] = bind(props.minHeight)
138
- if (props.maxHeight !== undefined) dimensions.maxHeight[index] = bind(props.maxHeight)
137
+ if (props.width !== undefined) dimensions.width.setSource(index, props.width)
138
+ if (props.height !== undefined) dimensions.height.setSource(index, props.height)
139
+ if (props.minWidth !== undefined) dimensions.minWidth.setSource(index, props.minWidth)
140
+ if (props.maxWidth !== undefined) dimensions.maxWidth.setSource(index, props.maxWidth)
141
+ if (props.minHeight !== undefined) dimensions.minHeight.setSource(index, props.minHeight)
142
+ if (props.maxHeight !== undefined) dimensions.maxHeight.setSource(index, props.maxHeight)
139
143
 
140
144
  // ==========================================================================
141
145
  // PADDING - Shorthand support
142
146
  // ==========================================================================
143
147
  if (props.padding !== undefined) {
144
- spacing.paddingTop[index] = bind(props.paddingTop ?? props.padding)
145
- spacing.paddingRight[index] = bind(props.paddingRight ?? props.padding)
146
- spacing.paddingBottom[index] = bind(props.paddingBottom ?? props.padding)
147
- spacing.paddingLeft[index] = bind(props.paddingLeft ?? props.padding)
148
+ spacing.paddingTop.setSource(index, props.paddingTop ?? props.padding)
149
+ spacing.paddingRight.setSource(index, props.paddingRight ?? props.padding)
150
+ spacing.paddingBottom.setSource(index, props.paddingBottom ?? props.padding)
151
+ spacing.paddingLeft.setSource(index, props.paddingLeft ?? props.padding)
148
152
  } else {
149
- if (props.paddingTop !== undefined) spacing.paddingTop[index] = bind(props.paddingTop)
150
- if (props.paddingRight !== undefined) spacing.paddingRight[index] = bind(props.paddingRight)
151
- if (props.paddingBottom !== undefined) spacing.paddingBottom[index] = bind(props.paddingBottom)
152
- if (props.paddingLeft !== undefined) spacing.paddingLeft[index] = bind(props.paddingLeft)
153
+ if (props.paddingTop !== undefined) spacing.paddingTop.setSource(index, props.paddingTop)
154
+ if (props.paddingRight !== undefined) spacing.paddingRight.setSource(index, props.paddingRight)
155
+ if (props.paddingBottom !== undefined) spacing.paddingBottom.setSource(index, props.paddingBottom)
156
+ if (props.paddingLeft !== undefined) spacing.paddingLeft.setSource(index, props.paddingLeft)
153
157
  }
154
158
 
155
159
  // ==========================================================================
156
160
  // FLEX ITEM - Only bind if passed (text can be a flex item)
157
161
  // ==========================================================================
158
- if (props.grow !== undefined) layout.flexGrow[index] = bind(props.grow)
159
- if (props.shrink !== undefined) layout.flexShrink[index] = bind(props.shrink)
160
- if (props.flexBasis !== undefined) layout.flexBasis[index] = bind(props.flexBasis)
161
- if (props.alignSelf !== undefined) layout.alignSelf[index] = bind(props.alignSelf)
162
+ if (props.grow !== undefined) layout.flexGrow.setSource(index, props.grow)
163
+ if (props.shrink !== undefined) layout.flexShrink.setSource(index, props.shrink)
164
+ if (props.flexBasis !== undefined) layout.flexBasis.setSource(index, props.flexBasis)
165
+ if (props.alignSelf !== undefined) layout.alignSelf.setSource(index, enumSource(props.alignSelf, alignSelfToNum))
162
166
 
163
167
  // ==========================================================================
164
168
  // VISUAL - Colors with variant support (only bind what's needed)
@@ -167,33 +171,33 @@ export function text(props: TextProps): Cleanup {
167
171
  // Variant colors - inline bindings that read theme at read time (no deriveds!)
168
172
  const variant = props.variant
169
173
  if (props.fg !== undefined) {
170
- visual.fgColor[index] = bind(props.fg)
174
+ visual.fgColor.setSource(index, props.fg)
171
175
  } else {
172
- visual.fgColor[index] = {
173
- [BINDING_SYMBOL]: true,
174
- get value() { return getVariantStyle(variant).fg },
175
- set value(_) {},
176
- } as any
176
+ visual.fgColor.setSource(index, () => getVariantStyle(variant).fg)
177
177
  }
178
178
  if (props.bg !== undefined) {
179
- visual.bgColor[index] = bind(props.bg)
179
+ visual.bgColor.setSource(index, props.bg)
180
180
  } else {
181
- visual.bgColor[index] = {
182
- [BINDING_SYMBOL]: true,
183
- get value() { return getVariantStyle(variant).bg },
184
- set value(_) {},
185
- } as any
181
+ visual.bgColor.setSource(index, () => getVariantStyle(variant).bg)
186
182
  }
187
183
  } else {
188
184
  // Direct colors - only bind if passed
189
- if (props.fg !== undefined) visual.fgColor[index] = bind(props.fg)
190
- if (props.bg !== undefined) visual.bgColor[index] = bind(props.bg)
185
+ if (props.fg !== undefined) visual.fgColor.setSource(index, props.fg)
186
+ if (props.bg !== undefined) visual.bgColor.setSource(index, props.bg)
191
187
  }
192
- if (props.opacity !== undefined) visual.opacity[index] = bind(props.opacity)
188
+ if (props.opacity !== undefined) visual.opacity.setSource(index, props.opacity)
193
189
 
194
190
  // Cleanup function
195
- return () => {
191
+ const cleanup = () => {
196
192
  cleanupKeyboardListeners(index)
197
193
  releaseIndex(index)
198
194
  }
195
+
196
+ // Auto-register with active scope if one exists
197
+ const scope = getActiveScope()
198
+ if (scope) {
199
+ scope.cleanups.push(cleanup)
200
+ }
201
+
202
+ return cleanup
199
203
  }
@@ -137,7 +137,7 @@ export interface BoxProps extends StyleProps, BorderProps, DimensionProps, Spaci
137
137
  // TEXT PROPS
138
138
  // =============================================================================
139
139
 
140
- export interface TextProps extends StyleProps, DimensionProps, SpacingProps {
140
+ export interface TextProps extends StyleProps, DimensionProps, SpacingProps, LayoutProps {
141
141
  /** Text content */
142
142
  content: Reactive<string>
143
143
  /** Text alignment: 'left' | 'center' | 'right' */
@@ -0,0 +1,102 @@
1
+ /**
2
+ * TUI Framework - When Primitive
3
+ *
4
+ * Async rendering. Shows loading/error/success states based on promise state.
5
+ * When the promise resolves or rejects, the appropriate components are shown.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * when(() => fetchData(), {
10
+ * pending: () => text({ content: 'Loading...' }),
11
+ * then: (data) => text({ content: `Got: ${data}` }),
12
+ * catch: (err) => text({ content: `Error: ${err.message}` })
13
+ * })
14
+ * ```
15
+ */
16
+
17
+ import { effect, effectScope, onScopeDispose } from '@rlabs-inc/signals'
18
+ import { getCurrentParentIndex, pushParentContext, popParentContext } from '../engine/registry'
19
+ import type { Cleanup } from './types'
20
+
21
+ interface WhenOptions<T> {
22
+ pending?: () => Cleanup
23
+ then: (value: T) => Cleanup
24
+ catch?: (error: Error) => Cleanup
25
+ }
26
+
27
+ /**
28
+ * Render based on async promise state.
29
+ *
30
+ * @param promiseGetter - Getter that returns a promise (creates dependency)
31
+ * @param options - Handlers for pending, then, and catch states
32
+ */
33
+ export function when<T>(
34
+ promiseGetter: () => Promise<T>,
35
+ options: WhenOptions<T>
36
+ ): Cleanup {
37
+ let cleanup: Cleanup | null = null
38
+ let currentPromise: Promise<T> | null = null
39
+ const parentIndex = getCurrentParentIndex()
40
+ const scope = effectScope()
41
+
42
+ const render = (fn: () => Cleanup) => {
43
+ if (cleanup) {
44
+ cleanup()
45
+ cleanup = null
46
+ }
47
+ pushParentContext(parentIndex)
48
+ try {
49
+ cleanup = fn()
50
+ } finally {
51
+ popParentContext()
52
+ }
53
+ }
54
+
55
+ const handlePromise = (promise: Promise<T>) => {
56
+ if (promise !== currentPromise) return
57
+
58
+ promise
59
+ .then((value) => {
60
+ if (promise !== currentPromise) return
61
+ render(() => options.then(value))
62
+ })
63
+ .catch((error) => {
64
+ if (promise !== currentPromise) return
65
+ if (options.catch) {
66
+ render(() => options.catch!(error))
67
+ }
68
+ })
69
+ }
70
+
71
+ scope.run(() => {
72
+ // Initial setup
73
+ const initialPromise = promiseGetter()
74
+ currentPromise = initialPromise
75
+ if (options.pending) {
76
+ render(options.pending)
77
+ }
78
+ handlePromise(initialPromise)
79
+
80
+ // Effect for updates - skip first run
81
+ let initialized = false
82
+ effect(() => {
83
+ const promise = promiseGetter()
84
+ if (initialized) {
85
+ if (promise === currentPromise) return
86
+ currentPromise = promise
87
+ if (options.pending) {
88
+ render(options.pending)
89
+ }
90
+ handlePromise(promise)
91
+ }
92
+ initialized = true
93
+ })
94
+
95
+ onScopeDispose(() => {
96
+ currentPromise = null
97
+ if (cleanup) cleanup()
98
+ })
99
+ })
100
+
101
+ return () => scope.stop()
102
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * TUI Framework - Append Mode Renderer
3
+ *
4
+ * Simple renderer for append mode:
5
+ * - Clears active region (eraseDown from cursor)
6
+ * - Renders active content
7
+ *
8
+ * History content is handled separately via renderToHistory().
9
+ * The app controls what goes to history vs active area.
10
+ *
11
+ * This renderer is essentially inline mode with cursor at the
12
+ * boundary between frozen history and active content.
13
+ */
14
+
15
+ import type { FrameBuffer, RGBA, CellAttrs } from '../types'
16
+ import { Attr } from '../types'
17
+ import { rgbaEqual } from '../types/color'
18
+ import * as ansi from './ansi'
19
+
20
+ // =============================================================================
21
+ // APPEND MODE RENDERER
22
+ // =============================================================================
23
+
24
+ /**
25
+ * Simple append mode renderer.
26
+ * Erases previous active content and renders fresh.
27
+ *
28
+ * Like InlineRenderer but only erases the active area (preserves history).
29
+ */
30
+ export class AppendRegionRenderer {
31
+ // Cell rendering state (for ANSI optimization)
32
+ private lastFg: RGBA | null = null
33
+ private lastBg: RGBA | null = null
34
+ private lastAttrs: CellAttrs = Attr.NONE
35
+
36
+ // Track previous height to know how many lines to erase
37
+ private previousHeight = 0
38
+
39
+ /**
40
+ * Render frame buffer as active content.
41
+ * Erases exactly the previous content, then writes fresh.
42
+ */
43
+ render(buffer: FrameBuffer): void {
44
+ const output = this.buildOutput(buffer)
45
+
46
+ // Erase previous active content (move up and clear each line)
47
+ if (this.previousHeight > 0) {
48
+ process.stdout.write(ansi.eraseLines(this.previousHeight))
49
+ }
50
+
51
+ // Render active content
52
+ process.stdout.write(output)
53
+
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
57
+ }
58
+
59
+ /**
60
+ * Erase the current active area.
61
+ * Call this BEFORE writing to history so we clear the screen first.
62
+ */
63
+ eraseActive(): void {
64
+ if (this.previousHeight > 0) {
65
+ process.stdout.write(ansi.eraseLines(this.previousHeight))
66
+ this.previousHeight = 0
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Call this after writing to history.
72
+ * Resets height tracking so next render doesn't try to erase history.
73
+ */
74
+ invalidate(): void {
75
+ this.previousHeight = 0
76
+ }
77
+
78
+ /**
79
+ * Build output string for the buffer.
80
+ */
81
+ private buildOutput(buffer: FrameBuffer): string {
82
+ if (buffer.height === 0) return ''
83
+
84
+ const chunks: string[] = []
85
+
86
+ // Reset rendering state
87
+ this.lastFg = null
88
+ this.lastBg = null
89
+ this.lastAttrs = Attr.NONE
90
+
91
+ for (let y = 0; y < buffer.height; y++) {
92
+ if (y > 0) {
93
+ chunks.push('\n')
94
+ }
95
+
96
+ for (let x = 0; x < buffer.width; x++) {
97
+ const cell = buffer.cells[y]![x]
98
+ this.renderCell(chunks, cell!)
99
+ }
100
+ }
101
+
102
+ chunks.push(ansi.reset)
103
+ chunks.push('\n')
104
+
105
+ return chunks.join('')
106
+ }
107
+
108
+ /**
109
+ * Render a single cell with ANSI optimization.
110
+ */
111
+ private renderCell(chunks: string[], cell: { char: number; fg: RGBA; bg: RGBA; attrs: CellAttrs }): void {
112
+ // Attributes changed - reset first
113
+ if (cell.attrs !== this.lastAttrs) {
114
+ chunks.push(ansi.reset)
115
+ if (cell.attrs !== Attr.NONE) {
116
+ chunks.push(ansi.attrs(cell.attrs))
117
+ }
118
+ this.lastFg = null
119
+ this.lastBg = null
120
+ this.lastAttrs = cell.attrs
121
+ }
122
+
123
+ // Foreground color changed
124
+ if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
125
+ chunks.push(ansi.fg(cell.fg))
126
+ this.lastFg = cell.fg
127
+ }
128
+
129
+ // Background color changed
130
+ if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
131
+ chunks.push(ansi.bg(cell.bg))
132
+ this.lastBg = cell.bg
133
+ }
134
+
135
+ // Output character
136
+ if (cell.char === 0) {
137
+ chunks.push(' ')
138
+ } else {
139
+ chunks.push(String.fromCodePoint(cell.char))
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Reset renderer state.
145
+ */
146
+ reset(): void {
147
+ this.previousHeight = 0
148
+ this.lastFg = null
149
+ this.lastBg = null
150
+ this.lastAttrs = Attr.NONE
151
+ }
152
+
153
+ /**
154
+ * Cleanup (no-op for simplified renderer).
155
+ */
156
+ cleanup(): void {
157
+ // Nothing to cleanup - we don't own the FileSink
158
+ }
159
+ }
@@ -36,5 +36,7 @@ export {
36
36
  finalizeAppendMode,
37
37
  } from './output'
38
38
 
39
- // Input parsing
40
- export { InputBuffer, type ParsedInput } from './input'
39
+ // Two-region append renderer
40
+ export { AppendRegionRenderer } from './append-region'
41
+
42
+ // Input parsing moved to src/state/input.ts
@@ -312,12 +312,11 @@ export function finalizeAppendMode(output: OutputBuffer, height: number): void {
312
312
  // =============================================================================
313
313
 
314
314
  /**
315
- * Inline renderer matching Ink's log-update approach.
316
- * Uses our own ansi.ts (zero dependencies).
315
+ * Inline renderer for non-fullscreen mode.
316
+ * Uses clearTerminal for reliable rendering without ghost lines.
317
317
  */
318
318
  export class InlineRenderer {
319
319
  private output = new OutputBuffer()
320
- private previousLineCount = 0
321
320
  private previousOutput = ''
322
321
 
323
322
  // Cell rendering state (for ANSI optimization)
@@ -327,18 +326,14 @@ export class InlineRenderer {
327
326
 
328
327
  /**
329
328
  * Render a frame buffer for inline mode.
330
- * Follows log-update's algorithm:
331
- * 1. Build output with trailing newline
332
- * 2. eraseLines(previousLineCount) + output
333
- * 3. Track new line count
334
329
  *
335
- * KEY INSIGHT FROM INK:
336
- * When content height >= terminal rows, eraseLines can't reach content
337
- * that scrolled off the top into scrollback. In this case, use clearTerminal
338
- * to wipe everything including scrollback, then redraw.
330
+ * Uses clearTerminal (clears screen + scrollback) before each render.
331
+ * This is simpler and more reliable than tracking line counts:
332
+ * - No ghost lines from eraseLines edge cases
333
+ * - Once content exceeds terminal height, scrollback is cleared anyway
334
+ * - The slight overhead of clearing is negligible with fast renders
339
335
  */
340
336
  render(buffer: FrameBuffer): void {
341
- // Build the output string
342
337
  const output = this.buildOutput(buffer)
343
338
 
344
339
  // Skip if output unchanged
@@ -346,26 +341,11 @@ export class InlineRenderer {
346
341
  return
347
342
  }
348
343
 
349
- // Get terminal viewport height
350
- const terminalRows = process.stdout.rows || 24
351
-
352
- // When content height >= terminal rows, eraseLines can't reach content
353
- // that scrolled off into scrollback. Use clearTerminal instead.
354
- if (this.previousLineCount >= terminalRows) {
355
- this.output.write(ansi.clearTerminal + output)
356
- } else {
357
- this.output.write(ansi.eraseLines(this.previousLineCount) + output)
358
- }
344
+ // Clear everything and render fresh
345
+ this.output.write(ansi.clearTerminal + output)
359
346
  this.output.flushSync()
360
347
 
361
- // Track for next render
362
348
  this.previousOutput = output
363
- // buffer.height + 1 because:
364
- // - We output buffer.height lines of content
365
- // - Plus a trailing newline that puts cursor on the next line
366
- // - eraseLines works from cursor position upward, so we need to erase
367
- // buffer.height lines PLUS the empty line we're currently on
368
- this.previousLineCount = buffer.height + 1
369
349
  }
370
350
 
371
351
  /**
@@ -431,10 +411,8 @@ export class InlineRenderer {
431
411
  * Clear all rendered content and reset state.
432
412
  */
433
413
  clear(): void {
434
- if (this.previousLineCount > 0) {
435
- this.output.write(ansi.eraseLines(this.previousLineCount))
436
- this.output.flushSync()
437
- }
414
+ this.output.write(ansi.clearTerminal)
415
+ this.output.flushSync()
438
416
  this.reset()
439
417
  }
440
418
 
@@ -442,7 +420,6 @@ export class InlineRenderer {
442
420
  * Reset the renderer state.
443
421
  */
444
422
  reset(): void {
445
- this.previousLineCount = 0
446
423
  this.previousOutput = ''
447
424
  this.lastFg = null
448
425
  this.lastBg = null
@@ -65,7 +65,10 @@ export function restoreFocusFromHistory(): boolean {
65
65
  while (focusHistory.length > 0) {
66
66
  const index = focusHistory.pop()!
67
67
  // Check if component is still valid and focusable
68
- if (unwrap(focusable[index]) && unwrap(visible[index])) {
68
+ // Match TITAN's logic: undefined means visible, only 0/false means hidden
69
+ const isVisible = unwrap(visible[index])
70
+ const isActuallyVisible = isVisible !== 0 && isVisible !== false
71
+ if (unwrap(focusable[index]) && isActuallyVisible) {
69
72
  focusedIndex.value = index
70
73
  return true
71
74
  }
@@ -83,7 +86,11 @@ export function getFocusableIndices(): number[] {
83
86
  const result: number[] = []
84
87
 
85
88
  for (const i of indices) {
86
- if (unwrap(focusable[i]) && unwrap(visible[i])) {
89
+ const isFocusable = unwrap(focusable[i])
90
+ const isVisible = unwrap(visible[i])
91
+ // Match TITAN's logic: undefined means visible, only 0/false means hidden
92
+ const isActuallyVisible = isVisible !== 0 && isVisible !== false
93
+ if (isFocusable && isActuallyVisible) {
87
94
  result.push(i)
88
95
  }
89
96
  }
@@ -141,8 +148,9 @@ function findNextFocusable(fromIndex: number, direction: 1 | -1): number {
141
148
 
142
149
  /** Move focus to next focusable component */
143
150
  export function focusNext(): boolean {
144
- const next = findNextFocusable(focusedIndex.value, 1)
145
- if (next !== -1 && next !== focusedIndex.value) {
151
+ const current = focusedIndex.value
152
+ const next = findNextFocusable(current, 1)
153
+ if (next !== -1 && next !== current) {
146
154
  saveFocusToHistory()
147
155
  focusedIndex.value = next
148
156
  return true
@@ -163,7 +171,10 @@ export function focusPrevious(): boolean {
163
171
 
164
172
  /** Focus a specific component by index */
165
173
  export function focus(index: number): boolean {
166
- if (unwrap(focusable[index]) && unwrap(visible[index])) {
174
+ // Match TITAN's logic: undefined means visible, only 0/false means hidden
175
+ const isVisible = unwrap(visible[index])
176
+ const isActuallyVisible = isVisible !== 0 && isVisible !== false
177
+ if (unwrap(focusable[index]) && isActuallyVisible) {
167
178
  if (focusedIndex.value !== index) {
168
179
  saveFocusToHistory()
169
180
  focusedIndex.value = index