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