@vsuryav/agent-sim 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +25 -0
  2. package/bin/agent-sim.js +25 -0
  3. package/package.json +72 -0
  4. package/src/app-paths.ts +29 -0
  5. package/src/app-sync.test.ts +75 -0
  6. package/src/app-sync.ts +110 -0
  7. package/src/cli.ts +129 -0
  8. package/src/collector/claude-code.test.ts +102 -0
  9. package/src/collector/claude-code.ts +133 -0
  10. package/src/collector/codex-cli.test.ts +116 -0
  11. package/src/collector/codex-cli.ts +149 -0
  12. package/src/collector/db.test.ts +59 -0
  13. package/src/collector/db.ts +125 -0
  14. package/src/collector/names.test.ts +21 -0
  15. package/src/collector/names.ts +28 -0
  16. package/src/collector/personality.test.ts +40 -0
  17. package/src/collector/personality.ts +46 -0
  18. package/src/collector/remote-sync.test.ts +31 -0
  19. package/src/collector/remote-sync.ts +171 -0
  20. package/src/collector/sync.test.ts +67 -0
  21. package/src/collector/sync.ts +148 -0
  22. package/src/collector/types.ts +1 -0
  23. package/src/engine/bootstrap/state.ts +3 -0
  24. package/src/engine/buddy/CompanionSprite.tsx +371 -0
  25. package/src/engine/buddy/companion.ts +133 -0
  26. package/src/engine/buddy/prompt.ts +36 -0
  27. package/src/engine/buddy/sprites.ts +514 -0
  28. package/src/engine/buddy/types.ts +148 -0
  29. package/src/engine/buddy/useBuddyNotification.tsx +98 -0
  30. package/src/engine/ink/Ansi.tsx +292 -0
  31. package/src/engine/ink/bidi.ts +139 -0
  32. package/src/engine/ink/clearTerminal.ts +74 -0
  33. package/src/engine/ink/colorize.ts +231 -0
  34. package/src/engine/ink/components/AlternateScreen.tsx +80 -0
  35. package/src/engine/ink/components/App.tsx +658 -0
  36. package/src/engine/ink/components/AppContext.ts +21 -0
  37. package/src/engine/ink/components/Box.tsx +214 -0
  38. package/src/engine/ink/components/Button.tsx +192 -0
  39. package/src/engine/ink/components/ClockContext.tsx +112 -0
  40. package/src/engine/ink/components/CursorDeclarationContext.ts +32 -0
  41. package/src/engine/ink/components/ErrorOverview.tsx +109 -0
  42. package/src/engine/ink/components/Link.tsx +42 -0
  43. package/src/engine/ink/components/Newline.tsx +39 -0
  44. package/src/engine/ink/components/NoSelect.tsx +68 -0
  45. package/src/engine/ink/components/RawAnsi.tsx +57 -0
  46. package/src/engine/ink/components/ScrollBox.tsx +237 -0
  47. package/src/engine/ink/components/Spacer.tsx +20 -0
  48. package/src/engine/ink/components/StdinContext.ts +49 -0
  49. package/src/engine/ink/components/TerminalFocusContext.tsx +52 -0
  50. package/src/engine/ink/components/TerminalSizeContext.tsx +7 -0
  51. package/src/engine/ink/components/Text.tsx +254 -0
  52. package/src/engine/ink/constants.ts +2 -0
  53. package/src/engine/ink/dom.ts +484 -0
  54. package/src/engine/ink/events/click-event.ts +38 -0
  55. package/src/engine/ink/events/dispatcher.ts +233 -0
  56. package/src/engine/ink/events/emitter.ts +39 -0
  57. package/src/engine/ink/events/event-handlers.ts +73 -0
  58. package/src/engine/ink/events/event.ts +11 -0
  59. package/src/engine/ink/events/focus-event.ts +21 -0
  60. package/src/engine/ink/events/input-event.ts +205 -0
  61. package/src/engine/ink/events/keyboard-event.ts +51 -0
  62. package/src/engine/ink/events/terminal-event.ts +107 -0
  63. package/src/engine/ink/events/terminal-focus-event.ts +19 -0
  64. package/src/engine/ink/focus.ts +181 -0
  65. package/src/engine/ink/frame.ts +124 -0
  66. package/src/engine/ink/get-max-width.ts +27 -0
  67. package/src/engine/ink/global.d.ts +18 -0
  68. package/src/engine/ink/hit-test.ts +130 -0
  69. package/src/engine/ink/hooks/use-animation-frame.ts +57 -0
  70. package/src/engine/ink/hooks/use-app.ts +8 -0
  71. package/src/engine/ink/hooks/use-declared-cursor.ts +73 -0
  72. package/src/engine/ink/hooks/use-input.ts +92 -0
  73. package/src/engine/ink/hooks/use-interval.ts +67 -0
  74. package/src/engine/ink/hooks/use-search-highlight.ts +53 -0
  75. package/src/engine/ink/hooks/use-selection.ts +104 -0
  76. package/src/engine/ink/hooks/use-stdin.ts +8 -0
  77. package/src/engine/ink/hooks/use-tab-status.ts +72 -0
  78. package/src/engine/ink/hooks/use-terminal-focus.ts +16 -0
  79. package/src/engine/ink/hooks/use-terminal-title.ts +31 -0
  80. package/src/engine/ink/hooks/use-terminal-viewport.ts +96 -0
  81. package/src/engine/ink/ink.tsx +1723 -0
  82. package/src/engine/ink/instances.ts +10 -0
  83. package/src/engine/ink/layout/engine.ts +6 -0
  84. package/src/engine/ink/layout/geometry.ts +97 -0
  85. package/src/engine/ink/layout/node.ts +152 -0
  86. package/src/engine/ink/layout/yoga.ts +308 -0
  87. package/src/engine/ink/line-width-cache.ts +24 -0
  88. package/src/engine/ink/log-update.ts +773 -0
  89. package/src/engine/ink/measure-element.ts +23 -0
  90. package/src/engine/ink/measure-text.ts +47 -0
  91. package/src/engine/ink/node-cache.ts +54 -0
  92. package/src/engine/ink/optimizer.ts +93 -0
  93. package/src/engine/ink/output.ts +797 -0
  94. package/src/engine/ink/parse-keypress.ts +801 -0
  95. package/src/engine/ink/reconciler.ts +512 -0
  96. package/src/engine/ink/render-border.ts +231 -0
  97. package/src/engine/ink/render-node-to-output.ts +1462 -0
  98. package/src/engine/ink/render-to-screen.ts +231 -0
  99. package/src/engine/ink/renderer.ts +178 -0
  100. package/src/engine/ink/root.ts +184 -0
  101. package/src/engine/ink/screen.ts +1486 -0
  102. package/src/engine/ink/searchHighlight.ts +93 -0
  103. package/src/engine/ink/selection.ts +917 -0
  104. package/src/engine/ink/squash-text-nodes.ts +92 -0
  105. package/src/engine/ink/stringWidth.ts +222 -0
  106. package/src/engine/ink/styles.ts +771 -0
  107. package/src/engine/ink/supports-hyperlinks.ts +57 -0
  108. package/src/engine/ink/tabstops.ts +46 -0
  109. package/src/engine/ink/terminal-focus-state.ts +47 -0
  110. package/src/engine/ink/terminal-querier.ts +212 -0
  111. package/src/engine/ink/terminal.ts +248 -0
  112. package/src/engine/ink/termio/ansi.ts +75 -0
  113. package/src/engine/ink/termio/csi.ts +319 -0
  114. package/src/engine/ink/termio/dec.ts +60 -0
  115. package/src/engine/ink/termio/esc.ts +67 -0
  116. package/src/engine/ink/termio/osc.ts +493 -0
  117. package/src/engine/ink/termio/parser.ts +394 -0
  118. package/src/engine/ink/termio/sgr.ts +308 -0
  119. package/src/engine/ink/termio/tokenize.ts +319 -0
  120. package/src/engine/ink/termio/types.ts +236 -0
  121. package/src/engine/ink/useTerminalNotification.ts +126 -0
  122. package/src/engine/ink/warn.ts +9 -0
  123. package/src/engine/ink/widest-line.ts +19 -0
  124. package/src/engine/ink/wrap-text.ts +74 -0
  125. package/src/engine/ink/wrapAnsi.ts +20 -0
  126. package/src/engine/native-ts/yoga-layout/enums.ts +134 -0
  127. package/src/engine/native-ts/yoga-layout/index.ts +2578 -0
  128. package/src/engine/stubs/bootstrap-state.ts +4 -0
  129. package/src/engine/stubs/debug.ts +6 -0
  130. package/src/engine/stubs/log.ts +4 -0
  131. package/src/engine/utils/debug.ts +5 -0
  132. package/src/engine/utils/earlyInput.ts +4 -0
  133. package/src/engine/utils/env.ts +15 -0
  134. package/src/engine/utils/envUtils.ts +4 -0
  135. package/src/engine/utils/execFileNoThrow.ts +24 -0
  136. package/src/engine/utils/fullscreen.ts +4 -0
  137. package/src/engine/utils/intl.ts +9 -0
  138. package/src/engine/utils/log.ts +3 -0
  139. package/src/engine/utils/semver.ts +13 -0
  140. package/src/engine/utils/sliceAnsi.ts +10 -0
  141. package/src/engine/utils/theme.ts +17 -0
  142. package/src/game/App.tsx +141 -0
  143. package/src/game/agents/behavior.ts +249 -0
  144. package/src/game/agents/speech.ts +57 -0
  145. package/src/game/canvas.ts +98 -0
  146. package/src/game/launch.ts +36 -0
  147. package/src/game/ship/ShipView.tsx +145 -0
  148. package/src/game/ship/ship-map.ts +172 -0
  149. package/src/game/ui/AgentBio.tsx +72 -0
  150. package/src/game/ui/HUD.tsx +63 -0
  151. package/src/game/ui/StatusBar.tsx +49 -0
  152. package/src/game/useKeyboard.ts +62 -0
  153. package/src/main.tsx +22 -0
  154. package/src/run-interactive.ts +74 -0
@@ -0,0 +1,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
+ }