feat(ssh): open a plain SSH terminal to a host whose move to its Orca server is blocked (#31716)

* feat(ssh): open a plain SSH terminal to a host whose move to its Orca server is blocked

A refused or unserved host fails the whole connect, so the user had no way to
reach their work there. The blocked-move banner and the SSH host row now offer
an unmanaged, unpersisted ssh login in a non-modal window. Main builds the argv
from the saved target (no -T, BatchMode or Orca multiplexing; the config alias
stays bare so ssh_config applies) and spawns ssh with an argv array through the
local PTY spawn primitive.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): claim a host before awaiting, wait for ssh on quit, and share the preview terminal's keys

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): give the plain SSH terminal Orca's Unicode widths, kitty flags and spawn-helper repair

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): one standalone terminal for the agent preview and plain SSH

Extracts the preview's xterm setup (Unicode widths, links, IME bridge, kitty
flags, copy/paste and shortcut policy, user-input routing) into
openStandaloneTerminal, so the plain SSH terminal reuses it instead of a second
copy. Plain SSH output is now acked once rendered, and main pauses ssh while too
much is unrendered.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): plain SSH answers terminal queries, keeps early exits, and ends a paused pty on Windows

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): plain SSH applies theme changes live and never refits while minimized

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ssh): a native call on a just-exited plain SSH pty never crashes main

Co-Authored-By: Claude <noreply@anthropic.com>

* test(ssh): give the plain SSH test's ResizeObserver stub unobserve, which Tooltip needs

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-21 19:35:32 -07:00
committed by GitHub
co-authored by Claude
parent 99d18f7a2f
commit 56143a9a5e
28 changed files with 2066 additions and 174 deletions
@@ -0,0 +1,98 @@
/** IPC for plain SSH terminals; the renderer names a saved target id and never any argv. */
import { homedir } from 'node:os'
import { ipcMain, type WebContents } from 'electron'
import {
PLAIN_SSH_TERMINAL_CHANNELS,
type PlainSshTerminalEvent
} from '../../../shared/plain-ssh-terminal'
import { isSshHostMoveBlocked } from '../../../shared/ssh-host-move-blocked'
import { mayUserSshConfigClaimAlias } from '../../ssh/config/ssh-config-alias-claim'
import { getSshHostServerStatus } from '../../ssh/ssh-host-server-status'
import { getSshTargetRegistryStore } from '../../ssh/targets/ssh-target-registry'
import { findSystemSsh } from '../../ssh/system-transport/system-ssh-binary'
import { ensureNodePtySpawnHelperExecutable } from '../../providers/local-pty-utils'
import {
ackPlainSshTerminal,
closePlainSshTerminal,
closePlainSshTerminalsOwnedBy,
openPlainSshTerminal,
resizePlainSshTerminal,
writePlainSshTerminal,
type PlainSshTerminalDeps
} from '../../ssh/plain-ssh-terminal/plain-ssh-terminal-sessions'
const deps: PlainSshTerminalDeps = {
getTarget: (targetId) => getSshTargetRegistryStore()?.getTarget(targetId),
isMoveBlocked: (targetId) => isSshHostMoveBlocked(getSshHostServerStatus(targetId)),
findSsh: findSystemSsh,
mayConfigClaimAlias: mayUserSshConfigClaimAlias,
loadPtySpawn: async () => {
// Why: a packaged macOS build can lose node-pty's spawn-helper exec bit; the pane paths repair it too.
ensureNodePtySpawnHelperExecutable()
return (await import('node-pty')).spawn
},
homeDir: homedir,
platform: process.platform
}
const watchedOwners = new Set<number>()
/** A reload, a crash or a closed window can't show its sessions again, so they end with it. */
function endSessionsWithOwner(sender: WebContents): void {
if (watchedOwners.has(sender.id)) {
return
}
watchedOwners.add(sender.id)
const owner = sender.id
const end = (): void => closePlainSshTerminalsOwnedBy(owner)
sender.on('did-start-navigation', (details) => {
if (details.isMainFrame && !details.isSameDocument) {
end()
}
})
sender.on('render-process-gone', end)
sender.once('destroyed', () => {
watchedOwners.delete(owner)
end()
})
}
export function registerPlainSshTerminalHandlers(): void {
const { open, write, resize, close, ack, event } = PLAIN_SSH_TERMINAL_CHANNELS
ipcMain.removeHandler(open)
ipcMain.removeAllListeners(write)
ipcMain.removeAllListeners(resize)
ipcMain.removeAllListeners(close)
ipcMain.removeAllListeners(ack)
ipcMain.handle(
open,
async (ipcEvent, args: { targetId?: unknown; cols?: unknown; rows?: unknown }) => {
const sender = ipcEvent.sender
endSessionsWithOwner(sender)
return openPlainSshTerminal(
deps,
{ targetId: args?.targetId, cols: args?.cols, rows: args?.rows, owner: sender.id },
(payload: PlainSshTerminalEvent) => {
if (sender.isDestroyed()) {
return false
}
sender.send(event, payload)
return true
}
)
}
)
ipcMain.on(write, (ipcEvent, args: { sessionId?: unknown; data?: unknown }) => {
writePlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.data)
})
ipcMain.on(resize, (ipcEvent, args: { sessionId?: unknown; cols?: unknown; rows?: unknown }) => {
resizePlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.cols, args?.rows)
})
ipcMain.on(close, (ipcEvent, args: { sessionId?: unknown }) => {
closePlainSshTerminal(args?.sessionId, ipcEvent.sender.id)
})
ipcMain.on(ack, (ipcEvent, args: { sessionId?: unknown; chars?: unknown }) => {
ackPlainSshTerminal(args?.sessionId, ipcEvent.sender.id, args?.chars)
})
}
+6
View File
@@ -5,6 +5,7 @@ import {
} from './ssh-connect-attempt-registry'
import { connectionManager, portForwardManager } from './ssh-ipc-context'
import { shutdownOrcadManagedTunnels } from '../../managed-server/lifecycle/orcad-managed-tunnel'
import { closeAllPlainSshTerminals } from '../../ssh/plain-ssh-terminal/plain-ssh-terminal-sessions'
// Why one budget for the whole sequence rather than one per phase: an invalidated connect only
// observes its cancellation at the next checkpoint, and one blocked in the transport handshake can
@@ -70,6 +71,11 @@ function sshShutdownTasks(): SshShutdownTask[] {
// its local port, so the next launch cannot bind a managed tunnel's fixed port.
targetId: '*forwards',
promise: Promise.all([portForwardManager?.closeAll(), shutdownOrcadManagedTunnels()])
},
{
// Why: nothing restores a plain SSH terminal, so none may outlive Orca.
targetId: '*plain-ssh',
promise: closeAllPlainSshTerminals()
}
]
}
+2
View File
@@ -32,6 +32,7 @@ import { createSshConnectionCallbacks } from './ssh-connection-state-callbacks'
import { resetSshCredentialDeclinesForTests } from '../../ssh/ssh-credential-decline'
import { resetSshUserDisconnectsForTests } from '../../ssh/ssh-user-disconnect'
import { registerSshConnectionHandlers } from './ssh-connection-handlers'
import { registerPlainSshTerminalHandlers } from './plain-ssh-terminal-handlers'
import {
registerPowerMonitorReconnect,
unregisterPowerMonitorReconnect
@@ -135,6 +136,7 @@ export function registerSshHandlers(
registerSshTargetCrudHandlers()
registerSshConnectionHandlers()
registerSshPortForwardHandlers()
registerPlainSshTerminalHandlers()
// COMPAT(legacy-relay-bridge): terminals an older Orca's relay still runs.
registerLegacyRelayBridgeIpc({
store,
@@ -24,34 +24,7 @@ export function buildLocalPtySpawnEnvironment(args: {
plan: LocalPtyLaunchPlan
}): Record<string, string> | Promise<Record<string, string>> {
const { id, spawn, getOptions, plan } = args
const inheritedEnv = stripInheritedBuildModeEnv(process.env)
restoreManagedDataAccountEnvironment(inheritedEnv)
const spawnEnv: Record<string, string> = {
...mergeGitConfigEnvProtocol(inheritedEnv, spawn.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
// Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working.
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
// Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them.
FORCE_HYPERLINK: '1',
[ORCA_IMAGE_PROTOCOL_ENV]: ORCA_IMAGE_PROTOCOL_VALUE
} satisfies Record<string, string>
removeUnspecifiedPaneIdentityEnv(spawnEnv, spawn.env)
stripPiProcessOwnerEnv(spawnEnv)
removeAppImageRuntimeEnv(spawnEnv)
removeChromiumDisabledSessionBus(spawnEnv)
removeInheritedNoColor(spawnEnv)
for (const key of spawn.envToDelete ?? []) {
delete spawnEnv[key]
}
if (spawn.env?.TERM) {
spawnEnv.TERM = spawn.env.TERM
}
// Why after the strips and deletes: an alias must never outlive the value it mirrors.
applyScrubSafeAgentEnvAliases(spawnEnv)
spawnEnv.LANG ??= 'en_US.UTF-8'
const spawnEnv = buildInheritedLocalPtyEnvironment(spawn.env, spawn.envToDelete)
// Why: on Windows LANG doesn't set the console code page; PYTHONUTF8=1 forces Python UTF-8 stdio to avoid garbled CJK.
if (process.platform === 'win32') {
@@ -77,6 +50,42 @@ export function buildLocalPtySpawnEnvironment(args: {
})
}
/** What any local PTY inherits from Orca: its terminal identity, minus build and pane leftovers. */
export function buildInheritedLocalPtyEnvironment(
explicitEnv: Record<string, string> | undefined,
envToDelete: readonly string[] = []
): Record<string, string> {
const inheritedEnv = stripInheritedBuildModeEnv(process.env)
restoreManagedDataAccountEnvironment(inheritedEnv)
const spawnEnv: Record<string, string> = {
...mergeGitConfigEnvProtocol(inheritedEnv, explicitEnv),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'Orca',
// Why: TUIs feature-gate on TERM_PROGRAM_VERSION; the fallback keeps tests and non-Electron runs working.
TERM_PROGRAM_VERSION: process.env.ORCA_APP_VERSION ?? '0.0.0-dev',
// Why: supports-hyperlinks rejects TERM_PROGRAM=Orca, so tools drop OSC 8 links; force it since xterm.js parses them.
FORCE_HYPERLINK: '1',
[ORCA_IMAGE_PROTOCOL_ENV]: ORCA_IMAGE_PROTOCOL_VALUE
} satisfies Record<string, string>
removeUnspecifiedPaneIdentityEnv(spawnEnv, explicitEnv)
stripPiProcessOwnerEnv(spawnEnv)
removeAppImageRuntimeEnv(spawnEnv)
removeChromiumDisabledSessionBus(spawnEnv)
removeInheritedNoColor(spawnEnv)
for (const key of envToDelete) {
delete spawnEnv[key]
}
if (explicitEnv?.TERM) {
spawnEnv.TERM = explicitEnv.TERM
}
// Why after the strips and deletes: an alias must never outlive the value it mirrors.
applyScrubSafeAgentEnvAliases(spawnEnv)
spawnEnv.LANG ??= 'en_US.UTF-8'
return spawnEnv
}
/** App-level env builders can reintroduce deleted keys; enforce isolation after they finish. */
export function enforceLocalPtySpawnEnvironmentOverrides(
spawn: PtySpawnOptions,
+23 -3
View File
@@ -212,6 +212,24 @@ function spawnWindowsFallbackChain(
return null
}
export type PtyExecutableSpawnParams = Pick<
ShellSpawnParams,
'termName' | 'cols' | 'rows' | 'cwd' | 'env' | 'ptySpawn'
> & { file: string; args: string[] }
/** One executable with an argv array, no shell and no fallback; throws when it can't start. */
export function spawnPtyExecutable(params: PtyExecutableSpawnParams): pty.IPty {
const { file, args, termName = 'xterm-256color', cols, rows, cwd, env, ptySpawn } = params
return ptySpawn(file, args, {
name: termName,
cols,
rows,
cwd,
env,
...windowsConptyDllOptions()
})
}
/**
* Attempt to spawn a PTY shell. If the primary shell fails, try fallback shells
* (Unix: zsh/bash/sh; Windows: the PowerShell -> cmd.exe chain) before giving up.
@@ -239,13 +257,15 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu
try {
const wrapped = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, env)
return {
process: ptySpawn(wrapped.file, wrapped.args, {
name: termName,
process: spawnPtyExecutable({
file: wrapped.file,
args: wrapped.args,
termName,
cols,
rows,
cwd,
env,
...windowsConptyDllOptions()
ptySpawn
}),
shellPath,
reportsChildExitStatus: hostReportsChildExitStatus(wrapped.file)
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import type { SshTarget } from '../../../shared/ssh-types'
import { buildPlainSshTerminalArgs } from './plain-ssh-terminal-args'
const manual: SshTarget = {
id: 't1',
label: 'lambda',
source: 'manual',
host: '203.0.113.7',
port: 2222,
username: 'ubuntu',
identityFile: '/home/me/.ssh/lambda key',
jumpHost: 'bastion.example.com'
}
describe('plain SSH terminal argv', () => {
it('logs in interactively: no -T, BatchMode or Orca multiplexing', () => {
const args = buildPlainSshTerminalArgs(manual)
expect(args).not.toContain('-T')
expect(args.join(' ')).not.toMatch(/BatchMode|ControlMaster|ControlPath|ControlPersist|-S/)
expect(args.join(' ')).not.toMatch(/StrictHostKeyChecking|UserKnownHostsFile/)
})
it('states a manual target’s endpoint and ends with -- user@host', () => {
expect(buildPlainSshTerminalArgs(manual)).toEqual([
'-p',
'2222',
'-i',
'/home/me/.ssh/lambda key',
'-J',
'bastion.example.com',
'--',
'ubuntu@203.0.113.7'
])
})
it('keeps a config alias bare, so Include, Match and ProxyJump in ssh_config apply', () => {
const alias: SshTarget = {
...manual,
source: 'ssh-config',
configHost: 'lambda',
host: 'lambda'
}
expect(buildPlainSshTerminalArgs(alias)).toEqual(['--', 'lambda'])
})
it('restores the stored endpoint only when no config block claims the alias', () => {
const alias: SshTarget = {
...manual,
source: 'ssh-config',
configHost: 'lambda',
host: '203.0.113.7'
}
expect(buildPlainSshTerminalArgs(alias, { aliasClaimedByConfig: false })).toEqual([
'-o',
'Hostname=203.0.113.7',
'-p',
'2222',
'-l',
'ubuntu',
'--',
'lambda'
])
})
it('passes Windows paths and option-like hosts as single argv elements, after --', () => {
const windows: SshTarget = {
...manual,
host: '-oProxyCommand=calc',
username: '',
identityFile: 'C:\\Users\\Me\\.ssh\\id ed25519',
jumpHost: undefined,
port: 22
}
const args = buildPlainSshTerminalArgs(windows)
expect(args).toEqual(['-i', 'C:\\Users\\Me\\.ssh\\id ed25519', '--', '-oProxyCommand=calc'])
expect(args.indexOf('--')).toBe(args.length - 2)
})
})
@@ -0,0 +1,20 @@
/**
* argv for an interactive, unmanaged `ssh` login to a saved target. Only the target's identity is
* shared with Orca's transport: no `-T`, no BatchMode and no Orca ControlMaster, so OpenSSH
* allocates a TTY, prompts for passphrases, OTPs and host keys in the terminal, and applies the
* user's own ssh_config (Include, Match, ProxyJump, ControlMaster) to the saved alias.
*/
import type { SshTarget } from '../../../shared/ssh-types'
import {
appendSshTargetDestinationArgs,
type SystemSshBuildArgsOptions
} from '../system-transport/system-ssh-args'
export function buildPlainSshTerminalArgs(
target: SshTarget,
options?: Pick<SystemSshBuildArgsOptions, 'aliasClaimedByConfig'>
): string[] {
const args: string[] = []
appendSshTargetDestinationArgs(args, target, options)
return args
}
@@ -0,0 +1,295 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as pty from 'node-pty'
import type { SshTarget } from '../../../shared/ssh-types'
import type { PlainSshTerminalEvent } from '../../../shared/plain-ssh-terminal'
import {
ackPlainSshTerminal,
closeAllPlainSshTerminals,
closePlainSshTerminal,
closePlainSshTerminalsOwnedBy,
openPlainSshTerminal,
resetPlainSshTerminalsForTests,
resizePlainSshTerminal,
writePlainSshTerminal,
type PlainSshTerminalDeps
} from './plain-ssh-terminal-sessions'
const lambda: SshTarget = {
id: 'lambda',
label: 'lambda',
source: 'ssh-config',
configHost: 'lambda',
host: 'lambda',
port: 22,
username: ''
}
type FakePty = {
file: string
args: string[]
options: pty.IPtyForkOptions
write: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
pause: ReturnType<typeof vi.fn>
resume: ReturnType<typeof vi.fn>
emitData: (data: string) => void
emitExit: (exitCode: number, signal?: number) => void
}
const spawned: FakePty[] = []
function fakeSpawn(file: string, args: string[] | string, options: pty.IPtyForkOptions): pty.IPty {
let onData: ((data: string) => void) | null = null
let onExit: ((event: { exitCode: number; signal?: number }) => void) | null = null
const fake: FakePty = {
file,
args: Array.isArray(args) ? args : [args],
options,
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => fake.emitExit(129)),
pause: vi.fn(),
resume: vi.fn(),
emitData: (data) => onData?.(data),
emitExit: (exitCode, signal) => onExit?.({ exitCode, signal })
}
spawned.push(fake)
const proc = {
cols: 80,
rows: 24,
write: fake.write,
resize: fake.resize,
kill: fake.kill,
pause: fake.pause,
resume: fake.resume,
destroy: vi.fn(),
onData: (listener: (data: string) => void) => {
onData = listener
return { dispose: () => {} }
},
onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => {
onExit = listener
return { dispose: () => {} }
}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sessions module only calls the IPty members this fake implements.
return proc as unknown as pty.IPty
}
function deps(overrides: Partial<PlainSshTerminalDeps> = {}): PlainSshTerminalDeps {
return {
getTarget: (id) => (id === lambda.id ? lambda : undefined),
isMoveBlocked: () => true,
findSsh: () => '/usr/bin/ssh',
mayConfigClaimAlias: () => true,
loadPtySpawn: async () => fakeSpawn,
homeDir: () => '/home/me',
platform: 'linux',
...overrides
}
}
function open(
d: PlainSshTerminalDeps,
targetId: unknown = 'lambda',
owner = 1,
events: PlainSshTerminalEvent[] = []
): ReturnType<typeof openPlainSshTerminal> {
return openPlainSshTerminal(d, { targetId, cols: 100, rows: 30, owner }, (event) => {
events.push(event)
return true
})
}
afterEach(async () => {
await closeAllPlainSshTerminals()
resetPlainSshTerminalsForTests()
spawned.length = 0
})
describe('plain SSH terminal sessions', () => {
it('spawns ssh itself with an argv array in the home directory, never a shell', async () => {
const result = await open(deps())
expect(result).toMatchObject({ ok: true, label: 'lambda' })
expect(spawned).toHaveLength(1)
expect(spawned[0]).toMatchObject({ file: '/usr/bin/ssh', args: ['--', 'lambda'] })
expect(spawned[0].options).toMatchObject({ cols: 100, rows: 30, cwd: '/home/me' })
})
it('refuses an unknown, internal or non-string target without spawning', async () => {
const internal: SshTarget = {
...lambda,
id: 'hop',
owner: { type: 'on-demand-runtime', runtimeId: 'r1' }
}
const d = deps({ getTarget: (id) => (id === 'hop' ? internal : undefined) })
expect(await open(d, 'nope')).toEqual({ ok: false, reason: 'unknown-target' })
expect(await open(d, 'hop')).toEqual({ ok: false, reason: 'unknown-target' })
expect(await open(d, { id: 'lambda' })).toEqual({ ok: false, reason: 'unknown-target' })
expect(spawned).toHaveLength(0)
})
it('refuses a host whose move isn’t blocked: Orca’s own terminals serve it', async () => {
expect(await open(deps({ isMoveBlocked: () => false }))).toEqual({
ok: false,
reason: 'not-blocked'
})
expect(spawned).toHaveLength(0)
})
it('names OpenSSH Client when Windows has no ssh.exe, with no shell workaround', async () => {
const result = await open(deps({ findSsh: () => null, platform: 'win32' }))
expect(result).toMatchObject({ ok: false, reason: 'ssh-missing' })
expect(result.ok ? '' : 'message' in result ? result.message : '').toContain('OpenSSH Client')
})
it('keeps one session per host across windows', async () => {
await open(deps(), 'lambda', 1)
expect(await open(deps(), 'lambda', 2)).toEqual({ ok: false, reason: 'already-open' })
expect(spawned).toHaveLength(1)
})
it('claims the host before awaiting, so concurrent opens never spawn twice', async () => {
let release: () => void = () => {}
const gate = new Promise<void>((resolve) => {
release = resolve
})
const slow = deps({
loadPtySpawn: async () => {
await gate
return fakeSpawn
}
})
const first = open(slow, 'lambda', 1)
const other = await open(slow, 'lambda', 2)
// The same window replacing its own pending open (e.g. a remount) wins; the older one yields.
const again = open(slow, 'lambda', 1)
release()
expect(other).toEqual({ ok: false, reason: 'already-open' })
expect(await first).toEqual({ ok: false, reason: 'cancelled' })
expect(await again).toMatchObject({ ok: true })
expect(spawned).toHaveLength(1)
})
it('spawns nothing for an open whose window reloaded while it was pending', async () => {
let release: () => void = () => {}
const gate = new Promise<void>((resolve) => {
release = resolve
})
const pending = open(
deps({
loadPtySpawn: async () => {
await gate
return fakeSpawn
}
}),
'lambda',
3
)
closePlainSshTerminalsOwnedBy(3)
release()
expect(await pending).toEqual({ ok: false, reason: 'cancelled' })
expect(spawned).toHaveLength(0)
expect(await open(deps(), 'lambda', 4)).toMatchObject({ ok: true })
})
it('frees the host as soon as a close is asked, before ssh has exited', async () => {
const first = await open(deps(), 'lambda', 1)
spawned[0].kill.mockImplementation(() => {})
closePlainSshTerminal(first.ok ? first.sessionId : '', 1)
expect(await open(deps(), 'lambda', 2)).toMatchObject({ ok: true })
spawned[0].emitExit(129)
})
it('pauses ssh while the window has too much unrendered output, and resumes on ack', async () => {
const result = await open(deps(), 'lambda', 1)
const sessionId = result.ok ? result.sessionId : ''
const chunk = 'x'.repeat(64 * 1024)
for (let i = 0; i < 8; i++) {
spawned[0].emitData(chunk)
}
expect(spawned[0].pause).not.toHaveBeenCalled()
spawned[0].emitData(chunk)
expect(spawned[0].pause).toHaveBeenCalledTimes(1)
// Another window's ack is ignored.
ackPlainSshTerminal(sessionId, 2, 9 * chunk.length)
expect(spawned[0].resume).not.toHaveBeenCalled()
ackPlainSshTerminal(sessionId, 1, 6 * chunk.length)
expect(spawned[0].resume).not.toHaveBeenCalled()
ackPlainSshTerminal(sessionId, 1, 2 * chunk.length)
expect(spawned[0].resume).toHaveBeenCalledTimes(1)
})
it('resumes a paused ssh before hanging up, or Windows would never report its exit', async () => {
const result = await open(deps(), 'lambda', 1)
const order: string[] = []
spawned[0].resume.mockImplementation(() => order.push('resume'))
spawned[0].kill.mockImplementation(() => order.push('kill'))
spawned[0].emitData('x'.repeat(600 * 1024))
expect(spawned[0].pause).toHaveBeenCalledTimes(1)
closePlainSshTerminal(result.ok ? result.sessionId : '', 1)
expect(order).toEqual(['resume', 'kill'])
spawned[0].emitExit(129)
})
it('swallows native calls on a pty that exited but hasn’t reported it, as Windows does', async () => {
const result = await open(deps(), 'lambda', 1)
const sessionId = result.ok ? result.sessionId : ''
const exited = (): never => {
throw new Error('Cannot resize a pty that has already exited')
}
spawned[0].resize.mockImplementation(exited)
spawned[0].write.mockImplementation(exited)
expect(() => resizePlainSshTerminal(sessionId, 1, 100, 30)).not.toThrow()
expect(() => writePlainSshTerminal(sessionId, 1, 'ls\r')).not.toThrow()
})
it('reports a signal kill, which is not a clean logout', async () => {
const events: PlainSshTerminalEvent[] = []
const result = await open(deps(), 'lambda', 1, events)
spawned[0].emitExit(0, 9)
expect(events).toEqual([
{ kind: 'exit', sessionId: result.ok ? result.sessionId : '', exitCode: 0, signal: 9 }
])
})
it('forwards output and exit, and only its owner can write, resize or close it', async () => {
const events: PlainSshTerminalEvent[] = []
const result = await open(deps(), 'lambda', 7, events)
const sessionId = result.ok ? result.sessionId : ''
spawned[0].emitData('Welcome to lambda\r\n')
writePlainSshTerminal(sessionId, 8, 'rm -rf /\r')
resizePlainSshTerminal(sessionId, 8, 10, 10)
closePlainSshTerminal(sessionId, 8)
expect(spawned[0].write).not.toHaveBeenCalled()
expect(spawned[0].resize).not.toHaveBeenCalled()
expect(spawned[0].kill).not.toHaveBeenCalled()
writePlainSshTerminal(sessionId, 7, 'ls\r')
resizePlainSshTerminal(sessionId, 7, 120, 40)
expect(spawned[0].write).toHaveBeenCalledWith('ls\r')
expect(spawned[0].resize).toHaveBeenCalledWith(120, 40)
closePlainSshTerminal(sessionId, 7)
expect(events).toEqual([
{ kind: 'data', sessionId, data: 'Welcome to lambda\r\n' },
{ kind: 'exit', sessionId, exitCode: 129 }
])
// Ended: a new one may open.
expect(await open(deps(), 'lambda', 7)).toMatchObject({ ok: true })
})
it('on quit, hangs up every session, waits for it to exit, and opens no more', async () => {
await open(deps())
spawned[0].kill.mockImplementation(() => {})
let done = false
const quit = closeAllPlainSshTerminals().then(() => {
done = true
})
expect(spawned[0].kill).toHaveBeenCalledTimes(1)
await Promise.resolve()
expect(done).toBe(false)
spawned[0].emitExit(129)
await quit
expect(await open(deps(), 'lambda', 9)).toEqual({ ok: false, reason: 'cancelled' })
})
})
@@ -0,0 +1,310 @@
/**
* Plain SSH terminals: an unmanaged `ssh` login to a host whose move to its Orca server is blocked,
* so the user can keep working there meanwhile. A session is not an Orca workspace or terminal:
* nothing persists or restores it, no agent or status runs in it, and it never marks the host
* connected. One per host; it ends when ssh exits, its window closes or reloads, or Orca quits.
*/
import { randomUUID } from 'node:crypto'
import type * as pty from 'node-pty'
import type { SshTarget } from '../../../shared/ssh-types'
import type {
PlainSshTerminalEvent,
PlainSshTerminalOpenResult
} from '../../../shared/plain-ssh-terminal'
import { buildInheritedLocalPtyEnvironment } from '../../providers/local-pty-spawn-environment'
import { spawnPtyExecutable } from '../../providers/local-pty-utils'
import { destroyPtyProcess } from '../../providers/local-pty-termination'
import { buildPlainSshTerminalArgs } from './plain-ssh-terminal-args'
export type PlainSshTerminalDeps = {
getTarget: (targetId: string) => SshTarget | undefined
/** True only while the host's move to its Orca server is blocked. */
isMoveBlocked: (targetId: string) => boolean
findSsh: () => string | null
mayConfigClaimAlias: (alias: string) => boolean
loadPtySpawn: () => Promise<typeof pty.spawn>
homeDir: () => string
platform: NodeJS.Platform
}
/** Where a session's output and exit go; false once that window is gone. */
export type PlainSshTerminalSink = (event: PlainSshTerminalEvent) => boolean
type Session = {
id: string
owner: number
proc: pty.IPty
exited: Promise<void>
hungUp: boolean
/** Output sent but not yet rendered by the window, in UTF-16 units. */
unacked: number
paused: boolean
}
/** A host's one claim, taken before the first await so concurrent opens can't both spawn. */
type Slot = { owner: number; token: symbol; session: Session | null }
const FORCE_KILL_AFTER_MS = 5_000
const QUIT_FORCE_KILL_AFTER_MS = 1_000
// Why: a flood (cat of a big file) must wait for the window to render, not pile up in IPC.
const PAUSE_ABOVE_UNACKED = 512 * 1024
const RESUME_BELOW_UNACKED = 128 * 1024
const MIN_SIZE = 2
const MAX_SIZE = 1_000
const slots = new Map<string, Slot>()
/** Every process not yet exited, including ones already hung up. */
const sessions = new Map<string, Session>()
let quitting = false
function sshMissingMessage(platform: NodeJS.Platform): string {
return platform === 'win32'
? 'Orca couldn’t find OpenSSH (ssh.exe) on this computer. Install “OpenSSH Client” in Windows Settings > System > Optional features, then try again.'
: 'Orca couldn’t find ssh on this computer. Install the OpenSSH client, then try again.'
}
function clampSize(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value)
? Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.floor(value)))
: fallback
}
/** Frees a host's claim; a live session in it is hung up. */
function releaseSlot(targetId: string, slot: Slot): void {
if (slots.get(targetId) === slot) {
slots.delete(targetId)
}
if (slot.session) {
hangUp(slot.session, FORCE_KILL_AFTER_MS)
}
}
/**
* Why: on Windows node-pty records the exit before it emits one, and a native call in that gap
* throws ("already exited"). From an IPC listener that would reach the fatal main-process guard.
*/
function onLivePty(session: Session, call: (proc: pty.IPty) => void): void {
try {
call(session.proc)
} catch {
// Exited; its exit event follows.
}
}
function hangUp(session: Session, forceAfterMs: number): void {
if (!session.hungUp) {
session.hungUp = true
if (session.paused) {
// Why: a paused Windows pty never reads its socket's end, so it would never report exit.
session.paused = false
onLivePty(session, (proc) => proc.resume())
}
try {
session.proc.kill()
} catch {
// Already gone; its exit cleans up.
}
}
// Why armed again when already hung up: quit's shorter deadline must apply to it too.
const forceKill = setTimeout(() => {
if (sessions.has(session.id)) {
try {
// Why: Windows rejects a signal name; its kill() above already terminated the console.
session.proc.kill('SIGKILL')
} catch {
// Already gone.
}
}
}, forceAfterMs)
forceKill.unref?.()
void session.exited.then(() => clearTimeout(forceKill))
}
export async function openPlainSshTerminal(
deps: PlainSshTerminalDeps,
request: { targetId: unknown; cols: unknown; rows: unknown; owner: number },
sink: PlainSshTerminalSink
): Promise<PlainSshTerminalOpenResult> {
const targetId = typeof request.targetId === 'string' ? request.targetId : ''
const target = targetId ? deps.getTarget(targetId) : undefined
// Why `owner`: internal targets (a server's own SSH hops) are never the user's host.
if (!target || target.owner) {
return { ok: false, reason: 'unknown-target' }
}
if (!deps.isMoveBlocked(target.id)) {
return { ok: false, reason: 'not-blocked' }
}
if (quitting) {
return { ok: false, reason: 'cancelled' }
}
const held = slots.get(target.id)
if (held && held.owner !== request.owner) {
return { ok: false, reason: 'already-open' }
}
// Why the same window may replace its own: it shows one per host, so an older one is stale.
if (held) {
releaseSlot(target.id, held)
}
const slot: Slot = { owner: request.owner, token: Symbol(target.id), session: null }
slots.set(target.id, slot)
const stillClaimed = (): boolean => !quitting && slots.get(target.id)?.token === slot.token
const sshPath = deps.findSsh()
if (!sshPath) {
releaseSlot(target.id, slot)
return { ok: false, reason: 'ssh-missing', message: sshMissingMessage(deps.platform) }
}
const args = buildPlainSshTerminalArgs(
target,
target.configHost && !deps.mayConfigClaimAlias(target.configHost)
? { aliasClaimedByConfig: false }
: undefined
)
let proc: pty.IPty
try {
const ptySpawn = await deps.loadPtySpawn()
// A reload, a newer open or quit during the await took the claim; spawn nothing.
if (!stillClaimed()) {
return { ok: false, reason: 'cancelled' }
}
proc = spawnPtyExecutable({
file: sshPath,
args,
cols: clampSize(request.cols, 80),
rows: clampSize(request.rows, 24),
cwd: deps.homeDir(),
env: buildInheritedLocalPtyEnvironment(undefined),
ptySpawn
})
} catch (error) {
releaseSlot(target.id, slot)
return {
ok: false,
reason: 'spawn-failed',
message: error instanceof Error ? error.message : String(error)
}
}
let markExited: () => void = () => {}
const session: Session = {
id: randomUUID(),
owner: request.owner,
proc,
exited: new Promise((resolve) => {
markExited = resolve
}),
hungUp: false,
unacked: 0,
paused: false
}
sessions.set(session.id, session)
slot.session = session
proc.onData((data) => {
if (session.hungUp) {
return
}
if (!sink({ kind: 'data', sessionId: session.id, data })) {
releaseSlot(target.id, slot)
return
}
session.unacked += data.length
if (!session.paused && session.unacked > PAUSE_ABOVE_UNACKED) {
session.paused = true
onLivePty(session, (live) => live.pause())
}
})
proc.onExit(({ exitCode, signal }) => {
sessions.delete(session.id)
if (slots.get(target.id) === slot) {
slots.delete(target.id)
}
destroyPtyProcess(proc, { alreadyKilled: true })
markExited()
sink({
kind: 'exit',
sessionId: session.id,
exitCode,
...(signal ? { signal } : {})
})
})
return { ok: true, sessionId: session.id, label: target.label }
}
function ownedSession(sessionId: unknown, owner: number): Session | null {
const session = typeof sessionId === 'string' ? sessions.get(sessionId) : undefined
return session && session.owner === owner && !session.hungUp ? session : null
}
export function writePlainSshTerminal(sessionId: unknown, owner: number, data: unknown): void {
const session = ownedSession(sessionId, owner)
if (session && typeof data === 'string') {
onLivePty(session, (proc) => proc.write(data))
}
}
export function resizePlainSshTerminal(
sessionId: unknown,
owner: number,
cols: unknown,
rows: unknown
): void {
const session = ownedSession(sessionId, owner)
if (session) {
onLivePty(session, (proc) =>
proc.resize(clampSize(cols, proc.cols), clampSize(rows, proc.rows))
)
}
}
export function ackPlainSshTerminal(sessionId: unknown, owner: number, chars: unknown): void {
const session = ownedSession(sessionId, owner)
if (!session || typeof chars !== 'number' || !Number.isFinite(chars) || chars <= 0) {
return
}
session.unacked = Math.max(0, session.unacked - chars)
if (session.paused && session.unacked < RESUME_BELOW_UNACKED) {
session.paused = false
onLivePty(session, (proc) => proc.resume())
}
}
/** Hangs up ssh and frees its host at once; a stuck process is killed outright later. */
export function closePlainSshTerminal(sessionId: unknown, owner: number): void {
const session = ownedSession(sessionId, owner)
if (!session) {
return
}
for (const [targetId, slot] of slots) {
if (slot.session === session) {
releaseSlot(targetId, slot)
return
}
}
hangUp(session, FORCE_KILL_AFTER_MS)
}
/** Every session a window owns, opened or still opening, e.g. when it closes or reloads. */
export function closePlainSshTerminalsOwnedBy(owner: number): void {
for (const [targetId, slot] of slots) {
if (slot.owner === owner) {
releaseSlot(targetId, slot)
}
}
}
/** On quit: nothing restores these, so none may outlive Orca. Resolves once all have exited. */
export async function closeAllPlainSshTerminals(): Promise<void> {
quitting = true
slots.clear()
const pending = [...sessions.values()]
for (const session of pending) {
hangUp(session, QUIT_FORCE_KILL_AFTER_MS)
}
await Promise.all(pending.map((session) => session.exited))
}
/** Test seam: quit is one-way in a real app. */
export function resetPlainSshTerminalsForTests(): void {
quitting = false
slots.clear()
sessions.clear()
}
@@ -63,6 +63,19 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
args.push('-o', 'ServerAliveCountMax=3')
}
appendSshTargetDestinationArgs(args, target, options)
return args
}
/**
* Who to log in as and how to reach the host, ending `-- destination`: the part of a dial that
* belongs to the saved target rather than to Orca's transport (multiplexing, batch mode, `-T`).
*/
export function appendSshTargetDestinationArgs(
args: string[],
target: SshTarget,
options?: Pick<SystemSshBuildArgsOptions, 'aliasClaimedByConfig' | 'gssapiOnly'>
): void {
const useConfigHost = shouldUseOpenSshConfigHost(target)
// Why: a wildcard `Host *` block supplies ProxyCommand/ProxyJump for every alias, so an alias
@@ -109,8 +122,6 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti
// must not override a fresh wildcard, Include, or Match result.
const userHost = useConfigHost ? host : target.username ? `${target.username}@${host}` : host
args.push('--', userHost)
return args
}
/**
+3
View File
@@ -58,6 +58,7 @@ import type { KeybindingsApi, SettingsApi } from './api/settings-api'
import type { ShellApi } from './api/shell-api'
import type { SpeechApi } from './api/speech-api'
import type { SshApi } from './api/ssh-api'
import type { PlainSshTerminalApi } from './api/plain-ssh-terminal-bridge'
import type { LegacyRelayBridgeApi } from './api/legacy-relay-bridge-api'
import type { DiagnosticsApi, MemoryApi, StatsApi, TelemetryApi } from './api/telemetry-api'
import type { UiCommandEventApi } from './api/ui-command-event-api'
@@ -153,6 +154,8 @@ export type PreloadApi = {
grokAccounts: GrokAccountsApi
cursorAccounts: CursorAccountsApi
ssh: SshApi
/** Desktop only: an unmanaged ssh login to a host whose move is blocked. */
plainSsh?: PlainSshTerminalApi
/** COMPAT(legacy-relay-bridge): desktop-only, revisit 2027-01-15. */
legacyRelayBridge?: LegacyRelayBridgeApi
automations: AutomationsApi
@@ -0,0 +1,33 @@
import { ipcRenderer } from 'electron'
import {
PLAIN_SSH_TERMINAL_CHANNELS,
type PlainSshTerminalEvent,
type PlainSshTerminalOpenResult
} from '../../shared/plain-ssh-terminal'
export type PlainSshTerminalApi = {
open: (args: {
targetId: string
cols: number
rows: number
}) => Promise<PlainSshTerminalOpenResult>
write: (args: { sessionId: string; data: string }) => void
resize: (args: { sessionId: string; cols: number; rows: number }) => void
close: (args: { sessionId: string }) => void
ack: (args: { sessionId: string; chars: number }) => void
onEvent: (callback: (event: PlainSshTerminalEvent) => void) => () => void
}
export const plainSshTerminalApi: PlainSshTerminalApi = {
open: (args) => ipcRenderer.invoke(PLAIN_SSH_TERMINAL_CHANNELS.open, args),
write: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.write, args),
resize: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.resize, args),
close: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.close, args),
ack: (args) => ipcRenderer.send(PLAIN_SSH_TERMINAL_CHANNELS.ack, args),
onEvent: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, payload: PlainSshTerminalEvent): void =>
callback(payload)
ipcRenderer.on(PLAIN_SSH_TERMINAL_CHANNELS.event, listener)
return () => ipcRenderer.removeListener(PLAIN_SSH_TERMINAL_CHANNELS.event, listener)
}
}
+2
View File
@@ -78,6 +78,7 @@ import { zcodePlanCredentialsApi } from './api/zcode-plan-credentials-bridge'
import { grokAccountsApi } from './api/grok-accounts-bridge'
import { cursorAccountsApi } from './api/cursor-accounts-bridge'
import { sshApi } from './api/ssh-bridge'
import { plainSshTerminalApi } from './api/plain-ssh-terminal-bridge'
import { legacyRelayBridgeApi } from './api/legacy-relay-bridge-api'
import { automationsApi } from './api/automations-bridge'
import { e2eApi } from './api/e2e-bridge'
@@ -179,6 +180,7 @@ const api = {
grokAccounts: grokAccountsApi,
cursorAccounts: cursorAccountsApi,
ssh: sshApi,
plainSsh: plainSshTerminalApi,
legacyRelayBridge: legacyRelayBridgeApi,
automations: automationsApi,
e2e: e2eApi,
+2
View File
@@ -35,6 +35,7 @@ import { usePersistedUIWriter } from './app-shell/use-persisted-ui-writer'
import { useRuntimeGraphSync } from './app-shell/use-runtime-graph-sync'
import { useWindowVisibilityEffects } from './app-shell/use-window-visibility-effects'
import { SpaceWindowStatePublisher } from './components/workspace-space/SpaceWindowStatePublisher'
import { PlainSshTerminalPanels } from './components/plain-ssh-terminal/PlainSshTerminalPanels'
import { installActiveWorktreeRemovalSuccessor } from './lib/active-worktree-removal-successor'
function App(): React.JSX.Element {
@@ -110,6 +111,7 @@ function App(): React.JSX.Element {
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<PlainSshTerminalPanels />
</TooltipProvider>
<SkillFreshnessNudge />
<WorktreeBaseFallbackDialog />
@@ -1,9 +1,6 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Terminal, type ITheme } from '@xterm/xterm'
import type { ITheme, Terminal } from '@xterm/xterm'
import { useShallow } from 'zustand/react/shallow'
import '@xterm/xterm/css/xterm.css'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
@@ -15,21 +12,17 @@ import {
previewAdvertisesKittyKeyboard
} from './preview-terminal-options'
import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
import { installPreviewTerminalCompatibility } from './preview-terminal-compatibility'
import { createPreviewClipboardPaster } from './preview-terminal-paste'
import { installPreviewImeBridge, type PreviewImeBridge } from './preview-terminal-ime-bridge'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message'
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { installPreviewTerminalKeyHandler } from './preview-terminal-key-handler'
import { createPreviewGridClaim } from './preview-grid-claim'
import { createPreviewBoxFit } from './preview-terminal-box-fit'
import { installPreviewTerminalAppMenuClipboard } from './preview-terminal-app-menu-clipboard'
import { installPreviewTerminalRightClickPaste } from './preview-terminal-right-click-paste'
import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/terminal-native-copy-gutter'
import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import {
openStandaloneTerminal,
type StandaloneTerminal
} from '../standalone-terminal/open-standalone-terminal'
import type { TerminalPreviewDataPayload } from '../../../../shared/terminal-preview'
import { terminalPreviewApiFor } from './terminal-preview-api'
@@ -110,13 +103,9 @@ export function AgentTerminalPreview({
return
}
let disposed = false
let standalone: StandaloneTerminal | null = null
let terminal: Terminal | null = null
let offData: (() => void) | null = null
let userInputDisposable: { dispose: () => void } | null = null
let imeBridge: PreviewImeBridge | null = null
let disposeKeyHandler: (() => void) | null = null
let disposeNativeCopyGutterTrim: (() => void) | null = null
let disposeTerminalCompatibility: (() => void) | null = null
const previewApi = terminalPreviewApiFor(ptyId)
// Why one read: the xterm's advertisement and its mirror must never disagree.
const mountTerminalInput = terminalInputRef.current
@@ -150,20 +139,13 @@ export function AgentTerminalPreview({
}
boxResizeObserver?.observe(container)
let replayDepth = 0
const writeReplayed = (chunk: string, onDone?: () => void, live = false): void => {
// Why: a redelivered snapshot repeats the TUI's one-time kitty push, so
// replayed bytes must apply as idempotent sets (see the tracker's docs).
if (live) {
kittyKeyboardModes.scan(chunk)
} else {
kittyKeyboardModes.scanReplay(chunk)
}
replayDepth++
terminal?.write(chunk, () => {
replayDepth--
boxFit.schedule()
onDone?.()
standalone?.write(chunk, {
live,
onDone: () => {
boxFit.schedule()
onDone?.()
}
})
}
@@ -183,72 +165,6 @@ export function AgentTerminalPreview({
)
}
const pasteClipboardText = createPreviewClipboardPaster({
ptyId,
container,
getTerminal: () => terminal,
getTerminalInput: () => terminalInputRef.current,
isDisposed: () => disposed
})
const disposeImeNativeTextBridge = (): void => {
imeBridge?.dispose()
imeBridge = null
}
const installImeNativeTextBridge = (): void => {
if (terminal) {
// Why a live getter: kitty state can change between keydown and commit,
// and the tracker outlives every reconnect inside this effect.
imeBridge = installPreviewImeBridge(terminal, {
getKittyKeyboardFlags: () => kittyKeyboardModes.flags
})
}
}
const installKeyHandler = (): void => {
if (!terminal) {
return
}
disposeKeyHandler = installPreviewTerminalKeyHandler({
terminal,
claimImeKeyEvent: (event) => imeBridge?.claimKeyEvent(event) ?? false,
pasteClipboardText: (activeElement, source) =>
void pasteClipboardText(activeElement, source),
// Why: route through terminal.input so the chord's bytes carry core's user-input signal, like typed keys.
sendInput: (data) => terminal?.input(data),
getShortcutContext: () => ({
clientPlatform: getShortcutPlatform(),
macOptionAsAlt: macOptionAsAltRef.current,
keybindings: useAppStore.getState().keybindings,
terminalInput: terminalInputRef.current,
getKittyKeyboardFlags: () => kittyKeyboardModes.flags,
terminalShortcutPolicy: settingsRef.current?.terminalShortcutPolicy
})
})
}
const installInputRouting = (): void => {
if (!terminal) {
return
}
let pendingUserInputSignals = 0
userInputDisposable = subscribeToTerminalUserInput(terminal, () => {
pendingUserInputSignals = Math.min(32, pendingUserInputSignals + 1)
})
terminal.onData((data) => {
const signaledUserInput = pendingUserInputSignals > 0
if (signaledUserInput) {
pendingUserInputSignals--
}
// Why: core's signal distinguishes real input from parser replies, so typing survives live replay without forwarding synthetic CPR/DA bytes.
if (userInputDisposable ? !signaledUserInput : replayDepth > 0) {
return
}
void previewApi.input(ptyId, data)
})
}
const replayConnection = (
connection: Awaited<ReturnType<typeof previewApi.connect>>,
replaceExisting: boolean,
@@ -256,8 +172,9 @@ export function AgentTerminalPreview({
): void => {
const snap = connection.snapshot!
if (!terminal) {
terminal = new Terminal(
buildPreviewTerminalOptions({
standalone = openStandaloneTerminal({
container,
options: buildPreviewTerminalOptions({
settings: settingsRef.current,
terminalInput: mountTerminalInput,
macOptionIsMeta: macOptionAsAltRef.current === 'true',
@@ -266,23 +183,20 @@ export function AgentTerminalPreview({
cols: clamp(snap.cols ?? FALLBACK_COLS, 2, 500),
rows: clamp(snap.rows ?? FALLBACK_ROWS, 2, 200),
scrollback: PREVIEW_SCROLLBACK_BUFFER_ROWS
})
)
try {
terminal.open(container)
} catch {
terminal.dispose()
terminal = null
}),
kittyKeyboardModes,
sessionKey: ptyId,
sendInput: (data) => previewApi.input(ptyId, data),
answersQueries: false,
getSettings: () => settingsRef.current,
getMacOptionAsAlt: () => macOptionAsAltRef.current,
getTerminalInput: () => terminalInputRef.current
})
if (!standalone) {
return
}
terminal = standalone.terminal
terminalRef.current = terminal
disposeTerminalCompatibility = installPreviewTerminalCompatibility(terminal, {
getSettings: () => settingsRef.current
})
disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose
installInputRouting()
installImeNativeTextBridge()
installKeyHandler()
} else if (replaceExisting) {
// Why: keep the old frame visible during capture, then atomically replace it once the authoritative snapshot arrives.
terminal.resize(
@@ -325,16 +239,8 @@ export function AgentTerminalPreview({
const releaseTerminal = (): void => {
offData?.()
offData = null
userInputDisposable?.dispose()
userInputDisposable = null
disposeImeNativeTextBridge()
disposeTerminalCompatibility?.()
disposeTerminalCompatibility = null
disposeKeyHandler?.()
disposeKeyHandler = null
disposeNativeCopyGutterTrim?.()
disposeNativeCopyGutterTrim = null
terminal?.dispose()
standalone?.dispose()
standalone = null
terminal = null
terminalRef.current = null
void previewApi.unsubscribe(ptyId)
@@ -367,20 +273,6 @@ export function AgentTerminalPreview({
replayConnection(connection, replaceExisting, () => void setup(true))
}
const disposeAppMenuClipboard = installPreviewTerminalAppMenuClipboard({
container,
getTerminal: () => terminal,
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source)
})
const disposeRightClickPaste = installPreviewTerminalRightClickPaste({
container,
getTerminal: () => terminal,
// Same default as the pane: Windows users expect terminal-style right-click.
isRightClickToPasteEnabled: () =>
settingsRef.current?.terminalRightClickToPaste ?? isWindowsUserAgent(),
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source)
})
offData = previewApi.onData((payload) => {
if (payload.ptyId !== ptyId) {
return
@@ -402,8 +294,6 @@ export function AgentTerminalPreview({
}
gridClaim.dispose()
boxResizeObserver?.disconnect()
disposeAppMenuClipboard()
disposeRightClickPaste()
releaseTerminal()
}
}, [
@@ -6,17 +6,19 @@ import { resolveTerminalPasteRuntime } from '@/components/terminal-pane/terminal
import { TERMINAL_PASTE_MAX_BYTES } from '@/components/terminal-pane/terminal-paste-limits'
import { pasteTerminalText } from '@/components/terminal-pane/terminal-bracketed-paste'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
import { terminalPreviewApiFor } from './terminal-preview-api'
export type PreviewTerminalPasteSource = 'keyboard' | 'app-menu' | 'right-click'
/**
* Clipboard paste for the preview terminal, on the pane's coordinator: large
* Clipboard paste for a standalone terminal, on the pane's coordinator: large
* pastes stream as bounded IPC payloads, and the plan re-checks that the same
* terminal still owns focus between chunks.
*/
export function createPreviewClipboardPaster(deps: {
/** Names the session for paste planning; a remote-runtime pty id routes as remote. */
ptyId: string
/** Where pasted bytes go when they bypass xterm, e.g. a bracketed paste's closing marker. */
writeInput: (data: string) => boolean | Promise<boolean>
container: HTMLElement
getTerminal: () => Terminal | null
getTerminalInput: () => DashboardCardTerminalInput | null
@@ -61,7 +63,7 @@ export function createPreviewClipboardPaster(deps: {
await executeTerminalPastePlan(plan, {
// Why: stream large pastes so the renderer never emits one huge IPC payload.
pasteText: (text, options) => pasteTerminalText(pasteTerminal, text, options),
writePty: (data) => terminalPreviewApiFor(deps.ptyId).input(deps.ptyId, data),
writePty: (data) => deps.writeInput(data),
isTargetCurrent: targetIsCurrent,
// Why: if focus changes mid-bracketed paste, the closing marker must still reach the live PTY.
canContinue: () => true
@@ -0,0 +1,35 @@
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
import { isSshHostMoveBlocked } from '../../../../shared/ssh-host-move-blocked'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import { showPlainSshPanel } from '@/ssh/plain-ssh-terminal-panels'
/**
* The way to keep working on a host whose move is blocked. Only offered then, and only for a host
* this desktop dials itself: a remote Orca server's host is that server's to reach.
*/
export function OpenPlainSshTerminalButton({
targetId,
targetLabel,
status,
ownerEnvironmentId = null
}: {
targetId: string
targetLabel: string
status: SshManagedServerStatus | undefined
ownerEnvironmentId?: string | null
}): React.JSX.Element | null {
if (ownerEnvironmentId || !isSshHostMoveBlocked(status) || !window.api.plainSsh) {
return null
}
return (
<Button
type="button"
size="xs"
variant="outline"
onClick={() => showPlainSshPanel(targetId, targetLabel)}
>
{translate('auto.components.plainSshTerminal.open', 'Open plain SSH terminal')}
</Button>
)
}
@@ -0,0 +1,256 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshManagedServerStatus } from '../../../../shared/ssh-types'
import type {
PlainSshTerminalEvent,
PlainSshTerminalOpenResult
} from '../../../../shared/plain-ssh-terminal'
import { OpenPlainSshTerminalButton } from './OpenPlainSshTerminalButton'
import { PlainSshTerminalPanels } from './PlainSshTerminalPanels'
import { removePlainSshPanel } from '@/ssh/plain-ssh-terminal-panels'
import { TooltipProvider } from '@/components/ui/tooltip'
import { useAppStore } from '@/store'
import { getDefaultSettings } from '../../../../shared/constants'
const written: string[] = []
// The standalone terminal is the preview's, covered by its tests; this stands in for xterm.
vi.mock('@/components/standalone-terminal/open-standalone-terminal', () => ({
openStandaloneTerminal: () => ({
terminal: {
options: {},
cols: 80,
rows: 24,
loadAddon: () => {},
focus: () => {},
onResize: () => ({ dispose: () => {} })
},
write: (chunk: string, options: { onDone?: () => void }) => {
written.push(chunk)
options.onDone?.()
},
dispose: () => {}
})
}))
const fit = vi.fn()
vi.mock('@xterm/addon-fit', () => ({
FitAddon: class {
fit(): void {
fit()
}
}
}))
const blocked: SshManagedServerStatus = {
kind: 'relay',
reason: 'refused',
detail: 'This SSH host cannot move yet: automations (2).'
}
let emit: (event: PlainSshTerminalEvent) => void = () => {}
const open = vi.fn<(args: unknown) => Promise<PlainSshTerminalOpenResult>>()
const close = vi.fn()
const ack = vi.fn()
beforeEach(() => {
written.length = 0
open.mockReset()
close.mockReset()
ack.mockReset()
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
// Full surface: a hovered header button's Tooltip positions itself with unobserve too.
value: class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
plainSsh: {
open,
close,
ack,
write: vi.fn(),
resize: vi.fn(),
onEvent: (callback: (event: PlainSshTerminalEvent) => void) => {
emit = callback
return () => {}
}
}
}
})
})
afterEach(() => {
act(() => removePlainSshPanel('lambda'))
cleanup()
})
/** The open reply arrives when the test says, so nothing races it. */
function deferOpen(): (result: PlainSshTerminalOpenResult) => void {
let resolve: (result: PlainSshTerminalOpenResult) => void = () => {}
open.mockImplementation(
() =>
new Promise((done) => {
resolve = done
})
)
return (result) => resolve(result)
}
function Harness({ status }: { status: SshManagedServerStatus | undefined }): React.JSX.Element {
return (
<TooltipProvider>
<OpenPlainSshTerminalButton targetId="lambda" targetLabel="lambda" status={status} />
<PlainSshTerminalPanels />
</TooltipProvider>
)
}
describe('plain SSH terminal', () => {
it('is offered only while the host’s move is blocked, and only for this desktop’s host', () => {
const { rerender } = render(<Harness status={undefined} />)
expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull()
rerender(<Harness status={{ kind: 'managed', environmentId: 'e' }} />)
expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull()
rerender(<Harness status={{ ...blocked, bridged: true }} />)
expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull()
rerender(
<OpenPlainSshTerminalButton
targetId="lambda"
targetLabel="lambda"
status={blocked}
ownerEnvironmentId="env-1"
/>
)
expect(screen.queryByRole('button', { name: 'Open plain SSH terminal' })).toBeNull()
})
it('opens a labelled unmanaged window, shows early output, and closes when ssh exits cleanly', async () => {
let resolveOpen: (result: PlainSshTerminalOpenResult) => void = () => {}
open.mockImplementation(
() =>
new Promise((resolve) => {
resolveOpen = resolve
})
)
const user = userEvent.setup()
render(<Harness status={blocked} />)
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
expect(screen.getByText('Plain SSH · lambda')).toBeInTheDocument()
expect(screen.getByText(/Plain SSH — unmanaged\. Not an Orca workspace/)).toBeInTheDocument()
expect(open).toHaveBeenCalledWith({ targetId: 'lambda', cols: 80, rows: 24 })
// ssh can print before the open reply arrives.
act(() => emit({ kind: 'data', sessionId: 's1', data: 'Enter passphrase: ' }))
await act(async () => resolveOpen({ ok: true, sessionId: 's1', label: 'lambda' }))
expect(written).toEqual(['Enter passphrase: '])
// Rendered output is acked, so main resumes a paused ssh.
expect(ack).toHaveBeenCalledWith({ sessionId: 's1', chars: 'Enter passphrase: '.length })
act(() => emit({ kind: 'exit', sessionId: 's1', exitCode: 0 }))
await waitFor(() => expect(screen.queryByText('Plain SSH · lambda')).toBeNull())
})
it('keeps output when ssh is killed by a signal, even with exit code 0', async () => {
const resolveOpen = deferOpen()
const user = userEvent.setup()
render(<Harness status={blocked} />)
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
await waitFor(() => expect(open).toHaveBeenCalled())
await act(async () => resolveOpen({ ok: true, sessionId: 's4', label: 'lambda' }))
act(() => emit({ kind: 'exit', sessionId: 's4', exitCode: 0, signal: 9 }))
expect(await screen.findByText(/ssh was stopped \(signal 9\)/)).toBeInTheDocument()
})
it('still ends a session whose exit arrives before its open reply', async () => {
let resolveOpen: (result: PlainSshTerminalOpenResult) => void = () => {}
open.mockImplementation(
() =>
new Promise((resolve) => {
resolveOpen = resolve
})
)
const user = userEvent.setup()
render(<Harness status={blocked} />)
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
act(() => {
// A flood from another window's session must not crowd out this session's exit.
emit({ kind: 'data', sessionId: 'other', data: 'y'.repeat(300 * 1024) })
emit({ kind: 'data', sessionId: 's5', data: 'Permission denied (publickey).\r\n' })
emit({ kind: 'exit', sessionId: 's5', exitCode: 255 })
})
await act(async () => resolveOpen({ ok: true, sessionId: 's5', label: 'lambda' }))
expect(await screen.findByText(/ssh exited with code 255/)).toBeInTheDocument()
expect(written).toEqual(['Permission denied (publickey).\r\n'])
})
it('keeps a failed login readable, with no shell to fall back to', async () => {
const resolveOpen = deferOpen()
const user = userEvent.setup()
render(<Harness status={blocked} />)
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
await waitFor(() => expect(open).toHaveBeenCalled())
await act(async () => resolveOpen({ ok: true, sessionId: 's2', label: 'lambda' }))
act(() => emit({ kind: 'exit', sessionId: 's2', exitCode: 255 }))
expect(await screen.findByText(/ssh exited with code 255/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Close and end session' }))
expect(screen.queryByText('Plain SSH · lambda')).toBeNull()
})
it('never refits a minimized window, which would shrink the remote terminal to 2x1', async () => {
const width = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600)
const height = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(400)
try {
const resolveOpen = deferOpen()
const user = userEvent.setup()
render(<Harness status={blocked} />)
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
await act(async () => resolveOpen({ ok: true, sessionId: 's6', label: 'lambda' }))
await user.click(screen.getByRole('button', { name: 'Minimize' }))
fit.mockClear()
act(() =>
useAppStore.setState({
settings: { ...getDefaultSettings('/home/me'), terminalFontSize: 18 }
})
)
expect(fit).not.toHaveBeenCalled()
} finally {
width.mockRestore()
height.mockRestore()
}
})
it('shows why it can’t open, e.g. no OpenSSH on Windows', async () => {
open.mockResolvedValue({
ok: false,
reason: 'ssh-missing',
message: 'Orca couldn’t find OpenSSH (ssh.exe) on this computer.'
})
const user = userEvent.setup()
render(<Harness status={blocked} />)
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
expect(await screen.findByText(/couldn’t find OpenSSH/)).toBeInTheDocument()
})
it('hangs up when its window closes', async () => {
const resolveOpen = deferOpen()
const user = userEvent.setup()
render(<Harness status={blocked} />)
await user.click(screen.getByRole('button', { name: 'Open plain SSH terminal' }))
await waitFor(() => expect(open).toHaveBeenCalled())
await act(async () => resolveOpen({ ok: true, sessionId: 's3', label: 'lambda' }))
await user.click(screen.getByRole('button', { name: 'Close and end session' }))
expect(close).toHaveBeenCalledWith({ sessionId: 's3' })
})
})
@@ -0,0 +1,150 @@
import { useState } from 'react'
import { Minus, SquareTerminal, X } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
minimizePlainSshPanel,
removePlainSshPanel,
showPlainSshPanel,
usePlainSshPanels,
type PlainSshPanel
} from '@/ssh/plain-ssh-terminal-panels'
import { PlainSshTerminalView, type PlainSshTerminalPhase } from './PlainSshTerminalView'
export function plainSshUnmanagedNotice(): string {
return translate(
'auto.components.plainSshTerminal.unmanaged',
'Plain SSH — unmanaged. Not an Orca workspace: agents and tabs here aren’t tracked, and this terminal isn’t restored. Closing it ends the SSH session.'
)
}
function phaseText(phase: PlainSshTerminalPhase): string | null {
switch (phase.kind) {
case 'opening':
case 'open':
return null
case 'failed':
return phase.message
case 'ended':
return phase.signal
? translate(
'auto.components.plainSshTerminal.endedBySignal',
'ssh was stopped (signal {{signal}}). Close this window when you’re done reading.',
{ signal: phase.signal }
)
: translate(
'auto.components.plainSshTerminal.ended',
'ssh exited with code {{code}}. Close this window when you’re done reading.',
{ code: phase.exitCode }
)
}
}
function HeaderIconButton({
label,
onClick,
children
}: {
label: string
onClick: () => void
children: React.ReactNode
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" size="icon-xs" variant="ghost" aria-label={label} onClick={onClick}>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
/** A non-modal window over Orca; minimized windows wait in a strip at the bottom right. */
function PlainSshTerminalCard({ panel }: { panel: PlainSshPanel }): React.JSX.Element {
const [phase, setPhase] = useState<PlainSshTerminalPhase>({ kind: 'opening' })
const message = phaseText(phase)
return (
<section
aria-label={translate('auto.components.plainSshTerminal.title', 'Plain SSH · {{host}}', {
host: panel.label
})}
data-plain-ssh-terminal={panel.targetId}
// Why hidden, not unmounted: minimizing must keep the SSH session and its scrollback.
className={cn(
'pointer-events-auto flex h-[26rem] w-[min(44rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-floating',
panel.minimized && 'hidden'
)}
>
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<SquareTerminal className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
{translate('auto.components.plainSshTerminal.title', 'Plain SSH · {{host}}', {
host: panel.label
})}
</div>
<HeaderIconButton
label={translate('auto.components.plainSshTerminal.minimize', 'Minimize')}
onClick={() => minimizePlainSshPanel(panel.targetId)}
>
<Minus />
</HeaderIconButton>
<HeaderIconButton
label={translate('auto.components.plainSshTerminal.close', 'Close and end session')}
onClick={() => removePlainSshPanel(panel.targetId)}
>
<X />
</HeaderIconButton>
</header>
<p className="border-b border-border bg-muted px-3 py-1.5 text-xs text-status-warning">
{plainSshUnmanagedNotice()}
</p>
{message ? (
<p className="px-3 py-1.5 text-xs text-destructive [overflow-wrap:anywhere]">{message}</p>
) : null}
<div className="min-h-0 flex-1">
<PlainSshTerminalView
targetId={panel.targetId}
hidden={panel.minimized}
onPhase={setPhase}
/>
</div>
</section>
)
}
export function PlainSshTerminalPanels(): React.JSX.Element | null {
const panels = usePlainSshPanels()
if (panels.length === 0) {
return null
}
const minimized = panels.filter((panel) => panel.minimized)
return (
<div className="pointer-events-none fixed right-4 bottom-4 z-50 flex flex-col items-end gap-2">
{panels.map((panel) => (
<PlainSshTerminalCard key={panel.targetId} panel={panel} />
))}
{minimized.length > 0 ? (
<div className="pointer-events-auto flex flex-wrap justify-end gap-2">
{minimized.map((panel) => (
<Button
key={panel.targetId}
type="button"
size="xs"
variant="outline"
onClick={() => showPlainSshPanel(panel.targetId, panel.label)}
>
<SquareTerminal />
{translate('auto.components.plainSshTerminal.title', 'Plain SSH · {{host}}', {
host: panel.label
})}
</Button>
))}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,318 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { FitAddon } from '@xterm/addon-fit'
import type { ITheme, Terminal } from '@xterm/xterm'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type {
PlainSshTerminalEvent,
PlainSshTerminalOpenResult
} from '../../../../shared/plain-ssh-terminal'
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
import { syncPreviewTerminalLigatures } from '@/components/dashboard-popout/preview-terminal-ligatures'
import {
buildPreviewAppearanceOptions,
buildPreviewTerminalOptions
} from '@/components/dashboard-popout/preview-terminal-options'
import { openStandaloneTerminal } from '@/components/standalone-terminal/open-standalone-terminal'
import { removePlainSshPanel } from '@/ssh/plain-ssh-terminal-panels'
export type PlainSshTerminalPhase =
| { kind: 'opening' }
| { kind: 'open' }
| { kind: 'failed'; message: string }
| { kind: 'ended'; exitCode: number; signal?: number }
/** Output held while the open reply is in flight; a login banner or prompt is far smaller. */
const EARLY_DATA_CHAR_LIMIT = 256 * 1024
const SCROLLBACK_ROWS = 5_000
export function plainSshOpenFailureText(
result: Extract<PlainSshTerminalOpenResult, { ok: false }>
): string {
switch (result.reason) {
case 'unknown-target':
return translate(
'auto.components.plainSshTerminal.unknownTarget',
'This SSH host is no longer saved in Orca.'
)
case 'not-blocked':
return translate(
'auto.components.plainSshTerminal.notBlocked',
'Orca can reach this host again, so use its terminals instead. Plain SSH is only for while Orca can’t set up on the host.'
)
case 'already-open':
return translate(
'auto.components.plainSshTerminal.alreadyOpen',
'Another Orca window already has a plain SSH terminal open to this host.'
)
case 'cancelled':
return translate(
'auto.components.plainSshTerminal.cancelled',
'This plain SSH terminal was closed before it started.'
)
case 'ssh-missing':
case 'spawn-failed':
return result.message
}
}
function resolveThemeColors(
settings: GlobalSettings | null,
systemPrefersDark: boolean
): { theme: ITheme | null; mode: 'dark' | 'light' } {
if (!settings) {
return { theme: null, mode: 'dark' }
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
return {
theme: composeActiveTerminalTheme(
appearance.theme ?? getBuiltinTheme(appearance.themeName),
settings
),
mode: appearance.mode
}
}
/** One plain SSH session on Orca's standalone terminal; it starts on mount and hangs up on unmount. */
export function PlainSshTerminalView({
targetId,
hidden,
onPhase
}: {
targetId: string
hidden: boolean
onPhase: (phase: PlainSshTerminalPhase) => void
}): React.JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const terminalRef = useRef<Terminal | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const settings = useAppStore((s) => s.settings)
const systemPrefersDark = useSystemPrefersDark()
const macOptionAsAlt = useEffectiveMacOptionAsAlt(settings?.terminalMacOptionAsAlt)
// Why refs: keys and appearance read live values; a remount would hang up the SSH session.
const settingsRef = useRef(settings)
const systemPrefersDarkRef = useRef(systemPrefersDark)
const macOptionAsAltRef = useRef(macOptionAsAlt)
const onPhaseRef = useRef(onPhase)
useLayoutEffect(() => {
settingsRef.current = settings
systemPrefersDarkRef.current = systemPrefersDark
macOptionAsAltRef.current = macOptionAsAlt
onPhaseRef.current = onPhase
}, [settings, systemPrefersDark, macOptionAsAlt, onPhase])
useEffect(() => {
const container = containerRef.current
const api = window.api.plainSsh
if (!container || !api) {
return
}
const mountSettings = settingsRef.current
const { theme, mode } = resolveThemeColors(mountSettings, systemPrefersDarkRef.current)
const kittyKeyboardModes = new TerminalKittyKeyboardModeTracker({ kittyKeyboard: true })
let sessionId: string | null = null
const standalone = openStandaloneTerminal({
container,
options: buildPreviewTerminalOptions({
settings: mountSettings,
terminalInput: null,
macOptionIsMeta: macOptionAsAltRef.current === 'true',
theme,
themeMode: mode,
cols: 80,
rows: 24,
scrollback: SCROLLBACK_ROWS
}),
kittyKeyboardModes,
sessionKey: `plain-ssh:${targetId}`,
sendInput: (data) => {
if (!sessionId) {
return false
}
api.write({ sessionId, data })
return true
},
// Why: this xterm is the SSH session's only emulator, so remote programs wait on its replies.
answersQueries: true,
getSettings: () => settingsRef.current,
getMacOptionAsAlt: () => macOptionAsAltRef.current,
getTerminalInput: () => null
})
if (!standalone) {
onPhaseRef.current({
kind: 'failed',
message: translate(
'auto.components.plainSshTerminal.terminalUnavailable',
'Orca couldn’t open a terminal here.'
)
})
return
}
const { terminal } = standalone
const fit = new FitAddon()
terminal.loadAddon(fit)
terminalRef.current = terminal
fitRef.current = fit
fit.fit()
let phase: 'opening' | 'open' | 'done' = 'opening'
let disposed = false
const early: PlainSshTerminalEvent[] = []
let earlyDataChars = 0
// Why: output dropped past the early cap was never rendered, but main still counts it unacked.
const droppedEarlyChars = new Map<string, number>()
let unsubscribe: () => void = () => {}
const finish = (next: PlainSshTerminalPhase | null): void => {
phase = 'done'
sessionId = null
early.length = 0
earlyDataChars = 0
droppedEarlyChars.clear()
unsubscribe()
if (next) {
onPhaseRef.current(next)
}
}
const handle = (event: PlainSshTerminalEvent, id: string): void => {
if (event.kind === 'data') {
standalone.write(event.data, {
live: true,
onDone: () => {
if (!disposed) {
api.ack({ sessionId: id, chars: event.data.length })
}
}
})
return
}
// A clean logout closes the window; anything else keeps its output, with no shell after it.
const clean = event.exitCode === 0 && !event.signal
finish(clean ? null : { kind: 'ended', exitCode: event.exitCode, signal: event.signal })
if (clean) {
removePlainSshPanel(targetId)
}
}
unsubscribe = api.onEvent((event) => {
if (phase === 'opening') {
// Exits are always kept: a session that ended before its id arrived must still end here.
if (event.kind === 'exit') {
early.push(event)
} else if (
// Why: once a session drops output, later chunks would leave a hole mid escape sequence.
!droppedEarlyChars.has(event.sessionId) &&
earlyDataChars + event.data.length <= EARLY_DATA_CHAR_LIMIT
) {
earlyDataChars += event.data.length
early.push(event)
} else {
droppedEarlyChars.set(
event.sessionId,
(droppedEarlyChars.get(event.sessionId) ?? 0) + event.data.length
)
}
} else if (phase === 'open' && sessionId && event.sessionId === sessionId) {
handle(event, sessionId)
}
})
void api
.open({ targetId, cols: terminal.cols, rows: terminal.rows })
.then((result) => {
if (disposed) {
if (result.ok) {
api.close({ sessionId: result.sessionId })
}
return
}
if (!result.ok) {
finish({ kind: 'failed', message: plainSshOpenFailureText(result) })
return
}
const id = result.sessionId
sessionId = id
phase = 'open'
onPhaseRef.current({ kind: 'open' })
const dropped = droppedEarlyChars.get(id)
droppedEarlyChars.clear()
if (dropped) {
api.ack({ sessionId: id, chars: dropped })
}
earlyDataChars = 0
for (const event of early.splice(0)) {
if (phase === 'open' && event.sessionId === id) {
handle(event, id)
}
}
terminal.focus()
})
.catch((error: unknown) => {
if (!disposed) {
finish({
kind: 'failed',
message: error instanceof Error ? error.message : String(error)
})
}
})
const resizeListener = terminal.onResize(({ cols, rows }) => {
if (sessionId) {
api.resize({ sessionId, cols, rows })
}
})
const observer = new ResizeObserver(() => {
if (container.offsetWidth > 0 && container.offsetHeight > 0) {
fit.fit()
}
})
observer.observe(container)
return () => {
disposed = true
observer.disconnect()
resizeListener.dispose()
unsubscribe()
if (sessionId) {
api.close({ sessionId })
}
standalone.dispose()
terminalRef.current = null
fitRef.current = null
}
}, [targetId])
// Why: appearance and Option-as-Alt changes must land live; reopening would hang up ssh.
useEffect(() => {
const terminal = terminalRef.current
if (!terminal) {
return
}
const { theme, mode } = resolveThemeColors(settings, systemPrefersDark)
Object.assign(terminal.options, {
...buildPreviewAppearanceOptions(settings, macOptionAsAlt === 'true'),
theme: theme ?? undefined,
minimumContrastRatio: resolveTerminalMinimumContrastRatio(
theme?.background,
mode,
settings?.terminalMinimumContrastRatio
)
})
syncPreviewTerminalLigatures(terminal, settings)
// Why the guard: a minimized panel measures 0x0, and fitting would shrink the remote pty to 2x1.
const container = containerRef.current
if (!hidden && container && container.offsetWidth > 0 && container.offsetHeight > 0) {
fitRef.current?.fit()
}
}, [settings, systemPrefersDark, macOptionAsAlt, hidden])
useEffect(() => {
if (!hidden) {
fitRef.current?.fit()
terminalRef.current?.focus()
}
}, [hidden])
return <div ref={containerRef} className="size-full min-h-0 overflow-hidden px-2 py-1" />
}
@@ -10,6 +10,7 @@ import {
} from './SshHostKeepServerVersionDialog'
import { SshHostDeltaMoveDialog } from './SshHostDeltaMoveDialog'
import { SshHostNodeMirrorAction } from './SshHostNodeMirrorAction'
import { OpenPlainSshTerminalButton } from '../plain-ssh-terminal/OpenPlainSshTerminalButton'
import { canMoveSshHostAnyway, SshHostMoveAnywayDialog } from './SshHostMoveAnywayAction'
import { canRestartSshHostServer, SshHostRestartServerDialog } from './SshHostRestartServerAction'
import { getManagedOrcadApi } from './use-managed-server-catalog'
@@ -48,6 +49,11 @@ export function SshTargetServerStatusLine({
{line.hint ? <p className="px-1 text-xs text-muted-foreground">{line.hint}</p> : null}
{/* Why inline, not in the menu: it is the way out of this failure. */}
<SshHostNodeMirrorAction targetId={target.id} status={state?.managedServer} />
<OpenPlainSshTerminalButton
targetId={target.id}
targetLabel={target.label}
status={state?.managedServer}
/>
</div>
)
}
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import { openStandaloneTerminal } from './open-standalone-terminal'
let emitData: (data: string) => void = () => {}
let signalUserInput: () => void = () => {}
vi.mock('@xterm/xterm', () => ({
Terminal: class {
open(): void {}
dispose(): void {}
write(_data: string, done?: () => void): void {
done?.()
}
onData(listener: (data: string) => void): { dispose: () => void } {
emitData = listener
return { dispose: () => {} }
}
}
}))
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
vi.mock('@/components/terminal-pane/terminal-user-input-signal', () => ({
subscribeToTerminalUserInput: (_terminal: unknown, listener: () => void) => {
signalUserInput = listener
return { dispose: () => {} }
}
}))
vi.mock('@/components/terminal-pane/terminal-native-copy-gutter', () => ({
installTerminalNativeCopyGutterTrim: () => ({ dispose: () => {} })
}))
vi.mock('../dashboard-popout/preview-terminal-compatibility', () => ({
installPreviewTerminalCompatibility: () => () => {}
}))
vi.mock('../dashboard-popout/preview-terminal-ime-bridge', () => ({
installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: () => {} })
}))
vi.mock('../dashboard-popout/preview-terminal-key-handler', () => ({
installPreviewTerminalKeyHandler: () => () => {}
}))
vi.mock('../dashboard-popout/preview-terminal-app-menu-clipboard', () => ({
installPreviewTerminalAppMenuClipboard: () => () => {}
}))
vi.mock('../dashboard-popout/preview-terminal-right-click-paste', () => ({
installPreviewTerminalRightClickPaste: () => () => {}
}))
function open(answersQueries: boolean): ReturnType<typeof vi.fn> {
const sendInput = vi.fn(() => true)
openStandaloneTerminal({
container: document.createElement('div'),
options: {},
kittyKeyboardModes: new TerminalKittyKeyboardModeTracker(),
sessionKey: 's',
sendInput,
answersQueries,
getSettings: () => null,
getMacOptionAsAlt: () => 'false',
getTerminalInput: () => null
})
return sendInput
}
const CURSOR_POSITION_REPLY = '\u001b[5;1R'
describe('standalone terminal input', () => {
it('forwards the user’s typing either way', () => {
const sendInput = open(false)
signalUserInput()
emitData('ls\r')
expect(sendInput).toHaveBeenCalledWith('ls\r')
})
it('leaves query replies to the real pane when it only mirrors one', () => {
const sendInput = open(false)
emitData(CURSOR_POSITION_REPLY)
expect(sendInput).not.toHaveBeenCalled()
})
it('answers queries when it is the session’s only emulator', () => {
const sendInput = open(true)
emitData(CURSOR_POSITION_REPLY)
expect(sendInput).toHaveBeenCalledWith(CURSOR_POSITION_REPLY)
})
})
@@ -0,0 +1,161 @@
/**
* One xterm outside the pane system, with everything a typed-into terminal needs: Orca's Unicode
* widths, links and IME handling, the pane's copy/paste and shortcut policy, kitty keyboard flags,
* and input that never forwards xterm's own replies to replayed output. Callers own the transport
* (where output comes from, where input goes, and the backpressure ack in `write`'s callback).
* Used by the agent dashboard preview and the plain SSH terminal.
*/
import { Terminal, type ITerminalInitOnlyOptions, type ITerminalOptions } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useAppStore } from '@/store'
import type { MacOptionAsAlt } from '@/components/terminal-pane/terminal-shortcut-policy'
import { subscribeToTerminalUserInput } from '@/components/terminal-pane/terminal-user-input-signal'
import { installTerminalNativeCopyGutterTrim } from '@/components/terminal-pane/terminal-native-copy-gutter'
import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { installPreviewTerminalCompatibility } from '../dashboard-popout/preview-terminal-compatibility'
import { createPreviewClipboardPaster } from '../dashboard-popout/preview-terminal-paste'
import { installPreviewImeBridge } from '../dashboard-popout/preview-terminal-ime-bridge'
import { installPreviewTerminalKeyHandler } from '../dashboard-popout/preview-terminal-key-handler'
import { installPreviewTerminalAppMenuClipboard } from '../dashboard-popout/preview-terminal-app-menu-clipboard'
import { installPreviewTerminalRightClickPaste } from '../dashboard-popout/preview-terminal-right-click-paste'
export type StandaloneTerminalArgs = {
container: HTMLElement
options: ITerminalOptions & ITerminalInitOnlyOptions
/** Owned by the caller so it can outlive a terminal the caller recreates. */
kittyKeyboardModes: TerminalKittyKeyboardModeTracker
/** Names the session for paste planning; a remote-runtime pty id routes as remote. */
sessionKey: string
/** The user's keystrokes and pastes, plus xterm's replies to output when `answersQueries`. */
sendInput: (data: string) => boolean | Promise<boolean>
/**
* True when this xterm is the session's only emulator, so programs wait on its replies to
* cursor, device and keyboard queries. A mirror (the preview) leaves them to the real pane.
*/
answersQueries: boolean
getSettings: () => GlobalSettings | null
getMacOptionAsAlt: () => MacOptionAsAlt
getTerminalInput: () => DashboardCardTerminalInput | null
}
export type StandaloneTerminal = {
terminal: Terminal
/**
* `live`: new output, scanned for kitty pushes as they happen; otherwise replayed bytes, applied
* idempotently. `onDone` runs once xterm has parsed the chunk, which is when to ack backpressure.
*/
write: (chunk: string, options: { live: boolean; onDone?: () => void }) => void
dispose: () => void
}
/** Null when xterm can't open in the container. */
export function openStandaloneTerminal(args: StandaloneTerminalArgs): StandaloneTerminal | null {
const { container, kittyKeyboardModes } = args
const terminal = new Terminal(args.options)
try {
terminal.open(container)
} catch {
terminal.dispose()
return null
}
let disposed = false
let replayDepth = 0
const disposeCompatibility = installPreviewTerminalCompatibility(terminal, {
getSettings: args.getSettings
})
const disposeNativeCopyGutterTrim = installTerminalNativeCopyGutterTrim(terminal).dispose
let pendingUserInputSignals = 0
const userInputDisposable = subscribeToTerminalUserInput(terminal, () => {
pendingUserInputSignals = Math.min(32, pendingUserInputSignals + 1)
})
terminal.onData((data) => {
const signaledUserInput = pendingUserInputSignals > 0
if (signaledUserInput) {
pendingUserInputSignals--
}
// Why: core's signal distinguishes real input from parser replies, so typing survives live replay without forwarding synthetic CPR/DA bytes.
if (!args.answersQueries && (userInputDisposable ? !signaledUserInput : replayDepth > 0)) {
return
}
void args.sendInput(data)
})
// Why a live getter: kitty state can change between keydown and commit.
const imeBridge = installPreviewImeBridge(terminal, {
getKittyKeyboardFlags: () => kittyKeyboardModes.flags
})
const pasteClipboardText = createPreviewClipboardPaster({
ptyId: args.sessionKey,
writeInput: args.sendInput,
container,
getTerminal: () => (disposed ? null : terminal),
getTerminalInput: args.getTerminalInput,
isDisposed: () => disposed
})
const disposeKeyHandler = installPreviewTerminalKeyHandler({
terminal,
claimImeKeyEvent: (event) => imeBridge?.claimKeyEvent(event) ?? false,
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source),
// Why: route through terminal.input so the chord's bytes carry core's user-input signal, like typed keys.
sendInput: (data) => terminal.input(data),
getShortcutContext: () => ({
clientPlatform: getShortcutPlatform(),
macOptionAsAlt: args.getMacOptionAsAlt(),
keybindings: useAppStore.getState().keybindings,
terminalInput: args.getTerminalInput(),
getKittyKeyboardFlags: () => kittyKeyboardModes.flags,
terminalShortcutPolicy: args.getSettings()?.terminalShortcutPolicy
})
})
const disposeAppMenuClipboard = installPreviewTerminalAppMenuClipboard({
container,
getTerminal: () => (disposed ? null : terminal),
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source)
})
const disposeRightClickPaste = installPreviewTerminalRightClickPaste({
container,
getTerminal: () => (disposed ? null : terminal),
// Same default as the pane: Windows users expect terminal-style right-click.
isRightClickToPasteEnabled: () =>
args.getSettings()?.terminalRightClickToPaste ?? isWindowsUserAgent(),
pasteClipboardText: (activeElement, source) => void pasteClipboardText(activeElement, source)
})
return {
terminal,
write: (chunk, { live, onDone }) => {
// Why: a redelivered snapshot repeats the TUI's one-time kitty push, so
// replayed bytes must apply as idempotent sets (see the tracker's docs).
if (live) {
kittyKeyboardModes.scan(chunk)
} else {
kittyKeyboardModes.scanReplay(chunk)
}
replayDepth++
terminal.write(chunk, () => {
replayDepth--
onDone?.()
})
},
dispose: () => {
if (disposed) {
return
}
disposed = true
userInputDisposable?.dispose()
imeBridge?.dispose()
disposeCompatibility()
disposeKeyHandler()
disposeNativeCopyGutterTrim()
disposeAppMenuClipboard()
disposeRightClickPaste()
terminal.dispose()
}
}
}
@@ -4,6 +4,7 @@ import { toastSshConnectFailure } from '@/ssh/ssh-connect-failure-toast'
import { Button } from '@/components/ui/button'
import { SshHostNodeMirrorAction } from '@/components/settings/SshHostNodeMirrorAction'
import { SshHostMoveAnywayButton } from '@/components/settings/SshHostMoveAnywayAction'
import { OpenPlainSshTerminalButton } from '@/components/plain-ssh-terminal/OpenPlainSshTerminalButton'
import { useAppStore } from '@/store'
import type { SshConnectionStatus } from '../../../../shared/ssh-types'
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
@@ -220,6 +221,12 @@ export function TerminalSshReconnectOverlay({
disabled={isConnecting}
/>
)}
<OpenPlainSshTerminalButton
targetId={targetId}
targetLabel={targetLabel}
status={managedServer}
ownerEnvironmentId={sshOwnerEnvironmentId}
/>
</div>
) : null}
</div>
+14
View File
@@ -18585,6 +18585,20 @@
"copyFailed": "Could not copy the link.",
"signingIn": "Signing in…",
"cancel": "Cancel"
},
"plainSshTerminal": {
"open": "Open plain SSH terminal",
"unmanaged": "Plain SSH — unmanaged. Not an Orca workspace: agents and tabs here aren’t tracked, and this terminal isn’t restored. Closing it ends the SSH session.",
"ended": "ssh exited with code {{code}}. Close this window when you’re done reading.",
"title": "Plain SSH · {{host}}",
"minimize": "Minimize",
"close": "Close and end session",
"unknownTarget": "This SSH host is no longer saved in Orca.",
"notBlocked": "Orca can reach this host again, so use its terminals instead. Plain SSH is only for while Orca can’t set up on the host.",
"alreadyOpen": "Another Orca window already has a plain SSH terminal open to this host.",
"endedBySignal": "ssh was stopped (signal {{signal}}). Close this window when you’re done reading.",
"cancelled": "This plain SSH terminal was closed before it started.",
"terminalUnavailable": "Orca couldn’t open a terminal here."
}
},
"i18n": {
@@ -0,0 +1,45 @@
/**
* Which plain SSH terminal windows are open, one per host. Deliberately outside the app store and
* the workspace session: these windows are never persisted, restored or synced.
*/
import { useSyncExternalStore } from 'react'
export type PlainSshPanel = { targetId: string; label: string; minimized: boolean }
let panels: readonly PlainSshPanel[] = []
const listeners = new Set<() => void>()
function publish(next: readonly PlainSshPanel[]): void {
panels = next
for (const listener of listeners) {
listener()
}
}
/** Shows the host's window, opening it if needed; only one is expanded at a time. */
export function showPlainSshPanel(targetId: string, label: string): void {
const rest = panels
.filter((panel) => panel.targetId !== targetId)
.map((panel) => (panel.minimized ? panel : { ...panel, minimized: true }))
const current = panels.find((panel) => panel.targetId === targetId)
publish([...rest, { targetId, label: current?.label ?? label, minimized: false }])
}
export function minimizePlainSshPanel(targetId: string): void {
publish(
panels.map((panel) => (panel.targetId === targetId ? { ...panel, minimized: true } : panel))
)
}
export function removePlainSshPanel(targetId: string): void {
publish(panels.filter((panel) => panel.targetId !== targetId))
}
function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function usePlainSshPanels(): readonly PlainSshPanel[] {
return useSyncExternalStore(subscribe, () => panels)
}
+23
View File
@@ -0,0 +1,23 @@
/** The plain SSH terminal's IPC contract: open by saved target id, then talk by session id. */
export const PLAIN_SSH_TERMINAL_CHANNELS = {
open: 'plainSsh:open',
write: 'plainSsh:write',
resize: 'plainSsh:resize',
close: 'plainSsh:close',
/** Output the window has rendered, in UTF-16 units of `data`; ssh is paused while too much is unrendered. */
ack: 'plainSsh:ack',
event: 'plainSsh:event'
} as const
export type PlainSshTerminalOpenResult =
| { ok: true; sessionId: string; label: string }
/** `already-open`: another window holds this host's one session. */
| { ok: false; reason: 'unknown-target' | 'not-blocked' | 'already-open' }
/** A newer open from the same window, a reload or quit took this one's place. */
| { ok: false; reason: 'cancelled' }
| { ok: false; reason: 'ssh-missing' | 'spawn-failed'; message: string }
export type PlainSshTerminalEvent =
| { kind: 'data'; sessionId: string; data: string }
/** `signal`: set when ssh was killed, so a zero `exitCode` is not a clean logout. */
| { kind: 'exit'; sessionId: string; exitCode: number; signal?: number }
+9
View File
@@ -0,0 +1,9 @@
import type { SshManagedServerStatus } from './ssh-types'
/**
* A host its Orca server doesn't serve, so this desktop can't open Orca terminals there. A bridged
* host stays connected for its old terminals, so it isn't blocked.
*/
export function isSshHostMoveBlocked(status: SshManagedServerStatus | undefined): boolean {
return status?.kind === 'relay' && !status.bridged
}