@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,57 @@
1
+ import supportsHyperlinksLib from 'supports-hyperlinks'
2
+
3
+ // Additional terminals that support OSC 8 hyperlinks but aren't detected by supports-hyperlinks.
4
+ // Checked against both TERM_PROGRAM and LC_TERMINAL (the latter is preserved inside tmux).
5
+ export const ADDITIONAL_HYPERLINK_TERMINALS = [
6
+ 'ghostty',
7
+ 'Hyper',
8
+ 'kitty',
9
+ 'alacritty',
10
+ 'iTerm.app',
11
+ 'iTerm2',
12
+ ]
13
+
14
+ type EnvLike = Record<string, string | undefined>
15
+
16
+ type SupportsHyperlinksOptions = {
17
+ env?: EnvLike
18
+ stdoutSupported?: boolean
19
+ }
20
+
21
+ /**
22
+ * Returns whether stdout supports OSC 8 hyperlinks.
23
+ * Extends the supports-hyperlinks library with additional terminal detection.
24
+ * @param options Optional overrides for testing (env, stdoutSupported)
25
+ */
26
+ export function supportsHyperlinks(
27
+ options?: SupportsHyperlinksOptions,
28
+ ): boolean {
29
+ const stdoutSupported =
30
+ options?.stdoutSupported ?? supportsHyperlinksLib.stdout
31
+ if (stdoutSupported) {
32
+ return true
33
+ }
34
+
35
+ const env = options?.env ?? process.env
36
+
37
+ // Check for additional terminals not detected by supports-hyperlinks
38
+ const termProgram = env['TERM_PROGRAM']
39
+ if (termProgram && ADDITIONAL_HYPERLINK_TERMINALS.includes(termProgram)) {
40
+ return true
41
+ }
42
+
43
+ // LC_TERMINAL is set by some terminals (e.g. iTerm2) and preserved inside tmux,
44
+ // where TERM_PROGRAM is overwritten to 'tmux'.
45
+ const lcTerminal = env['LC_TERMINAL']
46
+ if (lcTerminal && ADDITIONAL_HYPERLINK_TERMINALS.includes(lcTerminal)) {
47
+ return true
48
+ }
49
+
50
+ // Kitty sets TERM=xterm-kitty
51
+ const term = env['TERM']
52
+ if (term?.includes('kitty')) {
53
+ return true
54
+ }
55
+
56
+ return false
57
+ }
@@ -0,0 +1,46 @@
1
+ // Tab expansion, inspired by Ghostty's Tabstops.zig
2
+ // Uses 8-column intervals (POSIX default, hardcoded in terminals like Ghostty)
3
+
4
+ import { stringWidth } from './stringWidth.js'
5
+ import { createTokenizer } from './termio/tokenize.js'
6
+
7
+ const DEFAULT_TAB_INTERVAL = 8
8
+
9
+ export function expandTabs(
10
+ text: string,
11
+ interval = DEFAULT_TAB_INTERVAL,
12
+ ): string {
13
+ if (!text.includes('\t')) {
14
+ return text
15
+ }
16
+
17
+ const tokenizer = createTokenizer()
18
+ const tokens = tokenizer.feed(text)
19
+ tokens.push(...tokenizer.flush())
20
+
21
+ let result = ''
22
+ let column = 0
23
+
24
+ for (const token of tokens) {
25
+ if (token.type === 'sequence') {
26
+ result += token.value
27
+ } else {
28
+ const parts = token.value.split(/(\t|\n)/)
29
+ for (const part of parts) {
30
+ if (part === '\t') {
31
+ const spaces = interval - (column % interval)
32
+ result += ' '.repeat(spaces)
33
+ column += spaces
34
+ } else if (part === '\n') {
35
+ result += part
36
+ column = 0
37
+ } else {
38
+ result += part
39
+ column += stringWidth(part)
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ return result
46
+ }
@@ -0,0 +1,47 @@
1
+ // Terminal focus state signal — non-React access to DECSET 1004 focus events.
2
+ // 'unknown' is the default for terminals that don't support focus reporting;
3
+ // consumers treat 'unknown' identically to 'focused' (no throttling).
4
+ // Subscribers are notified synchronously when focus changes, used by
5
+ // TerminalFocusProvider to avoid polling.
6
+ export type TerminalFocusState = 'focused' | 'blurred' | 'unknown'
7
+
8
+ let focusState: TerminalFocusState = 'unknown'
9
+ const resolvers: Set<() => void> = new Set()
10
+ const subscribers: Set<() => void> = new Set()
11
+
12
+ export function setTerminalFocused(v: boolean): void {
13
+ focusState = v ? 'focused' : 'blurred'
14
+ // Notify useSyncExternalStore subscribers
15
+ for (const cb of subscribers) {
16
+ cb()
17
+ }
18
+ if (!v) {
19
+ for (const resolve of resolvers) {
20
+ resolve()
21
+ }
22
+ resolvers.clear()
23
+ }
24
+ }
25
+
26
+ export function getTerminalFocused(): boolean {
27
+ return focusState !== 'blurred'
28
+ }
29
+
30
+ export function getTerminalFocusState(): TerminalFocusState {
31
+ return focusState
32
+ }
33
+
34
+ // For useSyncExternalStore
35
+ export function subscribeTerminalFocus(cb: () => void): () => void {
36
+ subscribers.add(cb)
37
+ return () => {
38
+ subscribers.delete(cb)
39
+ }
40
+ }
41
+
42
+ export function resetTerminalFocusState(): void {
43
+ focusState = 'unknown'
44
+ for (const cb of subscribers) {
45
+ cb()
46
+ }
47
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Query the terminal and await responses without timeouts.
3
+ *
4
+ * Terminal queries (DECRQM, DA1, OSC 11, etc.) share the stdin stream
5
+ * with keyboard input. Response sequences are syntactically
6
+ * distinguishable from key events, so the input parser recognizes them
7
+ * and dispatches them here.
8
+ *
9
+ * To avoid timeouts, each query batch is terminated by a DA1 sentinel
10
+ * (CSI c) — every terminal since VT100 responds to DA1, and terminals
11
+ * answer queries in order. So: if your query's response arrives before
12
+ * DA1's, the terminal supports it; if DA1 arrives first, it doesn't.
13
+ *
14
+ * Usage:
15
+ * const [sync, grapheme] = await Promise.all([
16
+ * querier.send(decrqm(2026)),
17
+ * querier.send(decrqm(2027)),
18
+ * querier.flush(),
19
+ * ])
20
+ * // sync and grapheme are DECRPM responses or undefined if unsupported
21
+ */
22
+
23
+ import type { TerminalResponse } from './parse-keypress.js'
24
+ import { csi } from './termio/csi.js'
25
+ import { osc } from './termio/osc.js'
26
+
27
+ /** A terminal query: an outbound request sequence paired with a matcher
28
+ * that recognizes the expected inbound response. Built by `decrqm()`,
29
+ * `oscColor()`, `kittyKeyboard()`, etc. */
30
+ export type TerminalQuery<T extends TerminalResponse = TerminalResponse> = {
31
+ /** Escape sequence to write to stdout */
32
+ request: string
33
+ /** Recognizes the expected response in the inbound stream */
34
+ match: (r: TerminalResponse) => r is T
35
+ }
36
+
37
+ type DecrpmResponse = Extract<TerminalResponse, { type: 'decrpm' }>
38
+ type Da1Response = Extract<TerminalResponse, { type: 'da1' }>
39
+ type Da2Response = Extract<TerminalResponse, { type: 'da2' }>
40
+ type KittyResponse = Extract<TerminalResponse, { type: 'kittyKeyboard' }>
41
+ type CursorPosResponse = Extract<TerminalResponse, { type: 'cursorPosition' }>
42
+ type OscResponse = Extract<TerminalResponse, { type: 'osc' }>
43
+ type XtversionResponse = Extract<TerminalResponse, { type: 'xtversion' }>
44
+
45
+ // -- Query builders --
46
+
47
+ /** DECRQM: request DEC private mode status (CSI ? mode $ p).
48
+ * Terminal replies with DECRPM (CSI ? mode ; status $ y) or ignores. */
49
+ export function decrqm(mode: number): TerminalQuery<DecrpmResponse> {
50
+ return {
51
+ request: csi(`?${mode}$p`),
52
+ match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode,
53
+ }
54
+ }
55
+
56
+ /** Primary Device Attributes query (CSI c). Every terminal answers this —
57
+ * used internally by flush() as a universal sentinel. Call directly if
58
+ * you want the DA1 params. */
59
+ export function da1(): TerminalQuery<Da1Response> {
60
+ return {
61
+ request: csi('c'),
62
+ match: (r): r is Da1Response => r.type === 'da1',
63
+ }
64
+ }
65
+
66
+ /** Secondary Device Attributes query (CSI > c). Returns terminal version. */
67
+ export function da2(): TerminalQuery<Da2Response> {
68
+ return {
69
+ request: csi('>c'),
70
+ match: (r): r is Da2Response => r.type === 'da2',
71
+ }
72
+ }
73
+
74
+ /** Query current Kitty keyboard protocol flags (CSI ? u).
75
+ * Terminal replies with CSI ? flags u or ignores. */
76
+ export function kittyKeyboard(): TerminalQuery<KittyResponse> {
77
+ return {
78
+ request: csi('?u'),
79
+ match: (r): r is KittyResponse => r.type === 'kittyKeyboard',
80
+ }
81
+ }
82
+
83
+ /** DECXCPR: request cursor position with DEC-private marker (CSI ? 6 n).
84
+ * Terminal replies with CSI ? row ; col R. The `?` marker is critical —
85
+ * the plain DSR form (CSI 6 n → CSI row;col R) is ambiguous with
86
+ * modified F3 keys (Shift+F3 = CSI 1;2 R, etc.). */
87
+ export function cursorPosition(): TerminalQuery<CursorPosResponse> {
88
+ return {
89
+ request: csi('?6n'),
90
+ match: (r): r is CursorPosResponse => r.type === 'cursorPosition',
91
+ }
92
+ }
93
+
94
+ /** OSC dynamic color query (e.g. OSC 11 for bg color, OSC 10 for fg).
95
+ * The `?` data slot asks the terminal to reply with the current value. */
96
+ export function oscColor(code: number): TerminalQuery<OscResponse> {
97
+ return {
98
+ request: osc(code, '?'),
99
+ match: (r): r is OscResponse => r.type === 'osc' && r.code === code,
100
+ }
101
+ }
102
+
103
+ /** XTVERSION: request terminal name/version (CSI > 0 q).
104
+ * Terminal replies with DCS > | name ST (e.g. "xterm.js(5.5.0)") or ignores.
105
+ * This survives SSH — the query goes through the pty, not the environment,
106
+ * so it identifies the *client* terminal even when TERM_PROGRAM isn't
107
+ * forwarded. Used to detect xterm.js for wheel-scroll compensation. */
108
+ export function xtversion(): TerminalQuery<XtversionResponse> {
109
+ return {
110
+ request: csi('>0q'),
111
+ match: (r): r is XtversionResponse => r.type === 'xtversion',
112
+ }
113
+ }
114
+
115
+ // -- Querier --
116
+
117
+ /** Sentinel request sequence (DA1). Kept internal; flush() writes it. */
118
+ const SENTINEL = csi('c')
119
+
120
+ type Pending =
121
+ | {
122
+ kind: 'query'
123
+ match: (r: TerminalResponse) => boolean
124
+ resolve: (r: TerminalResponse | undefined) => void
125
+ }
126
+ | { kind: 'sentinel'; resolve: () => void }
127
+
128
+ export class TerminalQuerier {
129
+ /**
130
+ * Interleaved queue of queries and sentinels in send order. Terminals
131
+ * respond in order, so each flush() barrier only drains queries queued
132
+ * before it — concurrent batches from independent callers stay isolated.
133
+ */
134
+ private queue: Pending[] = []
135
+
136
+ constructor(private stdout: NodeJS.WriteStream) {}
137
+
138
+ /**
139
+ * Send a query and wait for its response.
140
+ *
141
+ * Resolves with the response when `query.match` matches an incoming
142
+ * TerminalResponse, or with `undefined` when a flush() sentinel arrives
143
+ * before any matching response (meaning the terminal ignored the query).
144
+ *
145
+ * Never rejects; never times out on its own. If you never call flush()
146
+ * and the terminal doesn't respond, the promise remains pending.
147
+ */
148
+ send<T extends TerminalResponse>(
149
+ query: TerminalQuery<T>,
150
+ ): Promise<T | undefined> {
151
+ return new Promise(resolve => {
152
+ this.queue.push({
153
+ kind: 'query',
154
+ match: query.match,
155
+ resolve: r => resolve(r as T | undefined),
156
+ })
157
+ this.stdout.write(query.request)
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Send the DA1 sentinel. Resolves when DA1's response arrives.
163
+ *
164
+ * As a side effect, all queries still pending when DA1 arrives are
165
+ * resolved with `undefined` (terminal didn't respond → doesn't support
166
+ * the query). This is the barrier that makes send() timeout-free.
167
+ *
168
+ * Safe to call with no pending queries — still waits for a round-trip.
169
+ */
170
+ flush(): Promise<void> {
171
+ return new Promise(resolve => {
172
+ this.queue.push({ kind: 'sentinel', resolve })
173
+ this.stdout.write(SENTINEL)
174
+ })
175
+ }
176
+
177
+ /**
178
+ * Dispatch a response parsed from stdin. Called by App.tsx's
179
+ * processKeysInBatch for every `kind: 'response'` item.
180
+ *
181
+ * Matching strategy:
182
+ * - First, try to match a pending query (FIFO, first match wins).
183
+ * This lets callers send(da1()) explicitly if they want the DA1
184
+ * params — a separate DA1 write means the terminal sends TWO DA1
185
+ * responses. The first matches the explicit query; the second
186
+ * (unmatched) fires the sentinel.
187
+ * - Otherwise, if this is a DA1, fire the FIRST pending sentinel:
188
+ * resolve any queries queued before that sentinel with undefined
189
+ * (the terminal answered DA1 without answering them → unsupported)
190
+ * and signal its flush() completion. Only draining up to the first
191
+ * sentinel keeps later batches intact when multiple callers have
192
+ * concurrent queries in flight.
193
+ * - Unsolicited responses (no match, no sentinel) are silently dropped.
194
+ */
195
+ onResponse(r: TerminalResponse): void {
196
+ const idx = this.queue.findIndex(p => p.kind === 'query' && p.match(r))
197
+ if (idx !== -1) {
198
+ const [q] = this.queue.splice(idx, 1)
199
+ if (q?.kind === 'query') q.resolve(r)
200
+ return
201
+ }
202
+
203
+ if (r.type === 'da1') {
204
+ const s = this.queue.findIndex(p => p.kind === 'sentinel')
205
+ if (s === -1) return
206
+ for (const p of this.queue.splice(0, s + 1)) {
207
+ if (p.kind === 'query') p.resolve(undefined)
208
+ else p.resolve()
209
+ }
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,248 @@
1
+ import { coerce } from 'semver'
2
+ import type { Writable } from 'stream'
3
+ import { env } from '../utils/env.js'
4
+ import { gte } from '../utils/semver.js'
5
+ import { getClearTerminalSequence } from './clearTerminal.js'
6
+ import type { Diff } from './frame.js'
7
+ import { cursorMove, cursorTo, eraseLines } from './termio/csi.js'
8
+ import { BSU, ESU, HIDE_CURSOR, SHOW_CURSOR } from './termio/dec.js'
9
+ import { link } from './termio/osc.js'
10
+
11
+ export type Progress = {
12
+ state: 'running' | 'completed' | 'error' | 'indeterminate'
13
+ percentage?: number
14
+ }
15
+
16
+ /**
17
+ * Checks if the terminal supports OSC 9;4 progress reporting.
18
+ * Supported terminals:
19
+ * - ConEmu (Windows) - all versions
20
+ * - Ghostty 1.2.0+
21
+ * - iTerm2 3.6.6+
22
+ *
23
+ * Note: Windows Terminal interprets OSC 9;4 as notifications, not progress.
24
+ */
25
+ export function isProgressReportingAvailable(): boolean {
26
+ // Only available if we have a TTY (not piped)
27
+ if (!process.stdout.isTTY) {
28
+ return false
29
+ }
30
+
31
+ // Explicitly exclude Windows Terminal, which interprets OSC 9;4 as
32
+ // notifications rather than progress indicators
33
+ if (process.env.WT_SESSION) {
34
+ return false
35
+ }
36
+
37
+ // ConEmu supports OSC 9;4 for progress (all versions)
38
+ if (
39
+ process.env.ConEmuANSI ||
40
+ process.env.ConEmuPID ||
41
+ process.env.ConEmuTask
42
+ ) {
43
+ return true
44
+ }
45
+
46
+ const version = coerce(process.env.TERM_PROGRAM_VERSION)
47
+ if (!version) {
48
+ return false
49
+ }
50
+
51
+ // Ghostty 1.2.0+ supports OSC 9;4 for progress
52
+ // https://ghostty.org/docs/install/release-notes/1-2-0
53
+ if (process.env.TERM_PROGRAM === 'ghostty') {
54
+ return gte(version.version, '1.2.0')
55
+ }
56
+
57
+ // iTerm2 3.6.6+ supports OSC 9;4 for progress
58
+ // https://iterm2.com/downloads.html
59
+ if (process.env.TERM_PROGRAM === 'iTerm.app') {
60
+ return gte(version.version, '3.6.6')
61
+ }
62
+
63
+ return false
64
+ }
65
+
66
+ /**
67
+ * Checks if the terminal supports DEC mode 2026 (synchronized output).
68
+ * When supported, BSU/ESU sequences prevent visible flicker during redraws.
69
+ */
70
+ export function isSynchronizedOutputSupported(): boolean {
71
+ // tmux parses and proxies every byte but doesn't implement DEC 2026.
72
+ // BSU/ESU pass through to the outer terminal but tmux has already
73
+ // broken atomicity by chunking. Skip to save 16 bytes/frame + parser work.
74
+ if (process.env.TMUX) return false
75
+
76
+ const termProgram = process.env.TERM_PROGRAM
77
+ const term = process.env.TERM
78
+
79
+ // Modern terminals with known DEC 2026 support
80
+ if (
81
+ termProgram === 'iTerm.app' ||
82
+ termProgram === 'WezTerm' ||
83
+ termProgram === 'WarpTerminal' ||
84
+ termProgram === 'ghostty' ||
85
+ termProgram === 'contour' ||
86
+ termProgram === 'vscode' ||
87
+ termProgram === 'alacritty'
88
+ ) {
89
+ return true
90
+ }
91
+
92
+ // kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID
93
+ if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) return true
94
+
95
+ // Ghostty may set TERM=xterm-ghostty without TERM_PROGRAM
96
+ if (term === 'xterm-ghostty') return true
97
+
98
+ // foot sets TERM=foot or TERM=foot-extra
99
+ if (term?.startsWith('foot')) return true
100
+
101
+ // Alacritty may set TERM containing 'alacritty'
102
+ if (term?.includes('alacritty')) return true
103
+
104
+ // Zed uses the alacritty_terminal crate which supports DEC 2026
105
+ if (process.env.ZED_TERM) return true
106
+
107
+ // Windows Terminal
108
+ if (process.env.WT_SESSION) return true
109
+
110
+ // VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68
111
+ const vteVersion = process.env.VTE_VERSION
112
+ if (vteVersion) {
113
+ const version = parseInt(vteVersion, 10)
114
+ if (version >= 6800) return true
115
+ }
116
+
117
+ return false
118
+ }
119
+
120
+ // -- XTVERSION-detected terminal name (populated async at startup) --
121
+ //
122
+ // TERM_PROGRAM is not forwarded over SSH by default, so env-based detection
123
+ // fails when claude runs remotely inside a VS Code integrated terminal.
124
+ // XTVERSION (CSI > 0 q → DCS > | name ST) goes through the pty — the query
125
+ // reaches the *client* terminal and the reply comes back through stdin.
126
+ // App.tsx fires the query when raw mode enables; setXtversionName() is called
127
+ // from the response handler. Readers should treat undefined as "not yet known"
128
+ // and fall back to env-var detection.
129
+
130
+ let xtversionName: string | undefined
131
+
132
+ /** Record the XTVERSION response. Called once from App.tsx when the reply
133
+ * arrives on stdin. No-op if already set (defend against re-probe). */
134
+ export function setXtversionName(name: string): void {
135
+ if (xtversionName === undefined) xtversionName = name
136
+ }
137
+
138
+ /** True if running in an xterm.js-based terminal (VS Code, Cursor, Windsurf
139
+ * integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but
140
+ * not forwarded over SSH) with the XTVERSION probe result (async, survives
141
+ * SSH — query/reply goes through the pty). Early calls may miss the probe
142
+ * reply — call lazily (e.g. in an event handler) if SSH detection matters. */
143
+ export function isXtermJs(): boolean {
144
+ if (process.env.TERM_PROGRAM === 'vscode') return true
145
+ return xtversionName?.startsWith('xterm.js') ?? false
146
+ }
147
+
148
+ // Terminals known to correctly implement the Kitty keyboard protocol
149
+ // (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+<letter>
150
+ // disambiguation. We previously enabled unconditionally (#23350), assuming
151
+ // terminals silently ignore unknown CSI — but some terminals honor the enable
152
+ // and emit codepoints our input parser doesn't handle (notably over SSH and
153
+ // in xterm.js-based terminals like VS Code). tmux is allowlisted because it
154
+ // accepts modifyOtherKeys and doesn't forward the kitty sequence to the outer
155
+ // terminal.
156
+ const EXTENDED_KEYS_TERMINALS = [
157
+ 'iTerm.app',
158
+ 'kitty',
159
+ 'WezTerm',
160
+ 'ghostty',
161
+ 'tmux',
162
+ 'windows-terminal',
163
+ ]
164
+
165
+ /** True if this terminal correctly handles extended key reporting
166
+ * (Kitty keyboard protocol + xterm modifyOtherKeys). */
167
+ export function supportsExtendedKeys(): boolean {
168
+ return EXTENDED_KEYS_TERMINALS.includes(env.terminal ?? '')
169
+ }
170
+
171
+ /** True if the terminal scrolls the viewport when it receives cursor-up
172
+ * sequences that reach above the visible area. On Windows, conhost's
173
+ * SetConsoleCursorPosition follows the cursor into scrollback
174
+ * (microsoft/terminal#14774), yanking users to the top of their buffer
175
+ * mid-stream. WT_SESSION catches WSL-in-Windows-Terminal where platform
176
+ * is linux but output still routes through conhost. */
177
+ export function hasCursorUpViewportYankBug(): boolean {
178
+ return process.platform === 'win32' || !!process.env.WT_SESSION
179
+ }
180
+
181
+ // Computed once at module load — terminal capabilities don't change mid-session.
182
+ // Exported so callers can pass a sync-skip hint gated to specific modes.
183
+ export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported()
184
+
185
+ export type Terminal = {
186
+ stdout: Writable
187
+ stderr: Writable
188
+ }
189
+
190
+ export function writeDiffToTerminal(
191
+ terminal: Terminal,
192
+ diff: Diff,
193
+ skipSyncMarkers = false,
194
+ ): void {
195
+ // No output if there are no patches
196
+ if (diff.length === 0) {
197
+ return
198
+ }
199
+
200
+ // BSU/ESU wrapping is opt-out to keep main-screen behavior unchanged.
201
+ // Callers pass skipSyncMarkers=true when the terminal doesn't support
202
+ // DEC 2026 (e.g. tmux) AND the cost matters (high-frequency alt-screen).
203
+ const useSync = !skipSyncMarkers
204
+
205
+ // Buffer all writes into a single string to avoid multiple write calls
206
+ let buffer = useSync ? BSU : ''
207
+
208
+ for (const patch of diff) {
209
+ switch (patch.type) {
210
+ case 'stdout':
211
+ buffer += patch.content
212
+ break
213
+ case 'clear':
214
+ if (patch.count > 0) {
215
+ buffer += eraseLines(patch.count)
216
+ }
217
+ break
218
+ case 'clearTerminal':
219
+ buffer += getClearTerminalSequence()
220
+ break
221
+ case 'cursorHide':
222
+ buffer += HIDE_CURSOR
223
+ break
224
+ case 'cursorShow':
225
+ buffer += SHOW_CURSOR
226
+ break
227
+ case 'cursorMove':
228
+ buffer += cursorMove(patch.x, patch.y)
229
+ break
230
+ case 'cursorTo':
231
+ buffer += cursorTo(patch.col)
232
+ break
233
+ case 'carriageReturn':
234
+ buffer += '\r'
235
+ break
236
+ case 'hyperlink':
237
+ buffer += link(patch.uri)
238
+ break
239
+ case 'styleStr':
240
+ buffer += patch.str
241
+ break
242
+ }
243
+ }
244
+
245
+ // Add synchronized update end and flush buffer
246
+ if (useSync) buffer += ESU
247
+ terminal.stdout.write(buffer)
248
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ANSI Control Characters and Escape Sequence Introducers
3
+ *
4
+ * Based on ECMA-48 / ANSI X3.64 standards.
5
+ */
6
+
7
+ /**
8
+ * C0 (7-bit) control characters
9
+ */
10
+ export const C0 = {
11
+ NUL: 0x00,
12
+ SOH: 0x01,
13
+ STX: 0x02,
14
+ ETX: 0x03,
15
+ EOT: 0x04,
16
+ ENQ: 0x05,
17
+ ACK: 0x06,
18
+ BEL: 0x07,
19
+ BS: 0x08,
20
+ HT: 0x09,
21
+ LF: 0x0a,
22
+ VT: 0x0b,
23
+ FF: 0x0c,
24
+ CR: 0x0d,
25
+ SO: 0x0e,
26
+ SI: 0x0f,
27
+ DLE: 0x10,
28
+ DC1: 0x11,
29
+ DC2: 0x12,
30
+ DC3: 0x13,
31
+ DC4: 0x14,
32
+ NAK: 0x15,
33
+ SYN: 0x16,
34
+ ETB: 0x17,
35
+ CAN: 0x18,
36
+ EM: 0x19,
37
+ SUB: 0x1a,
38
+ ESC: 0x1b,
39
+ FS: 0x1c,
40
+ GS: 0x1d,
41
+ RS: 0x1e,
42
+ US: 0x1f,
43
+ DEL: 0x7f,
44
+ } as const
45
+
46
+ // String constants for output generation
47
+ export const ESC = '\x1b'
48
+ export const BEL = '\x07'
49
+ export const SEP = ';'
50
+
51
+ /**
52
+ * Escape sequence type introducers (byte after ESC)
53
+ */
54
+ export const ESC_TYPE = {
55
+ CSI: 0x5b, // [ - Control Sequence Introducer
56
+ OSC: 0x5d, // ] - Operating System Command
57
+ DCS: 0x50, // P - Device Control String
58
+ APC: 0x5f, // _ - Application Program Command
59
+ PM: 0x5e, // ^ - Privacy Message
60
+ SOS: 0x58, // X - Start of String
61
+ ST: 0x5c, // \ - String Terminator
62
+ } as const
63
+
64
+ /** Check if a byte is a C0 control character */
65
+ export function isC0(byte: number): boolean {
66
+ return byte < 0x20 || byte === 0x7f
67
+ }
68
+
69
+ /**
70
+ * Check if a byte is an ESC sequence final byte (0-9, :, ;, <, =, >, ?, @ through ~)
71
+ * ESC sequences have a wider final byte range than CSI
72
+ */
73
+ export function isEscFinal(byte: number): boolean {
74
+ return byte >= 0x30 && byte <= 0x7e
75
+ }