@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,231 @@
|
|
|
1
|
+
import noop from 'lodash-es/noop.js'
|
|
2
|
+
import type { ReactElement } from 'react'
|
|
3
|
+
import { LegacyRoot } from 'react-reconciler/constants.js'
|
|
4
|
+
import { logForDebugging } from '../utils/debug.js'
|
|
5
|
+
import { createNode, type DOMElement } from './dom.js'
|
|
6
|
+
import { FocusManager } from './focus.js'
|
|
7
|
+
import Output from './output.js'
|
|
8
|
+
import reconciler from './reconciler.js'
|
|
9
|
+
import renderNodeToOutput, {
|
|
10
|
+
resetLayoutShifted,
|
|
11
|
+
} from './render-node-to-output.js'
|
|
12
|
+
import {
|
|
13
|
+
CellWidth,
|
|
14
|
+
CharPool,
|
|
15
|
+
cellAtIndex,
|
|
16
|
+
createScreen,
|
|
17
|
+
HyperlinkPool,
|
|
18
|
+
type Screen,
|
|
19
|
+
StylePool,
|
|
20
|
+
setCellStyleId,
|
|
21
|
+
} from './screen.js'
|
|
22
|
+
|
|
23
|
+
/** Position of a match within a rendered message, relative to the message's
|
|
24
|
+
* own bounding box (row 0 = message top). Stable across scroll — to
|
|
25
|
+
* highlight on the real screen, add the message's screen-row offset. */
|
|
26
|
+
export type MatchPosition = {
|
|
27
|
+
row: number
|
|
28
|
+
col: number
|
|
29
|
+
/** Number of CELLS the match spans (= query.length for ASCII, more
|
|
30
|
+
* for wide chars in the query). */
|
|
31
|
+
len: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Shared across calls. Pools accumulate style/char interns — reusing them
|
|
35
|
+
// means later calls hit cache more. Root/container reuse saves the
|
|
36
|
+
// createContainer cost (~1ms). LegacyRoot: all work sync, no scheduling —
|
|
37
|
+
// ConcurrentRoot's scheduler backlog leaks across roots via flushSyncWork.
|
|
38
|
+
let root: DOMElement | undefined
|
|
39
|
+
let container: ReturnType<typeof reconciler.createContainer> | undefined
|
|
40
|
+
let stylePool: StylePool | undefined
|
|
41
|
+
let charPool: CharPool | undefined
|
|
42
|
+
let hyperlinkPool: HyperlinkPool | undefined
|
|
43
|
+
let output: Output | undefined
|
|
44
|
+
|
|
45
|
+
const timing = { reconcile: 0, yoga: 0, paint: 0, scan: 0, calls: 0 }
|
|
46
|
+
const LOG_EVERY = 20
|
|
47
|
+
|
|
48
|
+
/** Render a React element (wrapped in all contexts the component needs —
|
|
49
|
+
* caller's job) to an isolated Screen buffer at the given width. Returns
|
|
50
|
+
* the Screen + natural height (from yoga). Used for search: render ONE
|
|
51
|
+
* message, scan its Screen for the query, get exact (row, col) positions.
|
|
52
|
+
*
|
|
53
|
+
* ~1-3ms per call (yoga alloc + calculateLayout + paint). The
|
|
54
|
+
* flushSyncWork cross-root leak measured ~0.0003ms/call growth — fine
|
|
55
|
+
* for on-demand single-message rendering, pathological for render-all-
|
|
56
|
+
* 8k-upfront. Cache per (msg, query, width) upstream.
|
|
57
|
+
*
|
|
58
|
+
* Unmounts between calls. Root/container/pools persist for reuse. */
|
|
59
|
+
export function renderToScreen(
|
|
60
|
+
el: ReactElement,
|
|
61
|
+
width: number,
|
|
62
|
+
): { screen: Screen; height: number } {
|
|
63
|
+
if (!root) {
|
|
64
|
+
root = createNode('ink-root')
|
|
65
|
+
root.focusManager = new FocusManager(() => false)
|
|
66
|
+
stylePool = new StylePool()
|
|
67
|
+
charPool = new CharPool()
|
|
68
|
+
hyperlinkPool = new HyperlinkPool()
|
|
69
|
+
// @ts-expect-error react-reconciler 0.33 takes 10 args; @types says 11
|
|
70
|
+
container = reconciler.createContainer(
|
|
71
|
+
root,
|
|
72
|
+
LegacyRoot,
|
|
73
|
+
null,
|
|
74
|
+
false,
|
|
75
|
+
null,
|
|
76
|
+
'search-render',
|
|
77
|
+
noop,
|
|
78
|
+
noop,
|
|
79
|
+
noop,
|
|
80
|
+
noop,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const t0 = performance.now()
|
|
85
|
+
// @ts-expect-error updateContainerSync exists but not in @types
|
|
86
|
+
reconciler.updateContainerSync(el, container, null, noop)
|
|
87
|
+
// @ts-expect-error flushSyncWork exists but not in @types
|
|
88
|
+
reconciler.flushSyncWork()
|
|
89
|
+
const t1 = performance.now()
|
|
90
|
+
|
|
91
|
+
// Yoga layout. Root might not have a yogaNode if the tree is empty.
|
|
92
|
+
root.yogaNode?.setWidth(width)
|
|
93
|
+
root.yogaNode?.calculateLayout(width)
|
|
94
|
+
const height = Math.ceil(root.yogaNode?.getComputedHeight() ?? 0)
|
|
95
|
+
const t2 = performance.now()
|
|
96
|
+
|
|
97
|
+
// Paint to a fresh Screen. Width = given, height = yoga's natural.
|
|
98
|
+
// No alt-screen, no prevScreen (every call is fresh).
|
|
99
|
+
const screen = createScreen(
|
|
100
|
+
width,
|
|
101
|
+
Math.max(1, height), // avoid 0-height Screen (createScreen may choke)
|
|
102
|
+
stylePool!,
|
|
103
|
+
charPool!,
|
|
104
|
+
hyperlinkPool!,
|
|
105
|
+
)
|
|
106
|
+
if (!output) {
|
|
107
|
+
output = new Output({ width, height, stylePool: stylePool!, screen })
|
|
108
|
+
} else {
|
|
109
|
+
output.reset(width, height, screen)
|
|
110
|
+
}
|
|
111
|
+
resetLayoutShifted()
|
|
112
|
+
renderNodeToOutput(root, output, { prevScreen: undefined })
|
|
113
|
+
// renderNodeToOutput queues writes into Output; .get() flushes the
|
|
114
|
+
// queue into the Screen's cell arrays. Without this the screen is
|
|
115
|
+
// blank (constructor-zero).
|
|
116
|
+
const rendered = output.get()
|
|
117
|
+
const t3 = performance.now()
|
|
118
|
+
|
|
119
|
+
// Unmount so next call gets a fresh tree. Leaves root/container/pools.
|
|
120
|
+
// @ts-expect-error updateContainerSync exists but not in @types
|
|
121
|
+
reconciler.updateContainerSync(null, container, null, noop)
|
|
122
|
+
// @ts-expect-error flushSyncWork exists but not in @types
|
|
123
|
+
reconciler.flushSyncWork()
|
|
124
|
+
|
|
125
|
+
timing.reconcile += t1 - t0
|
|
126
|
+
timing.yoga += t2 - t1
|
|
127
|
+
timing.paint += t3 - t2
|
|
128
|
+
if (++timing.calls % LOG_EVERY === 0) {
|
|
129
|
+
const total = timing.reconcile + timing.yoga + timing.paint + timing.scan
|
|
130
|
+
logForDebugging(
|
|
131
|
+
`renderToScreen: ${timing.calls} calls · ` +
|
|
132
|
+
`reconcile=${timing.reconcile.toFixed(1)}ms yoga=${timing.yoga.toFixed(1)}ms ` +
|
|
133
|
+
`paint=${timing.paint.toFixed(1)}ms scan=${timing.scan.toFixed(1)}ms · ` +
|
|
134
|
+
`total=${total.toFixed(1)}ms · avg ${(total / timing.calls).toFixed(2)}ms/call`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { screen: rendered, height }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Scan a Screen buffer for all occurrences of query. Returns positions
|
|
142
|
+
* relative to the buffer (row 0 = buffer top). Same cell-skip logic as
|
|
143
|
+
* applySearchHighlight (SpacerTail/SpacerHead/noSelect) so positions
|
|
144
|
+
* match what the overlay highlight would find. Case-insensitive.
|
|
145
|
+
*
|
|
146
|
+
* For the side-render use: this Screen is the FULL message (natural
|
|
147
|
+
* height, not viewport-clipped). Positions are stable — to highlight
|
|
148
|
+
* on the real screen, add the message's screen offset (lo). */
|
|
149
|
+
export function scanPositions(screen: Screen, query: string): MatchPosition[] {
|
|
150
|
+
const lq = query.toLowerCase()
|
|
151
|
+
if (!lq) return []
|
|
152
|
+
const qlen = lq.length
|
|
153
|
+
const w = screen.width
|
|
154
|
+
const h = screen.height
|
|
155
|
+
const noSelect = screen.noSelect
|
|
156
|
+
const positions: MatchPosition[] = []
|
|
157
|
+
|
|
158
|
+
const t0 = performance.now()
|
|
159
|
+
for (let row = 0; row < h; row++) {
|
|
160
|
+
const rowOff = row * w
|
|
161
|
+
// Same text-build as applySearchHighlight. Keep in sync — or extract
|
|
162
|
+
// to a shared helper (TODO once both are stable). codeUnitToCell
|
|
163
|
+
// maps indexOf positions (code units in the LOWERCASED text) to cell
|
|
164
|
+
// indices in colOf — surrogate pairs (emoji) and multi-unit lowercase
|
|
165
|
+
// (Turkish İ → i + U+0307) make text.length > colOf.length.
|
|
166
|
+
let text = ''
|
|
167
|
+
const colOf: number[] = []
|
|
168
|
+
const codeUnitToCell: number[] = []
|
|
169
|
+
for (let col = 0; col < w; col++) {
|
|
170
|
+
const idx = rowOff + col
|
|
171
|
+
const cell = cellAtIndex(screen, idx)
|
|
172
|
+
if (
|
|
173
|
+
cell.width === CellWidth.SpacerTail ||
|
|
174
|
+
cell.width === CellWidth.SpacerHead ||
|
|
175
|
+
noSelect[idx] === 1
|
|
176
|
+
) {
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
const lc = cell.char.toLowerCase()
|
|
180
|
+
const cellIdx = colOf.length
|
|
181
|
+
for (let i = 0; i < lc.length; i++) {
|
|
182
|
+
codeUnitToCell.push(cellIdx)
|
|
183
|
+
}
|
|
184
|
+
text += lc
|
|
185
|
+
colOf.push(col)
|
|
186
|
+
}
|
|
187
|
+
// Non-overlapping — same advance as applySearchHighlight.
|
|
188
|
+
let pos = text.indexOf(lq)
|
|
189
|
+
while (pos >= 0) {
|
|
190
|
+
const startCi = codeUnitToCell[pos]!
|
|
191
|
+
const endCi = codeUnitToCell[pos + qlen - 1]!
|
|
192
|
+
const col = colOf[startCi]!
|
|
193
|
+
const endCol = colOf[endCi]! + 1
|
|
194
|
+
positions.push({ row, col, len: endCol - col })
|
|
195
|
+
pos = text.indexOf(lq, pos + qlen)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
timing.scan += performance.now() - t0
|
|
199
|
+
|
|
200
|
+
return positions
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Write CURRENT (yellow+bold+underline) at positions[currentIdx] +
|
|
204
|
+
* rowOffset. OTHER positions are NOT styled here — the scan-highlight
|
|
205
|
+
* (applySearchHighlight with null hint) does inverse for all visible
|
|
206
|
+
* matches, including these. Two-layer: scan = 'you could go here',
|
|
207
|
+
* position = 'you ARE here'. Writing inverse again here would be a
|
|
208
|
+
* no-op (withInverse idempotent) but wasted work.
|
|
209
|
+
*
|
|
210
|
+
* Positions are message-relative (row 0 = message top). rowOffset =
|
|
211
|
+
* message's current screen-top (lo). Clips outside [0, height). */
|
|
212
|
+
export function applyPositionedHighlight(
|
|
213
|
+
screen: Screen,
|
|
214
|
+
stylePool: StylePool,
|
|
215
|
+
positions: MatchPosition[],
|
|
216
|
+
rowOffset: number,
|
|
217
|
+
currentIdx: number,
|
|
218
|
+
): boolean {
|
|
219
|
+
if (currentIdx < 0 || currentIdx >= positions.length) return false
|
|
220
|
+
const p = positions[currentIdx]!
|
|
221
|
+
const row = p.row + rowOffset
|
|
222
|
+
if (row < 0 || row >= screen.height) return false
|
|
223
|
+
const transform = (id: number) => stylePool.withCurrentMatch(id)
|
|
224
|
+
const rowOff = row * screen.width
|
|
225
|
+
for (let col = p.col; col < p.col + p.len; col++) {
|
|
226
|
+
if (col < 0 || col >= screen.width) continue
|
|
227
|
+
const cell = cellAtIndex(screen, rowOff + col)
|
|
228
|
+
setCellStyleId(screen, col, row, transform(cell.styleId))
|
|
229
|
+
}
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { logForDebugging } from '../utils/debug.js'
|
|
2
|
+
import { type DOMElement, markDirty } from './dom.js'
|
|
3
|
+
import type { Frame } from './frame.js'
|
|
4
|
+
import { consumeAbsoluteRemovedFlag } from './node-cache.js'
|
|
5
|
+
import Output from './output.js'
|
|
6
|
+
import renderNodeToOutput, {
|
|
7
|
+
getScrollDrainNode,
|
|
8
|
+
getScrollHint,
|
|
9
|
+
resetLayoutShifted,
|
|
10
|
+
resetScrollDrainNode,
|
|
11
|
+
resetScrollHint,
|
|
12
|
+
} from './render-node-to-output.js'
|
|
13
|
+
import { createScreen, type StylePool } from './screen.js'
|
|
14
|
+
|
|
15
|
+
export type RenderOptions = {
|
|
16
|
+
frontFrame: Frame
|
|
17
|
+
backFrame: Frame
|
|
18
|
+
isTTY: boolean
|
|
19
|
+
terminalWidth: number
|
|
20
|
+
terminalRows: number
|
|
21
|
+
altScreen: boolean
|
|
22
|
+
// True when the previous frame's screen buffer was mutated post-render
|
|
23
|
+
// (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT),
|
|
24
|
+
// or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would
|
|
25
|
+
// copy stale inverted cells, blanks, or nothing. When false, blit is safe.
|
|
26
|
+
prevFrameContaminated: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Renderer = (options: RenderOptions) => Frame
|
|
30
|
+
|
|
31
|
+
export default function createRenderer(
|
|
32
|
+
node: DOMElement,
|
|
33
|
+
stylePool: StylePool,
|
|
34
|
+
): Renderer {
|
|
35
|
+
// Reuse Output across frames so charCache (tokenize + grapheme clustering)
|
|
36
|
+
// persists — most lines don't change between renders.
|
|
37
|
+
let output: Output | undefined
|
|
38
|
+
return options => {
|
|
39
|
+
const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } =
|
|
40
|
+
options
|
|
41
|
+
const prevScreen = frontFrame.screen
|
|
42
|
+
const backScreen = backFrame.screen
|
|
43
|
+
// Read pools from the back buffer's screen — pools may be replaced
|
|
44
|
+
// between frames (generational reset), so we can't capture them in the closure
|
|
45
|
+
const charPool = backScreen.charPool
|
|
46
|
+
const hyperlinkPool = backScreen.hyperlinkPool
|
|
47
|
+
|
|
48
|
+
// Return empty frame if yoga node doesn't exist or layout hasn't been computed yet.
|
|
49
|
+
// getComputedHeight() returns NaN before calculateLayout() is called.
|
|
50
|
+
// Also check for invalid dimensions (negative, Infinity) that would cause RangeError
|
|
51
|
+
// when creating arrays.
|
|
52
|
+
const computedHeight = node.yogaNode?.getComputedHeight()
|
|
53
|
+
const computedWidth = node.yogaNode?.getComputedWidth()
|
|
54
|
+
const hasInvalidHeight =
|
|
55
|
+
computedHeight === undefined ||
|
|
56
|
+
!Number.isFinite(computedHeight) ||
|
|
57
|
+
computedHeight < 0
|
|
58
|
+
const hasInvalidWidth =
|
|
59
|
+
computedWidth === undefined ||
|
|
60
|
+
!Number.isFinite(computedWidth) ||
|
|
61
|
+
computedWidth < 0
|
|
62
|
+
|
|
63
|
+
if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) {
|
|
64
|
+
// Log to help diagnose root cause (visible with --debug flag)
|
|
65
|
+
if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) {
|
|
66
|
+
logForDebugging(
|
|
67
|
+
`Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` +
|
|
68
|
+
`childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
screen: createScreen(
|
|
73
|
+
terminalWidth,
|
|
74
|
+
0,
|
|
75
|
+
stylePool,
|
|
76
|
+
charPool,
|
|
77
|
+
hyperlinkPool,
|
|
78
|
+
),
|
|
79
|
+
viewport: { width: terminalWidth, height: terminalRows },
|
|
80
|
+
cursor: { x: 0, y: 0, visible: true },
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const width = Math.floor(node.yogaNode.getComputedWidth())
|
|
85
|
+
const yogaHeight = Math.floor(node.yogaNode.getComputedHeight())
|
|
86
|
+
// Alt-screen: the screen buffer IS the alt buffer — always exactly
|
|
87
|
+
// terminalRows tall. <AlternateScreen> wraps children in <Box
|
|
88
|
+
// height={rows} flexShrink={0}>, so yogaHeight should equal
|
|
89
|
+
// terminalRows. But if something renders as a SIBLING of that Box
|
|
90
|
+
// (bug: MessageSelector was outside <FullscreenLayout>), yogaHeight
|
|
91
|
+
// exceeds rows and every assumption below (viewport +1 hack, cursor.y
|
|
92
|
+
// clamp, log-update's heightDelta===0 fast path) breaks, desyncing
|
|
93
|
+
// virtual/physical cursors. Clamping here enforces the invariant:
|
|
94
|
+
// overflow writes land at y >= screen.height and setCellAt drops
|
|
95
|
+
// them. The sibling is invisible (obvious, easy to find) instead of
|
|
96
|
+
// corrupting the whole terminal.
|
|
97
|
+
const height = options.altScreen ? terminalRows : yogaHeight
|
|
98
|
+
if (options.altScreen && yogaHeight > terminalRows) {
|
|
99
|
+
logForDebugging(
|
|
100
|
+
`alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows} — ` +
|
|
101
|
+
`something is rendering outside <AlternateScreen>. Overflow clipped.`,
|
|
102
|
+
{ level: 'warn' },
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
const screen =
|
|
106
|
+
backScreen ??
|
|
107
|
+
createScreen(width, height, stylePool, charPool, hyperlinkPool)
|
|
108
|
+
if (output) {
|
|
109
|
+
output.reset(width, height, screen)
|
|
110
|
+
} else {
|
|
111
|
+
output = new Output({ width, height, stylePool, screen })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
resetLayoutShifted()
|
|
115
|
+
resetScrollHint()
|
|
116
|
+
resetScrollDrainNode()
|
|
117
|
+
|
|
118
|
+
// prevFrameContaminated: selection overlay mutated the returned screen
|
|
119
|
+
// buffer post-render (in ink.tsx), resetFramesForAltScreen() replaced it
|
|
120
|
+
// with blanks, or forceRedraw() reset it to 0×0. Blit on the NEXT frame
|
|
121
|
+
// would copy stale inverted cells / blanks / nothing. When clean, blit
|
|
122
|
+
// restores the O(unchanged) fast path for steady-state frames (spinner
|
|
123
|
+
// tick, text stream).
|
|
124
|
+
// Removing an absolute-positioned node poisons prevScreen: it may
|
|
125
|
+
// have painted over non-siblings (e.g. an overlay over a ScrollBox
|
|
126
|
+
// earlier in tree order), so their blits would restore the removed
|
|
127
|
+
// node's pixels. hasRemovedChild only shields direct siblings.
|
|
128
|
+
// Normal-flow removals don't paint cross-subtree and are fine.
|
|
129
|
+
const absoluteRemoved = consumeAbsoluteRemovedFlag()
|
|
130
|
+
renderNodeToOutput(node, output, {
|
|
131
|
+
prevScreen:
|
|
132
|
+
absoluteRemoved || options.prevFrameContaminated
|
|
133
|
+
? undefined
|
|
134
|
+
: prevScreen,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const renderedScreen = output.get()
|
|
138
|
+
|
|
139
|
+
// Drain continuation: render cleared scrollbox.dirty, so next frame's
|
|
140
|
+
// root blit would skip the subtree. markDirty walks ancestors so the
|
|
141
|
+
// next frame descends. Done AFTER render so the clear-dirty at the end
|
|
142
|
+
// of renderNodeToOutput doesn't overwrite this.
|
|
143
|
+
const drainNode = getScrollDrainNode()
|
|
144
|
+
if (drainNode) markDirty(drainNode)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
scrollHint: options.altScreen ? getScrollHint() : null,
|
|
148
|
+
scrollDrainPending: drainNode !== null,
|
|
149
|
+
screen: renderedScreen,
|
|
150
|
+
viewport: {
|
|
151
|
+
width: terminalWidth,
|
|
152
|
+
// Alt screen: fake viewport.height = rows + 1 so that
|
|
153
|
+
// shouldClearScreen()'s `screen.height >= viewport.height` check
|
|
154
|
+
// (which treats exactly-filling content as "overflows" for
|
|
155
|
+
// scrollback purposes) never fires. Alt-screen content is always
|
|
156
|
+
// exactly `rows` tall (via <Box height={rows}>) but never
|
|
157
|
+
// scrolls — the cursor.y clamp below keeps the cursor-restore
|
|
158
|
+
// from emitting an LF. With the standard diff path, every frame
|
|
159
|
+
// is incremental; no fullResetSequence_CAUSES_FLICKER.
|
|
160
|
+
height: options.altScreen ? terminalRows + 1 : terminalRows,
|
|
161
|
+
},
|
|
162
|
+
cursor: {
|
|
163
|
+
x: 0,
|
|
164
|
+
// In the alt screen, keep the cursor inside the viewport. When
|
|
165
|
+
// screen.height === terminalRows exactly (content fills the alt
|
|
166
|
+
// screen), cursor.y = screen.height would trigger log-update's
|
|
167
|
+
// cursor-restore LF at the last row, scrolling one row off the top
|
|
168
|
+
// of the alt buffer and desyncing the diff's cursor model. The
|
|
169
|
+
// cursor is hidden so its position only matters for diff coords.
|
|
170
|
+
y: options.altScreen
|
|
171
|
+
? Math.max(0, Math.min(screen.height, terminalRows) - 1)
|
|
172
|
+
: screen.height,
|
|
173
|
+
// Hide cursor when there's dynamic output to render (only in TTY mode)
|
|
174
|
+
visible: !isTTY || screen.height === 0,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { logForDebugging } from '../utils/debug.js'
|
|
3
|
+
import { Stream } from 'stream'
|
|
4
|
+
import type { FrameEvent } from './frame.js'
|
|
5
|
+
import Ink, { type Options as InkOptions } from './ink.js'
|
|
6
|
+
import instances from './instances.js'
|
|
7
|
+
|
|
8
|
+
export type RenderOptions = {
|
|
9
|
+
/**
|
|
10
|
+
* Output stream where app will be rendered.
|
|
11
|
+
*
|
|
12
|
+
* @default process.stdout
|
|
13
|
+
*/
|
|
14
|
+
stdout?: NodeJS.WriteStream
|
|
15
|
+
/**
|
|
16
|
+
* Input stream where app will listen for input.
|
|
17
|
+
*
|
|
18
|
+
* @default process.stdin
|
|
19
|
+
*/
|
|
20
|
+
stdin?: NodeJS.ReadStream
|
|
21
|
+
/**
|
|
22
|
+
* Error stream.
|
|
23
|
+
* @default process.stderr
|
|
24
|
+
*/
|
|
25
|
+
stderr?: NodeJS.WriteStream
|
|
26
|
+
/**
|
|
27
|
+
* Configure whether Ink should listen to Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and process is expected to handle it manually.
|
|
28
|
+
*
|
|
29
|
+
* @default true
|
|
30
|
+
*/
|
|
31
|
+
exitOnCtrlC?: boolean
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Patch console methods to ensure console output doesn't mix with Ink output.
|
|
35
|
+
*
|
|
36
|
+
* @default true
|
|
37
|
+
*/
|
|
38
|
+
patchConsole?: boolean
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Called after each frame render with timing and flicker information.
|
|
42
|
+
*/
|
|
43
|
+
onFrame?: (event: FrameEvent) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Instance = {
|
|
47
|
+
/**
|
|
48
|
+
* Replace previous root node with a new one or update props of the current root node.
|
|
49
|
+
*/
|
|
50
|
+
rerender: Ink['render']
|
|
51
|
+
/**
|
|
52
|
+
* Manually unmount the whole Ink app.
|
|
53
|
+
*/
|
|
54
|
+
unmount: Ink['unmount']
|
|
55
|
+
/**
|
|
56
|
+
* Returns a promise, which resolves when app is unmounted.
|
|
57
|
+
*/
|
|
58
|
+
waitUntilExit: Ink['waitUntilExit']
|
|
59
|
+
cleanup: () => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A managed Ink root, similar to react-dom's createRoot API.
|
|
64
|
+
* Separates instance creation from rendering so the same root
|
|
65
|
+
* can be reused for multiple sequential screens.
|
|
66
|
+
*/
|
|
67
|
+
export type Root = {
|
|
68
|
+
render: (node: ReactNode) => void
|
|
69
|
+
unmount: () => void
|
|
70
|
+
waitUntilExit: () => Promise<void>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mount a component and render the output.
|
|
75
|
+
*/
|
|
76
|
+
export const renderSync = (
|
|
77
|
+
node: ReactNode,
|
|
78
|
+
options?: NodeJS.WriteStream | RenderOptions,
|
|
79
|
+
): Instance => {
|
|
80
|
+
const opts = getOptions(options)
|
|
81
|
+
const inkOptions: InkOptions = {
|
|
82
|
+
stdout: process.stdout,
|
|
83
|
+
stdin: process.stdin,
|
|
84
|
+
stderr: process.stderr,
|
|
85
|
+
exitOnCtrlC: true,
|
|
86
|
+
patchConsole: true,
|
|
87
|
+
...opts,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const instance: Ink = getInstance(
|
|
91
|
+
inkOptions.stdout,
|
|
92
|
+
() => new Ink(inkOptions),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
instance.render(node)
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
rerender: instance.render,
|
|
99
|
+
unmount() {
|
|
100
|
+
instance.unmount()
|
|
101
|
+
},
|
|
102
|
+
waitUntilExit: instance.waitUntilExit,
|
|
103
|
+
cleanup: () => instances.delete(inkOptions.stdout),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const wrappedRender = async (
|
|
108
|
+
node: ReactNode,
|
|
109
|
+
options?: NodeJS.WriteStream | RenderOptions,
|
|
110
|
+
): Promise<Instance> => {
|
|
111
|
+
// Preserve the microtask boundary that `await loadYoga()` used to provide.
|
|
112
|
+
// Without it, the first render fires synchronously before async startup work
|
|
113
|
+
// (e.g. useReplBridge notification state) settles, and the subsequent Static
|
|
114
|
+
// write overwrites scrollback instead of appending below the logo.
|
|
115
|
+
await Promise.resolve()
|
|
116
|
+
const instance = renderSync(node, options)
|
|
117
|
+
logForDebugging(
|
|
118
|
+
`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`,
|
|
119
|
+
)
|
|
120
|
+
return instance
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default wrappedRender
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create an Ink root without rendering anything yet.
|
|
127
|
+
* Like react-dom's createRoot — call root.render() to mount a tree.
|
|
128
|
+
*/
|
|
129
|
+
export async function createRoot({
|
|
130
|
+
stdout = process.stdout,
|
|
131
|
+
stdin = process.stdin,
|
|
132
|
+
stderr = process.stderr,
|
|
133
|
+
exitOnCtrlC = true,
|
|
134
|
+
patchConsole = true,
|
|
135
|
+
onFrame,
|
|
136
|
+
}: RenderOptions = {}): Promise<Root> {
|
|
137
|
+
// See wrappedRender — preserve microtask boundary from the old WASM await.
|
|
138
|
+
await Promise.resolve()
|
|
139
|
+
const instance = new Ink({
|
|
140
|
+
stdout,
|
|
141
|
+
stdin,
|
|
142
|
+
stderr,
|
|
143
|
+
exitOnCtrlC,
|
|
144
|
+
patchConsole,
|
|
145
|
+
onFrame,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Register in the instances map so that code that looks up the Ink
|
|
149
|
+
// instance by stdout (e.g. external editor pause/resume) can find it.
|
|
150
|
+
instances.set(stdout, instance)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
render: node => instance.render(node),
|
|
154
|
+
unmount: () => instance.unmount(),
|
|
155
|
+
waitUntilExit: () => instance.waitUntilExit(),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const getOptions = (
|
|
160
|
+
stdout: NodeJS.WriteStream | RenderOptions | undefined = {},
|
|
161
|
+
): RenderOptions => {
|
|
162
|
+
if (stdout instanceof Stream) {
|
|
163
|
+
return {
|
|
164
|
+
stdout,
|
|
165
|
+
stdin: process.stdin,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return stdout
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const getInstance = (
|
|
173
|
+
stdout: NodeJS.WriteStream,
|
|
174
|
+
createInstance: () => Ink,
|
|
175
|
+
): Ink => {
|
|
176
|
+
let instance = instances.get(stdout)
|
|
177
|
+
|
|
178
|
+
if (!instance) {
|
|
179
|
+
instance = createInstance()
|
|
180
|
+
instances.set(stdout, instance)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return instance
|
|
184
|
+
}
|