@vsuryav/agent-sim 0.1.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 (154) hide show
  1. package/README.md +25 -0
  2. package/bin/agent-sim.js +25 -0
  3. package/package.json +72 -0
  4. package/src/app-paths.ts +29 -0
  5. package/src/app-sync.test.ts +75 -0
  6. package/src/app-sync.ts +110 -0
  7. package/src/cli.ts +129 -0
  8. package/src/collector/claude-code.test.ts +102 -0
  9. package/src/collector/claude-code.ts +133 -0
  10. package/src/collector/codex-cli.test.ts +116 -0
  11. package/src/collector/codex-cli.ts +149 -0
  12. package/src/collector/db.test.ts +59 -0
  13. package/src/collector/db.ts +125 -0
  14. package/src/collector/names.test.ts +21 -0
  15. package/src/collector/names.ts +28 -0
  16. package/src/collector/personality.test.ts +40 -0
  17. package/src/collector/personality.ts +46 -0
  18. package/src/collector/remote-sync.test.ts +31 -0
  19. package/src/collector/remote-sync.ts +171 -0
  20. package/src/collector/sync.test.ts +67 -0
  21. package/src/collector/sync.ts +148 -0
  22. package/src/collector/types.ts +1 -0
  23. package/src/engine/bootstrap/state.ts +3 -0
  24. package/src/engine/buddy/CompanionSprite.tsx +371 -0
  25. package/src/engine/buddy/companion.ts +133 -0
  26. package/src/engine/buddy/prompt.ts +36 -0
  27. package/src/engine/buddy/sprites.ts +514 -0
  28. package/src/engine/buddy/types.ts +148 -0
  29. package/src/engine/buddy/useBuddyNotification.tsx +98 -0
  30. package/src/engine/ink/Ansi.tsx +292 -0
  31. package/src/engine/ink/bidi.ts +139 -0
  32. package/src/engine/ink/clearTerminal.ts +74 -0
  33. package/src/engine/ink/colorize.ts +231 -0
  34. package/src/engine/ink/components/AlternateScreen.tsx +80 -0
  35. package/src/engine/ink/components/App.tsx +658 -0
  36. package/src/engine/ink/components/AppContext.ts +21 -0
  37. package/src/engine/ink/components/Box.tsx +214 -0
  38. package/src/engine/ink/components/Button.tsx +192 -0
  39. package/src/engine/ink/components/ClockContext.tsx +112 -0
  40. package/src/engine/ink/components/CursorDeclarationContext.ts +32 -0
  41. package/src/engine/ink/components/ErrorOverview.tsx +109 -0
  42. package/src/engine/ink/components/Link.tsx +42 -0
  43. package/src/engine/ink/components/Newline.tsx +39 -0
  44. package/src/engine/ink/components/NoSelect.tsx +68 -0
  45. package/src/engine/ink/components/RawAnsi.tsx +57 -0
  46. package/src/engine/ink/components/ScrollBox.tsx +237 -0
  47. package/src/engine/ink/components/Spacer.tsx +20 -0
  48. package/src/engine/ink/components/StdinContext.ts +49 -0
  49. package/src/engine/ink/components/TerminalFocusContext.tsx +52 -0
  50. package/src/engine/ink/components/TerminalSizeContext.tsx +7 -0
  51. package/src/engine/ink/components/Text.tsx +254 -0
  52. package/src/engine/ink/constants.ts +2 -0
  53. package/src/engine/ink/dom.ts +484 -0
  54. package/src/engine/ink/events/click-event.ts +38 -0
  55. package/src/engine/ink/events/dispatcher.ts +233 -0
  56. package/src/engine/ink/events/emitter.ts +39 -0
  57. package/src/engine/ink/events/event-handlers.ts +73 -0
  58. package/src/engine/ink/events/event.ts +11 -0
  59. package/src/engine/ink/events/focus-event.ts +21 -0
  60. package/src/engine/ink/events/input-event.ts +205 -0
  61. package/src/engine/ink/events/keyboard-event.ts +51 -0
  62. package/src/engine/ink/events/terminal-event.ts +107 -0
  63. package/src/engine/ink/events/terminal-focus-event.ts +19 -0
  64. package/src/engine/ink/focus.ts +181 -0
  65. package/src/engine/ink/frame.ts +124 -0
  66. package/src/engine/ink/get-max-width.ts +27 -0
  67. package/src/engine/ink/global.d.ts +18 -0
  68. package/src/engine/ink/hit-test.ts +130 -0
  69. package/src/engine/ink/hooks/use-animation-frame.ts +57 -0
  70. package/src/engine/ink/hooks/use-app.ts +8 -0
  71. package/src/engine/ink/hooks/use-declared-cursor.ts +73 -0
  72. package/src/engine/ink/hooks/use-input.ts +92 -0
  73. package/src/engine/ink/hooks/use-interval.ts +67 -0
  74. package/src/engine/ink/hooks/use-search-highlight.ts +53 -0
  75. package/src/engine/ink/hooks/use-selection.ts +104 -0
  76. package/src/engine/ink/hooks/use-stdin.ts +8 -0
  77. package/src/engine/ink/hooks/use-tab-status.ts +72 -0
  78. package/src/engine/ink/hooks/use-terminal-focus.ts +16 -0
  79. package/src/engine/ink/hooks/use-terminal-title.ts +31 -0
  80. package/src/engine/ink/hooks/use-terminal-viewport.ts +96 -0
  81. package/src/engine/ink/ink.tsx +1723 -0
  82. package/src/engine/ink/instances.ts +10 -0
  83. package/src/engine/ink/layout/engine.ts +6 -0
  84. package/src/engine/ink/layout/geometry.ts +97 -0
  85. package/src/engine/ink/layout/node.ts +152 -0
  86. package/src/engine/ink/layout/yoga.ts +308 -0
  87. package/src/engine/ink/line-width-cache.ts +24 -0
  88. package/src/engine/ink/log-update.ts +773 -0
  89. package/src/engine/ink/measure-element.ts +23 -0
  90. package/src/engine/ink/measure-text.ts +47 -0
  91. package/src/engine/ink/node-cache.ts +54 -0
  92. package/src/engine/ink/optimizer.ts +93 -0
  93. package/src/engine/ink/output.ts +797 -0
  94. package/src/engine/ink/parse-keypress.ts +801 -0
  95. package/src/engine/ink/reconciler.ts +512 -0
  96. package/src/engine/ink/render-border.ts +231 -0
  97. package/src/engine/ink/render-node-to-output.ts +1462 -0
  98. package/src/engine/ink/render-to-screen.ts +231 -0
  99. package/src/engine/ink/renderer.ts +178 -0
  100. package/src/engine/ink/root.ts +184 -0
  101. package/src/engine/ink/screen.ts +1486 -0
  102. package/src/engine/ink/searchHighlight.ts +93 -0
  103. package/src/engine/ink/selection.ts +917 -0
  104. package/src/engine/ink/squash-text-nodes.ts +92 -0
  105. package/src/engine/ink/stringWidth.ts +222 -0
  106. package/src/engine/ink/styles.ts +771 -0
  107. package/src/engine/ink/supports-hyperlinks.ts +57 -0
  108. package/src/engine/ink/tabstops.ts +46 -0
  109. package/src/engine/ink/terminal-focus-state.ts +47 -0
  110. package/src/engine/ink/terminal-querier.ts +212 -0
  111. package/src/engine/ink/terminal.ts +248 -0
  112. package/src/engine/ink/termio/ansi.ts +75 -0
  113. package/src/engine/ink/termio/csi.ts +319 -0
  114. package/src/engine/ink/termio/dec.ts +60 -0
  115. package/src/engine/ink/termio/esc.ts +67 -0
  116. package/src/engine/ink/termio/osc.ts +493 -0
  117. package/src/engine/ink/termio/parser.ts +394 -0
  118. package/src/engine/ink/termio/sgr.ts +308 -0
  119. package/src/engine/ink/termio/tokenize.ts +319 -0
  120. package/src/engine/ink/termio/types.ts +236 -0
  121. package/src/engine/ink/useTerminalNotification.ts +126 -0
  122. package/src/engine/ink/warn.ts +9 -0
  123. package/src/engine/ink/widest-line.ts +19 -0
  124. package/src/engine/ink/wrap-text.ts +74 -0
  125. package/src/engine/ink/wrapAnsi.ts +20 -0
  126. package/src/engine/native-ts/yoga-layout/enums.ts +134 -0
  127. package/src/engine/native-ts/yoga-layout/index.ts +2578 -0
  128. package/src/engine/stubs/bootstrap-state.ts +4 -0
  129. package/src/engine/stubs/debug.ts +6 -0
  130. package/src/engine/stubs/log.ts +4 -0
  131. package/src/engine/utils/debug.ts +5 -0
  132. package/src/engine/utils/earlyInput.ts +4 -0
  133. package/src/engine/utils/env.ts +15 -0
  134. package/src/engine/utils/envUtils.ts +4 -0
  135. package/src/engine/utils/execFileNoThrow.ts +24 -0
  136. package/src/engine/utils/fullscreen.ts +4 -0
  137. package/src/engine/utils/intl.ts +9 -0
  138. package/src/engine/utils/log.ts +3 -0
  139. package/src/engine/utils/semver.ts +13 -0
  140. package/src/engine/utils/sliceAnsi.ts +10 -0
  141. package/src/engine/utils/theme.ts +17 -0
  142. package/src/game/App.tsx +141 -0
  143. package/src/game/agents/behavior.ts +249 -0
  144. package/src/game/agents/speech.ts +57 -0
  145. package/src/game/canvas.ts +98 -0
  146. package/src/game/launch.ts +36 -0
  147. package/src/game/ship/ShipView.tsx +145 -0
  148. package/src/game/ship/ship-map.ts +172 -0
  149. package/src/game/ui/AgentBio.tsx +72 -0
  150. package/src/game/ui/HUD.tsx +63 -0
  151. package/src/game/ui/StatusBar.tsx +49 -0
  152. package/src/game/useKeyboard.ts +62 -0
  153. package/src/main.tsx +22 -0
  154. package/src/run-interactive.ts +74 -0
@@ -0,0 +1,107 @@
1
+ import { Event } from './event.js'
2
+
3
+ type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling'
4
+
5
+ type TerminalEventInit = {
6
+ bubbles?: boolean
7
+ cancelable?: boolean
8
+ }
9
+
10
+ /**
11
+ * Base class for all terminal events with DOM-style propagation.
12
+ *
13
+ * Extends Event so existing event types (ClickEvent, InputEvent,
14
+ * TerminalFocusEvent) share a common ancestor and can migrate later.
15
+ *
16
+ * Mirrors the browser's Event API: target, currentTarget, eventPhase,
17
+ * stopPropagation(), preventDefault(), timeStamp.
18
+ */
19
+ export class TerminalEvent extends Event {
20
+ readonly type: string
21
+ readonly timeStamp: number
22
+ readonly bubbles: boolean
23
+ readonly cancelable: boolean
24
+
25
+ private _target: EventTarget | null = null
26
+ private _currentTarget: EventTarget | null = null
27
+ private _eventPhase: EventPhase = 'none'
28
+ private _propagationStopped = false
29
+ private _defaultPrevented = false
30
+
31
+ constructor(type: string, init?: TerminalEventInit) {
32
+ super()
33
+ this.type = type
34
+ this.timeStamp = performance.now()
35
+ this.bubbles = init?.bubbles ?? true
36
+ this.cancelable = init?.cancelable ?? true
37
+ }
38
+
39
+ get target(): EventTarget | null {
40
+ return this._target
41
+ }
42
+
43
+ get currentTarget(): EventTarget | null {
44
+ return this._currentTarget
45
+ }
46
+
47
+ get eventPhase(): EventPhase {
48
+ return this._eventPhase
49
+ }
50
+
51
+ get defaultPrevented(): boolean {
52
+ return this._defaultPrevented
53
+ }
54
+
55
+ stopPropagation(): void {
56
+ this._propagationStopped = true
57
+ }
58
+
59
+ override stopImmediatePropagation(): void {
60
+ super.stopImmediatePropagation()
61
+ this._propagationStopped = true
62
+ }
63
+
64
+ preventDefault(): void {
65
+ if (this.cancelable) {
66
+ this._defaultPrevented = true
67
+ }
68
+ }
69
+
70
+ // -- Internal setters used by the Dispatcher
71
+
72
+ /** @internal */
73
+ _setTarget(target: EventTarget): void {
74
+ this._target = target
75
+ }
76
+
77
+ /** @internal */
78
+ _setCurrentTarget(target: EventTarget | null): void {
79
+ this._currentTarget = target
80
+ }
81
+
82
+ /** @internal */
83
+ _setEventPhase(phase: EventPhase): void {
84
+ this._eventPhase = phase
85
+ }
86
+
87
+ /** @internal */
88
+ _isPropagationStopped(): boolean {
89
+ return this._propagationStopped
90
+ }
91
+
92
+ /** @internal */
93
+ _isImmediatePropagationStopped(): boolean {
94
+ return this.didStopImmediatePropagation()
95
+ }
96
+
97
+ /**
98
+ * Hook for subclasses to do per-node setup before each handler fires.
99
+ * Default is a no-op.
100
+ */
101
+ _prepareForTarget(_target: EventTarget): void {}
102
+ }
103
+
104
+ export type EventTarget = {
105
+ parentNode: EventTarget | undefined
106
+ _eventHandlers?: Record<string, unknown>
107
+ }
@@ -0,0 +1,19 @@
1
+ import { Event } from './event.js'
2
+
3
+ export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur'
4
+
5
+ /**
6
+ * Event fired when the terminal window gains or loses focus.
7
+ *
8
+ * Uses DECSET 1004 focus reporting - the terminal sends:
9
+ * - CSI I (\x1b[I) when the terminal gains focus
10
+ * - CSI O (\x1b[O) when the terminal loses focus
11
+ */
12
+ export class TerminalFocusEvent extends Event {
13
+ readonly type: TerminalFocusEventType
14
+
15
+ constructor(type: TerminalFocusEventType) {
16
+ super()
17
+ this.type = type
18
+ }
19
+ }
@@ -0,0 +1,181 @@
1
+ import type { DOMElement } from './dom.js'
2
+ import { FocusEvent } from './events/focus-event.js'
3
+
4
+ const MAX_FOCUS_STACK = 32
5
+
6
+ /**
7
+ * DOM-like focus manager for the Ink terminal UI.
8
+ *
9
+ * Pure state — tracks activeElement and a focus stack. Has no reference
10
+ * to the tree; callers pass the root when tree walks are needed.
11
+ *
12
+ * Stored on the root DOMElement so any node can reach it by walking
13
+ * parentNode (like browser's `node.ownerDocument`).
14
+ */
15
+ export class FocusManager {
16
+ activeElement: DOMElement | null = null
17
+ private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean
18
+ private enabled = true
19
+ private focusStack: DOMElement[] = []
20
+
21
+ constructor(
22
+ dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean,
23
+ ) {
24
+ this.dispatchFocusEvent = dispatchFocusEvent
25
+ }
26
+
27
+ focus(node: DOMElement): void {
28
+ if (node === this.activeElement) return
29
+ if (!this.enabled) return
30
+
31
+ const previous = this.activeElement
32
+ if (previous) {
33
+ // Deduplicate before pushing to prevent unbounded growth from Tab cycling
34
+ const idx = this.focusStack.indexOf(previous)
35
+ if (idx !== -1) this.focusStack.splice(idx, 1)
36
+ this.focusStack.push(previous)
37
+ if (this.focusStack.length > MAX_FOCUS_STACK) this.focusStack.shift()
38
+ this.dispatchFocusEvent(previous, new FocusEvent('blur', node))
39
+ }
40
+ this.activeElement = node
41
+ this.dispatchFocusEvent(node, new FocusEvent('focus', previous))
42
+ }
43
+
44
+ blur(): void {
45
+ if (!this.activeElement) return
46
+
47
+ const previous = this.activeElement
48
+ this.activeElement = null
49
+ this.dispatchFocusEvent(previous, new FocusEvent('blur', null))
50
+ }
51
+
52
+ /**
53
+ * Called by the reconciler when a node is removed from the tree.
54
+ * Handles both the exact node and any focused descendant within
55
+ * the removed subtree. Dispatches blur and restores focus from stack.
56
+ */
57
+ handleNodeRemoved(node: DOMElement, root: DOMElement): void {
58
+ // Remove the node and any descendants from the stack
59
+ this.focusStack = this.focusStack.filter(
60
+ n => n !== node && isInTree(n, root),
61
+ )
62
+
63
+ // Check if activeElement is the removed node OR a descendant
64
+ if (!this.activeElement) return
65
+ if (this.activeElement !== node && isInTree(this.activeElement, root)) {
66
+ return
67
+ }
68
+
69
+ const removed = this.activeElement
70
+ this.activeElement = null
71
+ this.dispatchFocusEvent(removed, new FocusEvent('blur', null))
72
+
73
+ // Restore focus to the most recent still-mounted element
74
+ while (this.focusStack.length > 0) {
75
+ const candidate = this.focusStack.pop()!
76
+ if (isInTree(candidate, root)) {
77
+ this.activeElement = candidate
78
+ this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed))
79
+ return
80
+ }
81
+ }
82
+ }
83
+
84
+ handleAutoFocus(node: DOMElement): void {
85
+ this.focus(node)
86
+ }
87
+
88
+ handleClickFocus(node: DOMElement): void {
89
+ const tabIndex = node.attributes['tabIndex']
90
+ if (typeof tabIndex !== 'number') return
91
+ this.focus(node)
92
+ }
93
+
94
+ enable(): void {
95
+ this.enabled = true
96
+ }
97
+
98
+ disable(): void {
99
+ this.enabled = false
100
+ }
101
+
102
+ focusNext(root: DOMElement): void {
103
+ this.moveFocus(1, root)
104
+ }
105
+
106
+ focusPrevious(root: DOMElement): void {
107
+ this.moveFocus(-1, root)
108
+ }
109
+
110
+ private moveFocus(direction: 1 | -1, root: DOMElement): void {
111
+ if (!this.enabled) return
112
+
113
+ const tabbable = collectTabbable(root)
114
+ if (tabbable.length === 0) return
115
+
116
+ const currentIndex = this.activeElement
117
+ ? tabbable.indexOf(this.activeElement)
118
+ : -1
119
+
120
+ const nextIndex =
121
+ currentIndex === -1
122
+ ? direction === 1
123
+ ? 0
124
+ : tabbable.length - 1
125
+ : (currentIndex + direction + tabbable.length) % tabbable.length
126
+
127
+ const next = tabbable[nextIndex]
128
+ if (next) {
129
+ this.focus(next)
130
+ }
131
+ }
132
+ }
133
+
134
+ function collectTabbable(root: DOMElement): DOMElement[] {
135
+ const result: DOMElement[] = []
136
+ walkTree(root, result)
137
+ return result
138
+ }
139
+
140
+ function walkTree(node: DOMElement, result: DOMElement[]): void {
141
+ const tabIndex = node.attributes['tabIndex']
142
+ if (typeof tabIndex === 'number' && tabIndex >= 0) {
143
+ result.push(node)
144
+ }
145
+
146
+ for (const child of node.childNodes) {
147
+ if (child.nodeName !== '#text') {
148
+ walkTree(child, result)
149
+ }
150
+ }
151
+ }
152
+
153
+ function isInTree(node: DOMElement, root: DOMElement): boolean {
154
+ let current: DOMElement | undefined = node
155
+ while (current) {
156
+ if (current === root) return true
157
+ current = current.parentNode
158
+ }
159
+ return false
160
+ }
161
+
162
+ /**
163
+ * Walk up to root and return it. The root is the node that holds
164
+ * the FocusManager — like browser's `node.getRootNode()`.
165
+ */
166
+ export function getRootNode(node: DOMElement): DOMElement {
167
+ let current: DOMElement | undefined = node
168
+ while (current) {
169
+ if (current.focusManager) return current
170
+ current = current.parentNode
171
+ }
172
+ throw new Error('Node is not in a tree with a FocusManager')
173
+ }
174
+
175
+ /**
176
+ * Walk up to root and return its FocusManager.
177
+ * Like browser's `node.ownerDocument` — focus belongs to the root.
178
+ */
179
+ export function getFocusManager(node: DOMElement): FocusManager {
180
+ return getRootNode(node).focusManager!
181
+ }
@@ -0,0 +1,124 @@
1
+ import type { Cursor } from './cursor.js'
2
+ import type { Size } from './layout/geometry.js'
3
+ import type { ScrollHint } from './render-node-to-output.js'
4
+ import {
5
+ type CharPool,
6
+ createScreen,
7
+ type HyperlinkPool,
8
+ type Screen,
9
+ type StylePool,
10
+ } from './screen.js'
11
+
12
+ export type Frame = {
13
+ readonly screen: Screen
14
+ readonly viewport: Size
15
+ readonly cursor: Cursor
16
+ /** DECSTBM scroll optimization hint (alt-screen only, null otherwise). */
17
+ readonly scrollHint?: ScrollHint | null
18
+ /** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */
19
+ readonly scrollDrainPending?: boolean
20
+ }
21
+
22
+ export function emptyFrame(
23
+ rows: number,
24
+ columns: number,
25
+ stylePool: StylePool,
26
+ charPool: CharPool,
27
+ hyperlinkPool: HyperlinkPool,
28
+ ): Frame {
29
+ return {
30
+ screen: createScreen(0, 0, stylePool, charPool, hyperlinkPool),
31
+ viewport: { width: columns, height: rows },
32
+ cursor: { x: 0, y: 0, visible: true },
33
+ }
34
+ }
35
+
36
+ export type FlickerReason = 'resize' | 'offscreen' | 'clear'
37
+
38
+ export type FrameEvent = {
39
+ durationMs: number
40
+ /** Phase breakdown in ms + patch count. Populated when the ink instance
41
+ * has frame-timing instrumentation enabled (via onFrame wiring). */
42
+ phases?: {
43
+ /** createRenderer output: DOM → yoga layout → screen buffer */
44
+ renderer: number
45
+ /** LogUpdate.render(): screen diff → Patch[] (the hot path this PR optimizes) */
46
+ diff: number
47
+ /** optimize(): patch merge/dedupe */
48
+ optimize: number
49
+ /** writeDiffToTerminal(): serialize patches → ANSI → stdout */
50
+ write: number
51
+ /** Pre-optimize patch count (proxy for how much changed this frame) */
52
+ patches: number
53
+ /** yoga calculateLayout() time (runs in resetAfterCommit, before onRender) */
54
+ yoga: number
55
+ /** React reconcile time: scrollMutated → resetAfterCommit. 0 if no commit. */
56
+ commit: number
57
+ /** layoutNode() calls this frame (recursive, includes cache-hit returns) */
58
+ yogaVisited: number
59
+ /** measureFunc (text wrap/width) calls — the expensive part */
60
+ yogaMeasured: number
61
+ /** early returns via _hasL single-slot cache */
62
+ yogaCacheHits: number
63
+ /** total yoga Node instances alive (create - free). Growth = leak. */
64
+ yogaLive: number
65
+ }
66
+ flickers: Array<{
67
+ desiredHeight: number
68
+ availableHeight: number
69
+ reason: FlickerReason
70
+ }>
71
+ }
72
+
73
+ export type Patch =
74
+ | { type: 'stdout'; content: string }
75
+ | { type: 'clear'; count: number }
76
+ | {
77
+ type: 'clearTerminal'
78
+ reason: FlickerReason
79
+ // Populated by log-update when a scrollback diff triggers the reset.
80
+ // ink.tsx uses triggerY with findOwnerChainAtRow to attribute the
81
+ // flicker to its source React component.
82
+ debug?: { triggerY: number; prevLine: string; nextLine: string }
83
+ }
84
+ | { type: 'cursorHide' }
85
+ | { type: 'cursorShow' }
86
+ | { type: 'cursorMove'; x: number; y: number }
87
+ | { type: 'cursorTo'; col: number }
88
+ | { type: 'carriageReturn' }
89
+ | { type: 'hyperlink'; uri: string }
90
+ // Pre-serialized style transition string from StylePool.transition() —
91
+ // cached by (fromId, toId), zero allocations after warmup.
92
+ | { type: 'styleStr'; str: string }
93
+
94
+ export type Diff = Patch[]
95
+
96
+ /**
97
+ * Determines whether the screen should be cleared based on the current and previous frame.
98
+ * Returns the reason for clearing, or undefined if no clear is needed.
99
+ *
100
+ * Screen clearing is triggered when:
101
+ * 1. Terminal has been resized (viewport dimensions changed) → 'resize'
102
+ * 2. Current frame screen height exceeds available terminal rows → 'offscreen'
103
+ * 3. Previous frame screen height exceeded available terminal rows → 'offscreen'
104
+ */
105
+ export function shouldClearScreen(
106
+ prevFrame: Frame,
107
+ frame: Frame,
108
+ ): FlickerReason | undefined {
109
+ const didResize =
110
+ frame.viewport.height !== prevFrame.viewport.height ||
111
+ frame.viewport.width !== prevFrame.viewport.width
112
+ if (didResize) {
113
+ return 'resize'
114
+ }
115
+
116
+ const currentFrameOverflows = frame.screen.height >= frame.viewport.height
117
+ const previousFrameOverflowed =
118
+ prevFrame.screen.height >= prevFrame.viewport.height
119
+ if (currentFrameOverflows || previousFrameOverflowed) {
120
+ return 'offscreen'
121
+ }
122
+
123
+ return undefined
124
+ }
@@ -0,0 +1,27 @@
1
+ import { LayoutEdge, type LayoutNode } from './layout/node.js'
2
+
3
+ /**
4
+ * Returns the yoga node's content width (computed width minus padding and
5
+ * border).
6
+ *
7
+ * Warning: can return a value WIDER than the parent container. In a
8
+ * column-direction flex parent, width is the cross axis — align-items:
9
+ * stretch never shrinks children below their intrinsic size, so the text
10
+ * node overflows (standard CSS behavior). Yoga measures leaf nodes in two
11
+ * passes: the AtMost pass determines width, the Exactly pass determines
12
+ * height. getComputedWidth() reflects the wider AtMost result while
13
+ * getComputedHeight() reflects the narrower Exactly result. Callers that
14
+ * use this for wrapping should clamp to actual available screen space so
15
+ * the rendered line count stays consistent with the layout height.
16
+ */
17
+ const getMaxWidth = (yogaNode: LayoutNode): number => {
18
+ return (
19
+ yogaNode.getComputedWidth() -
20
+ yogaNode.getComputedPadding(LayoutEdge.Left) -
21
+ yogaNode.getComputedPadding(LayoutEdge.Right) -
22
+ yogaNode.getComputedBorder(LayoutEdge.Left) -
23
+ yogaNode.getComputedBorder(LayoutEdge.Right)
24
+ )
25
+ }
26
+
27
+ export default getMaxWidth
@@ -0,0 +1,18 @@
1
+ // Global type augmentations for the ink engine
2
+ import type { DOMElement } from './dom.js';
3
+
4
+ declare global {
5
+ namespace JSX {
6
+ interface IntrinsicElements {
7
+ 'ink-box': any;
8
+ 'ink-text': any;
9
+ 'ink-root': any;
10
+ 'ink-virtual-text': any;
11
+ 'ink-raw-ansi': {
12
+ rawText: string;
13
+ rawWidth: number;
14
+ rawHeight: number;
15
+ };
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,130 @@
1
+ import type { DOMElement } from './dom.js'
2
+ import { ClickEvent } from './events/click-event.js'
3
+ import type { EventHandlerProps } from './events/event-handlers.js'
4
+ import { nodeCache } from './node-cache.js'
5
+
6
+ /**
7
+ * Find the deepest DOM element whose rendered rect contains (col, row).
8
+ *
9
+ * Uses the nodeCache populated by renderNodeToOutput — rects are in screen
10
+ * coordinates with all offsets (including scrollTop translation) already
11
+ * applied. Children are traversed in reverse so later siblings (painted on
12
+ * top) win. Nodes not in nodeCache (not rendered this frame, or lacking a
13
+ * yogaNode) are skipped along with their subtrees.
14
+ *
15
+ * Returns the hit node even if it has no onClick — dispatchClick walks up
16
+ * via parentNode to find handlers.
17
+ */
18
+ export function hitTest(
19
+ node: DOMElement,
20
+ col: number,
21
+ row: number,
22
+ ): DOMElement | null {
23
+ const rect = nodeCache.get(node)
24
+ if (!rect) return null
25
+ if (
26
+ col < rect.x ||
27
+ col >= rect.x + rect.width ||
28
+ row < rect.y ||
29
+ row >= rect.y + rect.height
30
+ ) {
31
+ return null
32
+ }
33
+ // Later siblings paint on top; reversed traversal returns topmost hit.
34
+ for (let i = node.childNodes.length - 1; i >= 0; i--) {
35
+ const child = node.childNodes[i]!
36
+ if (child.nodeName === '#text') continue
37
+ const hit = hitTest(child, col, row)
38
+ if (hit) return hit
39
+ }
40
+ return node
41
+ }
42
+
43
+ /**
44
+ * Hit-test the root at (col, row) and bubble a ClickEvent from the deepest
45
+ * containing node up through parentNode. Only nodes with an onClick handler
46
+ * fire. Stops when a handler calls stopImmediatePropagation(). Returns
47
+ * true if at least one onClick handler fired.
48
+ */
49
+ export function dispatchClick(
50
+ root: DOMElement,
51
+ col: number,
52
+ row: number,
53
+ cellIsBlank = false,
54
+ ): boolean {
55
+ let target: DOMElement | undefined = hitTest(root, col, row) ?? undefined
56
+ if (!target) return false
57
+
58
+ // Click-to-focus: find the closest focusable ancestor and focus it.
59
+ // root is always ink-root, which owns the FocusManager.
60
+ if (root.focusManager) {
61
+ let focusTarget: DOMElement | undefined = target
62
+ while (focusTarget) {
63
+ if (typeof focusTarget.attributes['tabIndex'] === 'number') {
64
+ root.focusManager.handleClickFocus(focusTarget)
65
+ break
66
+ }
67
+ focusTarget = focusTarget.parentNode
68
+ }
69
+ }
70
+ const event = new ClickEvent(col, row, cellIsBlank)
71
+ let handled = false
72
+ while (target) {
73
+ const handler = target._eventHandlers?.onClick as
74
+ | ((event: ClickEvent) => void)
75
+ | undefined
76
+ if (handler) {
77
+ handled = true
78
+ const rect = nodeCache.get(target)
79
+ if (rect) {
80
+ event.localCol = col - rect.x
81
+ event.localRow = row - rect.y
82
+ }
83
+ handler(event)
84
+ if (event.didStopImmediatePropagation()) return true
85
+ }
86
+ target = target.parentNode
87
+ }
88
+ return handled
89
+ }
90
+
91
+ /**
92
+ * Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM
93
+ * mouseenter/mouseleave: does NOT bubble — moving between children does
94
+ * not re-fire on the parent. Walks up from the hit node collecting every
95
+ * ancestor with a hover handler; diffs against the previous hovered set;
96
+ * fires leave on the nodes exited, enter on the nodes entered.
97
+ *
98
+ * Mutates `hovered` in place so the caller (App instance) can hold it
99
+ * across calls. Clears the set when the hit is null (cursor moved into a
100
+ * non-rendered gap or off the root rect).
101
+ */
102
+ export function dispatchHover(
103
+ root: DOMElement,
104
+ col: number,
105
+ row: number,
106
+ hovered: Set<DOMElement>,
107
+ ): void {
108
+ const next = new Set<DOMElement>()
109
+ let node: DOMElement | undefined = hitTest(root, col, row) ?? undefined
110
+ while (node) {
111
+ const h = node._eventHandlers as EventHandlerProps | undefined
112
+ if (h?.onMouseEnter || h?.onMouseLeave) next.add(node)
113
+ node = node.parentNode
114
+ }
115
+ for (const old of hovered) {
116
+ if (!next.has(old)) {
117
+ hovered.delete(old)
118
+ // Skip handlers on detached nodes (removed between mouse events)
119
+ if (old.parentNode) {
120
+ ;(old._eventHandlers as EventHandlerProps | undefined)?.onMouseLeave?.()
121
+ }
122
+ }
123
+ }
124
+ for (const n of next) {
125
+ if (!hovered.has(n)) {
126
+ hovered.add(n)
127
+ ;(n._eventHandlers as EventHandlerProps | undefined)?.onMouseEnter?.()
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,57 @@
1
+ import { useContext, useEffect, useState } from 'react'
2
+ import { ClockContext } from '../components/ClockContext.js'
3
+ import type { DOMElement } from '../dom.js'
4
+ import { useTerminalViewport } from './use-terminal-viewport.js'
5
+
6
+ /**
7
+ * Hook for synchronized animations that pause when offscreen.
8
+ *
9
+ * Returns a ref to attach to the animated element and the current animation time.
10
+ * All instances share the same clock, so animations stay in sync.
11
+ * The clock only runs when at least one keepAlive subscriber exists.
12
+ *
13
+ * Pass `null` to pause — unsubscribes from the clock so no ticks fire.
14
+ * Time freezes at the last value and resumes from the current clock time
15
+ * when a number is passed again.
16
+ *
17
+ * @param intervalMs - How often to update, or null to pause
18
+ * @returns [ref, time] - Ref to attach to element, elapsed time in ms
19
+ *
20
+ * @example
21
+ * function Spinner() {
22
+ * const [ref, time] = useAnimationFrame(120)
23
+ * const frame = Math.floor(time / 120) % FRAMES.length
24
+ * return <Box ref={ref}>{FRAMES[frame]}</Box>
25
+ * }
26
+ *
27
+ * The clock automatically slows when the terminal is blurred,
28
+ * so consumers don't need to handle focus state.
29
+ */
30
+ export function useAnimationFrame(
31
+ intervalMs: number | null = 16,
32
+ ): [ref: (element: DOMElement | null) => void, time: number] {
33
+ const clock = useContext(ClockContext)
34
+ const [viewportRef, { isVisible }] = useTerminalViewport()
35
+ const [time, setTime] = useState(() => clock?.now() ?? 0)
36
+
37
+ const active = isVisible && intervalMs !== null
38
+
39
+ useEffect(() => {
40
+ if (!clock || !active) return
41
+
42
+ let lastUpdate = clock.now()
43
+
44
+ const onChange = (): void => {
45
+ const now = clock.now()
46
+ if (now - lastUpdate >= intervalMs!) {
47
+ lastUpdate = now
48
+ setTime(now)
49
+ }
50
+ }
51
+
52
+ // keepAlive: true — visible animations drive the clock
53
+ return clock.subscribe(onChange, true)
54
+ }, [clock, intervalMs, active])
55
+
56
+ return [viewportRef, time]
57
+ }
@@ -0,0 +1,8 @@
1
+ import { useContext } from 'react'
2
+ import AppContext from '../components/AppContext.js'
3
+
4
+ /**
5
+ * `useApp` is a React hook, which exposes a method to manually exit the app (unmount).
6
+ */
7
+ const useApp = () => useContext(AppContext)
8
+ export default useApp