@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,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
|
+
}
|