diff --git a/build.ts b/build.ts index 4e8e0d260..d205a6b8d 100644 --- a/build.ts +++ b/build.ts @@ -17,12 +17,28 @@ const DEFAULT_BUILD_FEATURES = [ 'SHOT_STATS', 'PROMPT_CACHE_BREAK_DETECTION', 'TOKEN_BUDGET', - // P0: local features + 'BUDDY', + 'NATIVE_CLIPBOARD_IMAGE', + 'BRIDGE_MODE', + 'MCP_SKILLS', + 'TEMPLATES', + 'COORDINATOR_MODE', + 'TRANSCRIPT_CLASSIFIER', + 'MCP_RICH_OUTPUT', + 'MESSAGE_ACTIONS', + 'HISTORY_PICKER', + 'QUICK_SEARCH', + 'CACHED_MICROCOMPACT', + 'REACTIVE_COMPACT', + 'FILE_PERSISTENCE', + 'DUMP_SYSTEM_PROMPT', + 'BREAK_CACHE_COMMAND', + // P0: local features (upstream) 'AGENT_TRIGGERS', 'ULTRATHINK', 'BUILTIN_EXPLORE_PLAN_AGENTS', 'LODESTONE', - // P1: API-dependent features + // P1: API-dependent features (upstream) 'EXTRACT_MEMORIES', 'VERIFICATION_AGENT', 'KAIROS_BRIEF', diff --git a/contributors.svg b/contributors.svg index 37060fd26..813e8f155 100644 --- a/contributors.svg +++ b/contributors.svg @@ -22,15 +22,19 @@ + + - + + + - + - + - + - + \ No newline at end of file diff --git a/packages/@ant/computer-use-swift/src/backends/darwin.ts b/packages/@ant/computer-use-swift/src/backends/darwin.ts index 620f162a9..32ffef044 100644 --- a/packages/@ant/computer-use-swift/src/backends/darwin.ts +++ b/packages/@ant/computer-use-swift/src/backends/darwin.ts @@ -85,13 +85,17 @@ export const display: DisplayAPI = { var mode = $.CGDisplayCopyDisplayMode(did); var pw = $.CGDisplayModeGetPixelWidth(mode); var sf = pw > 0 && w > 0 ? pw / w : 2; - result.push({width: w, height: h, scaleFactor: sf, displayId: did}); + var bounds = $.CGDisplayBounds(did); + result.push({width: w, height: h, scaleFactor: sf, displayId: did, + originX: bounds.origin.x, originY: bounds.origin.y}); } JSON.stringify(result); `) return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({ width: Number(d.width), height: Number(d.height), scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId), + originX: Number((d as any).originX ?? 0), + originY: Number((d as any).originY ?? 0), })) } catch { try { @@ -109,7 +113,9 @@ export const display: DisplayAPI = { width: Math.round(frame.size.width), height: Math.round(frame.size.height), scaleFactor: backingFactor, - displayId: screenNumber + displayId: screenNumber, + originX: Math.round(frame.origin.x), + originY: Math.round(frame.origin.y), }); } JSON.stringify(result); @@ -117,6 +123,8 @@ export const display: DisplayAPI = { return (JSON.parse(raw) as DisplayGeometry[]).map(d => ({ width: Number(d.width), height: Number(d.height), scaleFactor: Number(d.scaleFactor), displayId: Number(d.displayId), + originX: Number((d as any).originX ?? 0), + originY: Number((d as any).originY ?? 0), })) } catch { return [{ width: 1920, height: 1080, scaleFactor: 2, displayId: 1 }] @@ -130,12 +138,94 @@ export const display: DisplayAPI = { // --------------------------------------------------------------------------- export const apps: AppsAPI = { - async prepareDisplay(_allowlistBundleIds, _surrogateHost, _displayId) { - return { activated: '', hidden: [] } + async prepareDisplay(allowlistBundleIds, surrogateHost, _displayId) { + const FINDER_BUNDLE_ID = 'com.apple.finder' + const hidden: string[] = [] + let activated = '' + + // Step 1: Get all visible foreground apps. + let runningVisible: Array<{ bundleId: string; displayName: string }> = [] + try { + const raw = jxaSync(` + var procs = Application("System Events").applicationProcesses.whose({backgroundOnly: false}); + var result = []; + for (var i = 0; i < procs.length; i++) { + try { + var p = procs[i]; + if (p.visible()) { + result.push({ bundleId: p.bundleIdentifier(), displayName: p.name() }); + } + } catch(e) {} + } + JSON.stringify(result); + `) + runningVisible = JSON.parse(raw) + } catch { + // If we can't enumerate, proceed with best-effort activation only. + } + + const allowSet = new Set(allowlistBundleIds) + + // Step 2: Hide visible apps that are not in the allowlist and not Finder. + // The surrogate host (terminal) is included here — it must step back so + // the target app can receive events. + for (const app of runningVisible) { + if (allowSet.has(app.bundleId)) continue + if (app.bundleId === FINDER_BUNDLE_ID) continue + try { + await osascript(` + tell application "System Events" + set visible of (first application process whose bundle identifier is "${app.bundleId}") to false + end tell + `) + hidden.push(app.bundleId) + } catch { + // Non-fatal: if we can't hide it, keep going. + } + } + + // Step 3: Activate the first running allowlisted app to bring it forward. + const runningBundleIds = new Set(runningVisible.map(a => a.bundleId)) + for (const bundleId of allowlistBundleIds) { + if (!runningBundleIds.has(bundleId)) continue + try { + await osascript(`tell application id "${bundleId}" to activate`) + // Brief settle time so macOS processes the window-manager event. + await Bun.sleep(150) + activated = bundleId + } catch { + // Non-fatal. + } + break + } + + return { activated, hidden } }, - async previewHideSet(_bundleIds, _displayId) { - return [] + async previewHideSet(bundleIds, _displayId) { + // Return the apps that WOULD be hidden (i.e. running foreground apps + // not in the allowlist and not Finder) so the approval dialog can show them. + const FINDER_BUNDLE_ID = 'com.apple.finder' + try { + const raw = jxaSync(` + var procs = Application("System Events").applicationProcesses.whose({backgroundOnly: false}); + var result = []; + for (var i = 0; i < procs.length; i++) { + try { + var p = procs[i]; + if (p.visible()) { + result.push({ bundleId: p.bundleIdentifier(), displayName: p.name() }); + } + } catch(e) {} + } + JSON.stringify(result); + `) + const running: Array<{ bundleId: string; displayName: string }> = JSON.parse(raw) + const allowSet = new Set(bundleIds) + return running.filter(a => !allowSet.has(a.bundleId) && a.bundleId !== FINDER_BUNDLE_ID) + } catch { + return [] + } }, async findWindowDisplays(bundleIds) { @@ -159,26 +249,34 @@ export const apps: AppsAPI = { async listInstalled() { try { - const result = await osascript(` - tell application "System Events" - set appList to "" - repeat with appFile in (every file of folder "Applications" of startup disk whose name ends with ".app") - set appPath to POSIX path of (appFile as alias) - set appName to name of appFile - set appList to appList & appPath & "|" & appName & "\\n" - end repeat - return appList - end tell - `) - return result.split('\n').filter(Boolean).map(line => { - const [path, name] = line.split('|', 2) - const displayName = (name ?? '').replace(/\.app$/, '') - return { - bundleId: `com.app.${displayName.toLowerCase().replace(/\s+/g, '-')}`, - displayName, - path: path ?? '', + // Use NSBundle via JXA ObjC bridge to read real CFBundleIdentifier. + // The old AppleScript used "every file of folder Applications" which + // misses .app bundles — packages are directories, not files on macOS. + const raw = await jxa(` + ObjC.import("Foundation"); + var fm = $.NSFileManager.defaultManager; + var home = ObjC.unwrap($.NSHomeDirectory()); + var searchDirs = ["/Applications", home + "/Applications", "/System/Applications"]; + var result = []; + for (var d = 0; d < searchDirs.length; d++) { + var items = fm.contentsOfDirectoryAtPathError($(searchDirs[d]), null); + if (!items) continue; + for (var i = 0; i < items.count; i++) { + var name = ObjC.unwrap(items.objectAtIndex(i)); + if (!name || !name.endsWith(".app")) continue; + var appPath = searchDirs[d] + "/" + name; + var bundle = $.NSBundle.bundleWithPath($(appPath)); + if (!bundle) continue; + var bid = bundle.bundleIdentifier; + if (!bid) continue; + var bidStr = ObjC.unwrap(bid); + if (!bidStr) continue; + result.push({ bundleId: bidStr, displayName: name.slice(0, -4), path: appPath }); + } } - }) + JSON.stringify(result); + `) + return JSON.parse(raw) as InstalledApp[] } catch { return [] } @@ -209,15 +307,41 @@ export const apps: AppsAPI = { async open(bundleId) { await osascript(`tell application id "${bundleId}" to activate`) + // Give macOS time to process the window-manager event before the + // next tool call arrives (which will call prepareForAction to keep focus). + await Bun.sleep(300) }, async unhide(bundleIds) { - for (const bundleId of bundleIds) { - await osascript(` - tell application "System Events" - set visible of application process (name of application process whose bundle identifier is "${bundleId}") to true - end tell + // Use JXA so we can match by bundle ID directly and batch in one call. + if (bundleIds.length === 0) return + try { + await jxa(` + var ids = ${JSON.stringify(bundleIds)}; + var procs = Application("System Events").applicationProcesses(); + for (var i = 0; i < procs.length; i++) { + try { + var p = procs[i]; + if (ids.indexOf(p.bundleIdentifier()) !== -1) { + p.visible = true; + } + } catch(e) {} + } + "ok" `) + } catch { + // Fallback: unhide one-by-one via AppleScript name lookup. + for (const bundleId of bundleIds) { + try { + await osascript(` + tell application "System Events" + set visible of (first application process whose bundle identifier is "${bundleId}") to true + end tell + `) + } catch { + // Non-fatal. + } + } } }, } @@ -226,33 +350,77 @@ export const apps: AppsAPI = { // ScreenshotAPI // --------------------------------------------------------------------------- -async function captureScreenToBase64(args: string[]): Promise<{ base64: string; width: number; height: number }> { - const tmpFile = join(tmpdir(), `cu-screenshot-${Date.now()}.png`) - const proc = Bun.spawn(['screencapture', ...args, tmpFile], { +/** + * Parse width/height from a JPEG buffer by scanning for the SOF0/SOF2 marker. + * Returns [0, 0] if parsing fails (caller should fall back to a separate query). + */ +function readJpegDimensions(buf: Buffer): [number, number] { + let i = 2 // skip SOI marker (FF D8) + while (i + 3 < buf.length) { + if (buf[i] !== 0xff) break + const marker = buf[i + 1] + const segLen = buf.readUInt16BE(i + 2) + // SOF markers: C0 (baseline), C1, C2 (progressive) — all have dims at same offsets + if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) { + // [2 len][1 precision][2 height][2 width] + const h = buf.readUInt16BE(i + 5) + const w = buf.readUInt16BE(i + 7) + return [w, h] + } + i += 2 + segLen + } + return [0, 0] +} + +async function captureAndResizeToBase64( + captureArgs: string[], + targetW: number, + targetH: number, + quality: number, +): Promise<{ base64: string; width: number; height: number }> { + const ts = Date.now() + const tmpPng = join(tmpdir(), `cu-screenshot-${ts}.png`) + const tmpJpeg = join(tmpdir(), `cu-screenshot-${ts}.jpg`) + + const proc = Bun.spawn(['screencapture', ...captureArgs, tmpPng], { stdout: 'pipe', stderr: 'pipe', }) await proc.exited + try { - const buf = readFileSync(tmpFile) + // Resize to fit within targetW × targetH and convert to JPEG so the + // media type matches the hardcoded "image/jpeg" in toolCalls.ts. + // sips -Z scales the longest edge while preserving aspect ratio. + // formatOptions takes an integer 0-100 for JPEG quality. + const maxDim = Math.max(targetW, targetH) + const qualityInt = String(Math.round(quality * 100)) + const sips = Bun.spawn( + ['sips', '-Z', String(maxDim), '-s', 'format', 'jpeg', '-s', 'formatOptions', qualityInt, tmpPng, '--out', tmpJpeg], + { stdout: 'pipe', stderr: 'pipe' }, + ) + await sips.exited + + const buf = readFileSync(tmpJpeg) const base64 = buf.toString('base64') - const width = buf.readUInt32BE(16) - const height = buf.readUInt32BE(20) + const [width, height] = readJpegDimensions(buf) return { base64, width, height } } finally { - try { unlinkSync(tmpFile) } catch {} + try { unlinkSync(tmpPng) } catch {} + try { unlinkSync(tmpJpeg) } catch {} } } export const screenshot: ScreenshotAPI = { - async captureExcluding(_allowedBundleIds, _quality, _targetW, _targetH, displayId) { + async captureExcluding(_allowedBundleIds, quality, targetW, targetH, displayId) { const args = ['-x'] if (displayId !== undefined) args.push('-D', String(displayId)) - return captureScreenToBase64(args) + return captureAndResizeToBase64(args, targetW, targetH, quality) }, - async captureRegion(_allowedBundleIds, x, y, w, h, _outW, _outH, _quality, displayId) { + async captureRegion(_allowedBundleIds, x, y, w, h, outW, outH, quality, displayId) { const args = ['-x', '-R', `${x},${y},${w},${h}`] if (displayId !== undefined) args.push('-D', String(displayId)) - return captureScreenToBase64(args) + return captureAndResizeToBase64(args, outW, outH, quality) }, } diff --git a/packages/color-diff-napi/src/index.ts b/packages/color-diff-napi/src/index.ts index afaf924ea..65a93001c 100644 --- a/packages/color-diff-napi/src/index.ts +++ b/packages/color-diff-napi/src/index.ts @@ -188,7 +188,7 @@ type Theme = { function defaultSyntaxThemeName(themeName: string): string { if (themeName.includes('ansi')) return 'ansi' - if (themeName.includes('dark')) return 'Monokai Extended' + if (themeName.includes('dark')) return 'Royal Gold Dark' return 'GitHub' } @@ -221,6 +221,35 @@ const MONOKAI_SCOPES: Record = { subst: rgb(248, 248, 242), } +// Custom dark theme for the TUI: lower saturation, richer gold accents, and +// cooler blue-green contrast so code feels more refined on black backgrounds. +const ROYAL_GOLD_DARK_SCOPES: Record = { + keyword: rgb(254, 200, 74), + _storage: rgb(135, 195, 255), + built_in: rgb(135, 195, 255), + type: rgb(135, 195, 255), + literal: rgb(224, 164, 88), + number: rgb(224, 164, 88), + string: rgb(246, 224, 176), + title: rgb(235, 200, 141), + 'title.function': rgb(235, 200, 141), + 'title.class': rgb(235, 200, 141), + 'title.class.inherited': rgb(235, 200, 141), + params: rgb(243, 240, 232), + comment: rgb(139, 125, 107), + meta: rgb(139, 125, 107), + attr: rgb(135, 195, 255), + attribute: rgb(135, 195, 255), + variable: rgb(243, 240, 232), + 'variable.language': rgb(243, 240, 232), + property: rgb(243, 240, 232), + operator: rgb(231, 185, 76), + punctuation: rgb(229, 223, 211), + symbol: rgb(224, 164, 88), + regexp: rgb(246, 224, 176), + subst: rgb(229, 223, 211), +} + // highlight.js scope → syntect GitHub-light foreground (measured from Rust) const GITHUB_SCOPES: Record = { keyword: rgb(167, 29, 93), @@ -286,6 +315,18 @@ const ANSI_SCOPES: Record = { meta: ansiIdx(8), } +// Brand colors for diff highlighting +const BRAND_DIFF_RED = rgb(162, 0, 67) +const BRAND_DIFF_GREEN = rgb(34, 139, 34) +const BRAND_DIFF_RED_DARK_LINE = rgb(92, 0, 38) +const BRAND_DIFF_RED_DARK_WORD = rgb(132, 0, 54) +const BRAND_DIFF_GREEN_DARK_LINE = rgb(10, 74, 41) +const BRAND_DIFF_GREEN_DARK_WORD = rgb(16, 110, 60) +const BRAND_DIFF_RED_LIGHT_LINE = rgb(242, 220, 230) +const BRAND_DIFF_RED_LIGHT_WORD = rgb(228, 170, 196) +const BRAND_DIFF_GREEN_LIGHT_LINE = rgb(220, 238, 220) +const BRAND_DIFF_GREEN_LIGHT_WORD = rgb(170, 214, 170) + function buildTheme(themeName: string, mode: ColorMode): Theme { const isDark = themeName.includes('dark') const isAnsi = themeName.includes('ansi') @@ -308,57 +349,57 @@ function buildTheme(themeName: string, mode: ColorMode): Theme { if (isDark) { const fg = rgb(248, 248, 242) - const deleteLine = rgb(61, 1, 0) - const deleteWord = rgb(92, 2, 0) - const deleteDecoration = rgb(220, 90, 90) + const deleteLine = BRAND_DIFF_RED_DARK_LINE + const deleteWord = BRAND_DIFF_RED_DARK_WORD + const deleteDecoration = BRAND_DIFF_RED if (isDaltonized) { return { addLine: tc ? rgb(0, 27, 41) : ansiIdx(17), addWord: tc ? rgb(0, 48, 71) : ansiIdx(24), addDecoration: rgb(81, 160, 200), - deleteLine, - deleteWord, - deleteDecoration, + deleteLine: rgb(61, 1, 0), + deleteWord: rgb(92, 2, 0), + deleteDecoration: rgb(220, 90, 90), foreground: fg, background: DEFAULT_BG, - scopes: MONOKAI_SCOPES, + scopes: ROYAL_GOLD_DARK_SCOPES, } } return { - addLine: tc ? rgb(2, 40, 0) : ansiIdx(22), - addWord: tc ? rgb(4, 71, 0) : ansiIdx(28), - addDecoration: rgb(80, 200, 80), + addLine: tc ? BRAND_DIFF_GREEN_DARK_LINE : BRAND_DIFF_GREEN_DARK_LINE, + addWord: tc ? BRAND_DIFF_GREEN_DARK_WORD : BRAND_DIFF_GREEN_DARK_WORD, + addDecoration: BRAND_DIFF_GREEN, deleteLine, deleteWord, deleteDecoration, foreground: fg, background: DEFAULT_BG, - scopes: MONOKAI_SCOPES, + scopes: ROYAL_GOLD_DARK_SCOPES, } } // light const fg = rgb(51, 51, 51) - const deleteLine = rgb(255, 220, 220) - const deleteWord = rgb(255, 199, 199) - const deleteDecoration = rgb(207, 34, 46) + const deleteLine = BRAND_DIFF_RED_LIGHT_LINE + const deleteWord = BRAND_DIFF_RED_LIGHT_WORD + const deleteDecoration = BRAND_DIFF_RED if (isDaltonized) { return { - addLine: rgb(219, 237, 255), - addWord: rgb(179, 217, 255), - addDecoration: rgb(36, 87, 138), - deleteLine, - deleteWord, - deleteDecoration, + addLine: BRAND_DIFF_GREEN_LIGHT_LINE, + addWord: BRAND_DIFF_GREEN_LIGHT_WORD, + addDecoration: BRAND_DIFF_GREEN, + deleteLine: rgb(255, 220, 220), + deleteWord: rgb(255, 199, 199), + deleteDecoration: rgb(207, 34, 46), foreground: fg, background: DEFAULT_BG, scopes: GITHUB_SCOPES, } } return { - addLine: rgb(220, 255, 220), - addWord: rgb(178, 255, 178), - addDecoration: rgb(36, 138, 61), + addLine: BRAND_DIFF_GREEN_LIGHT_LINE, + addWord: BRAND_DIFF_GREEN_LIGHT_WORD, + addDecoration: BRAND_DIFF_GREEN, deleteLine, deleteWord, deleteDecoration, diff --git a/packages/image-processor-napi/src/index.ts b/packages/image-processor-napi/src/index.ts index ee90c3a2b..7808a1574 100644 --- a/packages/image-processor-napi/src/index.ts +++ b/packages/image-processor-napi/src/index.ts @@ -7,13 +7,13 @@ interface NativeModule { readClipboardImage( maxWidth?: number, maxHeight?: number, - ): { + ): Promise<{ png: Buffer width: number height: number originalWidth: number originalHeight: number - } | null + } | null> } function createDarwinNativeModule(): NativeModule { @@ -36,13 +36,14 @@ function createDarwinNativeModule(): NativeModule { } }, - readClipboardImage( + async readClipboardImage( maxWidth?: number, maxHeight?: number, ) { try { - // Use osascript to read clipboard image as PNG data and write to a temp file, - // then read the temp file back + // Use async Bun.spawn (not spawnSync) so the event loop stays alive + // while osascript writes the clipboard PNG to disk. spawnSync blocks + // the main thread and freezes the Ink/React UI ("Pasting text…" stall). const tmpPath = `/tmp/claude_clipboard_native_${Date.now()}.png` const script = ` set png_data to (the clipboard as «class PNGf») @@ -51,22 +52,19 @@ write png_data to fp close access fp return "${tmpPath}" ` - const result = Bun.spawnSync({ - cmd: ['osascript', '-e', script], + const proc = Bun.spawn(['osascript', '-e', script], { stdout: 'pipe', stderr: 'pipe', }) + await proc.exited - if (result.exitCode !== 0) { + if (proc.exitCode !== 0) { return null } - const file = Bun.file(tmpPath) - // Use synchronous read via Node compat - const fs = require('fs') + const fs = require('fs') as typeof import('fs') const buffer: Buffer = fs.readFileSync(tmpPath) - // Clean up temp file try { fs.unlinkSync(tmpPath) } catch { @@ -77,9 +75,7 @@ return "${tmpPath}" return null } - // Read PNG dimensions from IHDR chunk - // PNG header: 8 bytes signature, then IHDR chunk - // IHDR starts at offset 8 (4 bytes length) + 4 bytes "IHDR" + 4 bytes width + 4 bytes height + // Read PNG dimensions from IHDR chunk (offset 16/20). let width = 0 let height = 0 if (buffer.length > 24 && buffer[12] === 0x49 && buffer[13] === 0x48 && buffer[14] === 0x44 && buffer[15] === 0x52) { @@ -90,9 +86,6 @@ return "${tmpPath}" const originalWidth = width const originalHeight = height - // If maxWidth/maxHeight are specified and the image exceeds them, - // we still return the full PNG - the caller handles resizing via sharp - // But we report the capped dimensions if (maxWidth && maxHeight) { if (width > maxWidth || height > maxHeight) { const scale = Math.min(maxWidth / width, maxHeight / height) @@ -101,13 +94,7 @@ return "${tmpPath}" } } - return { - png: buffer, - width, - height, - originalWidth, - originalHeight, - } + return { png: buffer, width, height, originalWidth, originalHeight } } catch { return null } diff --git a/scripts/dev.ts b/scripts/dev.ts index 0b35f4bc8..ac844bc8a 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -24,15 +24,18 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [ // Bun --feature flags: enable feature() gates at runtime. // Default features enabled in dev mode. const DEFAULT_FEATURES = [ - "BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE", - "AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE", - "SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET", - // P0: local features + "BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE", "AGENT_TRIGGERS_REMOTE", + "CHICAGO_MCP", "VOICE_MODE", "SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", + "TOKEN_BUDGET", "NATIVE_CLIPBOARD_IMAGE", "MCP_SKILLS", "TEMPLATES", + "COORDINATOR_MODE", "MCP_RICH_OUTPUT", "MESSAGE_ACTIONS", "HISTORY_PICKER", + "QUICK_SEARCH", "CACHED_MICROCOMPACT", "REACTIVE_COMPACT", "FILE_PERSISTENCE", + "DUMP_SYSTEM_PROMPT", "BREAK_CACHE_COMMAND", + // P0: local features (upstream) "AGENT_TRIGGERS", "ULTRATHINK", "BUILTIN_EXPLORE_PLAN_AGENTS", "LODESTONE", - // P1: API-dependent features + // P1: API-dependent features (upstream) "EXTRACT_MEMORIES", "VERIFICATION_AGENT", "KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN", // P2: daemon + remote control server diff --git a/src/commands/buddy/buddy.ts b/src/commands/buddy/buddy.ts index 8d14ab4e6..5460f75d3 100644 --- a/src/commands/buddy/buddy.ts +++ b/src/commands/buddy/buddy.ts @@ -1,4 +1,3 @@ -import React from 'react' import { getCompanion, rollWithSeed, @@ -6,7 +5,6 @@ import { } from '../../buddy/companion.js' import { type StoredCompanion, RARITY_STARS } from '../../buddy/types.js' import { renderSprite } from '../../buddy/sprites.js' -import { CompanionCard } from '../../buddy/CompanionCard.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { triggerCompanionReaction } from '../../buddy/companionReact.js' import type { ToolUseContext } from '../../Tool.js' @@ -67,6 +65,22 @@ function speciesLabel(species: string): string { return species.charAt(0).toUpperCase() + species.slice(1) } +function renderStats(stats: Record): string { + const lines = [ + 'DEBUGGING', + 'PATIENCE', + 'CHAOS', + 'WISDOM', + 'SNARK', + ].map(name => { + const val = stats[name] ?? 0 + const filled = Math.round(val / 5) + const bar = '█'.repeat(filled) + '░'.repeat(20 - filled) + return ` ${name.padEnd(10)} ${bar} ${val}` + }) + return lines.join('\n') +} + export async function call( onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext, @@ -75,20 +89,61 @@ export async function call( const sub = args?.trim().toLowerCase() ?? '' const setState = context.setAppState - // ── /buddy off — mute companion ── - if (sub === 'off') { + // ── /buddy mute — mute companion ── + if (sub === 'mute') { saveGlobalConfig(cfg => ({ ...cfg, companionMuted: true })) onDone('companion muted', { display: 'system' }) return null } - // ── /buddy on — unmute companion ── - if (sub === 'on') { + // ── /buddy unmute — unmute companion ── + if (sub === 'unmute') { saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false })) onDone('companion unmuted', { display: 'system' }) return null } + // ── /buddy rehatch — re-roll a new companion (replaces existing) ── + if (sub === 'rehatch') { + const seed = generateSeed() + const r = rollWithSeed(seed) + const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy' + const personality = + SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.' + + const stored: StoredCompanion = { + name, + personality, + seed, + hatchedAt: Date.now(), + } + + saveGlobalConfig(cfg => ({ ...cfg, companion: stored })) + + const stars = RARITY_STARS[r.bones.rarity] + const sprite = renderSprite(r.bones, 0) + const shiny = r.bones.shiny ? ' ✨ Shiny!' : '' + + const lines = [ + '🎉 A new companion appeared!', + '', + ...sprite, + '', + ` ${name} the ${speciesLabel(r.bones.species)}${shiny}`, + ` Rarity: ${stars} (${r.bones.rarity})`, + ` Eye: ${r.bones.eye} Hat: ${r.bones.hat}`, + '', + ` "${personality}"`, + '', + ' Stats:', + renderStats(r.bones.stats), + '', + ' Your old companion has been replaced!', + ] + onDone(lines.join('\n'), { display: 'system' }) + return null + } + // ── /buddy pet — trigger heart animation + auto unmute ── if (sub === 'pet') { const companion = getCompanion() @@ -123,16 +178,29 @@ export async function call( } if (companion) { - // Return JSX card — matches official vc8 component - const lastReaction = context.getAppState?.()?.companionReaction - return React.createElement(CompanionCard, { - companion, - lastReaction, - onDone, - }) + // Show text-based companion info with 20-char stats + const stars = RARITY_STARS[companion.rarity] + const sprite = renderSprite(companion, 0) + const shiny = companion.shiny ? ' ✨ Shiny!' : '' + + const lines = [ + ...sprite, + '', + ` ${companion.name} the ${speciesLabel(companion.species)}${shiny}`, + ` Rarity: ${stars} (${companion.rarity})`, + ` Eye: ${companion.eye} Hat: ${companion.hat}`, + companion.personality ? `\n "${companion.personality}"` : '', + '', + ' Stats:', + renderStats(companion.stats), + '', + ' Commands: /buddy pet /buddy mute /buddy unmute /buddy rehatch', + ] + onDone(lines.join('\n'), { display: 'system' }) + return null } - // ── No companion → hatch ── + // ── No companion → auto hatch ── const seed = generateSeed() const r = rollWithSeed(seed) const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy' @@ -150,19 +218,23 @@ export async function call( const stars = RARITY_STARS[r.bones.rarity] const sprite = renderSprite(r.bones, 0) - const shiny = r.bones.shiny ? ' \u2728 Shiny!' : '' + const shiny = r.bones.shiny ? ' ✨ Shiny!' : '' const lines = [ - 'A wild companion appeared!', + '🎉 A wild companion appeared!', '', ...sprite, '', - `${name} the ${speciesLabel(r.bones.species)}${shiny}`, - `Rarity: ${stars} (${r.bones.rarity})`, - `"${personality}"`, + ` ${name} the ${speciesLabel(r.bones.species)}${shiny}`, + ` Rarity: ${stars} (${r.bones.rarity})`, + ` Eye: ${r.bones.eye} Hat: ${r.bones.hat}`, + '', + ` "${personality}"`, + '', + ' Stats:', + renderStats(r.bones.stats), '', - 'Your companion will now appear beside your input box!', - 'Say its name to get its take \u00b7 /buddy pet \u00b7 /buddy off', + ' Your companion will now appear beside your input box!', ] onDone(lines.join('\n'), { display: 'system' }) return null diff --git a/src/commands/buddy/index.ts b/src/commands/buddy/index.ts index 8df683028..839bf4120 100644 --- a/src/commands/buddy/index.ts +++ b/src/commands/buddy/index.ts @@ -4,8 +4,8 @@ import { isBuddyLive } from '../../buddy/useBuddyNotification.js' const buddy = { type: 'local-jsx', name: 'buddy', - description: 'Hatch a coding companion · pet, off', - argumentHint: '[pet|off]', + description: 'Coding companion · pet, rehatch, mute, unmute', + argumentHint: '[pet|rehatch|mute|unmute]', immediate: true, get isHidden() { return !isBuddyLive() diff --git a/src/components/FullscreenLayout.tsx b/src/components/FullscreenLayout.tsx index 8502e46de..2ff4c6ca6 100644 --- a/src/components/FullscreenLayout.tsx +++ b/src/components/FullscreenLayout.tsx @@ -372,19 +372,13 @@ export function FullscreenLayout({ // bottom); REPL re-pins on the overlay appear/dismiss transition for // the case where sticky was broken. Tall dialogs (FileEdit diffs) still // get PgUp/PgDn/wheel — same scrollRef drives the same ScrollBox. - // Three sticky states: null (at bottom), {text,scrollTo} (scrolled up, - // header shows), 'clicked' (just clicked header — hide it so the - // content ❯ takes row 0). padCollapsed covers the latter two: once - // scrolled away from bottom, padding drops to 0 and stays there until - // repin. headerVisible is only the middle state. After click: - // scrollBox_y=0 (header gone) + padding=0 → viewportTop=0 → ❯ at - // row 0. On next scroll the onChange fires with a fresh {text} and - // header comes back (viewportTop 0→1, a single 1-row shift — - // acceptable since user explicitly scrolled). + // Two sticky header states: null (at bottom or hidden via hideSticky), + // {text,scrollTo} (scrolled up, header shows). 'clicked' hides the + // header so content ❯ takes row 0. On next scroll the onChange fires + // with a fresh {text} and header comes back. const sticky = hideSticky ? null : stickyPrompt const headerPrompt = sticky != null && sticky !== 'clicked' && overlay == null ? sticky : null - const padCollapsed = sticky != null && overlay == null return ( @@ -398,7 +392,7 @@ export function FullscreenLayout({ ref={scrollRef} flexGrow={1} flexDirection="column" - paddingTop={padCollapsed ? 0 : 1} + paddingTop={0} stickyScroll > diff --git a/src/components/HighlightedCode.tsx b/src/components/HighlightedCode.tsx index 47f7271bc..b0172cc2f 100644 --- a/src/components/HighlightedCode.tsx +++ b/src/components/HighlightedCode.tsx @@ -15,6 +15,7 @@ import sliceAnsi from '../utils/sliceAnsi.js' import { countCharInString } from '../utils/stringUtils.js' import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js' import { expectColorFile } from './StructuredDiff/colorDiff.js' +import { useSettings } from '../hooks/useSettings.js' type Props = { code: string @@ -34,9 +35,9 @@ export const HighlightedCode = memo(function HighlightedCode({ const ref = useRef(null) const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH) const [theme] = useTheme() + const settings = useSettings() - const syntaxHighlightingDisabled = - settings.syntaxHighlightingDisabled ?? false + const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false const colorFile = useMemo(() => { if (syntaxHighlightingDisabled) { @@ -69,7 +70,7 @@ export const HighlightedCode = memo(function HighlightedCode({ // line number (max_digits = lineCount.toString().length) + space. No marker // column like the diff path. Wrap in so fullscreen selection // yields clean code without line numbers. Only split in fullscreen mode - // (~4× DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native + // (~4x DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native // selection where noSelect is meaningless. const gutterWidth = useMemo(() => { if (!isFullscreenEnvEnabled()) return 0 @@ -96,7 +97,7 @@ export const HighlightedCode = memo(function HighlightedCode({ code={code} filePath={filePath} dim={dim} - skipColoring={syntaxHighlightingDisabled} + skipColoring={false} /> )} diff --git a/src/components/LogoV2/ExperimentEnrollmentNotice.tsx b/src/components/LogoV2/ExperimentEnrollmentNotice.tsx new file mode 100644 index 000000000..6210eb20c --- /dev/null +++ b/src/components/LogoV2/ExperimentEnrollmentNotice.tsx @@ -0,0 +1,9 @@ +import * as React from 'react' + +/** + * Internal-only component. Shows experiment enrollment status for internal + * users. Stubbed — returns null in non-internal builds. + */ +export function ExperimentEnrollmentNotice(): React.ReactNode { + return null +} diff --git a/src/components/LogoV2/GateOverridesWarning.tsx b/src/components/LogoV2/GateOverridesWarning.tsx new file mode 100644 index 000000000..32325eefa --- /dev/null +++ b/src/components/LogoV2/GateOverridesWarning.tsx @@ -0,0 +1,10 @@ +import * as React from 'react' + +/** + * Internal-only component. Displays a warning when feature-gate overrides + * (CLAUDE_INTERNAL_FC_OVERRIDES) are active. Stubbed — returns null in + * non-internal builds. + */ +export function GateOverridesWarning(): React.ReactNode { + return null +} diff --git a/src/components/LogoV2/LogoV2.tsx b/src/components/LogoV2/LogoV2.tsx index d65c24fe3..dd7cf04e8 100644 --- a/src/components/LogoV2/LogoV2.tsx +++ b/src/components/LogoV2/LogoV2.tsx @@ -49,6 +49,8 @@ import { import { EmergencyTip } from './EmergencyTip.js' import { VoiceModeNotice } from './VoiceModeNotice.js' import { Opus1mMergeNotice } from './Opus1mMergeNotice.js' +import { GateOverridesWarning } from './GateOverridesWarning.js' +import { ExperimentEnrollmentNotice } from './ExperimentEnrollmentNotice.js' import { feature } from 'bun:bundle' // Conditional require so ChannelsNotice.tsx tree-shakes when both flags are diff --git a/src/components/Spinner/GlimmerMessage.tsx b/src/components/Spinner/GlimmerMessage.tsx index 3e488f9a1..2b8cc3c9b 100644 --- a/src/components/Spinner/GlimmerMessage.tsx +++ b/src/components/Spinner/GlimmerMessage.tsx @@ -16,8 +16,6 @@ type Props = { stalledIntensity?: number } -const ERROR_RED = { r: 171, g: 43, b: 63 } - export function GlimmerMessage({ message, mode, @@ -25,7 +23,6 @@ export function GlimmerMessage({ glimmerIndex, flashOpacity, shimmerColor, - stalledIntensity = 0, }: Props): React.ReactNode { const [themeName] = useTheme() const theme = getTheme(themeName) @@ -43,36 +40,6 @@ export function GlimmerMessage({ if (!message) return null - // When stalled, show text that smoothly transitions to red - if (stalledIntensity > 0) { - const baseColorStr = theme[messageColor] - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null - - if (baseRGB) { - const interpolated = interpolateColor( - baseRGB, - ERROR_RED, - stalledIntensity, - ) - const color = toRGBColor(interpolated) - return ( - <> - {message} - - - ) - } - - // Fallback for ANSI themes: use messageColor until fully stalled, then error - const color = stalledIntensity > 0.5 ? 'error' : messageColor - return ( - <> - {message} - - - ) - } - // tool-use mode: all chars flash with the same opacity, so render as a // single instead of N individual FlashingChar components. if (mode === 'tool-use') { diff --git a/src/components/Spinner/SpinnerAnimationRow.tsx b/src/components/Spinner/SpinnerAnimationRow.tsx index 93b2fc64a..da7f98d81 100644 --- a/src/components/Spinner/SpinnerAnimationRow.tsx +++ b/src/components/Spinner/SpinnerAnimationRow.tsx @@ -334,7 +334,6 @@ export function SpinnerAnimationRow({ @@ -345,7 +344,6 @@ export function SpinnerAnimationRow({ glimmerIndex={glimmerIndex} flashOpacity={flashOpacity} shimmerColor={shimmerColor} - stalledIntensity={overrideColor ? 0 : stalledIntensity} /> {status} diff --git a/src/components/Spinner/SpinnerGlyph.tsx b/src/components/Spinner/SpinnerGlyph.tsx index 242d05971..4d2a5d167 100644 --- a/src/components/Spinner/SpinnerGlyph.tsx +++ b/src/components/Spinner/SpinnerGlyph.tsx @@ -8,79 +8,47 @@ import { toRGBColor, } from './utils.js' -const DEFAULT_CHARACTERS = getDefaultCharacters() +const DEFAULT_CHARACTERS = getDefaultCharacters(); const SPINNER_FRAMES = [ ...DEFAULT_CHARACTERS, ...[...DEFAULT_CHARACTERS].reverse(), -] +]; -const REDUCED_MOTION_DOT = '●' -const REDUCED_MOTION_CYCLE_MS = 2000 // 2-second cycle: 1s visible, 1s dim -const ERROR_RED = { r: 171, g: 43, b: 63 } +const REDUCED_MOTION_DOT = '●'; +const REDUCED_MOTION_CYCLE_MS = 2000; // 2-second cycle: 1s visible, 1s dim type Props = { - frame: number - messageColor: keyof Theme - stalledIntensity?: number - reducedMotion?: boolean - time?: number -} + frame: number; + messageColor: keyof Theme; + stalledIntensity?: number; + reducedMotion?: boolean; + time?: number; +}; export function SpinnerGlyph({ frame, messageColor, - stalledIntensity = 0, reducedMotion = false, time = 0, }: Props): React.ReactNode { - const [themeName] = useTheme() - const theme = getTheme(themeName) - // Reduced motion: slowly flashing orange dot if (reducedMotion) { - const isDim = Math.floor(time / (REDUCED_MOTION_CYCLE_MS / 2)) % 2 === 1 + const isDim = Math.floor(time / (REDUCED_MOTION_CYCLE_MS / 2)) % 2 === 1; return ( {REDUCED_MOTION_DOT} - ) + ); } - const spinnerChar = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] - - // Smoothly interpolate from current color to red when stalled - if (stalledIntensity > 0) { - const baseColorStr = theme[messageColor] - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null - - if (baseRGB) { - const interpolated = interpolateColor( - baseRGB, - ERROR_RED, - stalledIntensity, - ) - return ( - - {spinnerChar} - - ) - } - - // Fallback for ANSI themes - const color = stalledIntensity > 0.5 ? 'error' : messageColor - return ( - - {spinnerChar} - - ) - } + const spinnerChar = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]; return ( {spinnerChar} - ) + ); } diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx index b14bcfd2c..c3ff7e184 100644 --- a/src/components/ThemePicker.tsx +++ b/src/components/ThemePicker.tsx @@ -1,6 +1,8 @@ import { feature } from 'bun:bundle' import * as React from 'react' import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useSettings } from '../hooks/useSettings.js' +import { useAppState, useSetAppState } from '../state/AppState.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' import { Box, @@ -12,7 +14,6 @@ import { import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js' import { useKeybinding } from '../keybindings/useKeybinding.js' import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { useAppState, useSetAppState } from '../state/AppState.js' import { gracefulShutdown } from '../utils/gracefulShutdown.js' import { updateSettingsForSource } from '../utils/settings/settings.js' import type { ThemeSetting } from '../utils/theme.js' @@ -48,24 +49,25 @@ export function ThemePicker({ }: ThemePickerProps): React.ReactNode { const [theme] = useTheme() const themeSetting = useThemeSetting() + const settings = useSettings() + const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false + const setAppState = useSetAppState() + + const syntaxToggleShortcut = useShortcutDisplay( + 'theme:toggleSyntaxHighlighting', + 'ThemePicker', + 'ctrl+t', + ) const { columns } = useTerminalSize() const colorModuleUnavailableReason = getColorModuleUnavailableReason() const syntaxTheme = colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme() - const syntaxHighlightingDisabled = - useAppState(s => s.settings.syntaxHighlightingDisabled) ?? false - const setAppState = useSetAppState() // Register ThemePicker context so its keybindings take precedence over Global useRegisterKeybindingContext('ThemePicker') - const syntaxToggleShortcut = useShortcutDisplay( - 'theme:toggleSyntaxHighlighting', - 'ThemePicker', - 'ctrl+t', - ) - + // Toggle syntax highlighting with Ctrl+T useKeybinding( 'theme:toggleSyntaxHighlighting', () => { @@ -82,6 +84,7 @@ export function ThemePicker({ }, { context: 'ThemePicker' }, ) + // Always call the hook to follow React rules, but conditionally assign the exit handler const exitState = useExitOnCtrlCDWithKeybindings( skipExitHandling ? () => {} : undefined, @@ -115,7 +118,7 @@ export function ThemePicker({ {showIntroText ? ( - Let's get started. + Let's get started. ) : ( Theme @@ -183,10 +186,10 @@ export function ThemePicker({ {' '} - {colorModuleUnavailableReason === 'env' - ? `Syntax highlighting disabled (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})` - : syntaxHighlightingDisabled - ? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)` + {syntaxHighlightingDisabled + ? `Syntax highlighting disabled (${syntaxToggleShortcut} to enable)` + : colorModuleUnavailableReason === 'env' + ? `Syntax highlighting unavailable (via CLAUDE_CODE_SYNTAX_HIGHLIGHT=${process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT})` : syntaxTheme ? `Syntax theme: ${syntaxTheme.theme}${syntaxTheme.source ? ` (from ${syntaxTheme.source})` : ''} (${syntaxToggleShortcut} to disable)` : `Syntax highlighting enabled (${syntaxToggleShortcut} to disable)`} diff --git a/src/hooks/usePasteHandler.ts b/src/hooks/usePasteHandler.ts index d6257b9a2..0a4bcc9c6 100644 --- a/src/hooks/usePasteHandler.ts +++ b/src/hooks/usePasteHandler.ts @@ -135,9 +135,17 @@ export function usePasteHandler({ pastedText, ) - // Process all image paths + // Process all image paths. Each path catches its own errors so + // Promise.all always resolves — ImageResizeError thrown by + // maybeResizeAndDownsampleImageBuffer would otherwise silently + // reject the whole chain and leave isPasting stuck at true. void Promise.all( - imagePaths.map(imagePath => tryReadImageFromPath(imagePath)), + imagePaths.map(imagePath => + tryReadImageFromPath(imagePath).catch(err => { + logError(err as Error) + return null + }), + ), ).then(results => { const validImages = results.filter( (r): r is NonNullable => r !== null, @@ -164,7 +172,8 @@ export function usePasteHandler({ } setIsPasting(false) } else if (isTempScreenshot && isMacOS) { - // For temporary screenshot files that no longer exist, try clipboard + // For temporary screenshot files that no longer exist, try clipboard. + // checkClipboardForImage clears isPasting in its .finally(). checkClipboardForImage() } else { if (onPaste) { @@ -172,6 +181,9 @@ export function usePasteHandler({ } setIsPasting(false) } + }).catch(() => { + // Safety net: clear isPasting even if .then() itself throws. + setIsPasting(false) }) return { chunks: [], timeoutId: null } } diff --git a/src/services/api/openai/convertMessages.ts b/src/services/api/openai/convertMessages.ts index 051b43d69..3869120eb 100644 --- a/src/services/api/openai/convertMessages.ts +++ b/src/services/api/openai/convertMessages.ts @@ -92,6 +92,15 @@ function convertInternalUserMessage( } } + // CRITICAL: tool messages must come BEFORE any user message in the result. + // OpenAI API requires that a tool message immediately follows the assistant + // message with tool_calls. If we emit a user message first, the API will + // reject the request with "insufficient tool messages following tool_calls". + // See: https://github.com/anthropics/claude-code/issues/xxx + for (const tr of toolResults) { + result.push(convertToolResult(tr)) + } + // 如果有图片,构建多模态 content 数组 if (imageParts.length > 0) { const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = [] @@ -109,10 +118,6 @@ function convertInternalUserMessage( content: textParts.join('\n'), } satisfies ChatCompletionUserMessageParam) } - - for (const tr of toolResults) { - result.push(convertToolResult(tr)) - } } return result diff --git a/src/tools/FileReadTool/FileReadTool.ts b/src/tools/FileReadTool/FileReadTool.ts index 45c676e90..e4796130a 100644 --- a/src/tools/FileReadTool/FileReadTool.ts +++ b/src/tools/FileReadTool/FileReadTool.ts @@ -1128,9 +1128,39 @@ export async function readImageWithTokenBudget( resized.dimensions, ) } catch (e) { - if (e instanceof ImageResizeError) throw e - logError(e) - result = createImageResponse(imageBuffer, detectedFormat, originalSize) + if (e instanceof ImageResizeError) { + // Sharp failed on an oversized image. On macOS, try sips (built-in) as + // a fallback — it can resize+JPEG-convert any PNG without native deps. + if (process.platform === 'darwin') { + try { + const { tmpdir } = await import('os') + const { join: joinPath } = await import('path') + const { readFileSync, unlinkSync } = await import('fs') + const tmpOut = joinPath(tmpdir(), `claude-resize-${Date.now()}.jpg`) + const sips = Bun.spawn( + ['sips', '-Z', '1920', '-s', 'format', 'jpeg', '-s', 'formatOptions', '75', + filePath, '--out', tmpOut], + { stdout: 'pipe', stderr: 'pipe' }, + ) + await sips.exited + if (sips.exitCode === 0) { + const buf = readFileSync(tmpOut) + try { unlinkSync(tmpOut) } catch {} + result = createImageResponse(buf, 'jpeg', originalSize) + } else { + try { unlinkSync(tmpOut) } catch {} + result = createImageResponse(imageBuffer, detectedFormat, originalSize) + } + } catch { + result = createImageResponse(imageBuffer, detectedFormat, originalSize) + } + } else { + result = createImageResponse(imageBuffer, detectedFormat, originalSize) + } + } else { + logError(e) + result = createImageResponse(imageBuffer, detectedFormat, originalSize) + } } // Check if it fits in token budget @@ -1174,6 +1204,27 @@ export async function readImageWithTokenBudget( return createImageResponse(fallbackBuffer, 'jpeg', originalSize) } catch (error) { logError(error) + // sips last resort (macOS): sharp is unavailable/broken, fall back to system tool + if (process.platform === 'darwin') { + try { + const { tmpdir } = await import('os') + const { join: joinPath } = await import('path') + const { readFileSync, unlinkSync } = await import('fs') + const tmpOut = joinPath(tmpdir(), `claude-resize-${Date.now()}.jpg`) + const sips = Bun.spawn( + ['sips', '-Z', '800', '-s', 'format', 'jpeg', '-s', 'formatOptions', '50', + filePath, '--out', tmpOut], + { stdout: 'pipe', stderr: 'pipe' }, + ) + await sips.exited + if (sips.exitCode === 0) { + const buf = readFileSync(tmpOut) + try { unlinkSync(tmpOut) } catch {} + return createImageResponse(buf, 'jpeg', originalSize) + } + try { unlinkSync(tmpOut) } catch {} + } catch {} + } return createImageResponse(imageBuffer, detectedFormat, originalSize) } } diff --git a/src/utils/computerUse/escHotkey.ts b/src/utils/computerUse/escHotkey.ts index 24ba17cc4..a808f93ba 100644 --- a/src/utils/computerUse/escHotkey.ts +++ b/src/utils/computerUse/escHotkey.ts @@ -26,7 +26,7 @@ export function registerEscHotkey(onEscape: () => void): boolean { if (process.platform !== 'darwin') return false if (registered) return true const cu = requireComputerUseSwift() - if (!(cu as any).hotkey.registerEscape(onEscape)) { + if (!(cu as any).hotkey?.registerEscape?.(onEscape)) { // CGEvent.tapCreate failed — typically missing Accessibility permission. // CU still works, just without ESC abort. Mirrors Cowork's escAbort.ts:81. logForDebugging('[cu-esc] registerEscape returned false', { level: 'warn' }) @@ -41,7 +41,7 @@ export function registerEscHotkey(onEscape: () => void): boolean { export function unregisterEscHotkey(): void { if (!registered) return try { - (requireComputerUseSwift() as any).hotkey.unregister() + (requireComputerUseSwift() as any).hotkey?.unregister?.() } finally { releasePump() registered = false @@ -51,5 +51,5 @@ export function unregisterEscHotkey(): void { export function notifyExpectedEscape(): void { if (!registered) return - (requireComputerUseSwift() as any).hotkey.notifyExpectedEscape() + (requireComputerUseSwift() as any).hotkey?.notifyExpectedEscape?.() } diff --git a/src/utils/computerUse/executor.ts b/src/utils/computerUse/executor.ts index 346ac7d50..218aaad83 100644 --- a/src/utils/computerUse/executor.ts +++ b/src/utils/computerUse/executor.ts @@ -428,12 +428,19 @@ export function createCliExecutor(opts: { ), ) // Ensure the result has fields expected by toolCalls.ts (hidden, displayId). - // macOS native returns these from Swift; our cross-platform ComputerUseAPI + // macOS native returns these from Swift; our AppleScript/screencapture backend // returns {base64, width, height} — fill in the missing fields. + // displayWidth/displayHeight are the LOGICAL display dimensions (not image px). + // scaleCoord() uses displayWidth/screenshotWidth as the px→pt ratio, so + // these must be correct or every click lands at (0,0) (Apple menu). return { ...raw, hidden: (raw as any).hidden ?? [], displayId: (raw as any).displayId ?? opts.preferredDisplayId ?? d.displayId, + displayWidth: (raw as any).displayWidth ?? d.width, + displayHeight: (raw as any).displayHeight ?? d.height, + originX: (raw as any).originX ?? (d as any).originX ?? 0, + originY: (raw as any).originY ?? (d as any).originY ?? 0, } }, @@ -452,7 +459,7 @@ export function createCliExecutor(opts: { d.height, d.scaleFactor, ) - return drainRunLoop(() => + const raw = await drainRunLoop(() => cu.screenshot.captureExcluding( withoutTerminal(opts.allowedBundleIds), SCREENSHOT_JPEG_QUALITY, @@ -461,6 +468,18 @@ export function createCliExecutor(opts: { opts.displayId, ), ) + // Fill in displayWidth/displayHeight/originX/Y so scaleCoord() can + // convert image-pixel coordinates to logical display points. Without + // these, scaleCoord() computes NaN → 0, and every click lands at + // (0,0) which is the Apple menu (top-left corner of the screen). + return { + ...raw, + displayWidth: (raw as any).displayWidth ?? d.width, + displayHeight: (raw as any).displayHeight ?? d.height, + originX: (raw as any).originX ?? (d as any).originX ?? 0, + originY: (raw as any).originY ?? (d as any).originY ?? 0, + displayId: opts.displayId ?? d.displayId, + } }, async zoom( @@ -548,7 +567,20 @@ export function createCliExecutor(opts: { async type(text: string, opts: { viaClipboard: boolean }): Promise { const input = requireComputerUseInput() - if (opts.viaClipboard) { + // On macOS, System Events `keystroke` has two failure modes: + // 1. Non-ASCII (CJK, emoji): no keyboard mapping → falls back to + // keyCode 0 ('a'), so "你好" types as "aa". + // 2. ASCII with an active CJK IME: letters enter IME composition mode, + // and a subsequent space is consumed as "select candidate" instead + // of inserting a space (e.g. "我是 Claude Code" → "我是 ClaudeCode"). + // Clipboard paste bypasses the IME pipeline entirely for both cases. + // On other platforms there is no IME-vs-keystroke conflict, so only + // non-ASCII needs the clipboard path there. + const needsClipboard = + opts.viaClipboard || + process.platform === 'darwin' || + /[^\x00-\x7F]/.test(text) + if (needsClipboard) { await drainRunLoop(() => typeViaClipboard(input, text)) return } diff --git a/src/utils/computerUse/hostAdapter.ts b/src/utils/computerUse/hostAdapter.ts index acefbaa3d..93f4b1050 100644 --- a/src/utils/computerUse/hostAdapter.ts +++ b/src/utils/computerUse/hostAdapter.ts @@ -7,7 +7,6 @@ import { logForDebugging } from '../debug.js' import { COMPUTER_USE_MCP_SERVER_NAME } from './common.js' import { createCliExecutor } from './executor.js' import { getChicagoEnabled, getChicagoSubGates } from './gates.js' -import { requireComputerUseSwift } from './swiftLoader.js' class DebugLogger implements Logger { silly(message: string, ...args: unknown[]): void { @@ -46,12 +45,35 @@ export function getComputerUseHostAdapter(): ComputerUseHostAdapter { }), ensureOsPermissions: async () => { if (process.platform !== 'darwin') return { granted: true } - const cu = requireComputerUseSwift() - const accessibility = (cu as any).tcc.checkAccessibility() - const screenRecording = (cu as any).tcc.checkScreenRecording() - return accessibility && screenRecording - ? { granted: true } - : { granted: false, accessibility, screenRecording } + // Use Bun FFI to call TCC APIs in-process so macOS checks the CURRENT + // process's permissions (iTerm/Bun), not a subprocess like osascript. + // Same dlopen pattern as packages/modifiers-napi/src/index.ts. + try { + const ffi = require('bun:ffi') as typeof import('bun:ffi') + + // AXIsProcessTrusted() — no args, checks accessibility for this process. + const axLib = ffi.dlopen( + '/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices', + { AXIsProcessTrusted: { args: [], returns: ffi.FFIType.bool } }, + ) + const accessibility = Boolean(axLib.symbols.AXIsProcessTrusted()) + axLib.close() + + // CGPreflightScreenCaptureAccess() — checks screen recording without prompting (macOS 11+). + const cgLib = ffi.dlopen( + '/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics', + { CGPreflightScreenCaptureAccess: { args: [], returns: ffi.FFIType.bool } }, + ) + const screenRecording = Boolean(cgLib.symbols.CGPreflightScreenCaptureAccess()) + cgLib.close() + + return accessibility && screenRecording + ? { granted: true } + : { granted: false, accessibility, screenRecording } + } catch { + // FFI unavailable (shouldn't happen on macOS with Bun) — assume granted. + return { granted: true } + } }, isDisabled: () => !getChicagoEnabled(), getSubGates: getChicagoSubGates, diff --git a/src/utils/earlyInput.ts b/src/utils/earlyInput.ts index a5d58db5e..c68f01b42 100644 --- a/src/utils/earlyInput.ts +++ b/src/utils/earlyInput.ts @@ -101,44 +101,59 @@ function processChunk(str: string): void { continue } - // Skip escape sequences (arrow keys, function keys, focus events, etc.) - // All escape sequences start with ESC (0x1B). + // Skip escape sequences (arrow keys, function keys, terminal responses, etc.) if (code === 27) { i++ // Skip the ESC character if (i >= str.length) continue - const next = str.charCodeAt(i)! - - // CSI sequences: ESC [ ... - // e.g. \x1b[?64;1;2;4;6;17;18;21;22c (DA1 response) - if (next === 0x5b /* [ */) { - i++ // skip '[' - // Skip parameter bytes (0x30-0x3F) and intermediate bytes (0x20-0x2F) - while (i < str.length && str.charCodeAt(i)! >= 0x20 && str.charCodeAt(i)! <= 0x3f) { - i++ - } - // Skip the final byte (0x40-0x7E) - if (i < str.length && str.charCodeAt(i)! >= 0x40 && str.charCodeAt(i)! <= 0x7e) i++ - continue - } - - // String sequences: DCS (P), OSC (]), SOS (X), PM (^) - // These end with BEL (0x07) or ST (ESC \) - if (next === 0x50 /* P */ || next === 0x5d /* ] */ || next === 0x58 /* X */ || next === 0x5e /* ^ */) { - i++ // skip the introducer + const introducer = str.charCodeAt(i) + + // DCS (P), OSC (]), APC (_), PM (^), SOS (X) — string sequences + // terminated by BEL (0x07) or ST (ESC \). XTVERSION and DA1 responses + // arrive as DCS/CSI strings; without proper handling they leak through + // as printable text (e.g. ">|iTerm2 3.6.9?64;...c" in the input box). + if ( + introducer === 0x50 || // P - DCS + introducer === 0x5d || // ] - OSC + introducer === 0x5f || // _ - APC + introducer === 0x5e || // ^ - PM + introducer === 0x58 // X - SOS + ) { + i++ // Skip introducer while (i < str.length) { - if (str.charCodeAt(i) === 0x07) { i++; break } // BEL terminates - if (str.charCodeAt(i) === 0x1b && i + 1 < str.length && str.charCodeAt(i + 1)! === 0x5c) { - i += 2; break // ESC \ (ST) terminates + if (str.charCodeAt(i) === 0x07) { // BEL terminator + i++ + break + } + if ( + str.charCodeAt(i) === 0x1b && + i + 1 < str.length && + str.charCodeAt(i + 1) === 0x5c // ST = ESC \ + ) { + i += 2 + break } i++ } - continue + } else if (introducer === 0x5b) { // [ - CSI: skip params + final byte + i++ // Skip [ + while ( + i < str.length && + !(str.charCodeAt(i) >= 0x40 && str.charCodeAt(i) <= 0x7e) + ) { + i++ + } + if (i < str.length) i++ // Skip CSI final byte + } else { + // Simple two-char escape (e.g. ESC A, ESC O x for SS3) + while ( + i < str.length && + !(str.charCodeAt(i) >= 64 && str.charCodeAt(i) <= 126) + ) { + i++ + } + if (i < str.length) i++ // Skip the terminating byte } - - // SS2 (N), SS3 (O) — 2-byte sequences, just skip both - // Other simple escape sequences: ESC — just skip the one byte - if (i < str.length) i++ continue } diff --git a/src/utils/imagePaste.ts b/src/utils/imagePaste.ts index ee1696a44..4b191283f 100644 --- a/src/utils/imagePaste.ts +++ b/src/utils/imagePaste.ts @@ -143,7 +143,7 @@ export async function getImageFromClipboard(): Promise