mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 00:03:08 +00:00
fix(terminal): send mode 2031 replies immediately
This commit is contained in:
@@ -6,10 +6,10 @@ import {
|
||||
applyTerminalAppearance,
|
||||
hexToRgba,
|
||||
installMode2031Handlers,
|
||||
maybePushMode2031Flip,
|
||||
mode2031SequenceFor,
|
||||
publishTerminalViewAttributesAtAppStart
|
||||
} from './terminal-appearance'
|
||||
import { maybePushMode2031Flip } from './terminal-mode-2031-replies'
|
||||
import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
|
||||
import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher'
|
||||
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
|
||||
@@ -17,12 +17,14 @@ import type { TerminalViewAttributes } from '../../../../shared/terminal-view-at
|
||||
function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): {
|
||||
isConnected: () => boolean
|
||||
sendInput: ReturnType<typeof vi.fn<(data: string) => boolean>>
|
||||
sendInputImmediate: ReturnType<typeof vi.fn<(data: string) => boolean>>
|
||||
} {
|
||||
const connected = overrides?.connected ?? true
|
||||
const sendOk = overrides?.sendOk ?? true
|
||||
return {
|
||||
isConnected: () => connected,
|
||||
sendInput: vi.fn<(data: string) => boolean>(() => sendOk)
|
||||
sendInput: vi.fn<(data: string) => boolean>(() => sendOk),
|
||||
sendInputImmediate: vi.fn<(data: string) => boolean>(() => sendOk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +44,7 @@ describe('maybePushMode2031Flip', () => {
|
||||
const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last)
|
||||
|
||||
expect(pushed).toBe(false)
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
expect(transport.sendInputImmediate).not.toHaveBeenCalled()
|
||||
expect(last.has(1)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -54,8 +56,9 @@ describe('maybePushMode2031Flip', () => {
|
||||
const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last)
|
||||
|
||||
expect(pushed).toBe(true)
|
||||
expect(transport.sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1)
|
||||
expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
expect(last.get(1)).toBe('dark')
|
||||
})
|
||||
|
||||
@@ -70,7 +73,7 @@ describe('maybePushMode2031Flip', () => {
|
||||
maybePushMode2031Flip(1, 'dark', transport, subs, last)
|
||||
maybePushMode2031Flip(1, 'dark', transport, subs, last)
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1)
|
||||
expect(last.get(1)).toBe('dark')
|
||||
})
|
||||
|
||||
@@ -83,7 +86,7 @@ describe('maybePushMode2031Flip', () => {
|
||||
maybePushMode2031Flip(1, 'light', transport, subs, last)
|
||||
maybePushMode2031Flip(1, 'dark', transport, subs, last)
|
||||
|
||||
expect(transport.sendInput.mock.calls.map((c) => c[0])).toEqual([
|
||||
expect(transport.sendInputImmediate.mock.calls.map((c) => c[0])).toEqual([
|
||||
'\x1b[?997;1n',
|
||||
'\x1b[?997;2n',
|
||||
'\x1b[?997;1n'
|
||||
@@ -99,11 +102,11 @@ describe('maybePushMode2031Flip', () => {
|
||||
const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last)
|
||||
|
||||
expect(pushed).toBe(false)
|
||||
expect(transport.sendInput).not.toHaveBeenCalled()
|
||||
expect(transport.sendInputImmediate).not.toHaveBeenCalled()
|
||||
expect(last.has(1)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves last-mode untouched when sendInput reports failure', () => {
|
||||
it('leaves last-mode untouched when immediate input reports failure', () => {
|
||||
// So a reconnect / retry will re-emit on the next appearance pass.
|
||||
const transport = fakeTransport({ sendOk: false })
|
||||
const subs = new Map([[1, true]])
|
||||
@@ -112,7 +115,7 @@ describe('maybePushMode2031Flip', () => {
|
||||
const pushed = maybePushMode2031Flip(1, 'dark', transport, subs, last)
|
||||
|
||||
expect(pushed).toBe(false)
|
||||
expect(transport.sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(transport.sendInputImmediate).toHaveBeenCalledTimes(1)
|
||||
expect(last.has(1)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -130,8 +133,8 @@ describe('maybePushMode2031Flip', () => {
|
||||
maybePushMode2031Flip(1, 'dark', transportA, subs, last) // suppressed
|
||||
maybePushMode2031Flip(2, 'dark', transportB, subs, last) // flip
|
||||
|
||||
expect(transportA.sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(transportB.sendInput).toHaveBeenCalledTimes(2)
|
||||
expect(transportA.sendInputImmediate).toHaveBeenCalledTimes(1)
|
||||
expect(transportB.sendInputImmediate).toHaveBeenCalledTimes(2)
|
||||
expect(last.get(1)).toBe('dark')
|
||||
expect(last.get(2)).toBe('dark')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { IDisposable, IParser, ITheme } from '@xterm/xterm'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts'
|
||||
import { resolveTerminalLigaturesEnabled } from '../../../../shared/terminal-ligatures'
|
||||
import {
|
||||
@@ -24,8 +23,7 @@ import { HEX_COLOR_RE } from '../../../../shared/color-validation'
|
||||
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
|
||||
import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher'
|
||||
import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings'
|
||||
|
||||
export { mode2031SequenceFor }
|
||||
import { maybePushMode2031Flip } from './terminal-mode-2031-replies'
|
||||
|
||||
// Why Pick<IParser, ...> over a hand-rolled structural type: keeps the helper
|
||||
// tied to xterm's canonical signature so any upstream tightening (added
|
||||
@@ -112,33 +110,6 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[
|
||||
]
|
||||
}
|
||||
|
||||
// Gate on actual mode flip so font/size/opacity tweaks — which also re-run
|
||||
// applyTerminalAppearance — don't spam subscribed TUIs with CSI 997. The
|
||||
// subscribe/last-mode maps are mutated in place so callers share state with
|
||||
// the lifecycle hook's seed path.
|
||||
export function maybePushMode2031Flip(
|
||||
paneId: number,
|
||||
mode: 'dark' | 'light',
|
||||
transport: Pick<PtyTransport, 'isConnected' | 'sendInput'>,
|
||||
paneMode2031: Map<number, boolean>,
|
||||
paneLastThemeMode: Map<number, 'dark' | 'light'>
|
||||
): boolean {
|
||||
if (!transport.isConnected()) {
|
||||
return false
|
||||
}
|
||||
if (!paneMode2031.get(paneId)) {
|
||||
return false
|
||||
}
|
||||
if (paneLastThemeMode.get(paneId) === mode) {
|
||||
return false
|
||||
}
|
||||
if (!transport.sendInput(mode2031SequenceFor(mode))) {
|
||||
return false
|
||||
}
|
||||
paneLastThemeMode.set(paneId, mode)
|
||||
return true
|
||||
}
|
||||
|
||||
export function hexToRgba(hex: string, alpha: number): string {
|
||||
let clean = hex.replace('#', '')
|
||||
if (clean.length === 3) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { pushMode2031SeedReply } from './terminal-mode-2031-replies'
|
||||
|
||||
describe('pushMode2031SeedReply', () => {
|
||||
function createHarness(connected: boolean): {
|
||||
connected: { current: boolean }
|
||||
subscribed: { current: boolean }
|
||||
sendInput: ReturnType<typeof vi.fn<(data: string) => boolean>>
|
||||
sendInputImmediate: ReturnType<typeof vi.fn<(data: string) => boolean>>
|
||||
scheduled: (() => void)[]
|
||||
recordMode: ReturnType<typeof vi.fn>
|
||||
push: () => void
|
||||
} {
|
||||
const connectedState = { current: connected }
|
||||
const subscribed = { current: true }
|
||||
const sendInput = vi.fn<(data: string) => boolean>(() => true)
|
||||
const sendInputImmediate = vi.fn<(data: string) => boolean>(() => true)
|
||||
const scheduled: (() => void)[] = []
|
||||
const recordMode = vi.fn()
|
||||
const transport = {
|
||||
isConnected: () => connectedState.current,
|
||||
sendInput,
|
||||
sendInputImmediate
|
||||
}
|
||||
return {
|
||||
connected: connectedState,
|
||||
subscribed,
|
||||
sendInput,
|
||||
sendInputImmediate,
|
||||
scheduled,
|
||||
recordMode,
|
||||
push: () =>
|
||||
pushMode2031SeedReply(1, {
|
||||
hasPane: () => true,
|
||||
isSubscribed: () => subscribed.current,
|
||||
getTransport: () => transport,
|
||||
getMode: () => 'dark',
|
||||
recordMode,
|
||||
schedule: (callback) => scheduled.push(callback)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('routes the DSR response through latency-critical input', () => {
|
||||
const harness = createHarness(true)
|
||||
|
||||
harness.push()
|
||||
|
||||
expect(harness.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
expect(harness.sendInput).not.toHaveBeenCalled()
|
||||
expect(harness.recordMode).toHaveBeenCalledWith(1, 'dark')
|
||||
})
|
||||
|
||||
it('cancels a pre-connect retry after the program unsubscribes', () => {
|
||||
const harness = createHarness(false)
|
||||
|
||||
harness.push()
|
||||
expect(harness.scheduled).toHaveLength(1)
|
||||
|
||||
harness.subscribed.current = false
|
||||
harness.connected.current = true
|
||||
harness.scheduled.shift()?.()
|
||||
|
||||
expect(harness.sendInputImmediate).not.toHaveBeenCalled()
|
||||
expect(harness.sendInput).not.toHaveBeenCalled()
|
||||
expect(harness.scheduled).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import type { PtyTransport } from './pty-transport'
|
||||
|
||||
const MODE_2031_CONNECT_RETRY_MS = 25
|
||||
const MODE_2031_CONNECT_ATTEMPTS = 8
|
||||
|
||||
type Mode2031ReplyTransport = Pick<PtyTransport, 'isConnected' | 'sendInputImmediate'>
|
||||
|
||||
type Mode2031SeedReplyDeps = {
|
||||
hasPane: (paneId: number) => boolean
|
||||
isSubscribed: (paneId: number) => boolean
|
||||
getTransport: (paneId: number) => Mode2031ReplyTransport | undefined
|
||||
getMode: () => TerminalColorSchemeMode | null
|
||||
recordMode: (paneId: number, mode: TerminalColorSchemeMode) => void
|
||||
schedule: (callback: () => void, delayMs: number) => void
|
||||
}
|
||||
|
||||
function sendMode2031Reply(
|
||||
transport: Mode2031ReplyTransport,
|
||||
mode: TerminalColorSchemeMode
|
||||
): boolean {
|
||||
// Why: fish stops reading mode-2031 replies quickly; remote input batching
|
||||
// can otherwise deliver this terminal response after the shell regains input.
|
||||
return transport.sendInputImmediate(mode2031SequenceFor(mode))
|
||||
}
|
||||
|
||||
export function pushMode2031SeedReply(paneId: number, deps: Mode2031SeedReplyDeps): void {
|
||||
let attempts = 0
|
||||
const send = (): void => {
|
||||
// Why: a TUI can unsubscribe while the PTY is connecting; every delayed
|
||||
// attempt must revalidate intent or its DSR reply can land at a shell prompt.
|
||||
if (!deps.hasPane(paneId) || !deps.isSubscribed(paneId)) {
|
||||
return
|
||||
}
|
||||
const transport = deps.getTransport(paneId)
|
||||
if (!transport?.isConnected()) {
|
||||
attempts += 1
|
||||
if (attempts < MODE_2031_CONNECT_ATTEMPTS) {
|
||||
deps.schedule(send, MODE_2031_CONNECT_RETRY_MS)
|
||||
}
|
||||
return
|
||||
}
|
||||
const mode = deps.getMode()
|
||||
if (mode && sendMode2031Reply(transport, mode)) {
|
||||
deps.recordMode(paneId, mode)
|
||||
}
|
||||
}
|
||||
send()
|
||||
}
|
||||
|
||||
// Appearance updates include font and opacity changes, so only report actual
|
||||
// color-mode flips to programs that still have mode 2031 enabled.
|
||||
export function maybePushMode2031Flip(
|
||||
paneId: number,
|
||||
mode: TerminalColorSchemeMode,
|
||||
transport: Mode2031ReplyTransport,
|
||||
paneMode2031: Map<number, boolean>,
|
||||
paneLastThemeMode: Map<number, TerminalColorSchemeMode>
|
||||
): boolean {
|
||||
if (!transport.isConnected()) {
|
||||
return false
|
||||
}
|
||||
if (!paneMode2031.get(paneId)) {
|
||||
return false
|
||||
}
|
||||
if (paneLastThemeMode.get(paneId) === mode) {
|
||||
return false
|
||||
}
|
||||
if (!sendMode2031Reply(transport, mode)) {
|
||||
return false
|
||||
}
|
||||
paneLastThemeMode.set(paneId, mode)
|
||||
return true
|
||||
}
|
||||
@@ -58,11 +58,8 @@ import {
|
||||
import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse'
|
||||
import {
|
||||
applyTerminalAppearance,
|
||||
installMode2031Handlers,
|
||||
mode2031SequenceFor
|
||||
} from './terminal-appearance'
|
||||
import { applyTerminalAppearance, installMode2031Handlers } from './terminal-appearance'
|
||||
import { pushMode2031SeedReply } from './terminal-mode-2031-replies'
|
||||
import { handleOsc52ClipboardRequest } from './osc52-clipboard'
|
||||
import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast'
|
||||
import { parseOsc7 } from './parse-osc7'
|
||||
@@ -604,34 +601,22 @@ export function useTerminalPaneLifecycle({
|
||||
}
|
||||
|
||||
const pushMode2031ForPane = (paneId: number): void => {
|
||||
let attempts = 0
|
||||
const send = (): void => {
|
||||
if (!managerRef.current?.getPanes().some((pane) => pane.id === paneId)) {
|
||||
return
|
||||
pushMode2031SeedReply(paneId, {
|
||||
hasPane: (candidateId) =>
|
||||
managerRef.current?.getPanes().some((pane) => pane.id === candidateId) === true,
|
||||
isSubscribed: (candidateId) => paneMode2031Ref.current.get(candidateId) === true,
|
||||
getTransport: (candidateId) => paneTransportsRef.current.get(candidateId),
|
||||
getMode: () => {
|
||||
const currentSettings = settingsRef.current
|
||||
return currentSettings
|
||||
? resolveEffectiveTerminalAppearance(currentSettings, systemPrefersDarkRef.current).mode
|
||||
: null
|
||||
},
|
||||
recordMode: (candidateId, mode) => paneLastThemeModeRef.current.set(candidateId, mode),
|
||||
schedule: (callback, delayMs) => {
|
||||
window.setTimeout(callback, delayMs)
|
||||
}
|
||||
const transport = paneTransportsRef.current.get(paneId)
|
||||
if (!transport?.isConnected()) {
|
||||
// Why: TUIs can subscribe before pty:spawn resolves. Retry briefly so
|
||||
// the recorded subscription still receives the initial dark/light seed.
|
||||
attempts += 1
|
||||
if (attempts < 8) {
|
||||
window.setTimeout(send, 25)
|
||||
}
|
||||
return
|
||||
}
|
||||
const currentSettings = settingsRef.current
|
||||
if (!currentSettings) {
|
||||
return
|
||||
}
|
||||
const { mode } = resolveEffectiveTerminalAppearance(
|
||||
currentSettings,
|
||||
systemPrefersDarkRef.current
|
||||
)
|
||||
if (transport.sendInput(mode2031SequenceFor(mode))) {
|
||||
paneLastThemeModeRef.current.set(paneId, mode)
|
||||
}
|
||||
}
|
||||
send()
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize PaneManager instance once
|
||||
|
||||
Reference in New Issue
Block a user