fix: improve windows computer use and desktop rendering

This commit is contained in:
Relakkes Yang 2026-04-20 17:59:42 +08:00
parent 3dfcdbb405
commit 4b513271ee
24 changed files with 708 additions and 121 deletions

View File

@ -323,7 +323,7 @@ dependencies = [
[[package]]
name = "claude-code-desktop"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"serde",
"serde_json",

View File

@ -0,0 +1,27 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { CodeViewer } from './CodeViewer'
vi.mock('react-shiki', () => ({
ShikiHighlighter: ({ children }: { children: string }) => (
<div data-testid="shiki-container">
<code>{children}</code>
</div>
),
}))
describe('CodeViewer', () => {
it('keeps the same inner padding for highlighted code content', async () => {
const { container } = render(
<CodeViewer code={'cd testb\nnpm run dev'} language="bash" />,
)
await waitFor(() => {
expect(screen.getByTestId('shiki-container')).toBeTruthy()
})
const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null
expect(contentWrapper).toBeTruthy()
expect(contentWrapper?.style.padding).toBe('0.5rem 12px')
})
})

View File

@ -45,6 +45,8 @@ const warmCodeTheme = {
],
}
const CODE_AREA_PADDING = '0.5rem 12px'
/**
* Wraps ShikiHighlighter with a plain-text fallback so the code area
* is never empty while the async WASM / language-grammar load is in-flight,
@ -74,13 +76,13 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
}, [code, language])
return (
<div ref={containerRef} className="code-viewer-area max-h-[420px] overflow-auto bg-[var(--color-code-bg)]">
<div ref={containerRef} className="code-viewer-area relative max-h-[420px] overflow-auto bg-[var(--color-code-bg)]">
{/* Plain-text fallback shown until Shiki finishes highlighting */}
{!loaded && (
<pre
style={{
margin: 0,
padding: '0.5rem 12px',
padding: CODE_AREA_PADDING,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: '1.45',
@ -92,7 +94,20 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
{code}
</pre>
)}
<div style={loaded ? undefined : { position: 'absolute', opacity: 0, pointerEvents: 'none' }}>
<div
data-code-viewer-content=""
style={
loaded
? { padding: CODE_AREA_PADDING }
: {
position: 'absolute',
inset: 0,
opacity: 0,
pointerEvents: 'none',
padding: CODE_AREA_PADDING,
}
}
>
<ShikiHighlighter
language={language || 'text'}
theme={warmCodeTheme}
@ -101,7 +116,6 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
addDefaultStyles={false}
style={{
margin: 0,
padding: '0.5rem 0',
fontFamily: 'var(--font-mono)',
fontSize: '12px',
lineHeight: '1.45',

View File

@ -37,7 +37,12 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
const icon = TOOL_ICONS[toolName] || 'build'
const filePath = typeof obj.file_path === 'string' ? obj.file_path : ''
const summary = getToolSummary(toolName, obj, t)
const outputSummary = getToolResultSummary(toolName, result?.content, t)
const outputSummary = getToolResultSummary(
toolName,
result?.content,
result?.isError ?? false,
t,
)
const preview = useMemo(() => renderPreview(toolName, obj, result, t), [obj, result, toolName, t])
const details = useMemo(() => renderDetails(toolName, obj, t), [obj, toolName, t])
@ -73,11 +78,17 @@ export function ToolCallBlock({ toolName, input, result, compact = false }: Prop
<span className="flex-1" />
)}
{result && outputSummary && (
<span className="shrink-0 text-[10px] text-[var(--color-outline)]">
<span
className={`shrink-0 text-[10px] ${
result.isError
? 'text-[var(--color-error)]'
: 'text-[var(--color-outline)]'
}`}
>
{outputSummary}
</span>
)}
{result?.isError && (
{result?.isError && (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
)}
{expandable && (
@ -175,12 +186,30 @@ function renderDetails(toolName: string, obj: Record<string, unknown>, t?: (key:
)
}
function getToolResultSummary(toolName: string, content: unknown, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
if (toolName === 'Bash') return ''
function getToolResultSummary(
toolName: string,
content: unknown,
isError: boolean,
t?: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
const text = extractTextContent(content)
if (!text) return ''
if (isError) {
const firstLine = text
.split('\n')
.map((line) => stripAnsi(line).replace(/\s+/g, ' ').trim())
.find(Boolean)
if (!firstLine) {
return t?.('tool.error') ?? 'Error'
}
return firstLine.length <= 72 ? firstLine : `${firstLine.slice(0, 72)}`
}
if (toolName === 'Bash') return ''
const lineCount = text.split('\n').length
if (lineCount > 1) {
return t?.('tool.linesOutput', { count: lineCount }) ?? `${lineCount} lines output`
@ -192,6 +221,10 @@ function getToolResultSummary(toolName: string, content: unknown, t?: (key: Tran
return `${compact.slice(0, 36)}`
}
function stripAnsi(value: string): string {
return value.replace(/\x1B\[[0-9;]*m/g, '')
}
function getToolSummary(toolName: string, obj: Record<string, unknown>, t?: (key: TranslationKey, params?: Record<string, string | number>) => string): string {
switch (toolName) {
case 'Bash':

View File

@ -62,6 +62,19 @@ describe('chat blocks', () => {
expect(container.textContent).not.toContain('file-a')
})
it('shows a collapsed error summary for failed bash commands', () => {
const { container } = render(
<ToolCallBlock
toolName="Bash"
input={{ command: 'git show 5016bc0 --no-stat', description: 'Show full diff of latest commit' }}
result={{ content: 'fatal: unrecognized argument: --no-stat\nExit code 128', isError: true }}
/>,
)
expect(container.textContent).toContain('Bash')
expect(container.textContent).toContain('fatal: unrecognized argument: --no-stat')
})
it('expands tool errors so full Computer Use gate messages are readable', () => {
const { container } = render(
<ToolCallBlock

View File

@ -51,6 +51,19 @@ describe('MarkdownRenderer', () => {
expect(screen.getByText('Body copy.')).toBeInTheDocument()
})
it('uses semantic code colors for inline code so both themes stay readable', () => {
const { container } = render(
<MarkdownRenderer content={'Use `claude-sonnet-4-6` for balanced speed.'} />,
)
const root = container.firstChild as HTMLDivElement
expect(root).toBeInTheDocument()
expect(root.className).toContain('prose-code:text-[var(--color-code-fg)]')
expect(root.className).toContain('prose-code:bg-[var(--color-code-bg)]')
expect(root.className).not.toContain('prose-code:text-[var(--color-primary-fixed)]')
expect(screen.getByText('claude-sonnet-4-6')).toBeInTheDocument()
})
it('renders mermaid fenced blocks with the Mermaid renderer', () => {
render(<MarkdownRenderer content={'```mermaid\ngraph TB\nA-->B\n```'} />)

View File

@ -109,7 +109,7 @@ const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm max-w-none text-[var(-
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-p:my-2 prose-p:leading-relaxed
prose-p:break-words
prose-code:text-[13px] prose-code:text-[var(--color-primary-fixed)] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-surface-container-high)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:hidden prose-code:after:hidden
prose-code:text-[13px] prose-code:text-[var(--color-code-fg)] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-code-bg)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:hidden prose-code:after:hidden
prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none
prose-a:text-[var(--color-text-accent)] prose-a:no-underline hover:prose-a:underline
prose-strong:text-[var(--color-text-primary)]

View File

@ -272,6 +272,7 @@ export const en = {
'settings.computerUse.setupSuccess': 'Environment setup complete!',
'settings.computerUse.setupFail': 'Setup failed',
'settings.computerUse.allReady': 'All checks passed. Computer Use is ready.',
'settings.computerUse.downloadPython': 'Download Python 3',
'settings.computerUse.recheckBtn': 'Recheck Status',
'settings.computerUse.requirementsLabel': 'Required packages',
'settings.computerUse.appsTitle': 'Authorized Apps',

View File

@ -274,6 +274,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.computerUse.setupSuccess': '环境安装完成!',
'settings.computerUse.setupFail': '安装失败',
'settings.computerUse.allReady': '所有检查通过Computer Use 已就绪。',
'settings.computerUse.downloadPython': '下载 Python 3',
'settings.computerUse.recheckBtn': '重新检测',
'settings.computerUse.requirementsLabel': '需要的 Python 包',
'settings.computerUse.appsTitle': '已授权应用',

View File

@ -3,6 +3,10 @@ import { computerUseApi, type ComputerUseStatus, type SetupResult, type Installe
import { useTranslation } from '../i18n'
type CheckState = 'loading' | 'ready' | 'error'
const PYTHON_DOWNLOAD_URLS: Record<string, string> = {
darwin: 'https://www.python.org/downloads/macos/',
win32: 'https://www.python.org/downloads/windows/',
}
function StatusIcon({ ok }: { ok: boolean | null }) {
if (ok === null) {
@ -31,6 +35,15 @@ async function openSystemSettings(pane: 'Privacy_ScreenCapture' | 'Privacy_Acces
await computerUseApi.openSettings(pane)
}
async function openExternalUrl(url: string) {
try {
const { open } = await import('@tauri-apps/plugin-shell')
await open(url)
} catch {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
export function ComputerUseSettings() {
const t = useTranslation()
const [status, setStatus] = useState<ComputerUseStatus | null>(null)
@ -153,6 +166,9 @@ export function ComputerUseSettings() {
const accessibilityNeedsAttention = status?.permissions.accessibility === false
const screenRecordingNeedsAttention = status?.permissions.screenRecording === false
const screenRecordingReady = status ? status.permissions.screenRecording !== false : null
const pythonDownloadUrl = status
? PYTHON_DOWNLOAD_URLS[status.platform] ?? 'https://www.python.org/downloads/'
: 'https://www.python.org/downloads/'
// Filter apps by search query
const filteredApps = useMemo(() => {
@ -297,6 +313,15 @@ export function ComputerUseSettings() {
{/* Action buttons */}
<div className="flex gap-3">
{!status.python.installed && (
<button
onClick={() => openExternalUrl(pythonDownloadUrl)}
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--color-brand)] text-white text-sm font-semibold rounded-lg hover:opacity-90 transition-opacity"
>
<span className="material-symbols-outlined text-[18px]">open_in_new</span>
{t('settings.computerUse.downloadPython')}
</button>
)}
{!envReady && status.python.installed && (
<button
onClick={handleSetup}

View File

@ -25,6 +25,15 @@ os.environ.setdefault("PYAUTOGUI_HIDE_SUPPORT_PROMPT", "1")
import pyautogui # noqa: E402
# The desktop app decodes helper stdout as UTF-8. On Windows, redirected Python
# stdout defaults to the active ANSI code page (for example GBK), which mangles
# localized app names from the registry. Force UTF-8 at process start so JSON
# responses stay stable regardless of the user's system locale.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="strict")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
pyautogui.FAILSAFE = False
pyautogui.PAUSE = 0
@ -311,13 +320,24 @@ def installed_apps() -> list[dict[str, Any]]:
display_icon = winreg.QueryValueEx(app_key, "DisplayIcon")[0]
except OSError:
display_icon = ""
# Use registry key name as bundleId equivalent
normalized_icon = str(display_icon).split(",")[0].strip().strip('"')
normalized_install_location = str(install_location).strip().strip('"')
bundle_id = name
for candidate in (normalized_icon, normalized_install_location):
if not candidate:
continue
candidate_path = Path(candidate)
if candidate_path.suffix.lower() == ".exe":
bundle_id = candidate_path.stem
break
app_path = normalized_icon or normalized_install_location or ""
if bundle_id not in results:
results[bundle_id] = {
"bundleId": bundle_id,
"displayName": str(display_name),
"path": str(install_location or display_icon or ""),
"path": app_path,
}
winreg.CloseKey(app_key)
finally:
@ -469,17 +489,43 @@ def open_app(bundle_id: str) -> None:
for hive, sub_key in reg_paths:
try:
key = winreg.OpenKey(hive, sub_key)
app_key = winreg.OpenKey(key, bundle_id)
try:
exe_path = winreg.QueryValueEx(app_key, "DisplayIcon")[0]
if exe_path and "," in exe_path:
exe_path = exe_path.split(",")[0]
except OSError:
i = 0
while True:
try:
exe_path = winreg.QueryValueEx(app_key, "InstallLocation")[0]
name = winreg.EnumKey(key, i)
i += 1
except OSError:
pass
winreg.CloseKey(app_key)
break
try:
app_key = winreg.OpenKey(key, name)
except OSError:
continue
try:
display_icon = winreg.QueryValueEx(app_key, "DisplayIcon")[0]
except OSError:
display_icon = ""
try:
install_location = winreg.QueryValueEx(app_key, "InstallLocation")[0]
except OSError:
install_location = ""
normalized_icon = str(display_icon).split(",")[0].strip().strip('"')
normalized_install_location = str(install_location).strip().strip('"')
derived_bundle_id = name
for candidate in (normalized_icon, normalized_install_location):
if not candidate:
continue
candidate_path = Path(candidate)
if candidate_path.suffix.lower() == ".exe":
derived_bundle_id = candidate_path.stem
break
if name == bundle_id or derived_bundle_id == bundle_id:
exe_path = normalized_icon or normalized_install_location or None
winreg.CloseKey(app_key)
break
winreg.CloseKey(app_key)
winreg.CloseKey(key)
if exe_path:
break

View File

@ -1605,7 +1605,7 @@ async function run(): Promise<CommanderCommand> {
// `type: 'stdio'`. An enterprise-config ant with the GB gate on would
// otherwise process.exit(1). Chrome has the same latent issue but has
// shipped without incident; chicago places itself correctly.
if (getPlatform() === 'macos') {
if (process.platform === 'darwin' || process.platform === 'win32') {
try {
const {
getChicagoEnabled

View File

@ -0,0 +1,75 @@
import { describe, expect, test } from 'bun:test'
import { detectPythonRuntime } from '../api/computer-use-python.js'
describe('detectPythonRuntime', () => {
test('prefers python3 on Windows when available', async () => {
const calls: string[] = []
const result = await detectPythonRuntime(
'win32',
async (cmd, args) => {
calls.push(`${cmd} ${args.join(' ')}`.trim())
if (cmd === 'python3' && args.join(' ') === '--version') {
return { ok: true, stdout: 'Python 3.12.11', stderr: '', code: 0 }
}
if (cmd === 'where' && args[0] === 'python3') {
return { ok: true, stdout: 'C:\\Python312\\python3.exe', stderr: '', code: 0 }
}
return { ok: false, stdout: '', stderr: '', code: 1 }
},
)
expect(result.installed).toBe(true)
expect(result.version).toBe('3.12.11')
expect(result.path).toBe('C:\\Python312\\python3.exe')
expect(result.command).toBe('python3')
expect(result.prefixArgs).toEqual([])
expect(result.source).toBe('system')
expect(calls).toEqual(['python3 --version', 'where python3'])
})
test('falls back to py -3 on Windows', async () => {
const result = await detectPythonRuntime(
'win32',
async (cmd, args) => {
if (cmd === 'python3' || cmd === 'python') {
return { ok: false, stdout: '', stderr: '', code: 1 }
}
if (cmd === 'py' && args.join(' ') === '-3 --version') {
return { ok: true, stdout: '', stderr: 'Python 3.12.11', code: 0 }
}
if (cmd === 'where' && args[0] === 'py') {
return { ok: true, stdout: 'C:\\Windows\\py.exe', stderr: '', code: 0 }
}
return { ok: false, stdout: '', stderr: '', code: 1 }
},
)
expect(result.installed).toBe(true)
expect(result.version).toBe('3.12.11')
expect(result.path).toBe('C:\\Windows\\py.exe')
expect(result.command).toBe('py')
expect(result.prefixArgs).toEqual(['-3'])
expect(result.source).toBe('system')
})
test('falls back to venv python when system python is not discoverable', async () => {
const venvPython = 'C:\\Users\\Relakkes\\.claude\\.runtime\\venv\\Scripts\\python.exe'
const result = await detectPythonRuntime(
'win32',
async (cmd, args) => {
if (cmd === venvPython && args.join(' ') === '--version') {
return { ok: true, stdout: 'Python 3.12.11', stderr: '', code: 0 }
}
return { ok: false, stdout: '', stderr: '', code: 1 }
},
venvPython,
)
expect(result.installed).toBe(true)
expect(result.version).toBe('3.12.11')
expect(result.path).toBe(venvPython)
expect(result.command).toBe(venvPython)
expect(result.prefixArgs).toEqual([])
expect(result.source).toBe('venv')
})
})

View File

@ -0,0 +1,131 @@
export type CommandResult = {
ok: boolean
stdout: string
stderr: string
code: number
}
export type CommandRunner = (
cmd: string,
args: string[],
) => Promise<CommandResult>
type PythonCandidate = {
command: string
prefixArgs: string[]
locator: {
command: string
args: string[]
} | null
}
export type PythonRuntimeResolution = {
installed: boolean
version: string | null
path: string | null
command: string | null
prefixArgs: string[]
source: 'system' | 'venv' | null
}
function getPythonCandidates(platform: NodeJS.Platform): PythonCandidate[] {
if (platform === 'win32') {
return [
{
command: 'python3',
prefixArgs: [],
locator: { command: 'where', args: ['python3'] },
},
{
command: 'python',
prefixArgs: [],
locator: { command: 'where', args: ['python'] },
},
{
command: 'py',
prefixArgs: ['-3'],
locator: { command: 'where', args: ['py'] },
},
{
command: 'py',
prefixArgs: [],
locator: { command: 'where', args: ['py'] },
},
]
}
return [
{
command: 'python3',
prefixArgs: [],
locator: { command: 'which', args: ['python3'] },
},
]
}
function extractPythonVersion(output: string): string | null {
const match = output.match(/Python\s+([0-9][^\s]*)/i)
return match?.[1] ?? null
}
function firstOutputLine(output: string): string | null {
const line = output
.split(/\r?\n/)
.map(value => value.trim())
.find(Boolean)
return line ?? null
}
async function locateCandidatePath(
candidate: PythonCandidate,
runCommand: CommandRunner,
): Promise<string | null> {
if (!candidate.locator) return null
const locateResult = await runCommand(candidate.locator.command, candidate.locator.args)
if (!locateResult.ok) return null
return firstOutputLine(locateResult.stdout)
}
export async function detectPythonRuntime(
platform: NodeJS.Platform,
runCommand: CommandRunner,
venvPythonPath?: string,
): Promise<PythonRuntimeResolution> {
for (const candidate of getPythonCandidates(platform)) {
const versionResult = await runCommand(candidate.command, [...candidate.prefixArgs, '--version'])
if (!versionResult.ok) continue
return {
installed: true,
version: extractPythonVersion(`${versionResult.stdout}\n${versionResult.stderr}`),
path: await locateCandidatePath(candidate, runCommand),
command: candidate.command,
prefixArgs: candidate.prefixArgs,
source: 'system',
}
}
if (venvPythonPath) {
const venvResult = await runCommand(venvPythonPath, ['--version'])
if (venvResult.ok) {
return {
installed: true,
version: extractPythonVersion(`${venvResult.stdout}\n${venvResult.stderr}`),
path: venvPythonPath,
command: venvPythonPath,
prefixArgs: [],
source: 'venv',
}
}
}
return {
installed: false,
version: null,
path: null,
command: null,
prefixArgs: [],
source: null,
}
}

View File

@ -14,6 +14,8 @@ import path from 'path'
import { fileURLToPath } from 'url'
import type { CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js'
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
import { detectPythonRuntime } from './computer-use-python.js'
import { DEFAULT_DESKTOP_GRANT_FLAGS } from '../../utils/computerUse/preauthorizedConfig.js'
// Embed helper scripts at compile time so they're available in bundled mode
// @ts-ignore — Bun text import
import MAC_HELPER_CONTENT from '../../../runtime/mac_helper.py' with { type: 'text' }
@ -49,6 +51,15 @@ screeninfo>=0.8.1
const isWindows = process.platform === 'win32'
const REQUIREMENTS_CONTENT = isWindows ? REQUIREMENTS_WIN32 : REQUIREMENTS_DARWIN
function getPythonCommandEnv(): Record<string, string> | undefined {
if (!isWindows) return undefined
return {
...process.env,
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1',
} as Record<string, string>
}
// 清华大学 PyPI 镜像,国内安装速度更快
const PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple/'
const PIP_TRUSTED_HOST = 'pypi.tuna.tsinghua.edu.cn'
@ -83,6 +94,7 @@ async function runCommand(
const proc = Bun.spawn([cmd, ...args], {
stdout: 'pipe',
stderr: 'pipe',
env: getPythonCommandEnv(),
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
@ -139,27 +151,14 @@ async function checkStatus(): Promise<EnvStatus> {
const platform = process.platform
const supported = platform === 'darwin' || platform === 'win32'
// Check Python 3 — Windows may only have `python`, not `python3`
const pythonCmd = isWindows ? 'python' : 'python3'
const pythonResult = await runCommand(pythonCmd, ['--version'])
const pythonInstalled = pythonResult.ok
const pythonVersion = pythonInstalled
? pythonResult.stdout.replace('Python ', '')
: null
let pythonPath: string | null = null
if (pythonInstalled) {
const whichCmd = isWindows ? 'where' : 'which'
const whichResult = await runCommand(whichCmd, [pythonCmd])
pythonPath = whichResult.ok ? whichResult.stdout.split('\n')[0] : null
}
// Check venv — different paths on Windows vs Unix
const venvPython = isWindows
? join(venvRoot, 'Scripts', 'python.exe')
: join(venvRoot, 'bin', 'python3')
const venvCreated = await pathExists(venvPython)
const pythonRuntime = await detectPythonRuntime(platform, runCommand, venvCreated ? venvPython : undefined)
// Check dependencies — use the state dir copy
const reqPath = getRequirementsPath()
const requirementsFound = await pathExists(reqPath)
@ -200,7 +199,11 @@ async function checkStatus(): Promise<EnvStatus> {
return {
platform,
supported,
python: { installed: pythonInstalled, version: pythonVersion, path: pythonPath },
python: {
installed: pythonRuntime.installed,
version: pythonRuntime.version,
path: pythonRuntime.path,
},
venv: { created: venvCreated, path: venvRoot },
dependencies: { installed: depsInstalled, requirementsFound: requirementsFound || true },
permissions: { accessibility, screenRecording },
@ -215,10 +218,18 @@ type SetupResult = {
async function runSetup(): Promise<SetupResult> {
const steps: SetupResult['steps'] = []
const venvPython = isWindows
? join(venvRoot, 'Scripts', 'python.exe')
: join(venvRoot, 'bin', 'python3')
const venvExists = await pathExists(venvPython)
// Step 1: Check python
const pythonCmd = isWindows ? 'python' : 'python3'
const pythonCheck = await runCommand(pythonCmd, ['--version'])
if (!pythonCheck.ok) {
const pythonRuntime = await detectPythonRuntime(
process.platform,
runCommand,
venvExists ? venvPython : undefined,
)
if (!pythonRuntime.installed) {
steps.push({
name: 'python_check',
ok: false,
@ -229,7 +240,9 @@ async function runSetup(): Promise<SetupResult> {
steps.push({
name: 'python_check',
ok: true,
message: `Python ${pythonCheck.stdout.replace('Python ', '')}`,
message: pythonRuntime.source === 'venv'
? `Python ${pythonRuntime.version}(使用现有虚拟环境)`
: `Python ${pythonRuntime.version}`,
})
// Step 2: Extract runtime files to ~/.claude/.runtime/
@ -246,12 +259,21 @@ async function runSetup(): Promise<SetupResult> {
}
// Step 3: Create venv
const venvPython = isWindows
? join(venvRoot, 'Scripts', 'python.exe')
: join(venvRoot, 'bin', 'python3')
const venvExists = await pathExists(venvPython)
if (!venvExists) {
const venvResult = await runCommand(pythonCmd, ['-m', 'venv', venvRoot])
if (!pythonRuntime.command) {
steps.push({
name: 'venv',
ok: false,
message: '未找到可用于创建虚拟环境的 Python 命令',
})
return { success: false, steps }
}
const venvResult = await runCommand(pythonRuntime.command, [
...pythonRuntime.prefixArgs,
'-m',
'venv',
venvRoot,
])
if (!venvResult.ok) {
steps.push({
name: 'venv',
@ -354,7 +376,7 @@ type RequestAccessBody = {
const DEFAULT_CONFIG: ComputerUseConfig = {
authorizedApps: [],
grantFlags: { clipboardRead: true, clipboardWrite: true, systemKeyCombos: true },
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
}
async function loadConfig(): Promise<ComputerUseConfig> {

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from 'bun:test'
import {
getCliComputerUseCapabilities,
isComputerUseSupportedPlatform,
} from './common.js'
describe('computer use platform helpers', () => {
it('recognizes supported platforms', () => {
expect(isComputerUseSupportedPlatform('darwin')).toBe(true)
expect(isComputerUseSupportedPlatform('win32')).toBe(true)
expect(isComputerUseSupportedPlatform('linux')).toBe(false)
})
it('returns macOS capabilities with native screenshot filtering', () => {
expect(getCliComputerUseCapabilities('darwin')).toEqual({
screenshotFiltering: 'native',
platform: 'darwin',
})
})
it('returns Windows capabilities with unfiltered screenshots', () => {
expect(getCliComputerUseCapabilities('win32')).toEqual({
screenshotFiltering: 'none',
platform: 'win32',
})
})
})

View File

@ -2,6 +2,13 @@ import { normalizeNameForMCP } from '../../services/mcp/normalization.js'
import { env } from '../env.js'
export const COMPUTER_USE_MCP_SERVER_NAME = 'computer-use'
export const CLI_HOST_PLATFORM_BUNDLE_ID = 'com.anthropic.claude-code.cli-no-window'
export function isComputerUseSupportedPlatform(
platform: NodeJS.Platform = process.platform,
): platform is 'darwin' | 'win32' {
return platform === 'darwin' || platform === 'win32'
}
/**
* Sentinel bundle ID for the frontmost gate. Claude Code is a terminal it has
@ -10,13 +17,12 @@ export const COMPUTER_USE_MCP_SERVER_NAME = 'computer-use'
* keyboard safety-net) is dead code for us. `prepareForAction`'s "exempt our
* own window" is likewise a no-op there is no window to exempt.
*/
export const CLI_HOST_BUNDLE_ID = 'com.anthropic.claude-code.cli-no-window'
export const CLI_HOST_BUNDLE_ID = CLI_HOST_PLATFORM_BUNDLE_ID
/**
* Fallback `env.terminal` bundleId map for when `__CFBundleIdentifier` is
* unset. Covers the macOS terminals we can distinguish Linux entries
* (konsole, gnome-terminal, xterm) are deliberately absent since
* `createCliExecutor` is darwin-guarded.
* unset. Covers the macOS terminals we can distinguish. On Windows the host is
* always the CLI sentinel above, so this table remains macOS-specific.
*/
const TERMINAL_BUNDLE_ID_FALLBACK: Readonly<Record<string, string>> = {
'iTerm.app': 'com.googlecode.iterm2',
@ -47,13 +53,32 @@ export function getTerminalBundleId(): string | null {
}
/**
* Static capabilities for macOS CLI. `hostBundleId` is not here it's added
* by `executor.ts` per `ComputerExecutor.capabilities`. `buildComputerUseTools`
* takes this shape (no `hostBundleId`, no `teachMode`).
* CLI computer-use capabilities by platform. `hostBundleId` is not here
* it's added by `executor.ts` per `ComputerExecutor.capabilities`.
*/
export const CLI_CU_CAPABILITIES = {
screenshotFiltering: 'native' as const,
platform: 'darwin' as const,
export function getCliComputerUseCapabilities(
platform: NodeJS.Platform = process.platform,
): {
screenshotFiltering: 'native' | 'none'
platform: 'darwin' | 'win32'
} {
if (platform === 'darwin') {
return {
screenshotFiltering: 'native',
platform: 'darwin',
}
}
if (platform !== 'win32') {
throw new Error(
`Computer Use is only supported on macOS and Windows (received ${platform}).`,
)
}
return {
screenshotFiltering: 'none',
platform: 'win32',
}
}
export function isComputerUseMCPServer(name: string): boolean {

View File

@ -2,7 +2,8 @@
* CLI `ComputerExecutor` implementation Python bridge variant.
*
* Replaces the native Swift/Rust modules with a Python subprocess bridge
* (pyautogui + mss + pyobjc). See `pythonBridge.ts` and `runtime/mac_helper.py`.
* (pyautogui + mss + platform helpers). See `pythonBridge.ts` and
* `runtime/{mac,win}_helper.py`.
*/
import type {
@ -16,7 +17,11 @@ import type {
} from '../../vendor/computer-use-mcp/index.js'
import { API_RESIZE_PARAMS, targetImageSize } from '../../vendor/computer-use-mcp/index.js'
import { sleep } from '../sleep.js'
import { CLI_CU_CAPABILITIES, CLI_HOST_BUNDLE_ID } from './common.js'
import {
CLI_HOST_BUNDLE_ID,
getCliComputerUseCapabilities,
isComputerUseSupportedPlatform,
} from './common.js'
import { callPythonHelper } from './pythonBridge.js'
const SCREENSHOT_JPEG_QUALITY = 0.75
@ -56,23 +61,48 @@ async function writeClipboardViaPbcopy(text: string): Promise<void> {
await callPythonHelper('write_clipboard', { text })
}
async function readClipboard(): Promise<string> {
if (process.platform === 'win32') {
return callPythonHelper<string>('read_clipboard', {})
}
return readClipboardViaPbpaste()
}
async function writeClipboard(text: string): Promise<void> {
if (process.platform === 'win32') {
await callPythonHelper('write_clipboard', { text })
return
}
await writeClipboardViaPbcopy(text)
}
async function typeViaClipboard(text: string): Promise<void> {
let saved: string | undefined
try {
saved = await readClipboardViaPbpaste()
saved = await readClipboard()
} catch {}
try {
await writeClipboardViaPbcopy(text)
// Give NSPasteboard a beat before paste, then keep the new contents
// resident long enough for Electron/WebView fields to consume them.
await sleep(40)
await callPythonHelper('paste_clipboard', {})
await sleep(180)
await writeClipboard(text)
if (process.platform === 'darwin') {
// Give NSPasteboard a beat before paste, then keep the new contents
// resident long enough for Electron/WebView fields to consume them.
await sleep(40)
await callPythonHelper('paste_clipboard', {})
await sleep(180)
} else {
await callPythonHelper('key', {
keySequence: 'ctrl+v',
repeat: 1,
})
await sleep(100)
}
} finally {
if (typeof saved === 'string') {
try {
await writeClipboardViaPbcopy(saved)
await writeClipboard(saved)
} catch {}
}
}
@ -82,21 +112,23 @@ export function createCliExecutor(_opts: {
getMouseAnimationEnabled: () => boolean
getHideBeforeActionEnabled: () => boolean
}): ComputerExecutor {
if (process.platform !== 'darwin') {
throw new Error(`createCliExecutor called on ${process.platform}. Computer control is macOS-only.`)
if (!isComputerUseSupportedPlatform()) {
throw new Error(
`createCliExecutor called on ${process.platform}. Computer control is only supported on macOS and Windows.`,
)
}
return {
capabilities: {
...CLI_CU_CAPABILITIES,
...getCliComputerUseCapabilities(),
hostBundleId,
},
async prepareForAction(): Promise<string[]> {
async prepareForAction(_allowlistBundleIds, _displayId): Promise<string[]> {
return callPythonHelper('prepare_for_action', {})
},
async previewHideSet() {
async previewHideSet(_allowlistBundleIds, _displayId) {
return callPythonHelper('preview_hide_set', {})
},
@ -170,8 +202,8 @@ export function createCliExecutor(_opts: {
await callPythonHelper('type', { text })
},
readClipboard: readClipboardViaPbpaste,
writeClipboard: writeClipboardViaPbcopy,
readClipboard,
writeClipboard,
async click(x, y, button, count, modifiers): Promise<void> {
await callPythonHelper('click', { x, y, button, count, modifiers })

View File

@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test'
import {
DEFAULT_DESKTOP_GRANT_FLAGS,
resolveStoredComputerUseConfig,
} from './preauthorizedConfig.js'
describe('resolveStoredComputerUseConfig', () => {
test('keeps desktop grant flags enabled by default even without authorized apps', () => {
expect(resolveStoredComputerUseConfig()).toEqual({
authorizedApps: [],
grantFlags: DEFAULT_DESKTOP_GRANT_FLAGS,
})
})
test('merges stored grant flags without discarding unspecified defaults', () => {
expect(
resolveStoredComputerUseConfig({
grantFlags: {
clipboardRead: false,
},
}),
).toEqual({
authorizedApps: [],
grantFlags: {
clipboardRead: false,
clipboardWrite: true,
systemKeyCombos: true,
},
})
})
})

View File

@ -0,0 +1,33 @@
import type { CuGrantFlags } from '../../vendor/computer-use-mcp/types.js'
export type StoredAuthorizedApp = {
bundleId: string
displayName: string
}
export type StoredComputerUseConfig = {
authorizedApps?: StoredAuthorizedApp[]
grantFlags?: Partial<CuGrantFlags>
}
export const DEFAULT_DESKTOP_GRANT_FLAGS: CuGrantFlags = {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
}
export function resolveStoredComputerUseConfig(
config?: StoredComputerUseConfig,
): {
authorizedApps: StoredAuthorizedApp[]
grantFlags: CuGrantFlags
} {
return {
authorizedApps: config?.authorizedApps ?? [],
grantFlags: {
...DEFAULT_DESKTOP_GRANT_FLAGS,
...(config?.grantFlags ?? {}),
},
}
}

View File

@ -28,6 +28,15 @@ const helperPath = path.join(runtimeStateRoot, helperFileName)
let bootstrapPromise: Promise<void> | undefined
function getPythonCommandEnv(): NodeJS.ProcessEnv | undefined {
if (!isWindows) return undefined
return {
...process.env,
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1',
}
}
function pythonBinPath(): string {
return isWindows
? path.join(venvRoot, 'Scripts', 'python.exe')
@ -131,7 +140,7 @@ export async function callPythonHelper<T>(command: string, payload: Record<strin
const { code, stdout, stderr } = await execFileNoThrow(
pythonBinPath(),
[helperPath, command, '--payload', JSON.stringify(payload)],
{ useCwd: false },
{ useCwd: false, env: getPythonCommandEnv() },
)
if (code !== 0 && !stdout.trim()) {

View File

@ -5,7 +5,10 @@ import { buildMcpToolName } from '../../services/mcp/mcpStringUtils.js'
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
import { isInBundledMode } from '../bundledMode.js'
import { CLI_CU_CAPABILITIES, COMPUTER_USE_MCP_SERVER_NAME } from './common.js'
import {
COMPUTER_USE_MCP_SERVER_NAME,
getCliComputerUseCapabilities,
} from './common.js'
import { getChicagoCoordinateMode } from './gates.js'
/**
@ -25,7 +28,7 @@ export function setupComputerUseMCP(): {
allowedTools: string[]
} {
const allowedTools = buildComputerUseTools(
CLI_CU_CAPABILITIES,
getCliComputerUseCapabilities(),
getChicagoCoordinateMode(),
).map(t => buildMcpToolName(COMPUTER_USE_MCP_SERVER_NAME, t.name))

View File

@ -27,6 +27,7 @@ import { registerEscHotkey } from './escHotkey.js';
import { getChicagoCoordinateMode } from './gates.js';
import { getComputerUseHostAdapter } from './hostAdapter.js';
import { getComputerUseMCPRenderingOverrides } from './toolRendering.js';
import { resolveStoredComputerUseConfig } from './preauthorizedConfig.js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
@ -269,6 +270,13 @@ async function runDesktopPermissionDialog(
* immediately no runtime permission dialog needed.
*/
async function loadPreAuthorizedApps(): Promise<void> {
let config:
| {
authorizedApps?: { bundleId: string; displayName: string }[]
grantFlags?: { clipboardRead?: boolean; clipboardWrite?: boolean; systemKeyCombos?: boolean }
}
| undefined
try {
const configPath = join(
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'),
@ -276,46 +284,57 @@ async function loadPreAuthorizedApps(): Promise<void> {
'computer-use-config.json',
)
const raw = await readFile(configPath, 'utf8')
const config = JSON.parse(raw) as {
authorizedApps?: { bundleId: string; displayName: string }[]
grantFlags?: { clipboardRead?: boolean; clipboardWrite?: boolean; systemKeyCombos?: boolean }
}
if (!config.authorizedApps?.length) return
const apps = config.authorizedApps.map(a => ({
bundleId: a.bundleId,
displayName: a.displayName,
grantedAt: Date.now(),
tier: 'full' as const,
}))
const flags = {
...DEFAULT_GRANT_FLAGS,
...(config.grantFlags ?? {}),
}
// Inject into appState so getAllowedApps() returns them.
// Merge with existing allowedApps (from permission dialog) instead of replacing.
if (currentToolUseContext) {
currentToolUseContext.setAppState(prev => {
const existing = prev.computerUseMcpState?.allowedApps ?? []
const existingIds = new Set(existing.map(a => a.bundleId))
const merged = [...existing, ...apps.filter(a => !existingIds.has(a.bundleId))]
return {
...prev,
computerUseMcpState: {
...prev.computerUseMcpState,
allowedApps: merged,
grantFlags: flags,
},
}
})
}
logForDebugging(`[Computer Use] Loaded ${apps.length} pre-authorized apps from config`)
config = JSON.parse(raw) as typeof config
} catch {
// Config doesn't exist or is invalid — no pre-authorized apps
// Config doesn't exist yet — still honor desktop defaults for grant flags.
}
if (!currentToolUseContext) {
return
}
const resolved = resolveStoredComputerUseConfig(config)
const apps = resolved.authorizedApps.map(a => ({
bundleId: a.bundleId,
displayName: a.displayName,
grantedAt: Date.now(),
tier: 'full' as const,
}))
const flags = {
...DEFAULT_GRANT_FLAGS,
...resolved.grantFlags,
}
// Inject into appState so getAllowedApps()/getGrantFlags() return persisted
// desktop settings immediately, even when no apps are pre-authorized yet.
currentToolUseContext.setAppState(prev => {
const existing = prev.computerUseMcpState?.allowedApps ?? []
const existingIds = new Set(existing.map(a => a.bundleId))
const merged = [...existing, ...apps.filter(a => !existingIds.has(a.bundleId))]
const currentFlags = prev.computerUseMcpState?.grantFlags
const sameFlags =
currentFlags?.clipboardRead === flags.clipboardRead &&
currentFlags?.clipboardWrite === flags.clipboardWrite &&
currentFlags?.systemKeyCombos === flags.systemKeyCombos
const sameApps = existing.length === merged.length
if (sameFlags && sameApps) {
return prev
}
return {
...prev,
computerUseMcpState: {
...prev.computerUseMcpState,
allowedApps: merged,
grantFlags: flags,
},
}
})
logForDebugging(
`[Computer Use] Loaded ${apps.length} pre-authorized apps and grant flags from config`,
)
}
let preAuthLoaded = false

View File

@ -2431,13 +2431,18 @@ async function handleType(
// §6 item 3 — clipboard-paste fast path. On macOS we also prefer the
// clipboard path for ordinary text when clipboardWrite is granted, because
// IME/input-source state can corrupt keystroke-by-keystroke typing even for
// plain ASCII. The save/restore + read-back-verify lives in the EXECUTOR,
// not here. Here we just route.
// plain ASCII. On Windows, non-ASCII text (Chinese, Japanese, emoji, etc.)
// is much more reliable through paste than through synthetic key events,
// which otherwise go through the active IME and can land in the wrong field
// or composition state. The save/restore lives in the EXECUTOR, not here.
const hasNonControlText = /[^\r\n\t]/u.test(text);
const hasNonAsciiText = /[^\u0000-\u007f]/u.test(text);
const viaClipboard =
(text.includes("\n") ||
(adapter.executor.capabilities.platform === "darwin" &&
hasNonControlText)) &&
hasNonControlText) ||
(adapter.executor.capabilities.platform === "win32" &&
hasNonAsciiText)) &&
overrides.grantFlags.clipboardWrite &&
subGates.clipboardPasteMultiline;