@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,493 @@
1
+ /**
2
+ * OSC (Operating System Command) Types and Parser
3
+ */
4
+
5
+ import { Buffer } from 'buffer'
6
+ import { env } from '../../utils/env.js'
7
+ import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
8
+ import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
9
+ import type { Action, Color, TabStatusAction } from './types.js'
10
+
11
+ export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC)
12
+
13
+ /** String Terminator (ESC \) - alternative to BEL for terminating OSC */
14
+ export const ST = ESC + '\\'
15
+
16
+ /** Generate an OSC sequence: ESC ] p1;p2;...;pN <terminator>
17
+ * Uses ST terminator for Kitty (avoids beeps), BEL for others */
18
+ export function osc(...parts: (string | number)[]): string {
19
+ const terminator = env.terminal === 'kitty' ? ST : BEL
20
+ return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
21
+ }
22
+
23
+ /**
24
+ * Wrap an escape sequence for terminal multiplexer passthrough.
25
+ * tmux and GNU screen intercept escape sequences; DCS passthrough
26
+ * tunnels them to the outer terminal unmodified.
27
+ *
28
+ * tmux 3.3+ gates this behind `allow-passthrough` (default off). When off,
29
+ * tmux silently drops the whole DCS — no junk, no worse than unwrapped OSC.
30
+ * Users who want passthrough set it in their .tmux.conf; we don't mutate it.
31
+ *
32
+ * Do NOT wrap BEL: raw \x07 triggers tmux's bell-action (window flag);
33
+ * wrapped \x07 is opaque DCS payload and tmux never sees the bell.
34
+ */
35
+ export function wrapForMultiplexer(sequence: string): string {
36
+ if (process.env['TMUX']) {
37
+ const escaped = sequence.replaceAll('\x1b', '\x1b\x1b')
38
+ return `\x1bPtmux;${escaped}\x1b\\`
39
+ }
40
+ if (process.env['STY']) {
41
+ return `\x1bP${sequence}\x1b\\`
42
+ }
43
+ return sequence
44
+ }
45
+
46
+ /**
47
+ * Which path setClipboard() will take, based on env state. Synchronous so
48
+ * callers can show an honest toast without awaiting the copy itself.
49
+ *
50
+ * - 'native': pbcopy (or equivalent) will run — high-confidence system
51
+ * clipboard write. tmux buffer may also be loaded as a bonus.
52
+ * - 'tmux-buffer': tmux load-buffer will run, but no native tool — paste
53
+ * with prefix+] works. System clipboard depends on tmux's set-clipboard
54
+ * option + outer terminal OSC 52 support; can't know from here.
55
+ * - 'osc52': only the raw OSC 52 sequence will be written to stdout.
56
+ * Best-effort; iTerm2 disables OSC 52 by default.
57
+ *
58
+ * pbcopy gating uses SSH_CONNECTION specifically, not SSH_TTY — tmux panes
59
+ * inherit SSH_TTY forever even after local reattach, but SSH_CONNECTION is
60
+ * in tmux's default update-environment set and gets cleared.
61
+ */
62
+ export type ClipboardPath = 'native' | 'tmux-buffer' | 'osc52'
63
+
64
+ export function getClipboardPath(): ClipboardPath {
65
+ const nativeAvailable =
66
+ process.platform === 'darwin' && !process.env['SSH_CONNECTION']
67
+ if (nativeAvailable) return 'native'
68
+ if (process.env['TMUX']) return 'tmux-buffer'
69
+ return 'osc52'
70
+ }
71
+
72
+ /**
73
+ * Wrap a payload in tmux's DCS passthrough: ESC P tmux ; <payload> ESC \
74
+ * tmux forwards the payload to the outer terminal, bypassing its own parser.
75
+ * Inner ESCs must be doubled. Requires `set -g allow-passthrough on` in
76
+ * ~/.tmux.conf; without it, tmux silently drops the whole DCS (no regression).
77
+ */
78
+ function tmuxPassthrough(payload: string): string {
79
+ return `${ESC}Ptmux;${payload.replaceAll(ESC, ESC + ESC)}${ST}`
80
+ }
81
+
82
+ /**
83
+ * Load text into tmux's paste buffer via `tmux load-buffer`.
84
+ * -w (tmux 3.2+) propagates to the outer terminal's clipboard via tmux's
85
+ * own OSC 52 emission. -w is dropped for iTerm2: tmux's OSC 52 emission
86
+ * crashes the iTerm2 session over SSH.
87
+ *
88
+ * Returns true if the buffer was loaded successfully.
89
+ */
90
+ export async function tmuxLoadBuffer(text: string): Promise<boolean> {
91
+ if (!process.env['TMUX']) return false
92
+ const args =
93
+ process.env['LC_TERMINAL'] === 'iTerm2'
94
+ ? ['load-buffer', '-']
95
+ : ['load-buffer', '-w', '-']
96
+ const { code } = await execFileNoThrow('tmux', args, {
97
+ input: text,
98
+ useCwd: false,
99
+ timeout: 2000,
100
+ })
101
+ return code === 0
102
+ }
103
+
104
+ /**
105
+ * OSC 52 clipboard write: ESC ] 52 ; c ; <base64> BEL/ST
106
+ * 'c' selects the clipboard (vs 'p' for primary selection on X11).
107
+ *
108
+ * When inside tmux ($TMUX set), `tmux load-buffer -w -` is the primary
109
+ * path. tmux's buffer is always reachable — works over SSH, survives
110
+ * detach/reattach, immune to stale env vars. The -w flag (tmux 3.2+) tells
111
+ * tmux to also propagate to the outer terminal via its own OSC 52 path,
112
+ * which tmux wraps correctly for the attached client. On older tmux, -w is
113
+ * ignored and the buffer is still loaded. -w is dropped for iTerm2 (#22432)
114
+ * because tmux's own OSC 52 emission (empty selection param: ESC]52;;b64)
115
+ * crashes iTerm2 over SSH.
116
+ *
117
+ * After load-buffer succeeds, we ALSO return a DCS-passthrough-wrapped
118
+ * OSC 52 for the caller to write to stdout. Our sequence uses explicit `c`
119
+ * (not tmux's crashy empty-param variant), so it sidesteps the #22432 path.
120
+ * With `allow-passthrough on` + an OSC-52-capable outer terminal, selection
121
+ * reaches the system clipboard; with either off, tmux silently drops the
122
+ * DCS and prefix+] still works. See Greg Smith's "free pony" in
123
+ * https://anthropic.slack.com/archives/C07VBSHV7EV/p1773177228548119.
124
+ *
125
+ * If load-buffer fails entirely, fall through to raw OSC 52.
126
+ *
127
+ * Outside tmux, write raw OSC 52 to stdout (caller handles the write).
128
+ *
129
+ * Local (no SSH_CONNECTION): also shell out to a native clipboard utility.
130
+ * OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables
131
+ * OSC 52 by default, VS Code shows a permission prompt on first use. Native
132
+ * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over
133
+ * SSH these would write to the remote clipboard — OSC 52 is the right path there.
134
+ *
135
+ * Returns the sequence for the caller to write to stdout (raw OSC 52
136
+ * outside tmux, DCS-wrapped inside).
137
+ */
138
+ export async function setClipboard(text: string): Promise<string> {
139
+ const b64 = Buffer.from(text, 'utf8').toString('base64')
140
+ const raw = osc(OSC.CLIPBOARD, 'c', b64)
141
+
142
+ // Native safety net — fire FIRST, before the tmux await, so a quick
143
+ // focus-switch after selecting doesn't race pbcopy. Previously this ran
144
+ // AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency
145
+ // before pbcopy even started — fast cmd+tab → paste would beat it
146
+ // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).
147
+ // Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY
148
+ // forever but SSH_CONNECTION is in tmux's default update-environment and
149
+ // clears on local attach. Fire-and-forget.
150
+ if (!process.env['SSH_CONNECTION']) copyNative(text)
151
+
152
+ const tmuxBufferLoaded = await tmuxLoadBuffer(text)
153
+
154
+ // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
155
+ // too, and BEL works everywhere for OSC 52.
156
+ if (tmuxBufferLoaded) return tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`)
157
+ return raw
158
+ }
159
+
160
+ // Linux clipboard tool: undefined = not yet probed, null = none available.
161
+ // Probe order: wl-copy (Wayland) → xclip (X11) → xsel (X11 fallback).
162
+ // Cached after first attempt so repeated mouse-ups skip the probe chain.
163
+ let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined
164
+
165
+ /**
166
+ * Shell out to a native clipboard utility as a safety net for OSC 52.
167
+ * Only called when not in an SSH session (over SSH, these would write to
168
+ * the remote machine's clipboard — OSC 52 is the right path there).
169
+ * Fire-and-forget: failures are silent since OSC 52 may have succeeded.
170
+ */
171
+ function copyNative(text: string): void {
172
+ const opts = { input: text, useCwd: false, timeout: 2000 }
173
+ switch (process.platform) {
174
+ case 'darwin':
175
+ void execFileNoThrow('pbcopy', [], opts)
176
+ return
177
+ case 'linux': {
178
+ if (linuxCopy === null) return
179
+ if (linuxCopy === 'wl-copy') {
180
+ void execFileNoThrow('wl-copy', [], opts)
181
+ return
182
+ }
183
+ if (linuxCopy === 'xclip') {
184
+ void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts)
185
+ return
186
+ }
187
+ if (linuxCopy === 'xsel') {
188
+ void execFileNoThrow('xsel', ['--clipboard', '--input'], opts)
189
+ return
190
+ }
191
+ // First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner.
192
+ void execFileNoThrow('wl-copy', [], opts).then(r => {
193
+ if (r.code === 0) {
194
+ linuxCopy = 'wl-copy'
195
+ return
196
+ }
197
+ void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(
198
+ r2 => {
199
+ if (r2.code === 0) {
200
+ linuxCopy = 'xclip'
201
+ return
202
+ }
203
+ void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(
204
+ r3 => {
205
+ linuxCopy = r3.code === 0 ? 'xsel' : null
206
+ },
207
+ )
208
+ },
209
+ )
210
+ })
211
+ return
212
+ }
213
+ case 'win32':
214
+ // clip.exe is always available on Windows. Unicode handling is
215
+ // imperfect (system locale encoding) but good enough for a fallback.
216
+ void execFileNoThrow('clip', [], opts)
217
+ return
218
+ }
219
+ }
220
+
221
+ /** @internal test-only */
222
+ export function _resetLinuxCopyCache(): void {
223
+ linuxCopy = undefined
224
+ }
225
+
226
+ /**
227
+ * OSC command numbers
228
+ */
229
+ export const OSC = {
230
+ SET_TITLE_AND_ICON: 0,
231
+ SET_ICON: 1,
232
+ SET_TITLE: 2,
233
+ SET_COLOR: 4,
234
+ SET_CWD: 7,
235
+ HYPERLINK: 8,
236
+ ITERM2: 9, // iTerm2 proprietary sequences
237
+ SET_FG_COLOR: 10,
238
+ SET_BG_COLOR: 11,
239
+ SET_CURSOR_COLOR: 12,
240
+ CLIPBOARD: 52,
241
+ KITTY: 99, // Kitty notification protocol
242
+ RESET_COLOR: 104,
243
+ RESET_FG_COLOR: 110,
244
+ RESET_BG_COLOR: 111,
245
+ RESET_CURSOR_COLOR: 112,
246
+ SEMANTIC_PROMPT: 133,
247
+ GHOSTTY: 777, // Ghostty notification protocol
248
+ TAB_STATUS: 21337, // Tab status extension
249
+ } as const
250
+
251
+ /**
252
+ * Parse an OSC sequence into an action
253
+ *
254
+ * @param content - The sequence content (without ESC ] and terminator)
255
+ */
256
+ export function parseOSC(content: string): Action | null {
257
+ const semicolonIdx = content.indexOf(';')
258
+ const command = semicolonIdx >= 0 ? content.slice(0, semicolonIdx) : content
259
+ const data = semicolonIdx >= 0 ? content.slice(semicolonIdx + 1) : ''
260
+
261
+ const commandNum = parseInt(command, 10)
262
+
263
+ // Window/icon title
264
+ if (commandNum === OSC.SET_TITLE_AND_ICON) {
265
+ return { type: 'title', action: { type: 'both', title: data } }
266
+ }
267
+ if (commandNum === OSC.SET_ICON) {
268
+ return { type: 'title', action: { type: 'iconName', name: data } }
269
+ }
270
+ if (commandNum === OSC.SET_TITLE) {
271
+ return { type: 'title', action: { type: 'windowTitle', title: data } }
272
+ }
273
+
274
+ // Hyperlinks (OSC 8)
275
+ if (commandNum === OSC.HYPERLINK) {
276
+ const parts = data.split(';')
277
+ const paramsStr = parts[0] ?? ''
278
+ const url = parts.slice(1).join(';')
279
+
280
+ if (url === '') {
281
+ return { type: 'link', action: { type: 'end' } }
282
+ }
283
+
284
+ const params: Record<string, string> = {}
285
+ if (paramsStr) {
286
+ for (const pair of paramsStr.split(':')) {
287
+ const eqIdx = pair.indexOf('=')
288
+ if (eqIdx >= 0) {
289
+ params[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1)
290
+ }
291
+ }
292
+ }
293
+
294
+ return {
295
+ type: 'link',
296
+ action: {
297
+ type: 'start',
298
+ url,
299
+ params: Object.keys(params).length > 0 ? params : undefined,
300
+ },
301
+ }
302
+ }
303
+
304
+ // Tab status (OSC 21337)
305
+ if (commandNum === OSC.TAB_STATUS) {
306
+ return { type: 'tabStatus', action: parseTabStatus(data) }
307
+ }
308
+
309
+ return { type: 'unknown', sequence: `\x1b]${content}` }
310
+ }
311
+
312
+ /**
313
+ * Parse an XParseColor-style color spec into an RGB Color.
314
+ * Accepts `#RRGGBB` and `rgb:R/G/B` (1–4 hex digits per component, scaled
315
+ * to 8-bit). Returns null on parse failure.
316
+ */
317
+ export function parseOscColor(spec: string): Color | null {
318
+ const hex = spec.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i)
319
+ if (hex) {
320
+ return {
321
+ type: 'rgb',
322
+ r: parseInt(hex[1]!, 16),
323
+ g: parseInt(hex[2]!, 16),
324
+ b: parseInt(hex[3]!, 16),
325
+ }
326
+ }
327
+ const rgb = spec.match(
328
+ /^rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})$/i,
329
+ )
330
+ if (rgb) {
331
+ // XParseColor: N hex digits → value / (16^N - 1), scale to 0-255
332
+ const scale = (s: string) =>
333
+ Math.round((parseInt(s, 16) / (16 ** s.length - 1)) * 255)
334
+ return {
335
+ type: 'rgb',
336
+ r: scale(rgb[1]!),
337
+ g: scale(rgb[2]!),
338
+ b: scale(rgb[3]!),
339
+ }
340
+ }
341
+ return null
342
+ }
343
+
344
+ /**
345
+ * Parse OSC 21337 payload: `key=value;key=value;...` with `\;` and `\\`
346
+ * escapes inside values. Bare key or `key=` clears that field; unknown
347
+ * keys are ignored.
348
+ */
349
+ function parseTabStatus(data: string): TabStatusAction {
350
+ const action: TabStatusAction = {}
351
+ for (const [key, value] of splitTabStatusPairs(data)) {
352
+ switch (key) {
353
+ case 'indicator':
354
+ action.indicator = value === '' ? null : parseOscColor(value)
355
+ break
356
+ case 'status':
357
+ action.status = value === '' ? null : value
358
+ break
359
+ case 'status-color':
360
+ action.statusColor = value === '' ? null : parseOscColor(value)
361
+ break
362
+ }
363
+ }
364
+ return action
365
+ }
366
+
367
+ /** Split `k=v;k=v` honoring `\;` and `\\` escapes. Yields [key, unescapedValue]. */
368
+ function* splitTabStatusPairs(data: string): Generator<[string, string]> {
369
+ let key = ''
370
+ let val = ''
371
+ let inVal = false
372
+ let esc = false
373
+ for (const c of data) {
374
+ if (esc) {
375
+ if (inVal) val += c
376
+ else key += c
377
+ esc = false
378
+ } else if (c === '\\') {
379
+ esc = true
380
+ } else if (c === ';') {
381
+ yield [key, val]
382
+ key = ''
383
+ val = ''
384
+ inVal = false
385
+ } else if (c === '=' && !inVal) {
386
+ inVal = true
387
+ } else if (inVal) {
388
+ val += c
389
+ } else {
390
+ key += c
391
+ }
392
+ }
393
+ if (key || inVal) yield [key, val]
394
+ }
395
+
396
+ // Output generators
397
+
398
+ /** Start a hyperlink (OSC 8). Auto-assigns an id= param derived from the URL
399
+ * so terminals group wrapped lines of the same link together (the spec says
400
+ * cells with matching URI *and* nonempty id are joined; without an id each
401
+ * wrapped line is a separate link — inconsistent hover, partial tooltips).
402
+ * Empty url = close sequence (empty params per spec). */
403
+ export function link(url: string, params?: Record<string, string>): string {
404
+ if (!url) return LINK_END
405
+ const p = { id: osc8Id(url), ...params }
406
+ const paramStr = Object.entries(p)
407
+ .map(([k, v]) => `${k}=${v}`)
408
+ .join(':')
409
+ return osc(OSC.HYPERLINK, paramStr, url)
410
+ }
411
+
412
+ function osc8Id(url: string): string {
413
+ let h = 0
414
+ for (let i = 0; i < url.length; i++)
415
+ h = ((h << 5) - h + url.charCodeAt(i)) | 0
416
+ return (h >>> 0).toString(36)
417
+ }
418
+
419
+ /** End a hyperlink (OSC 8) */
420
+ export const LINK_END = osc(OSC.HYPERLINK, '', '')
421
+
422
+ // iTerm2 OSC 9 subcommands
423
+
424
+ /** iTerm2 OSC 9 subcommand numbers */
425
+ export const ITERM2 = {
426
+ NOTIFY: 0,
427
+ BADGE: 2,
428
+ PROGRESS: 4,
429
+ } as const
430
+
431
+ /** Progress operation codes (for use with ITERM2.PROGRESS) */
432
+ export const PROGRESS = {
433
+ CLEAR: 0,
434
+ SET: 1,
435
+ ERROR: 2,
436
+ INDETERMINATE: 3,
437
+ } as const
438
+
439
+ /**
440
+ * Clear iTerm2 progress bar sequence (OSC 9;4;0;BEL)
441
+ * Uses BEL terminator since this is for cleanup (not runtime notification)
442
+ * and we want to ensure it's always sent regardless of terminal type.
443
+ */
444
+ export const CLEAR_ITERM2_PROGRESS = `${OSC_PREFIX}${OSC.ITERM2};${ITERM2.PROGRESS};${PROGRESS.CLEAR};${BEL}`
445
+
446
+ /**
447
+ * Clear terminal title sequence (OSC 0 with empty string + BEL).
448
+ * Uses BEL terminator for cleanup — safe on all terminals.
449
+ */
450
+ export const CLEAR_TERMINAL_TITLE = `${OSC_PREFIX}${OSC.SET_TITLE_AND_ICON};${BEL}`
451
+
452
+ /** Clear all three OSC 21337 tab-status fields. Used on exit. */
453
+ export const CLEAR_TAB_STATUS = osc(
454
+ OSC.TAB_STATUS,
455
+ 'indicator=;status=;status-color=',
456
+ )
457
+
458
+ /**
459
+ * Gate for emitting OSC 21337 (tab-status indicator). Ant-only while the
460
+ * spec is unstable. Terminals that don't recognize it discard silently, so
461
+ * emission is safe unconditionally — we don't gate on terminal detection
462
+ * since support is expected across several terminals.
463
+ *
464
+ * Callers must wrap output with wrapForMultiplexer() so tmux/screen
465
+ * DCS-passthrough carries the sequence to the outer terminal.
466
+ */
467
+ export function supportsTabStatus(): boolean {
468
+ return process.env.USER_TYPE === 'ant'
469
+ }
470
+
471
+ /**
472
+ * Emit an OSC 21337 tab-status sequence. Omitted fields are left unchanged
473
+ * by the receiving terminal; `null` sends an empty value to clear.
474
+ * `;` and `\` in status text are escaped per the spec.
475
+ */
476
+ export function tabStatus(fields: TabStatusAction): string {
477
+ const parts: string[] = []
478
+ const rgb = (c: Color) =>
479
+ c.type === 'rgb'
480
+ ? `#${[c.r, c.g, c.b].map(n => n.toString(16).padStart(2, '0')).join('')}`
481
+ : ''
482
+ if ('indicator' in fields)
483
+ parts.push(`indicator=${fields.indicator ? rgb(fields.indicator) : ''}`)
484
+ if ('status' in fields)
485
+ parts.push(
486
+ `status=${fields.status?.replaceAll('\\', '\\\\').replaceAll(';', '\\;') ?? ''}`,
487
+ )
488
+ if ('statusColor' in fields)
489
+ parts.push(
490
+ `status-color=${fields.statusColor ? rgb(fields.statusColor) : ''}`,
491
+ )
492
+ return osc(OSC.TAB_STATUS, parts.join(';'))
493
+ }