@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.
- package/README.md +25 -0
- package/bin/agent-sim.js +25 -0
- package/package.json +72 -0
- package/src/app-paths.ts +29 -0
- package/src/app-sync.test.ts +75 -0
- package/src/app-sync.ts +110 -0
- package/src/cli.ts +129 -0
- package/src/collector/claude-code.test.ts +102 -0
- package/src/collector/claude-code.ts +133 -0
- package/src/collector/codex-cli.test.ts +116 -0
- package/src/collector/codex-cli.ts +149 -0
- package/src/collector/db.test.ts +59 -0
- package/src/collector/db.ts +125 -0
- package/src/collector/names.test.ts +21 -0
- package/src/collector/names.ts +28 -0
- package/src/collector/personality.test.ts +40 -0
- package/src/collector/personality.ts +46 -0
- package/src/collector/remote-sync.test.ts +31 -0
- package/src/collector/remote-sync.ts +171 -0
- package/src/collector/sync.test.ts +67 -0
- package/src/collector/sync.ts +148 -0
- package/src/collector/types.ts +1 -0
- package/src/engine/bootstrap/state.ts +3 -0
- package/src/engine/buddy/CompanionSprite.tsx +371 -0
- package/src/engine/buddy/companion.ts +133 -0
- package/src/engine/buddy/prompt.ts +36 -0
- package/src/engine/buddy/sprites.ts +514 -0
- package/src/engine/buddy/types.ts +148 -0
- package/src/engine/buddy/useBuddyNotification.tsx +98 -0
- package/src/engine/ink/Ansi.tsx +292 -0
- package/src/engine/ink/bidi.ts +139 -0
- package/src/engine/ink/clearTerminal.ts +74 -0
- package/src/engine/ink/colorize.ts +231 -0
- package/src/engine/ink/components/AlternateScreen.tsx +80 -0
- package/src/engine/ink/components/App.tsx +658 -0
- package/src/engine/ink/components/AppContext.ts +21 -0
- package/src/engine/ink/components/Box.tsx +214 -0
- package/src/engine/ink/components/Button.tsx +192 -0
- package/src/engine/ink/components/ClockContext.tsx +112 -0
- package/src/engine/ink/components/CursorDeclarationContext.ts +32 -0
- package/src/engine/ink/components/ErrorOverview.tsx +109 -0
- package/src/engine/ink/components/Link.tsx +42 -0
- package/src/engine/ink/components/Newline.tsx +39 -0
- package/src/engine/ink/components/NoSelect.tsx +68 -0
- package/src/engine/ink/components/RawAnsi.tsx +57 -0
- package/src/engine/ink/components/ScrollBox.tsx +237 -0
- package/src/engine/ink/components/Spacer.tsx +20 -0
- package/src/engine/ink/components/StdinContext.ts +49 -0
- package/src/engine/ink/components/TerminalFocusContext.tsx +52 -0
- package/src/engine/ink/components/TerminalSizeContext.tsx +7 -0
- package/src/engine/ink/components/Text.tsx +254 -0
- package/src/engine/ink/constants.ts +2 -0
- package/src/engine/ink/dom.ts +484 -0
- package/src/engine/ink/events/click-event.ts +38 -0
- package/src/engine/ink/events/dispatcher.ts +233 -0
- package/src/engine/ink/events/emitter.ts +39 -0
- package/src/engine/ink/events/event-handlers.ts +73 -0
- package/src/engine/ink/events/event.ts +11 -0
- package/src/engine/ink/events/focus-event.ts +21 -0
- package/src/engine/ink/events/input-event.ts +205 -0
- package/src/engine/ink/events/keyboard-event.ts +51 -0
- package/src/engine/ink/events/terminal-event.ts +107 -0
- package/src/engine/ink/events/terminal-focus-event.ts +19 -0
- package/src/engine/ink/focus.ts +181 -0
- package/src/engine/ink/frame.ts +124 -0
- package/src/engine/ink/get-max-width.ts +27 -0
- package/src/engine/ink/global.d.ts +18 -0
- package/src/engine/ink/hit-test.ts +130 -0
- package/src/engine/ink/hooks/use-animation-frame.ts +57 -0
- package/src/engine/ink/hooks/use-app.ts +8 -0
- package/src/engine/ink/hooks/use-declared-cursor.ts +73 -0
- package/src/engine/ink/hooks/use-input.ts +92 -0
- package/src/engine/ink/hooks/use-interval.ts +67 -0
- package/src/engine/ink/hooks/use-search-highlight.ts +53 -0
- package/src/engine/ink/hooks/use-selection.ts +104 -0
- package/src/engine/ink/hooks/use-stdin.ts +8 -0
- package/src/engine/ink/hooks/use-tab-status.ts +72 -0
- package/src/engine/ink/hooks/use-terminal-focus.ts +16 -0
- package/src/engine/ink/hooks/use-terminal-title.ts +31 -0
- package/src/engine/ink/hooks/use-terminal-viewport.ts +96 -0
- package/src/engine/ink/ink.tsx +1723 -0
- package/src/engine/ink/instances.ts +10 -0
- package/src/engine/ink/layout/engine.ts +6 -0
- package/src/engine/ink/layout/geometry.ts +97 -0
- package/src/engine/ink/layout/node.ts +152 -0
- package/src/engine/ink/layout/yoga.ts +308 -0
- package/src/engine/ink/line-width-cache.ts +24 -0
- package/src/engine/ink/log-update.ts +773 -0
- package/src/engine/ink/measure-element.ts +23 -0
- package/src/engine/ink/measure-text.ts +47 -0
- package/src/engine/ink/node-cache.ts +54 -0
- package/src/engine/ink/optimizer.ts +93 -0
- package/src/engine/ink/output.ts +797 -0
- package/src/engine/ink/parse-keypress.ts +801 -0
- package/src/engine/ink/reconciler.ts +512 -0
- package/src/engine/ink/render-border.ts +231 -0
- package/src/engine/ink/render-node-to-output.ts +1462 -0
- package/src/engine/ink/render-to-screen.ts +231 -0
- package/src/engine/ink/renderer.ts +178 -0
- package/src/engine/ink/root.ts +184 -0
- package/src/engine/ink/screen.ts +1486 -0
- package/src/engine/ink/searchHighlight.ts +93 -0
- package/src/engine/ink/selection.ts +917 -0
- package/src/engine/ink/squash-text-nodes.ts +92 -0
- package/src/engine/ink/stringWidth.ts +222 -0
- package/src/engine/ink/styles.ts +771 -0
- package/src/engine/ink/supports-hyperlinks.ts +57 -0
- package/src/engine/ink/tabstops.ts +46 -0
- package/src/engine/ink/terminal-focus-state.ts +47 -0
- package/src/engine/ink/terminal-querier.ts +212 -0
- package/src/engine/ink/terminal.ts +248 -0
- package/src/engine/ink/termio/ansi.ts +75 -0
- package/src/engine/ink/termio/csi.ts +319 -0
- package/src/engine/ink/termio/dec.ts +60 -0
- package/src/engine/ink/termio/esc.ts +67 -0
- package/src/engine/ink/termio/osc.ts +493 -0
- package/src/engine/ink/termio/parser.ts +394 -0
- package/src/engine/ink/termio/sgr.ts +308 -0
- package/src/engine/ink/termio/tokenize.ts +319 -0
- package/src/engine/ink/termio/types.ts +236 -0
- package/src/engine/ink/useTerminalNotification.ts +126 -0
- package/src/engine/ink/warn.ts +9 -0
- package/src/engine/ink/widest-line.ts +19 -0
- package/src/engine/ink/wrap-text.ts +74 -0
- package/src/engine/ink/wrapAnsi.ts +20 -0
- package/src/engine/native-ts/yoga-layout/enums.ts +134 -0
- package/src/engine/native-ts/yoga-layout/index.ts +2578 -0
- package/src/engine/stubs/bootstrap-state.ts +4 -0
- package/src/engine/stubs/debug.ts +6 -0
- package/src/engine/stubs/log.ts +4 -0
- package/src/engine/utils/debug.ts +5 -0
- package/src/engine/utils/earlyInput.ts +4 -0
- package/src/engine/utils/env.ts +15 -0
- package/src/engine/utils/envUtils.ts +4 -0
- package/src/engine/utils/execFileNoThrow.ts +24 -0
- package/src/engine/utils/fullscreen.ts +4 -0
- package/src/engine/utils/intl.ts +9 -0
- package/src/engine/utils/log.ts +3 -0
- package/src/engine/utils/semver.ts +13 -0
- package/src/engine/utils/sliceAnsi.ts +10 -0
- package/src/engine/utils/theme.ts +17 -0
- package/src/game/App.tsx +141 -0
- package/src/game/agents/behavior.ts +249 -0
- package/src/game/agents/speech.ts +57 -0
- package/src/game/canvas.ts +98 -0
- package/src/game/launch.ts +36 -0
- package/src/game/ship/ShipView.tsx +145 -0
- package/src/game/ship/ship-map.ts +172 -0
- package/src/game/ui/AgentBio.tsx +72 -0
- package/src/game/ui/HUD.tsx +63 -0
- package/src/game/ui/StatusBar.tsx +49 -0
- package/src/game/useKeyboard.ts +62 -0
- package/src/main.tsx +22 -0
- 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
|
+
}
|