mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Add Windows remote terminal support (#642)
This commit is contained in:
@@ -14,6 +14,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.14
|
||||||
- run: bun install
|
- run: bun install
|
||||||
- run: bun run build:single-exe:all
|
- run: bun run build:single-exe:all
|
||||||
- name: Package release artifacts
|
- name: Package release artifacts
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.14
|
||||||
- run: bun install
|
- run: bun install
|
||||||
- run: bun typecheck
|
- run: bun typecheck
|
||||||
- name: Create integration test env
|
- name: Create integration test env
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.14
|
||||||
- run: bun install
|
- run: bun install
|
||||||
- name: Build web app
|
- name: Build web app
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": ">=25",
|
"@types/node": ">=25",
|
||||||
"bun-types": "^1.3.5",
|
"bun-types": "^1.3.14",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"@types/node": ">=25",
|
"@types/node": ">=25",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"bun-types": "^1.3.5",
|
"bun-types": "^1.3.14",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1064,6 +1064,8 @@
|
|||||||
|
|
||||||
"@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.18.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-y8hzgUN/f+4ibvnfGEfAm+Ck2UzR2ejW6r/9gJShKRAR/yf7TVsa0rXtLjvfET2qOUgQQRvT1QohFTdbNVQg5g=="],
|
"@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.18.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-y8hzgUN/f+4ibvnfGEfAm+Ck2UzR2ejW6r/9gJShKRAR/yf7TVsa0rXtLjvfET2qOUgQQRvT1QohFTdbNVQg5g=="],
|
||||||
|
|
||||||
|
"@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.18.1", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-PqFUcolcJwdSQJp3jqMv82SQ5VKfKSdZr3GjFzy7C9qZnbzycXKmQttXAaLyD98PGSNsV3rj84VB5uN9KHa09w=="],
|
||||||
|
|
||||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||||
|
|
||||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||||
@@ -1386,7 +1388,7 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -71,7 +71,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": ">=25",
|
"@types/node": ">=25",
|
||||||
"bun-types": "^1.3.5",
|
"bun-types": "^1.3.14",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
@@ -81,5 +81,5 @@
|
|||||||
"parse-path": "7.0.3",
|
"parse-path": "7.0.3",
|
||||||
"@types/parse-path": "7.0.3"
|
"@types/parse-path": "7.0.3"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.3.5"
|
"packageManager": "bun@1.3.14"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,4 +25,16 @@ describe('buildSessionMetadata', () => {
|
|||||||
|
|
||||||
expect(metadata.host).toBe('custom-session-host')
|
expect(metadata.host).toBe('custom-session-host')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('advertises remote terminal capability in session metadata', () => {
|
||||||
|
const metadata = buildSessionMetadata({
|
||||||
|
flavor: 'codex',
|
||||||
|
startedBy: 'terminal',
|
||||||
|
workingDirectory: '/tmp/project',
|
||||||
|
machineId: 'machine-1',
|
||||||
|
now: 123
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(metadata.capabilities?.terminal).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export function buildSessionMetadata(options: {
|
|||||||
lifecycleState: 'running',
|
lifecycleState: 'running',
|
||||||
lifecycleStateSince: now,
|
lifecycleStateSince: now,
|
||||||
flavor: options.flavor,
|
flavor: options.flavor,
|
||||||
|
capabilities: {
|
||||||
|
terminal: true
|
||||||
|
},
|
||||||
worktree: worktreeInfo ?? undefined,
|
worktree: worktreeInfo ?? undefined,
|
||||||
...options.metadataOverrides
|
...options.metadataOverrides
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
TerminalManager,
|
||||||
|
normalizeTerminalInputForHost,
|
||||||
|
resolveShellCommand
|
||||||
|
} from './TerminalManager'
|
||||||
|
|
||||||
|
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||||
|
const originalTerminalShell = process.env.HAPI_TERMINAL_SHELL
|
||||||
|
const originalComSpec = process.env.ComSpec
|
||||||
|
const globalWithBun = globalThis as unknown as {
|
||||||
|
Bun?: {
|
||||||
|
spawn?: unknown
|
||||||
|
which?: unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const originalBun = globalWithBun.Bun
|
||||||
|
|
||||||
|
function setPlatform(value: NodeJS.Platform): void {
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value,
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TerminalManager Windows support', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!originalPlatformDescriptor?.configurable) {
|
||||||
|
throw new Error('process.platform is not configurable in this runtime')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
|
||||||
|
process.env.HAPI_TERMINAL_SHELL = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||||
|
setPlatform('win32')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (originalPlatformDescriptor) {
|
||||||
|
Object.defineProperty(process, 'platform', originalPlatformDescriptor)
|
||||||
|
}
|
||||||
|
if (originalBun === undefined) {
|
||||||
|
delete globalWithBun.Bun
|
||||||
|
} else {
|
||||||
|
globalWithBun.Bun = originalBun
|
||||||
|
}
|
||||||
|
if (originalTerminalShell === undefined) {
|
||||||
|
delete process.env.HAPI_TERMINAL_SHELL
|
||||||
|
} else {
|
||||||
|
process.env.HAPI_TERMINAL_SHELL = originalTerminalShell
|
||||||
|
}
|
||||||
|
if (originalComSpec === undefined) {
|
||||||
|
delete process.env.ComSpec
|
||||||
|
} else {
|
||||||
|
process.env.ComSpec = originalComSpec
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves an explicit Windows terminal shell command', () => {
|
||||||
|
expect(resolveShellCommand()).toEqual(['C:\\Program Files\\PowerShell\\7\\pwsh.exe'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes lone line feeds to carriage returns for Windows terminal input', () => {
|
||||||
|
expect(normalizeTerminalInputForHost('echo one\nsecond\r\nthird\n')).toBe('echo one\rsecond\r\nthird\r')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a Windows PTY instead of rejecting the request', () => {
|
||||||
|
const terminal = {
|
||||||
|
write: vi.fn(),
|
||||||
|
resize: vi.fn(),
|
||||||
|
close: vi.fn()
|
||||||
|
} as unknown as Bun.Terminal
|
||||||
|
|
||||||
|
const proc = {
|
||||||
|
terminal,
|
||||||
|
killed: false,
|
||||||
|
exitCode: null,
|
||||||
|
signalCode: null,
|
||||||
|
kill: vi.fn()
|
||||||
|
} as unknown as Bun.Subprocess
|
||||||
|
|
||||||
|
const spawnMock = vi.fn(() => proc)
|
||||||
|
globalWithBun.Bun = {
|
||||||
|
spawn: spawnMock
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready: unknown[] = []
|
||||||
|
const errors: unknown[] = []
|
||||||
|
const manager = new TerminalManager({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
getSessionPath: () => 'C:\\workspace\\project',
|
||||||
|
onReady: (payload) => ready.push(payload),
|
||||||
|
onOutput: () => {},
|
||||||
|
onExit: () => {},
|
||||||
|
onError: (payload) => errors.push(payload),
|
||||||
|
idleTimeoutMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
manager.create('terminal-1', 120, 30)
|
||||||
|
manager.write('terminal-1', 'echo one\n')
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
expect(ready).toEqual([{ sessionId: 'session-1', terminalId: 'terminal-1' }])
|
||||||
|
expect(spawnMock).toHaveBeenCalledWith(
|
||||||
|
['C:\\Program Files\\PowerShell\\7\\pwsh.exe'],
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: 'C:\\workspace\\project',
|
||||||
|
terminal: expect.objectContaining({
|
||||||
|
cols: 120,
|
||||||
|
rows: 30
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(terminal.write).toHaveBeenCalledWith('echo one\r')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -38,6 +38,10 @@ const SENSITIVE_ENV_KEYS = new Set([
|
|||||||
'GOOGLE_API_KEY'
|
'GOOGLE_API_KEY'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
function getOptionalBun(): typeof Bun | null {
|
||||||
|
return typeof Bun === 'undefined' ? null : Bun
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEnvNumber(name: string, fallback: number): number {
|
function resolveEnvNumber(name: string, fallback: number): number {
|
||||||
const raw = process.env[name]
|
const raw = process.env[name]
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -47,14 +51,56 @@ function resolveEnvNumber(name: string, fallback: number): number {
|
|||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveShell(): string {
|
function resolveWindowsShellCommand(): string[] {
|
||||||
|
const configuredShell = process.env.HAPI_TERMINAL_SHELL?.trim()
|
||||||
|
if (configuredShell) {
|
||||||
|
return [configuredShell]
|
||||||
|
}
|
||||||
|
|
||||||
|
const bun = getOptionalBun()
|
||||||
|
const candidates = ['pwsh.exe', 'powershell.exe']
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const resolved = bun?.which?.(candidate)
|
||||||
|
if (resolved) {
|
||||||
|
return [resolved, '-NoLogo']
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore PATH lookup failures and try the next fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [process.env.ComSpec || 'cmd.exe']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveShellCommand(): string[] {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return resolveWindowsShellCommand()
|
||||||
|
}
|
||||||
if (process.env.SHELL) {
|
if (process.env.SHELL) {
|
||||||
return process.env.SHELL
|
return [process.env.SHELL]
|
||||||
}
|
}
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
return '/bin/zsh'
|
return ['/bin/zsh']
|
||||||
}
|
}
|
||||||
return '/bin/bash'
|
return ['/bin/bash']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTerminalInputForHost(data: string): string {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = ''
|
||||||
|
for (let index = 0; index < data.length; index += 1) {
|
||||||
|
const char = data[index]
|
||||||
|
if (char === '\n' && data[index - 1] !== '\r') {
|
||||||
|
normalized += '\r'
|
||||||
|
} else {
|
||||||
|
normalized += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFilteredEnv(): NodeJS.ProcessEnv {
|
function buildFilteredEnv(): NodeJS.ProcessEnv {
|
||||||
@@ -75,7 +121,7 @@ function buildFilteredEnv(): NodeJS.ProcessEnv {
|
|||||||
env.COLORTERM = 'truecolor'
|
env.COLORTERM = 'truecolor'
|
||||||
}
|
}
|
||||||
if (!env.LANG) {
|
if (!env.LANG) {
|
||||||
env.LANG = process.platform === 'darwin' ? 'en_US.UTF-8' : 'C.UTF-8'
|
env.LANG = process.platform === 'darwin' || process.platform === 'win32' ? 'en_US.UTF-8' : 'C.UTF-8'
|
||||||
}
|
}
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
@@ -105,11 +151,6 @@ export class TerminalManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(terminalId: string, cols: number, rows: number): void {
|
create(terminalId: string, cols: number, rows: number): void {
|
||||||
if (process.platform === 'win32') {
|
|
||||||
this.emitError(terminalId, 'Remote terminal is not supported on Windows yet.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = this.terminals.get(terminalId)
|
const existing = this.terminals.get(terminalId)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.cols = cols
|
existing.cols = cols
|
||||||
@@ -125,17 +166,18 @@ export class TerminalManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof Bun === 'undefined' || typeof Bun.spawn !== 'function') {
|
const bun = getOptionalBun()
|
||||||
|
if (!bun || typeof bun.spawn !== 'function') {
|
||||||
this.emitError(terminalId, 'Terminal is unavailable in this runtime.')
|
this.emitError(terminalId, 'Terminal is unavailable in this runtime.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionPath = this.getSessionPath() ?? getInvokedCwd()
|
const sessionPath = this.getSessionPath() ?? getInvokedCwd()
|
||||||
const shell = resolveShell()
|
const shellCommand = resolveShellCommand()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn([shell], {
|
const proc = bun.spawn(shellCommand, {
|
||||||
cwd: sessionPath,
|
cwd: sessionPath,
|
||||||
env: this.filteredEnv,
|
env: this.filteredEnv,
|
||||||
terminal: {
|
terminal: {
|
||||||
@@ -194,7 +236,12 @@ export class TerminalManager {
|
|||||||
this.onReady({ sessionId: this.sessionId, terminalId })
|
this.onReady({ sessionId: this.sessionId, terminalId })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('[TERMINAL] Failed to spawn terminal', { error })
|
logger.debug('[TERMINAL] Failed to spawn terminal', { error })
|
||||||
this.emitError(terminalId, 'Failed to spawn terminal.')
|
const message = process.platform === 'win32'
|
||||||
|
&& error instanceof Error
|
||||||
|
&& error.message.includes('terminal option is not supported')
|
||||||
|
? 'Remote terminal on Windows requires Bun 1.3.14 or newer.'
|
||||||
|
: 'Failed to spawn terminal.'
|
||||||
|
this.emitError(terminalId, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +251,7 @@ export class TerminalManager {
|
|||||||
this.emitError(terminalId, 'Terminal not found.')
|
this.emitError(terminalId, 'Terminal not found.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runtime.terminal.write(data)
|
runtime.terminal.write(normalizeTerminalInputForHost(data))
|
||||||
this.markActivity(runtime)
|
this.markActivity(runtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -97,9 +97,9 @@ Yes. Open any session and use the chat interface to send messages directly to th
|
|||||||
|
|
||||||
### Can I access a terminal remotely?
|
### Can I access a terminal remotely?
|
||||||
|
|
||||||
Yes, on Linux and macOS hosts. Open a session in the web app and tap the Terminal tab for a remote shell.
|
Yes. Open a session in the web app and tap the Terminal tab for a remote shell.
|
||||||
|
|
||||||
Windows hosts do not support the remote Terminal yet because the Bun PTY API used by HAPI is currently POSIX-only.
|
Linux and macOS hosts use Bun's POSIX PTY support. Windows hosts use Bun's ConPTY support, which requires Bun 1.3.14 or newer.
|
||||||
|
|
||||||
### How do I use voice control?
|
### How do I use voice control?
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
"@types/node": ">=25",
|
"@types/node": ">=25",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
"bun-types": "^1.3.5",
|
"bun-types": "^1.3.14",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ const MetadataSummarySchema = z.object({
|
|||||||
updatedAt: z.number()
|
updatedAt: z.number()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const SessionCapabilitiesSchema = z.object({
|
||||||
|
terminal: z.boolean().optional()
|
||||||
|
})
|
||||||
|
|
||||||
export const WorktreeMetadataSchema = z.object({
|
export const WorktreeMetadataSchema = z.object({
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
branch: z.string(),
|
branch: z.string(),
|
||||||
@@ -46,6 +50,7 @@ export const MetadataSchema = z.object({
|
|||||||
archivedBy: z.string().optional(),
|
archivedBy: z.string().optional(),
|
||||||
archiveReason: z.string().optional(),
|
archiveReason: z.string().optional(),
|
||||||
flavor: z.string().nullish(),
|
flavor: z.string().nullish(),
|
||||||
|
capabilities: SessionCapabilitiesSchema.optional(),
|
||||||
worktree: WorktreeMetadataSchema.optional()
|
worktree: WorktreeMetadataSchema.optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export default {
|
|||||||
'terminal.commandArgs': 'Command args',
|
'terminal.commandArgs': 'Command args',
|
||||||
'terminal.stdout': 'Stdout',
|
'terminal.stdout': 'Stdout',
|
||||||
'terminal.stderr': 'Stderr',
|
'terminal.stderr': 'Stderr',
|
||||||
'terminal.unsupportedWindows': 'Remote terminal is unavailable on Windows hosts.',
|
'terminal.unsupportedWindows': 'Remote terminal is unavailable on this host.',
|
||||||
'terminal.paste.fallbackTitle': 'Paste input',
|
'terminal.paste.fallbackTitle': 'Paste input',
|
||||||
'terminal.paste.fallbackDescription': 'Clipboard read is unavailable. Paste your text below.',
|
'terminal.paste.fallbackDescription': 'Clipboard read is unavailable. Paste your text below.',
|
||||||
'terminal.paste.placeholder': 'Paste terminal input here…',
|
'terminal.paste.placeholder': 'Paste terminal input here…',
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export default {
|
|||||||
'terminal.commandArgs': '命令参数',
|
'terminal.commandArgs': '命令参数',
|
||||||
'terminal.stdout': '标准输出',
|
'terminal.stdout': '标准输出',
|
||||||
'terminal.stderr': '标准错误',
|
'terminal.stderr': '标准错误',
|
||||||
'terminal.unsupportedWindows': 'Windows 主机暂不支持远程终端。',
|
'terminal.unsupportedWindows': '此主机暂不支持远程终端。',
|
||||||
'terminal.paste.fallbackTitle': '粘贴输入',
|
'terminal.paste.fallbackTitle': '粘贴输入',
|
||||||
'terminal.paste.fallbackDescription': '无法读取剪贴板,请在下方粘贴文本。',
|
'terminal.paste.fallbackDescription': '无法读取剪贴板,请在下方粘贴文本。',
|
||||||
'terminal.paste.placeholder': '在此粘贴终端输入…',
|
'terminal.paste.placeholder': '在此粘贴终端输入…',
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export type SessionMetadataSummary = {
|
|||||||
machineId?: string
|
machineId?: string
|
||||||
tools?: string[]
|
tools?: string[]
|
||||||
flavor?: string | null
|
flavor?: string | null
|
||||||
|
capabilities?: {
|
||||||
|
terminal?: boolean
|
||||||
|
}
|
||||||
worktree?: WorktreeMetadata
|
worktree?: WorktreeMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,29 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { isRemoteTerminalSupported, isWindowsHostOs } from './terminalSupport'
|
import { isRemoteTerminalSupported, isWindowsHostOs } from './terminalSupport'
|
||||||
|
|
||||||
describe('terminal support helpers', () => {
|
describe('terminal support helpers', () => {
|
||||||
it('detects Windows session hosts as unsupported', () => {
|
it('does not disable remote terminal only because the session host is Windows', () => {
|
||||||
expect(isWindowsHostOs('win32')).toBe(true)
|
expect(isWindowsHostOs('win32')).toBe(true)
|
||||||
expect(isRemoteTerminalSupported({ os: 'win32', path: '', host: '' })).toBe(false)
|
expect(isRemoteTerminalSupported({ os: 'win32', path: '', host: '' })).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps remote terminal enabled for non-Windows or unknown hosts', () => {
|
it('keeps remote terminal enabled for non-Windows or unknown hosts by default', () => {
|
||||||
expect(isWindowsHostOs('linux')).toBe(false)
|
expect(isWindowsHostOs('linux')).toBe(false)
|
||||||
expect(isRemoteTerminalSupported({ os: 'linux', path: '', host: '' })).toBe(true)
|
expect(isRemoteTerminalSupported({ os: 'linux', path: '', host: '' })).toBe(true)
|
||||||
expect(isRemoteTerminalSupported(null)).toBe(true)
|
expect(isRemoteTerminalSupported(null)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('respects explicit terminal capability metadata when present', () => {
|
||||||
|
expect(isRemoteTerminalSupported({
|
||||||
|
os: 'win32',
|
||||||
|
path: '',
|
||||||
|
host: '',
|
||||||
|
capabilities: { terminal: false }
|
||||||
|
})).toBe(false)
|
||||||
|
expect(isRemoteTerminalSupported({
|
||||||
|
os: 'win32',
|
||||||
|
path: '',
|
||||||
|
host: '',
|
||||||
|
capabilities: { terminal: true }
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export function isWindowsHostOs(os: string | null | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isRemoteTerminalSupported(metadata: SessionMetadataSummary | null | undefined): boolean {
|
export function isRemoteTerminalSupported(metadata: SessionMetadataSummary | null | undefined): boolean {
|
||||||
return !isWindowsHostOs(metadata?.os)
|
return metadata?.capabilities?.terminal ?? true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user