@rlabs-inc/tui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +126 -13
  2. package/index.ts +11 -5
  3. package/package.json +2 -2
  4. package/src/api/mount.ts +42 -27
  5. package/src/engine/arrays/core.ts +13 -21
  6. package/src/engine/arrays/dimensions.ts +22 -32
  7. package/src/engine/arrays/index.ts +88 -86
  8. package/src/engine/arrays/interaction.ts +34 -48
  9. package/src/engine/arrays/layout.ts +67 -92
  10. package/src/engine/arrays/spacing.ts +37 -52
  11. package/src/engine/arrays/text.ts +23 -31
  12. package/src/engine/arrays/visual.ts +56 -75
  13. package/src/engine/inheritance.ts +18 -18
  14. package/src/engine/registry.ts +15 -0
  15. package/src/pipeline/frameBuffer.ts +26 -26
  16. package/src/pipeline/layout/index.ts +2 -2
  17. package/src/pipeline/layout/titan-engine.ts +112 -84
  18. package/src/primitives/animation.ts +194 -0
  19. package/src/primitives/box.ts +74 -86
  20. package/src/primitives/each.ts +87 -0
  21. package/src/primitives/index.ts +7 -0
  22. package/src/primitives/scope.ts +215 -0
  23. package/src/primitives/show.ts +77 -0
  24. package/src/primitives/text.ts +63 -59
  25. package/src/primitives/types.ts +1 -1
  26. package/src/primitives/when.ts +102 -0
  27. package/src/renderer/append-region.ts +303 -0
  28. package/src/renderer/index.ts +4 -2
  29. package/src/renderer/output.ts +11 -34
  30. package/src/state/focus.ts +16 -5
  31. package/src/state/global-keys.ts +184 -0
  32. package/src/state/index.ts +44 -8
  33. package/src/state/input.ts +534 -0
  34. package/src/state/keyboard.ts +98 -674
  35. package/src/state/mouse.ts +163 -340
  36. package/src/state/scroll.ts +7 -9
  37. package/src/types/index.ts +6 -0
  38. package/src/renderer/input.ts +0 -518
@@ -0,0 +1,303 @@
1
+ /**
2
+ * TUI Framework - Two-Region Append Renderer
3
+ *
4
+ * Implements a hybrid static/reactive rendering mode optimized for chat-like UIs:
5
+ *
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
11
+ *
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
23
+ */
24
+
25
+ import type { Cell, RGBA, CellAttrs, FrameBuffer } from '../types'
26
+ import { Attr } from '../types'
27
+ import { rgbaEqual } from '../types/color'
28
+ import * as ansi from './ansi'
29
+
30
+ // =============================================================================
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
78
+ // =============================================================================
79
+
80
+ /**
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)
84
+ *
85
+ * The boundary between regions shifts as content completes.
86
+ */
87
+ export class AppendRegionRenderer {
88
+ private staticWriter = new StdoutWriter()
89
+ private frozenMessages = new Set<string>()
90
+ private totalStaticLines = 0
91
+
92
+ // Cell rendering state (for ANSI optimization)
93
+ private lastFg: RGBA | null = null
94
+ private lastBg: RGBA | null = null
95
+ private lastAttrs: CellAttrs = Attr.NONE
96
+
97
+ // Previous reactive output for change detection
98
+ private previousReactiveOutput = ''
99
+
100
+ /**
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
106
+ */
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)
119
+
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
134
+ }
135
+
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)
146
+
147
+ this.previousReactiveOutput = reactiveOutput
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Extract a region from the frame buffer.
153
+ */
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)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Build output for static region (append once, forget).
175
+ */
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('')
197
+ }
198
+
199
+ /**
200
+ * Build output for reactive region (cleared and re-rendered each frame).
201
+ */
202
+ private buildReactiveOutput(buffer: FrameBuffer): string {
203
+ if (buffer.height === 0) return ''
204
+
205
+ const chunks: string[] = []
206
+
207
+ // Reset rendering state
208
+ this.lastFg = null
209
+ this.lastBg = null
210
+ this.lastAttrs = Attr.NONE
211
+
212
+ for (let y = 0; y < buffer.height; y++) {
213
+ if (y > 0) {
214
+ chunks.push('\n')
215
+ }
216
+
217
+ for (let x = 0; x < buffer.width; x++) {
218
+ const cell = buffer.cells[y]![x]
219
+ this.renderCell(chunks, cell!)
220
+ }
221
+ }
222
+
223
+ chunks.push(ansi.reset)
224
+ chunks.push('\n')
225
+
226
+ return chunks.join('')
227
+ }
228
+
229
+ /**
230
+ * Render a single cell with ANSI optimization.
231
+ */
232
+ private renderCell(chunks: string[], cell: Cell): void {
233
+ // Attributes changed - reset first
234
+ if (cell.attrs !== this.lastAttrs) {
235
+ chunks.push(ansi.reset)
236
+ if (cell.attrs !== Attr.NONE) {
237
+ chunks.push(ansi.attrs(cell.attrs))
238
+ }
239
+ this.lastFg = null
240
+ this.lastBg = null
241
+ this.lastAttrs = cell.attrs
242
+ }
243
+
244
+ // Foreground color changed
245
+ if (!this.lastFg || !rgbaEqual(cell.fg, this.lastFg)) {
246
+ chunks.push(ansi.fg(cell.fg))
247
+ this.lastFg = cell.fg
248
+ }
249
+
250
+ // Background color changed
251
+ if (!this.lastBg || !rgbaEqual(cell.bg, this.lastBg)) {
252
+ chunks.push(ansi.bg(cell.bg))
253
+ this.lastBg = cell.bg
254
+ }
255
+
256
+ // Output character
257
+ if (cell.char === 0) {
258
+ chunks.push(' ')
259
+ } else {
260
+ chunks.push(String.fromCodePoint(cell.char))
261
+ }
262
+ }
263
+
264
+ /**
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.
287
+ */
288
+ reset(): void {
289
+ this.frozenMessages.clear()
290
+ this.totalStaticLines = 0
291
+ this.previousReactiveOutput = ''
292
+ this.lastFg = null
293
+ this.lastBg = null
294
+ this.lastAttrs = Attr.NONE
295
+ }
296
+
297
+ /**
298
+ * Cleanup when unmounting.
299
+ */
300
+ cleanup(): void {
301
+ this.staticWriter.end()
302
+ }
303
+ }
@@ -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
@@ -0,0 +1,184 @@
1
+ /**
2
+ * TUI Framework - Global Keys Module
3
+ *
4
+ * Central location for ALL global keyboard and mouse shortcuts.
5
+ * This is the ONLY place where global input behaviors are defined.
6
+ *
7
+ * Wires together: input, keyboard, mouse, focus, scroll
8
+ *
9
+ * Global behaviors:
10
+ * - Ctrl+C: Exit application
11
+ * - Tab/Shift+Tab: Focus navigation
12
+ * - Arrow keys: Scroll focused scrollable
13
+ * - Page Up/Down: Large scroll jumps
14
+ * - Home/End: Scroll to start/end
15
+ * - Mouse wheel: Scroll hovered or focused
16
+ */
17
+
18
+ import { input } from './input'
19
+ import { keyboard, dispatch as dispatchKeyboard, dispatchFocused } from './keyboard'
20
+ import { mouse, dispatch as dispatchMouse } from './mouse'
21
+ import { focusNext, focusPrevious, focusedIndex } from './focus'
22
+ import { handleArrowScroll, handlePageScroll, handleHomeEnd, handleWheelScroll } from './scroll'
23
+ import type { KeyboardEvent } from './keyboard'
24
+ import type { MouseEvent } from './mouse'
25
+
26
+ // =============================================================================
27
+ // STATE
28
+ // =============================================================================
29
+
30
+ let initialized = false
31
+ let cleanupCallback: (() => void) | null = null
32
+ let exitOnCtrlC = true
33
+
34
+ // =============================================================================
35
+ // EVENT HANDLERS
36
+ // =============================================================================
37
+
38
+ function handleKeyboardEvent(event: KeyboardEvent): void {
39
+ try {
40
+ // Only process press events for most shortcuts
41
+ const isPress = event.state === 'press'
42
+
43
+ // Ctrl+C - exit (always, even on release for safety)
44
+ if (exitOnCtrlC && event.key === 'c' && event.modifiers.ctrl) {
45
+ cleanup()
46
+ process.exit(0)
47
+ }
48
+
49
+ // Skip non-press events for other shortcuts
50
+ if (!isPress) {
51
+ // Still dispatch to handlers (they may want release events)
52
+ dispatchFocused(focusedIndex.value, event)
53
+ dispatchKeyboard(event)
54
+ return
55
+ }
56
+
57
+ // Tab - focus navigation
58
+ if (event.key === 'Tab' && !event.modifiers.ctrl && !event.modifiers.alt) {
59
+ if (event.modifiers.shift) {
60
+ focusPrevious()
61
+ } else {
62
+ focusNext()
63
+ }
64
+ return // Tab is always consumed
65
+ }
66
+
67
+ // Dispatch to focused component handlers first
68
+ if (dispatchFocused(focusedIndex.value, event)) {
69
+ return // Consumed by focused handler
70
+ }
71
+
72
+ // Dispatch to user handlers (keyboard.onKey)
73
+ // If user returns true, they handled it - skip framework defaults
74
+ if (dispatchKeyboard(event)) {
75
+ return // User handled it
76
+ }
77
+
78
+ // =========================================================================
79
+ // FRAMEWORK DEFAULTS - only run if user didn't handle
80
+ // =========================================================================
81
+
82
+ // Arrow keys - scroll focused scrollable
83
+ if (event.key === 'ArrowUp' && handleArrowScroll('up')) return
84
+ if (event.key === 'ArrowDown' && handleArrowScroll('down')) return
85
+ if (event.key === 'ArrowLeft' && handleArrowScroll('left')) return
86
+ if (event.key === 'ArrowRight' && handleArrowScroll('right')) return
87
+
88
+ // Page Up/Down
89
+ if (event.key === 'PageUp' && handlePageScroll('up')) return
90
+ if (event.key === 'PageDown' && handlePageScroll('down')) return
91
+
92
+ // Home/End (without Ctrl - Ctrl+Home/End could be used for something else)
93
+ if (event.key === 'Home' && !event.modifiers.ctrl && handleHomeEnd('home')) return
94
+ if (event.key === 'End' && !event.modifiers.ctrl && handleHomeEnd('end')) return
95
+ } catch (err) {
96
+ console.error('[TUI] Keyboard handler error:', err)
97
+ }
98
+ }
99
+
100
+ function handleMouseEvent(event: MouseEvent): void {
101
+ // Mouse wheel - scroll hovered or focused
102
+ if (event.action === 'scroll' && event.scroll) {
103
+ handleWheelScroll(event.x, event.y, event.scroll.direction)
104
+ }
105
+
106
+ // Dispatch to mouse module for hover/click handling
107
+ dispatchMouse(event)
108
+ }
109
+
110
+ // =============================================================================
111
+ // PUBLIC API
112
+ // =============================================================================
113
+
114
+ /**
115
+ * Initialize the global input system.
116
+ * Sets up stdin handling and wires all global shortcuts.
117
+ */
118
+ export function initialize(options?: {
119
+ onCleanup?: () => void
120
+ exitOnCtrlC?: boolean
121
+ enableMouse?: boolean
122
+ }): void {
123
+ if (initialized) return
124
+
125
+ initialized = true
126
+ cleanupCallback = options?.onCleanup ?? null
127
+ exitOnCtrlC = options?.exitOnCtrlC ?? true
128
+
129
+ // Initialize input system with our handlers
130
+ input.initialize(handleKeyboardEvent, handleMouseEvent)
131
+
132
+ // Enable mouse tracking if requested
133
+ if (options?.enableMouse !== false) {
134
+ mouse.enableTracking()
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Clean up the global input system.
140
+ */
141
+ export function cleanup(): void {
142
+ if (!initialized) return
143
+
144
+ initialized = false
145
+
146
+ // Clean up all modules
147
+ mouse.cleanup()
148
+ keyboard.cleanup()
149
+ input.cleanup()
150
+
151
+ // Show cursor
152
+ process.stdout.write('\x1b[?25h')
153
+
154
+ // Call user cleanup
155
+ if (cleanupCallback) {
156
+ cleanupCallback()
157
+ cleanupCallback = null
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Set whether Ctrl+C exits the application.
163
+ */
164
+ export function setExitOnCtrlC(enabled: boolean): void {
165
+ exitOnCtrlC = enabled
166
+ }
167
+
168
+ /**
169
+ * Check if global keys system is initialized.
170
+ */
171
+ export function isInitialized(): boolean {
172
+ return initialized
173
+ }
174
+
175
+ // =============================================================================
176
+ // EXPORT
177
+ // =============================================================================
178
+
179
+ export const globalKeys = {
180
+ initialize,
181
+ cleanup,
182
+ setExitOnCtrlC,
183
+ isInitialized,
184
+ }
@@ -11,9 +11,45 @@
11
11
  * - cursor: Cursor visibility, shape, position
12
12
  */
13
13
 
14
- // Keyboard is primary for focus navigation
15
- export * from './keyboard'
16
- export * from './mouse'
14
+ // Keyboard - explicit exports to avoid conflicts
15
+ export {
16
+ keyboard,
17
+ lastKey,
18
+ lastEvent,
19
+ on,
20
+ onKey,
21
+ onFocused,
22
+ cleanupIndex,
23
+ dispatch as dispatchKeyboard,
24
+ dispatchFocused,
25
+ cleanup as cleanupKeyboard,
26
+ } from './keyboard'
27
+ export type { Modifiers, KeyState, KeyboardEvent, KeyHandler } from './keyboard'
28
+
29
+ // Mouse - explicit exports to avoid conflicts
30
+ export {
31
+ mouse,
32
+ hitGrid,
33
+ lastMouseEvent,
34
+ mouseX,
35
+ mouseY,
36
+ isMouseDown,
37
+ HitGrid,
38
+ MouseButton,
39
+ onMouseDown,
40
+ onMouseUp,
41
+ onClick,
42
+ onScroll,
43
+ onComponent,
44
+ resize,
45
+ clearHitGrid,
46
+ enableTracking,
47
+ disableTracking,
48
+ isTrackingEnabled,
49
+ dispatch as dispatchMouse,
50
+ cleanup as cleanupMouse,
51
+ } from './mouse'
52
+ export type { MouseAction, ScrollInfo, MouseEvent, MouseHandlers, MouseHandler } from './mouse'
17
53
  // Focus exports - exclude duplicates that are in keyboard
18
54
  export {
19
55
  focusedIndex,
@@ -36,8 +72,8 @@ export {
36
72
  export * from './scroll'
37
73
  export * from './cursor'
38
74
 
39
- // Convenient namespace exports
40
- export { keyboard } from './keyboard'
41
- export { mouse } from './mouse'
42
- export { scroll } from './scroll'
43
- export { cursor } from './cursor'
75
+ // Global keys - all shortcuts wired together
76
+ export { globalKeys } from './global-keys'
77
+
78
+ // Input - stdin ownership
79
+ export { input } from './input'