@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,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Tokenizer - Escape sequence boundary detection
|
|
3
|
+
*
|
|
4
|
+
* Splits terminal input into tokens: text chunks and raw escape sequences.
|
|
5
|
+
* Unlike the Parser which interprets sequences semantically, this just
|
|
6
|
+
* identifies boundaries for use by keyboard input parsing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { C0, ESC_TYPE, isEscFinal } from './ansi.js'
|
|
10
|
+
import { isCSIFinal, isCSIIntermediate, isCSIParam } from './csi.js'
|
|
11
|
+
|
|
12
|
+
export type Token =
|
|
13
|
+
| { type: 'text'; value: string }
|
|
14
|
+
| { type: 'sequence'; value: string }
|
|
15
|
+
|
|
16
|
+
type State =
|
|
17
|
+
| 'ground'
|
|
18
|
+
| 'escape'
|
|
19
|
+
| 'escapeIntermediate'
|
|
20
|
+
| 'csi'
|
|
21
|
+
| 'ss3'
|
|
22
|
+
| 'osc'
|
|
23
|
+
| 'dcs'
|
|
24
|
+
| 'apc'
|
|
25
|
+
|
|
26
|
+
export type Tokenizer = {
|
|
27
|
+
/** Feed input and get resulting tokens */
|
|
28
|
+
feed(input: string): Token[]
|
|
29
|
+
/** Flush any buffered incomplete sequences */
|
|
30
|
+
flush(): Token[]
|
|
31
|
+
/** Reset tokenizer state */
|
|
32
|
+
reset(): void
|
|
33
|
+
/** Get any buffered incomplete sequence */
|
|
34
|
+
buffer(): string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type TokenizerOptions = {
|
|
38
|
+
/**
|
|
39
|
+
* Treat `CSI M` as an X10 mouse event prefix and consume 3 payload bytes.
|
|
40
|
+
* Only enable for stdin input — `\x1b[M` is also CSI DL (Delete Lines) in
|
|
41
|
+
* output streams, and enabling this there swallows display text. Default false.
|
|
42
|
+
*/
|
|
43
|
+
x10Mouse?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a streaming tokenizer for terminal input.
|
|
48
|
+
*
|
|
49
|
+
* Usage:
|
|
50
|
+
* ```typescript
|
|
51
|
+
* const tokenizer = createTokenizer()
|
|
52
|
+
* const tokens1 = tokenizer.feed('hello\x1b[')
|
|
53
|
+
* const tokens2 = tokenizer.feed('A') // completes the escape sequence
|
|
54
|
+
* const remaining = tokenizer.flush() // force output incomplete sequences
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export function createTokenizer(options?: TokenizerOptions): Tokenizer {
|
|
58
|
+
let currentState: State = 'ground'
|
|
59
|
+
let currentBuffer = ''
|
|
60
|
+
const x10Mouse = options?.x10Mouse ?? false
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
feed(input: string): Token[] {
|
|
64
|
+
const result = tokenize(
|
|
65
|
+
input,
|
|
66
|
+
currentState,
|
|
67
|
+
currentBuffer,
|
|
68
|
+
false,
|
|
69
|
+
x10Mouse,
|
|
70
|
+
)
|
|
71
|
+
currentState = result.state.state
|
|
72
|
+
currentBuffer = result.state.buffer
|
|
73
|
+
return result.tokens
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
flush(): Token[] {
|
|
77
|
+
const result = tokenize('', currentState, currentBuffer, true, x10Mouse)
|
|
78
|
+
currentState = result.state.state
|
|
79
|
+
currentBuffer = result.state.buffer
|
|
80
|
+
return result.tokens
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
reset(): void {
|
|
84
|
+
currentState = 'ground'
|
|
85
|
+
currentBuffer = ''
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
buffer(): string {
|
|
89
|
+
return currentBuffer
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type InternalState = {
|
|
95
|
+
state: State
|
|
96
|
+
buffer: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function tokenize(
|
|
100
|
+
input: string,
|
|
101
|
+
initialState: State,
|
|
102
|
+
initialBuffer: string,
|
|
103
|
+
flush: boolean,
|
|
104
|
+
x10Mouse: boolean,
|
|
105
|
+
): { tokens: Token[]; state: InternalState } {
|
|
106
|
+
const tokens: Token[] = []
|
|
107
|
+
const result: InternalState = {
|
|
108
|
+
state: initialState,
|
|
109
|
+
buffer: '',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const data = initialBuffer + input
|
|
113
|
+
let i = 0
|
|
114
|
+
let textStart = 0
|
|
115
|
+
let seqStart = 0
|
|
116
|
+
|
|
117
|
+
const flushText = (): void => {
|
|
118
|
+
if (i > textStart) {
|
|
119
|
+
const text = data.slice(textStart, i)
|
|
120
|
+
if (text) {
|
|
121
|
+
tokens.push({ type: 'text', value: text })
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
textStart = i
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const emitSequence = (seq: string): void => {
|
|
128
|
+
if (seq) {
|
|
129
|
+
tokens.push({ type: 'sequence', value: seq })
|
|
130
|
+
}
|
|
131
|
+
result.state = 'ground'
|
|
132
|
+
textStart = i
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
while (i < data.length) {
|
|
136
|
+
const code = data.charCodeAt(i)
|
|
137
|
+
|
|
138
|
+
switch (result.state) {
|
|
139
|
+
case 'ground':
|
|
140
|
+
if (code === C0.ESC) {
|
|
141
|
+
flushText()
|
|
142
|
+
seqStart = i
|
|
143
|
+
result.state = 'escape'
|
|
144
|
+
i++
|
|
145
|
+
} else {
|
|
146
|
+
i++
|
|
147
|
+
}
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
case 'escape':
|
|
151
|
+
if (code === ESC_TYPE.CSI) {
|
|
152
|
+
result.state = 'csi'
|
|
153
|
+
i++
|
|
154
|
+
} else if (code === ESC_TYPE.OSC) {
|
|
155
|
+
result.state = 'osc'
|
|
156
|
+
i++
|
|
157
|
+
} else if (code === ESC_TYPE.DCS) {
|
|
158
|
+
result.state = 'dcs'
|
|
159
|
+
i++
|
|
160
|
+
} else if (code === ESC_TYPE.APC) {
|
|
161
|
+
result.state = 'apc'
|
|
162
|
+
i++
|
|
163
|
+
} else if (code === 0x4f) {
|
|
164
|
+
// 'O' - SS3
|
|
165
|
+
result.state = 'ss3'
|
|
166
|
+
i++
|
|
167
|
+
} else if (isCSIIntermediate(code)) {
|
|
168
|
+
// Intermediate byte (e.g., ESC ( for charset) - continue buffering
|
|
169
|
+
result.state = 'escapeIntermediate'
|
|
170
|
+
i++
|
|
171
|
+
} else if (isEscFinal(code)) {
|
|
172
|
+
// Two-character escape sequence
|
|
173
|
+
i++
|
|
174
|
+
emitSequence(data.slice(seqStart, i))
|
|
175
|
+
} else if (code === C0.ESC) {
|
|
176
|
+
// Double escape - emit first, start new
|
|
177
|
+
emitSequence(data.slice(seqStart, i))
|
|
178
|
+
seqStart = i
|
|
179
|
+
result.state = 'escape'
|
|
180
|
+
i++
|
|
181
|
+
} else {
|
|
182
|
+
// Invalid - treat ESC as text
|
|
183
|
+
result.state = 'ground'
|
|
184
|
+
textStart = seqStart
|
|
185
|
+
}
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
case 'escapeIntermediate':
|
|
189
|
+
// After intermediate byte(s), wait for final byte
|
|
190
|
+
if (isCSIIntermediate(code)) {
|
|
191
|
+
// More intermediate bytes
|
|
192
|
+
i++
|
|
193
|
+
} else if (isEscFinal(code)) {
|
|
194
|
+
// Final byte - complete the sequence
|
|
195
|
+
i++
|
|
196
|
+
emitSequence(data.slice(seqStart, i))
|
|
197
|
+
} else {
|
|
198
|
+
// Invalid - treat as text
|
|
199
|
+
result.state = 'ground'
|
|
200
|
+
textStart = seqStart
|
|
201
|
+
}
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
case 'csi':
|
|
205
|
+
// X10 mouse: CSI M + 3 raw payload bytes (Cb+32, Cx+32, Cy+32).
|
|
206
|
+
// M immediately after [ (offset 2) means no params — SGR mouse
|
|
207
|
+
// (CSI < … M) has a `<` param byte first and reaches M at offset > 2.
|
|
208
|
+
// Terminals that ignore DECSET 1006 but honor 1000/1002 emit this
|
|
209
|
+
// legacy encoding; without this branch the 3 payload bytes leak
|
|
210
|
+
// through as text (`` `rK `` / `arK` garbage in the prompt).
|
|
211
|
+
//
|
|
212
|
+
// Gated on x10Mouse — `\x1b[M` is also CSI DL (Delete Lines) and
|
|
213
|
+
// blindly consuming 3 chars corrupts output rendering (Parser/Ansi)
|
|
214
|
+
// and fragments bracketed-paste PASTE_END. Only stdin enables this.
|
|
215
|
+
// The ≥0x20 check on each payload slot is belt-and-suspenders: X10
|
|
216
|
+
// guarantees Cb≥32, Cx≥33, Cy≥33, so a control byte (ESC=0x1B) in
|
|
217
|
+
// any slot means this is CSI DL adjacent to another sequence, not a
|
|
218
|
+
// mouse event. Checking all three slots prevents PASTE_END's ESC
|
|
219
|
+
// from being consumed when paste content ends in `\x1b[M`+0-2 chars.
|
|
220
|
+
//
|
|
221
|
+
// Known limitation: this counts JS string chars, but X10 is byte-
|
|
222
|
+
// oriented and stdin uses utf8 encoding (App.tsx). At col 162-191 ×
|
|
223
|
+
// row 96-159 the two coord bytes (0xC2-0xDF, 0x80-0xBF) form a valid
|
|
224
|
+
// UTF-8 2-byte sequence and collapse to one char — the length check
|
|
225
|
+
// fails and the event buffers until the next keypress absorbs it.
|
|
226
|
+
// Fixing this requires latin1 stdin; X10's 223-coord cap is exactly
|
|
227
|
+
// why SGR was invented, and no-SGR terminals at 162+ cols are rare.
|
|
228
|
+
if (
|
|
229
|
+
x10Mouse &&
|
|
230
|
+
code === 0x4d /* M */ &&
|
|
231
|
+
i - seqStart === 2 &&
|
|
232
|
+
(i + 1 >= data.length || data.charCodeAt(i + 1) >= 0x20) &&
|
|
233
|
+
(i + 2 >= data.length || data.charCodeAt(i + 2) >= 0x20) &&
|
|
234
|
+
(i + 3 >= data.length || data.charCodeAt(i + 3) >= 0x20)
|
|
235
|
+
) {
|
|
236
|
+
if (i + 4 <= data.length) {
|
|
237
|
+
i += 4
|
|
238
|
+
emitSequence(data.slice(seqStart, i))
|
|
239
|
+
} else {
|
|
240
|
+
// Incomplete — exit loop; end-of-input buffers from seqStart.
|
|
241
|
+
// Re-entry re-tokenizes from ground via the invalid-CSI fallthrough.
|
|
242
|
+
i = data.length
|
|
243
|
+
}
|
|
244
|
+
break
|
|
245
|
+
}
|
|
246
|
+
if (isCSIFinal(code)) {
|
|
247
|
+
i++
|
|
248
|
+
emitSequence(data.slice(seqStart, i))
|
|
249
|
+
} else if (isCSIParam(code) || isCSIIntermediate(code)) {
|
|
250
|
+
i++
|
|
251
|
+
} else {
|
|
252
|
+
// Invalid CSI - abort, treat as text
|
|
253
|
+
result.state = 'ground'
|
|
254
|
+
textStart = seqStart
|
|
255
|
+
}
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
case 'ss3':
|
|
259
|
+
// SS3 sequences: ESC O followed by a single final byte
|
|
260
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
261
|
+
i++
|
|
262
|
+
emitSequence(data.slice(seqStart, i))
|
|
263
|
+
} else {
|
|
264
|
+
// Invalid - treat as text
|
|
265
|
+
result.state = 'ground'
|
|
266
|
+
textStart = seqStart
|
|
267
|
+
}
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
case 'osc':
|
|
271
|
+
if (code === C0.BEL) {
|
|
272
|
+
i++
|
|
273
|
+
emitSequence(data.slice(seqStart, i))
|
|
274
|
+
} else if (
|
|
275
|
+
code === C0.ESC &&
|
|
276
|
+
i + 1 < data.length &&
|
|
277
|
+
data.charCodeAt(i + 1) === ESC_TYPE.ST
|
|
278
|
+
) {
|
|
279
|
+
i += 2
|
|
280
|
+
emitSequence(data.slice(seqStart, i))
|
|
281
|
+
} else {
|
|
282
|
+
i++
|
|
283
|
+
}
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
case 'dcs':
|
|
287
|
+
case 'apc':
|
|
288
|
+
if (code === C0.BEL) {
|
|
289
|
+
i++
|
|
290
|
+
emitSequence(data.slice(seqStart, i))
|
|
291
|
+
} else if (
|
|
292
|
+
code === C0.ESC &&
|
|
293
|
+
i + 1 < data.length &&
|
|
294
|
+
data.charCodeAt(i + 1) === ESC_TYPE.ST
|
|
295
|
+
) {
|
|
296
|
+
i += 2
|
|
297
|
+
emitSequence(data.slice(seqStart, i))
|
|
298
|
+
} else {
|
|
299
|
+
i++
|
|
300
|
+
}
|
|
301
|
+
break
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Handle end of input
|
|
306
|
+
if (result.state === 'ground') {
|
|
307
|
+
flushText()
|
|
308
|
+
} else if (flush) {
|
|
309
|
+
// Force output incomplete sequence
|
|
310
|
+
const remaining = data.slice(seqStart)
|
|
311
|
+
if (remaining) tokens.push({ type: 'sequence', value: remaining })
|
|
312
|
+
result.state = 'ground'
|
|
313
|
+
} else {
|
|
314
|
+
// Buffer incomplete sequence for next call
|
|
315
|
+
result.buffer = data.slice(seqStart)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { tokens, state: result }
|
|
319
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI Parser - Semantic Types
|
|
3
|
+
*
|
|
4
|
+
* These types represent the semantic meaning of ANSI escape sequences,
|
|
5
|
+
* not their string representation. Inspired by ghostty's action-based design.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Colors
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
/** Named colors from the 16-color palette */
|
|
13
|
+
export type NamedColor =
|
|
14
|
+
| 'black'
|
|
15
|
+
| 'red'
|
|
16
|
+
| 'green'
|
|
17
|
+
| 'yellow'
|
|
18
|
+
| 'blue'
|
|
19
|
+
| 'magenta'
|
|
20
|
+
| 'cyan'
|
|
21
|
+
| 'white'
|
|
22
|
+
| 'brightBlack'
|
|
23
|
+
| 'brightRed'
|
|
24
|
+
| 'brightGreen'
|
|
25
|
+
| 'brightYellow'
|
|
26
|
+
| 'brightBlue'
|
|
27
|
+
| 'brightMagenta'
|
|
28
|
+
| 'brightCyan'
|
|
29
|
+
| 'brightWhite'
|
|
30
|
+
|
|
31
|
+
/** Color specification - can be named, indexed (256), or RGB */
|
|
32
|
+
export type Color =
|
|
33
|
+
| { type: 'named'; name: NamedColor }
|
|
34
|
+
| { type: 'indexed'; index: number } // 0-255
|
|
35
|
+
| { type: 'rgb'; r: number; g: number; b: number }
|
|
36
|
+
| { type: 'default' }
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Text Styles
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
/** Underline style variants */
|
|
43
|
+
export type UnderlineStyle =
|
|
44
|
+
| 'none'
|
|
45
|
+
| 'single'
|
|
46
|
+
| 'double'
|
|
47
|
+
| 'curly'
|
|
48
|
+
| 'dotted'
|
|
49
|
+
| 'dashed'
|
|
50
|
+
|
|
51
|
+
/** Text style attributes - represents current styling state */
|
|
52
|
+
export type TextStyle = {
|
|
53
|
+
bold: boolean
|
|
54
|
+
dim: boolean
|
|
55
|
+
italic: boolean
|
|
56
|
+
underline: UnderlineStyle
|
|
57
|
+
blink: boolean
|
|
58
|
+
inverse: boolean
|
|
59
|
+
hidden: boolean
|
|
60
|
+
strikethrough: boolean
|
|
61
|
+
overline: boolean
|
|
62
|
+
fg: Color
|
|
63
|
+
bg: Color
|
|
64
|
+
underlineColor: Color
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Create a default (reset) text style */
|
|
68
|
+
export function defaultStyle(): TextStyle {
|
|
69
|
+
return {
|
|
70
|
+
bold: false,
|
|
71
|
+
dim: false,
|
|
72
|
+
italic: false,
|
|
73
|
+
underline: 'none',
|
|
74
|
+
blink: false,
|
|
75
|
+
inverse: false,
|
|
76
|
+
hidden: false,
|
|
77
|
+
strikethrough: false,
|
|
78
|
+
overline: false,
|
|
79
|
+
fg: { type: 'default' },
|
|
80
|
+
bg: { type: 'default' },
|
|
81
|
+
underlineColor: { type: 'default' },
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Check if two styles are equal */
|
|
86
|
+
export function stylesEqual(a: TextStyle, b: TextStyle): boolean {
|
|
87
|
+
return (
|
|
88
|
+
a.bold === b.bold &&
|
|
89
|
+
a.dim === b.dim &&
|
|
90
|
+
a.italic === b.italic &&
|
|
91
|
+
a.underline === b.underline &&
|
|
92
|
+
a.blink === b.blink &&
|
|
93
|
+
a.inverse === b.inverse &&
|
|
94
|
+
a.hidden === b.hidden &&
|
|
95
|
+
a.strikethrough === b.strikethrough &&
|
|
96
|
+
a.overline === b.overline &&
|
|
97
|
+
colorsEqual(a.fg, b.fg) &&
|
|
98
|
+
colorsEqual(a.bg, b.bg) &&
|
|
99
|
+
colorsEqual(a.underlineColor, b.underlineColor)
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Check if two colors are equal */
|
|
104
|
+
export function colorsEqual(a: Color, b: Color): boolean {
|
|
105
|
+
if (a.type !== b.type) return false
|
|
106
|
+
switch (a.type) {
|
|
107
|
+
case 'named':
|
|
108
|
+
return a.name === (b as typeof a).name
|
|
109
|
+
case 'indexed':
|
|
110
|
+
return a.index === (b as typeof a).index
|
|
111
|
+
case 'rgb':
|
|
112
|
+
return (
|
|
113
|
+
a.r === (b as typeof a).r &&
|
|
114
|
+
a.g === (b as typeof a).g &&
|
|
115
|
+
a.b === (b as typeof a).b
|
|
116
|
+
)
|
|
117
|
+
case 'default':
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// =============================================================================
|
|
123
|
+
// Cursor Actions
|
|
124
|
+
// =============================================================================
|
|
125
|
+
|
|
126
|
+
export type CursorDirection = 'up' | 'down' | 'forward' | 'back'
|
|
127
|
+
|
|
128
|
+
export type CursorAction =
|
|
129
|
+
| { type: 'move'; direction: CursorDirection; count: number }
|
|
130
|
+
| { type: 'position'; row: number; col: number }
|
|
131
|
+
| { type: 'column'; col: number }
|
|
132
|
+
| { type: 'row'; row: number }
|
|
133
|
+
| { type: 'save' }
|
|
134
|
+
| { type: 'restore' }
|
|
135
|
+
| { type: 'show' }
|
|
136
|
+
| { type: 'hide' }
|
|
137
|
+
| {
|
|
138
|
+
type: 'style'
|
|
139
|
+
style: 'block' | 'underline' | 'bar'
|
|
140
|
+
blinking: boolean
|
|
141
|
+
}
|
|
142
|
+
| { type: 'nextLine'; count: number }
|
|
143
|
+
| { type: 'prevLine'; count: number }
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// Erase Actions
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
export type EraseAction =
|
|
150
|
+
| { type: 'display'; region: 'toEnd' | 'toStart' | 'all' | 'scrollback' }
|
|
151
|
+
| { type: 'line'; region: 'toEnd' | 'toStart' | 'all' }
|
|
152
|
+
| { type: 'chars'; count: number }
|
|
153
|
+
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// Scroll Actions
|
|
156
|
+
// =============================================================================
|
|
157
|
+
|
|
158
|
+
export type ScrollAction =
|
|
159
|
+
| { type: 'up'; count: number }
|
|
160
|
+
| { type: 'down'; count: number }
|
|
161
|
+
| { type: 'setRegion'; top: number; bottom: number }
|
|
162
|
+
|
|
163
|
+
// =============================================================================
|
|
164
|
+
// Mode Actions
|
|
165
|
+
// =============================================================================
|
|
166
|
+
|
|
167
|
+
export type ModeAction =
|
|
168
|
+
| { type: 'alternateScreen'; enabled: boolean }
|
|
169
|
+
| { type: 'bracketedPaste'; enabled: boolean }
|
|
170
|
+
| { type: 'mouseTracking'; mode: 'off' | 'normal' | 'button' | 'any' }
|
|
171
|
+
| { type: 'focusEvents'; enabled: boolean }
|
|
172
|
+
|
|
173
|
+
// =============================================================================
|
|
174
|
+
// Link Actions (OSC 8)
|
|
175
|
+
// =============================================================================
|
|
176
|
+
|
|
177
|
+
export type LinkAction =
|
|
178
|
+
| { type: 'start'; url: string; params?: Record<string, string> }
|
|
179
|
+
| { type: 'end' }
|
|
180
|
+
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// Title Actions (OSC 0/1/2)
|
|
183
|
+
// =============================================================================
|
|
184
|
+
|
|
185
|
+
export type TitleAction =
|
|
186
|
+
| { type: 'windowTitle'; title: string }
|
|
187
|
+
| { type: 'iconName'; name: string }
|
|
188
|
+
| { type: 'both'; title: string }
|
|
189
|
+
|
|
190
|
+
// =============================================================================
|
|
191
|
+
// Tab Status Action (OSC 21337)
|
|
192
|
+
// =============================================================================
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Per-tab chrome metadata. Tristate for each field:
|
|
196
|
+
* - property absent → not mentioned in sequence, no change
|
|
197
|
+
* - null → explicitly cleared (bare key or key= with empty value)
|
|
198
|
+
* - value → set to this
|
|
199
|
+
*/
|
|
200
|
+
export type TabStatusAction = {
|
|
201
|
+
indicator?: Color | null
|
|
202
|
+
status?: string | null
|
|
203
|
+
statusColor?: Color | null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// =============================================================================
|
|
207
|
+
// Parsed Segments - The output of the parser
|
|
208
|
+
// =============================================================================
|
|
209
|
+
|
|
210
|
+
/** A segment of styled text */
|
|
211
|
+
export type TextSegment = {
|
|
212
|
+
type: 'text'
|
|
213
|
+
text: string
|
|
214
|
+
style: TextStyle
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A grapheme (visual character unit) with width info */
|
|
218
|
+
export type Grapheme = {
|
|
219
|
+
value: string
|
|
220
|
+
width: 1 | 2 // Display width in columns
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** All possible parsed actions */
|
|
224
|
+
export type Action =
|
|
225
|
+
| { type: 'text'; graphemes: Grapheme[]; style: TextStyle }
|
|
226
|
+
| { type: 'cursor'; action: CursorAction }
|
|
227
|
+
| { type: 'erase'; action: EraseAction }
|
|
228
|
+
| { type: 'scroll'; action: ScrollAction }
|
|
229
|
+
| { type: 'mode'; action: ModeAction }
|
|
230
|
+
| { type: 'link'; action: LinkAction }
|
|
231
|
+
| { type: 'title'; action: TitleAction }
|
|
232
|
+
| { type: 'tabStatus'; action: TabStatusAction }
|
|
233
|
+
| { type: 'sgr'; params: string } // Select Graphic Rendition (style change)
|
|
234
|
+
| { type: 'bell' }
|
|
235
|
+
| { type: 'reset' } // Full terminal reset (ESC c)
|
|
236
|
+
| { type: 'unknown'; sequence: string } // Unrecognized sequence
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo } from 'react'
|
|
2
|
+
import { isProgressReportingAvailable, type Progress } from './terminal.js'
|
|
3
|
+
import { BEL } from './termio/ansi.js'
|
|
4
|
+
import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from './termio/osc.js'
|
|
5
|
+
|
|
6
|
+
type WriteRaw = (data: string) => void
|
|
7
|
+
|
|
8
|
+
export const TerminalWriteContext = createContext<WriteRaw | null>(null)
|
|
9
|
+
|
|
10
|
+
export const TerminalWriteProvider = TerminalWriteContext.Provider
|
|
11
|
+
|
|
12
|
+
export type TerminalNotification = {
|
|
13
|
+
notifyITerm2: (opts: { message: string; title?: string }) => void
|
|
14
|
+
notifyKitty: (opts: { message: string; title: string; id: number }) => void
|
|
15
|
+
notifyGhostty: (opts: { message: string; title: string }) => void
|
|
16
|
+
notifyBell: () => void
|
|
17
|
+
/**
|
|
18
|
+
* Report progress to the terminal via OSC 9;4 sequences.
|
|
19
|
+
* Supported terminals: ConEmu, Ghostty 1.2.0+, iTerm2 3.6.6+
|
|
20
|
+
* Pass state=null to clear progress.
|
|
21
|
+
*/
|
|
22
|
+
progress: (state: Progress['state'] | null, percentage?: number) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useTerminalNotification(): TerminalNotification {
|
|
26
|
+
const writeRaw = useContext(TerminalWriteContext)
|
|
27
|
+
if (!writeRaw) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'useTerminalNotification must be used within TerminalWriteProvider',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const notifyITerm2 = useCallback(
|
|
34
|
+
({ message, title }: { message: string; title?: string }) => {
|
|
35
|
+
const displayString = title ? `${title}:\n${message}` : message
|
|
36
|
+
writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, `\n\n${displayString}`)))
|
|
37
|
+
},
|
|
38
|
+
[writeRaw],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const notifyKitty = useCallback(
|
|
42
|
+
({
|
|
43
|
+
message,
|
|
44
|
+
title,
|
|
45
|
+
id,
|
|
46
|
+
}: {
|
|
47
|
+
message: string
|
|
48
|
+
title: string
|
|
49
|
+
id: number
|
|
50
|
+
}) => {
|
|
51
|
+
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=0:p=title`, title)))
|
|
52
|
+
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:p=body`, message)))
|
|
53
|
+
writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=1:a=focus`, '')))
|
|
54
|
+
},
|
|
55
|
+
[writeRaw],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const notifyGhostty = useCallback(
|
|
59
|
+
({ message, title }: { message: string; title: string }) => {
|
|
60
|
+
writeRaw(wrapForMultiplexer(osc(OSC.GHOSTTY, 'notify', title, message)))
|
|
61
|
+
},
|
|
62
|
+
[writeRaw],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const notifyBell = useCallback(() => {
|
|
66
|
+
// Raw BEL — inside tmux this triggers tmux's bell-action (window flag).
|
|
67
|
+
// Wrapping would make it opaque DCS payload and lose that fallback.
|
|
68
|
+
writeRaw(BEL)
|
|
69
|
+
}, [writeRaw])
|
|
70
|
+
|
|
71
|
+
const progress = useCallback(
|
|
72
|
+
(state: Progress['state'] | null, percentage?: number) => {
|
|
73
|
+
if (!isProgressReportingAvailable()) {
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (!state) {
|
|
77
|
+
writeRaw(
|
|
78
|
+
wrapForMultiplexer(
|
|
79
|
+
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const pct = Math.max(0, Math.min(100, Math.round(percentage ?? 0)))
|
|
85
|
+
switch (state) {
|
|
86
|
+
case 'completed':
|
|
87
|
+
writeRaw(
|
|
88
|
+
wrapForMultiplexer(
|
|
89
|
+
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
break
|
|
93
|
+
case 'error':
|
|
94
|
+
writeRaw(
|
|
95
|
+
wrapForMultiplexer(
|
|
96
|
+
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.ERROR, pct),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
break
|
|
100
|
+
case 'indeterminate':
|
|
101
|
+
writeRaw(
|
|
102
|
+
wrapForMultiplexer(
|
|
103
|
+
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.INDETERMINATE, ''),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
break
|
|
107
|
+
case 'running':
|
|
108
|
+
writeRaw(
|
|
109
|
+
wrapForMultiplexer(
|
|
110
|
+
osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.SET, pct),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
break
|
|
114
|
+
case null:
|
|
115
|
+
// Handled by the if guard above
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
[writeRaw],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return useMemo(
|
|
123
|
+
() => ({ notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress }),
|
|
124
|
+
[notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress],
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { logForDebugging } from '../utils/debug.js'
|
|
2
|
+
|
|
3
|
+
export function ifNotInteger(value: number | undefined, name: string): void {
|
|
4
|
+
if (value === undefined) return
|
|
5
|
+
if (Number.isInteger(value)) return
|
|
6
|
+
logForDebugging(`${name} should be an integer, got ${value}`, {
|
|
7
|
+
level: 'warn',
|
|
8
|
+
})
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { lineWidth } from './line-width-cache.js'
|
|
2
|
+
|
|
3
|
+
export function widestLine(string: string): number {
|
|
4
|
+
let maxWidth = 0
|
|
5
|
+
let start = 0
|
|
6
|
+
|
|
7
|
+
while (start <= string.length) {
|
|
8
|
+
const end = string.indexOf('\n', start)
|
|
9
|
+
const line =
|
|
10
|
+
end === -1 ? string.substring(start) : string.substring(start, end)
|
|
11
|
+
|
|
12
|
+
maxWidth = Math.max(maxWidth, lineWidth(line))
|
|
13
|
+
|
|
14
|
+
if (end === -1) break
|
|
15
|
+
start = end + 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return maxWidth
|
|
19
|
+
}
|