From 33eca6aa7d1b9eb16115ff9be0bcf1e2a27f6499 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:45:22 +0800 Subject: [PATCH 01/18] feat: TUI brand colors and enhanced buddy system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces comprehensive TUI customizations with a cohesive brand color palette, an enhanced /buddy companion system, and proper keyboard shortcut hints in the ThemePicker. ### Theme Colors - Replace Claude orange brand color with blue (rgb(88,190,255)) - Add BRAND_COLOR, BRAND_COLOR_LIGHT, BRAND_RED, BRAND_GREEN constants - Update all 6 theme variants (light, dark, ansi, daltonized) with consistent palette - Change permission/suggestion/remember to cyan-blue (rgb(131,210,238)) - Set dark theme userMessageBackground to darker rgb(15,15,15) - Update diff colors to burgundy red and forest green ### Syntax Highlighting - Add new Royal Gold Dark theme (gold/blue palette, lower saturation) - Replace Monokai Extended as default dark syntax theme - Support syntaxHighlightingDisabled setting in HighlightedCode - Keep Ctrl+T toggle in ThemePicker with visible shortcut hint - Fix hex colors to rgb format for themeColorToAnsi compatibility - Use src/* aliases and semicolons in SpinnerGlyph imports ### UI Behavior - Remove spinner stalled-to-red warning animation - Remove top padding from FullscreenLayout (cleaner layout) - Remove unused padCollapsed variable - Set inline code color to amber gold (#FEC84A) in dark themes ### Buddy System - Add /buddy pet, rehatch, mute, unmute commands - Add 20-character stat bars (████████░░) for companion stats - Display sprite, rarity, eye, hat, personality in text format - Auto-hatch on first /buddy call (no explicit hatch needed) - Enable BUDDY feature in production builds by default ### Files Changed - build.ts - packages/color-diff-napi/src/index.ts - src/utils/theme.ts, src/utils/markdown.ts - src/components/Spinner/*.tsx - src/components/FullscreenLayout.tsx, HighlightedCode.tsx, ThemePicker.tsx - src/commands/buddy/buddy.ts, index.ts - src/keybindings/defaultBindings.ts, schema.ts Co-Authored-By: Claude Sonnet 4.6 --- build.ts | 1 + packages/color-diff-napi/src/index.ts | 89 +++++++++---- src/commands/buddy/buddy.ts | 114 +++++++++++++---- src/commands/buddy/index.ts | 4 +- src/components/FullscreenLayout.tsx | 16 +-- src/components/HighlightedCode.tsx | 10 +- src/components/Spinner/GlimmerMessage.tsx | 33 ----- .../Spinner/SpinnerAnimationRow.tsx | 2 - src/components/Spinner/SpinnerGlyph.tsx | 73 +++-------- src/components/ThemePicker.tsx | 33 ++--- src/utils/markdown.ts | 4 +- src/utils/theme.ts | 119 +++++++++--------- 12 files changed, 272 insertions(+), 226 deletions(-) diff --git a/build.ts b/build.ts index b22b327ab..a07785925 100644 --- a/build.ts +++ b/build.ts @@ -17,6 +17,7 @@ const DEFAULT_BUILD_FEATURES = [ 'SHOT_STATS', 'PROMPT_CACHE_BREAK_DETECTION', 'TOKEN_BUDGET', + 'BUDDY', ] // Collect FEATURE_* env vars → Bun.build features 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/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..7fd188d8a 100644 --- a/src/components/HighlightedCode.tsx +++ b/src/components/HighlightedCode.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react' -import { useSettings } from '../hooks/useSettings.js' import { Ansi, Box, @@ -15,6 +14,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 +34,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 +69,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 +96,7 @@ export const HighlightedCode = memo(function HighlightedCode({ code={code} filePath={filePath} dim={dim} - skipColoring={syntaxHighlightingDisabled} + skipColoring={false} /> )} 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..37a035fbb 100644 --- a/src/components/Spinner/SpinnerGlyph.tsx +++ b/src/components/Spinner/SpinnerGlyph.tsx @@ -1,86 +1,49 @@ -import * as React from 'react' -import { Box, Text, useTheme } from '../../ink.js' -import { getTheme, type Theme } from '../../utils/theme.js' -import { - getDefaultCharacters, - interpolateColor, - parseRGB, - toRGBColor, -} from './utils.js' +import * as React from 'react'; +import { Box, Text } from 'src/ink.js'; +import type { Theme } from 'src/utils/theme.js'; +import { getDefaultCharacters } 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/utils/markdown.ts b/src/utils/markdown.ts index b2c673e6d..f13681e9c 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -87,7 +87,9 @@ export function formatToken( } case 'codespan': { // inline code - return color('permission', theme)(token.text) + return color(theme?.startsWith('dark') ? '#FEC84A' : 'permission', theme)( + token.text, + ) } case 'em': return chalk.italic( diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 9491dca65..3b17e5154 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -1,6 +1,11 @@ import chalk, { Chalk } from 'chalk' import { env } from './env.js' +const BRAND_COLOR = 'rgb(88,190,255)' +const BRAND_COLOR_LIGHT = 'rgb(135,210,255)' // Lighter for shimmer +const BRAND_RED = 'rgb(162,0,67)' +const BRAND_GREEN = 'rgb(34,139,34)' + export type Theme = { autoAccept: string bashBorder: string @@ -115,8 +120,8 @@ export type ThemeSetting = (typeof THEME_SETTINGS)[number] const lightTheme: Theme = { autoAccept: 'rgb(135,0,255)', // Electric violet bashBorder: 'rgb(255,0,135)', // Vibrant pink - claude: 'rgb(215,119,87)', // Claude orange - claudeShimmer: 'rgb(245,149,117)', // Lighter claude orange for shimmer effect + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(87,105,247)', // Medium blue for system spinner claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(117,135,255)', // Lighter blue for system spinner shimmer permission: 'rgb(87,105,247)', // Medium blue @@ -133,17 +138,17 @@ const lightTheme: Theme = { suggestion: 'rgb(87,105,247)', // Medium blue remember: 'rgb(0,0,255)', // Blue background: 'rgb(0,153,153)', // Cyan - success: 'rgb(44,122,57)', // Green - error: 'rgb(171,43,63)', // Red + success: BRAND_GREEN, + error: BRAND_RED, warning: 'rgb(150,108,30)', // Amber merged: 'rgb(135,0,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(200,158,80)', // Lighter amber for shimmer effect - diffAdded: 'rgb(105,219,124)', // Light green - diffRemoved: 'rgb(255,168,180)', // Light red - diffAddedDimmed: 'rgb(199,225,203)', // Very light green - diffRemovedDimmed: 'rgb(253,210,216)', // Very light red - diffAddedWord: 'rgb(47,157,68)', // Medium green - diffRemovedWord: 'rgb(209,69,75)', // Medium red + diffAdded: 'rgb(153,204,255)', // Light blue instead of green + diffRemoved: 'rgb(255,204,204)', // Light red + diffAddedDimmed: 'rgb(209,231,253)', // Very light blue + diffRemovedDimmed: 'rgb(255,233,233)', // Very light red + diffAddedWord: 'rgb(51,102,204)', // Medium blue (less intense than deep blue) + diffRemovedWord: 'rgb(153,51,51)', // Softer red (less intense than deep red) // Agent colors red_FOR_SUBAGENTS_ONLY: 'rgb(220,38,38)', // Red 600 blue_FOR_SUBAGENTS_ONLY: 'rgb(37,99,235)', // Blue 600 @@ -158,7 +163,7 @@ const lightTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', userMessageBackground: 'rgb(240, 240, 240)', // Slightly darker grey for optimal contrast userMessageBackgroundHover: 'rgb(252, 252, 252)', // ≥250 to quantize distinct from base at 256-color level @@ -173,7 +178,7 @@ const lightTheme: Theme = { fastModeShimmer: 'rgb(255,150,50)', // Lighter orange for shimmer // Brief/assistant mode briefLabelYou: 'rgb(37,99,235)', // Blue - briefLabelClaude: 'rgb(215,119,87)', // Brand orange + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -254,7 +259,7 @@ const lightAnsiTheme: Theme = { fastMode: 'ansi:red', fastModeShimmer: 'ansi:redBright', briefLabelYou: 'ansi:blue', - briefLabelClaude: 'ansi:redBright', + briefLabelClaude: BRAND_COLOR, rainbow_red: 'ansi:red', rainbow_orange: 'ansi:redBright', rainbow_yellow: 'ansi:yellow', @@ -335,7 +340,7 @@ const darkAnsiTheme: Theme = { fastMode: 'ansi:redBright', fastModeShimmer: 'ansi:redBright', briefLabelYou: 'ansi:blueBright', - briefLabelClaude: 'ansi:redBright', + briefLabelClaude: BRAND_COLOR, rainbow_red: 'ansi:red', rainbow_orange: 'ansi:redBright', rainbow_yellow: 'ansi:yellow', @@ -359,8 +364,8 @@ const darkAnsiTheme: Theme = { const lightDaltonizedTheme: Theme = { autoAccept: 'rgb(135,0,255)', // Electric violet bashBorder: 'rgb(0,102,204)', // Blue instead of pink - claude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia - claudeShimmer: 'rgb(255,183,101)', // Lighter orange for shimmer effect + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(51,102,255)', // Bright blue for system spinner claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(101,152,255)', // Lighter bright blue for system spinner shimmer permission: 'rgb(51,102,255)', // Bright blue @@ -382,12 +387,12 @@ const lightDaltonizedTheme: Theme = { warning: 'rgb(255,153,0)', // Orange adjusted for deuteranopia merged: 'rgb(135,0,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(255,183,50)', // Lighter orange for shimmer - diffAdded: 'rgb(153,204,255)', // Light blue instead of green - diffRemoved: 'rgb(255,204,204)', // Light red - diffAddedDimmed: 'rgb(209,231,253)', // Very light blue - diffRemovedDimmed: 'rgb(255,233,233)', // Very light red - diffAddedWord: 'rgb(51,102,204)', // Medium blue (less intense than deep blue) - diffRemovedWord: 'rgb(153,51,51)', // Softer red (less intense than deep red) + diffAdded: 'rgb(170,214,170)', + diffRemoved: 'rgb(228,170,196)', + diffAddedDimmed: 'rgb(220,238,220)', + diffRemovedDimmed: 'rgb(242,220,230)', + diffAddedWord: BRAND_GREEN, + diffRemovedWord: BRAND_RED, // Agent colors (daltonism-friendly) red_FOR_SUBAGENTS_ONLY: 'rgb(204,0,0)', // Pure red blue_FOR_SUBAGENTS_ONLY: 'rgb(0,102,204)', // Pure blue @@ -402,7 +407,7 @@ const lightDaltonizedTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', userMessageBackground: 'rgb(220, 220, 220)', // Slightly darker grey for optimal contrast userMessageBackgroundHover: 'rgb(232, 232, 232)', // ≥230 to quantize distinct from base at 256-color level @@ -416,7 +421,7 @@ const lightDaltonizedTheme: Theme = { fastMode: 'rgb(255,106,0)', // Electric orange (color-blind safe) fastModeShimmer: 'rgb(255,150,50)', // Lighter orange for shimmer briefLabelYou: 'rgb(37,99,235)', // Blue - briefLabelClaude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia (matches claude) + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -440,12 +445,12 @@ const lightDaltonizedTheme: Theme = { const darkTheme: Theme = { autoAccept: 'rgb(175,135,255)', // Electric violet bashBorder: 'rgb(253,93,177)', // Bright pink - claude: 'rgb(215,119,87)', // Claude orange - claudeShimmer: 'rgb(235,159,127)', // Lighter claude orange for shimmer effect - claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(147,165,255)', // Blue for system spinner - claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(177,195,255)', // Lighter blue for system spinner shimmer - permission: 'rgb(177,185,249)', // Light blue-purple - permissionShimmer: 'rgb(207,215,255)', // Lighter blue-purple for shimmer + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, + claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(131,210,238)', // Light cyan-blue + claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(177,231,245)', // Lighter cyan-blue for shimmer + permission: 'rgb(131,210,238)', // Light cyan-blue + permissionShimmer: 'rgb(177,231,245)', // Lighter cyan-blue for shimmer planMode: 'rgb(72,150,140)', // Muted sage green ide: 'rgb(71,130,200)', // Muted blue promptBorder: 'rgb(136,136,136)', // Medium gray @@ -455,20 +460,20 @@ const darkTheme: Theme = { inactive: 'rgb(153,153,153)', // Light gray inactiveShimmer: 'rgb(193,193,193)', // Lighter gray for shimmer effect subtle: 'rgb(80,80,80)', // Dark gray - suggestion: 'rgb(177,185,249)', // Light blue-purple - remember: 'rgb(177,185,249)', // Light blue-purple + suggestion: 'rgb(131,210,238)', // Light cyan-blue + remember: 'rgb(131,210,238)', // Light cyan-blue background: 'rgb(0,204,204)', // Bright cyan - success: 'rgb(78,186,101)', // Bright green - error: 'rgb(255,107,128)', // Bright red + success: BRAND_GREEN, + error: BRAND_RED, warning: 'rgb(255,193,7)', // Bright amber merged: 'rgb(175,135,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(255,223,57)', // Lighter amber for shimmer - diffAdded: 'rgb(34,92,43)', // Dark green - diffRemoved: 'rgb(122,41,54)', // Dark red - diffAddedDimmed: 'rgb(71,88,74)', // Very dark green - diffRemovedDimmed: 'rgb(105,72,77)', // Very dark red - diffAddedWord: 'rgb(56,166,96)', // Medium green - diffRemovedWord: 'rgb(179,89,107)', // Softer red (less intense than bright red) + diffAdded: 'rgb(20,54,20)', + diffRemoved: 'rgb(74,0,31)', + diffAddedDimmed: 'rgb(38,48,38)', + diffRemovedDimmed: 'rgb(57,38,46)', + diffAddedWord: BRAND_GREEN, + diffRemovedWord: BRAND_RED, // Agent colors red_FOR_SUBAGENTS_ONLY: 'rgb(220,38,38)', // Red 600 blue_FOR_SUBAGENTS_ONLY: 'rgb(37,99,235)', // Blue 600 @@ -483,21 +488,21 @@ const darkTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', - userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast - userMessageBackgroundHover: 'rgb(70, 70, 70)', + userMessageBackground: 'rgb(15, 15, 15)', + userMessageBackgroundHover: 'rgb(25, 25, 25)', messageActionsBackground: 'rgb(44, 50, 62)', // cool gray, slight blue selectionBg: 'rgb(38, 79, 120)', // classic dark-mode selection blue (VS Code dark default); light fgs stay readable bashMessageBackgroundColor: 'rgb(65, 60, 65)', memoryBackgroundColor: 'rgb(55, 65, 70)', - rate_limit_fill: 'rgb(177,185,249)', // Light blue-purple + rate_limit_fill: 'rgb(131,210,238)', // Light cyan-blue rate_limit_empty: 'rgb(80,83,112)', // Medium blue-purple fastMode: 'rgb(255,120,20)', // Electric orange for dark bg fastModeShimmer: 'rgb(255,165,70)', // Lighter orange for shimmer briefLabelYou: 'rgb(122,180,232)', // Light blue - briefLabelClaude: 'rgb(215,119,87)', // Brand orange + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -521,8 +526,8 @@ const darkTheme: Theme = { const darkDaltonizedTheme: Theme = { autoAccept: 'rgb(175,135,255)', // Electric violet bashBorder: 'rgb(51,153,255)', // Bright blue - claude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia - claudeShimmer: 'rgb(255,183,101)', // Lighter orange for shimmer effect + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(153,204,255)', // Light blue for system spinner claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(183,224,255)', // Lighter blue for system spinner shimmer permission: 'rgb(153,204,255)', // Light blue @@ -539,17 +544,17 @@ const darkDaltonizedTheme: Theme = { suggestion: 'rgb(153,204,255)', // Light blue remember: 'rgb(153,204,255)', // Light blue background: 'rgb(0,204,204)', // Bright cyan (color-blind friendly) - success: 'rgb(51,153,255)', // Blue instead of green - error: 'rgb(255,102,102)', // Bright red + success: 'rgb(0,153,204)', // Cyan-blue instead of green for deuteranopia + error: 'rgb(255,102,102)', // Bright red for better distinction warning: 'rgb(255,204,0)', // Yellow-orange for deuteranopia merged: 'rgb(175,135,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(255,234,50)', // Lighter yellow-orange for shimmer - diffAdded: 'rgb(0,68,102)', // Dark blue - diffRemoved: 'rgb(102,0,0)', // Dark red - diffAddedDimmed: 'rgb(62,81,91)', // Dimmed blue - diffRemovedDimmed: 'rgb(62,44,44)', // Dimmed red - diffAddedWord: 'rgb(0,119,179)', // Medium blue - diffRemovedWord: 'rgb(179,0,0)', // Medium red + diffAdded: 'rgb(0,27,41)', // Dark blue instead of green + diffRemoved: 'rgb(122,41,54)', // Dark red + diffAddedDimmed: 'rgb(51,68,71)', // Very dark blue + diffRemovedDimmed: 'rgb(105,72,77)', // Very dark red + diffAddedWord: 'rgb(81,160,200)', // Medium blue + diffRemovedWord: 'rgb(179,89,107)', // Softer red // Agent colors (daltonism-friendly, dark mode) red_FOR_SUBAGENTS_ONLY: 'rgb(255,102,102)', // Bright red blue_FOR_SUBAGENTS_ONLY: 'rgb(102,178,255)', // Bright blue @@ -564,7 +569,7 @@ const darkDaltonizedTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast userMessageBackgroundHover: 'rgb(70, 70, 70)', @@ -578,7 +583,7 @@ const darkDaltonizedTheme: Theme = { fastMode: 'rgb(255,120,20)', // Electric orange for dark bg (color-blind safe) fastModeShimmer: 'rgb(255,165,70)', // Lighter orange for shimmer briefLabelYou: 'rgb(122,180,232)', // Light blue - briefLabelClaude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia (matches claude) + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', From b0cbdbac686028cff40b43b729dd44fb9c5a0601 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:59:17 +0800 Subject: [PATCH 02/18] feat: enable NATIVE_CLIPBOARD_IMAGE feature flag Add NATIVE_CLIPBOARD_IMAGE to default features in both dev and build configurations. Co-Authored-By: Claude Opus 4.6 --- build.ts | 1 + scripts/dev.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/build.ts b/build.ts index a07785925..34587ff99 100644 --- a/build.ts +++ b/build.ts @@ -18,6 +18,7 @@ const DEFAULT_BUILD_FEATURES = [ 'PROMPT_CACHE_BREAK_DETECTION', 'TOKEN_BUDGET', 'BUDDY', + 'NATIVE_CLIPBOARD_IMAGE', ] // Collect FEATURE_* env vars → Bun.build features diff --git a/scripts/dev.ts b/scripts/dev.ts index c1c141017..ae92084dd 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -23,7 +23,7 @@ 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"]; +const DEFAULT_FEATURES = ["BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE", "AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE", "SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET", "NATIVE_CLIPBOARD_IMAGE"]; // Any env var matching FEATURE_=1 will also enable that feature. // e.g. FEATURE_PROACTIVE=1 bun run dev From f60b631cce3444ed1fa8fa0466e448a47171d8cb Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Mon, 6 Apr 2026 09:24:20 +0000 Subject: [PATCH 03/18] docs: update contributors --- contributors.svg | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/contributors.svg b/contributors.svg index 5370a1222..65ecff2b0 100644 --- a/contributors.svg +++ b/contributors.svg @@ -10,25 +10,27 @@ + + - + - + - + - + - + - + - + - + - + - + \ No newline at end of file From d6e46c875d865a45893ef9257de24b25b02a5adf Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:24:44 +0800 Subject: [PATCH 04/18] feat: enable additional feature flags for build and dev Add new features to DEFAULT_BUILD_FEATURES and DEFAULT_FEATURES: - 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 Co-Authored-By: Claude Opus 4.6 --- build.ts | 14 ++++++++++++++ scripts/dev.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/build.ts b/build.ts index 34587ff99..7e42550fe 100644 --- a/build.ts +++ b/build.ts @@ -19,6 +19,20 @@ const DEFAULT_BUILD_FEATURES = [ 'TOKEN_BUDGET', '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', ] // Collect FEATURE_* env vars → Bun.build features diff --git a/scripts/dev.ts b/scripts/dev.ts index ae92084dd..2f89daad9 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -23,7 +23,14 @@ 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", "NATIVE_CLIPBOARD_IMAGE"]; +const DEFAULT_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" +]; // Any env var matching FEATURE_=1 will also enable that feature. // e.g. FEATURE_PROACTIVE=1 bun run dev From 69ac04b140360ab18d8bede1f5474ad7586f0255 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:12:27 +0800 Subject: [PATCH 05/18] fix: resolve macOS Computer Use and image paste issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hostAdapter: replace cu.tcc subprocess calls with Bun FFI in-process AXIsProcessTrusted() + CGPreflightScreenCaptureAccess() so macOS checks the terminal's own TCC permissions, not osascript's - darwin backend: fix listInstalled() to use NSBundle JXA ObjC bridge instead of AppleScript "every file" (which misses .app bundles since they are directories, not files) - darwin backend: replace captureScreenToBase64 with captureAndResizeToBase64 using sips to resize+JPEG-convert screenshots, fixing "image/jpeg" media type mismatch and oversized image 400 errors from the API - escHotkey: use optional chaining on hotkey.registerEscape/unregister/ notifyExpectedEscape to prevent crashes when the hotkey namespace is absent (e.g. when Accessibility permission is not yet granted) - image-processor-napi: convert readClipboardImage from Bun.spawnSync to async Bun.spawn + await proc.exited, preventing the event loop from blocking and freezing the "Pasting text…" Ink UI indicator - usePasteHandler: add per-path .catch(() => null) in Promise.all so ImageResizeError from one path doesn't reject the whole chain and leave isPasting stuck at true; add .catch safety net on the outer chain - FileReadTool: instead of re-throwing ImageResizeError when sharp fails on oversized images, fall back to sips (macOS built-in) at 1920px JPEG quality 75; add a second sips fallback at 800px as last resort when compressImageBufferWithTokenLimit also fails Co-Authored-By: Claude Sonnet 4.6 --- .../computer-use-swift/src/backends/darwin.ts | 112 +++++++++++++----- packages/image-processor-napi/src/index.ts | 37 ++---- src/hooks/usePasteHandler.ts | 18 ++- src/tools/FileReadTool/FileReadTool.ts | 57 ++++++++- src/utils/computerUse/escHotkey.ts | 6 +- src/utils/computerUse/hostAdapter.ts | 36 ++++-- src/utils/imagePaste.ts | 2 +- 7 files changed, 196 insertions(+), 72 deletions(-) diff --git a/packages/@ant/computer-use-swift/src/backends/darwin.ts b/packages/@ant/computer-use-swift/src/backends/darwin.ts index 620f162a9..0ddd087c3 100644 --- a/packages/@ant/computer-use-swift/src/backends/darwin.ts +++ b/packages/@ant/computer-use-swift/src/backends/darwin.ts @@ -159,26 +159,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"]; + 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 [] } @@ -226,33 +234,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/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/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/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/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/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 Date: Tue, 7 Apr 2026 00:12:47 +0000 Subject: [PATCH 06/18] docs: update contributors --- contributors.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributors.svg b/contributors.svg index 65ecff2b0..7fcaf4444 100644 --- a/contributors.svg +++ b/contributors.svg @@ -15,7 +15,7 @@ - + From 1979e3b60a13de2785fa3088460e4fd4c50bf55d Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:20:27 +0000 Subject: [PATCH 07/18] docs: update contributors --- contributors.svg | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/contributors.svg b/contributors.svg index 66c1cb1c9..2ce690c9b 100644 --- a/contributors.svg +++ b/contributors.svg @@ -10,25 +10,27 @@ + + - + - + - + - + - + - + - + - + - + - + \ No newline at end of file From 97f89a99fb51de28ee4ccd99911cf4bd6ab28303 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:31:46 +0800 Subject: [PATCH 08/18] fix: sync brand colors into @ant/ink theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After upstream PR #158 moved ink components to packages/@ant/ink, components that imported Box/Text/useTheme from @anthropic/ink started reading @ant/ink's built-in theme (Claude orange) instead of our customized theme (blue palette) in src/utils/theme.ts. Add BRAND_COLOR/BRAND_COLOR_LIGHT/BRAND_RED/BRAND_GREEN constants to packages/@ant/ink/src/theme/theme-types.ts and apply them across all 6 theme variants (light, dark, light/dark daltonized, light/dark ansi unchanged since they use ansi: tokens). Synced changes: - claude / claudeShimmer: orange → BRAND_COLOR blue - clawd_body / briefLabelClaude: orange → BRAND_COLOR blue - permission / permissionShimmer (dark): purple → cyan-blue - suggestion / remember (dark): purple → cyan-blue - success / error: upstream green/red → BRAND_GREEN/BRAND_RED - diffAdded/diffRemoved (light): green → blue palette - diffAdded/diffRemoved (dark): green → burgundy-green palette - userMessageBackground (dark): rgb(55,55,55) → rgb(15,15,15) Co-Authored-By: Claude Sonnet 4.6 --- packages/@ant/ink/src/theme/theme-types.ts | 84 ++++++++++++---------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/packages/@ant/ink/src/theme/theme-types.ts b/packages/@ant/ink/src/theme/theme-types.ts index 7e59f4059..b35b7edab 100644 --- a/packages/@ant/ink/src/theme/theme-types.ts +++ b/packages/@ant/ink/src/theme/theme-types.ts @@ -1,6 +1,12 @@ import chalk, { Chalk } from 'chalk' // env import replaced with process.env +// Custom brand palette — kept in sync with src/utils/theme.ts +const BRAND_COLOR = 'rgb(88,190,255)' +const BRAND_COLOR_LIGHT = 'rgb(135,210,255)' +const BRAND_RED = 'rgb(162,0,67)' +const BRAND_GREEN = 'rgb(34,139,34)' + export type Theme = { autoAccept: string bashBorder: string @@ -115,8 +121,8 @@ export type ThemeSetting = (typeof THEME_SETTINGS)[number] const lightTheme: Theme = { autoAccept: 'rgb(135,0,255)', // Electric violet bashBorder: 'rgb(255,0,135)', // Vibrant pink - claude: 'rgb(215,119,87)', // Claude orange - claudeShimmer: 'rgb(245,149,117)', // Lighter claude orange for shimmer effect + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(87,105,247)', // Medium blue for system spinner claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(117,135,255)', // Lighter blue for system spinner shimmer permission: 'rgb(87,105,247)', // Medium blue @@ -133,17 +139,17 @@ const lightTheme: Theme = { suggestion: 'rgb(87,105,247)', // Medium blue remember: 'rgb(0,0,255)', // Blue background: 'rgb(0,153,153)', // Cyan - success: 'rgb(44,122,57)', // Green - error: 'rgb(171,43,63)', // Red + success: BRAND_GREEN, + error: BRAND_RED, warning: 'rgb(150,108,30)', // Amber merged: 'rgb(135,0,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(200,158,80)', // Lighter amber for shimmer effect - diffAdded: 'rgb(105,219,124)', // Light green - diffRemoved: 'rgb(255,168,180)', // Light red - diffAddedDimmed: 'rgb(199,225,203)', // Very light green - diffRemovedDimmed: 'rgb(253,210,216)', // Very light red - diffAddedWord: 'rgb(47,157,68)', // Medium green - diffRemovedWord: 'rgb(209,69,75)', // Medium red + diffAdded: 'rgb(153,204,255)', // Light blue instead of green + diffRemoved: 'rgb(255,204,204)', // Light red + diffAddedDimmed: 'rgb(209,231,253)', // Very light blue + diffRemovedDimmed: 'rgb(255,233,233)', // Very light red + diffAddedWord: 'rgb(51,102,204)', // Medium blue + diffRemovedWord: 'rgb(153,51,51)', // Softer red // Agent colors red_FOR_SUBAGENTS_ONLY: 'rgb(220,38,38)', // Red 600 blue_FOR_SUBAGENTS_ONLY: 'rgb(37,99,235)', // Blue 600 @@ -158,7 +164,7 @@ const lightTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', userMessageBackground: 'rgb(240, 240, 240)', // Slightly darker grey for optimal contrast userMessageBackgroundHover: 'rgb(252, 252, 252)', // ≥250 to quantize distinct from base at 256-color level @@ -173,7 +179,7 @@ const lightTheme: Theme = { fastModeShimmer: 'rgb(255,150,50)', // Lighter orange for shimmer // Brief/assistant mode briefLabelYou: 'rgb(37,99,235)', // Blue - briefLabelClaude: 'rgb(215,119,87)', // Brand orange + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -402,7 +408,7 @@ const lightDaltonizedTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', userMessageBackground: 'rgb(220, 220, 220)', // Slightly darker grey for optimal contrast userMessageBackgroundHover: 'rgb(232, 232, 232)', // ≥230 to quantize distinct from base at 256-color level @@ -416,7 +422,7 @@ const lightDaltonizedTheme: Theme = { fastMode: 'rgb(255,106,0)', // Electric orange (color-blind safe) fastModeShimmer: 'rgb(255,150,50)', // Lighter orange for shimmer briefLabelYou: 'rgb(37,99,235)', // Blue - briefLabelClaude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia (matches claude) + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -440,12 +446,12 @@ const lightDaltonizedTheme: Theme = { const darkTheme: Theme = { autoAccept: 'rgb(175,135,255)', // Electric violet bashBorder: 'rgb(253,93,177)', // Bright pink - claude: 'rgb(215,119,87)', // Claude orange - claudeShimmer: 'rgb(235,159,127)', // Lighter claude orange for shimmer effect - claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(147,165,255)', // Blue for system spinner - claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(177,195,255)', // Lighter blue for system spinner shimmer - permission: 'rgb(177,185,249)', // Light blue-purple - permissionShimmer: 'rgb(207,215,255)', // Lighter blue-purple for shimmer + claude: BRAND_COLOR, + claudeShimmer: BRAND_COLOR_LIGHT, + claudeBlue_FOR_SYSTEM_SPINNER: 'rgb(131,210,238)', // Light cyan-blue + claudeBlueShimmer_FOR_SYSTEM_SPINNER: 'rgb(177,231,245)', // Lighter cyan-blue for shimmer + permission: 'rgb(131,210,238)', // Light cyan-blue + permissionShimmer: 'rgb(177,231,245)', // Lighter cyan-blue for shimmer planMode: 'rgb(72,150,140)', // Muted sage green ide: 'rgb(71,130,200)', // Muted blue promptBorder: 'rgb(136,136,136)', // Medium gray @@ -455,20 +461,20 @@ const darkTheme: Theme = { inactive: 'rgb(153,153,153)', // Light gray inactiveShimmer: 'rgb(193,193,193)', // Lighter gray for shimmer effect subtle: 'rgb(80,80,80)', // Dark gray - suggestion: 'rgb(177,185,249)', // Light blue-purple - remember: 'rgb(177,185,249)', // Light blue-purple + suggestion: 'rgb(131,210,238)', // Light cyan-blue + remember: 'rgb(131,210,238)', // Light cyan-blue background: 'rgb(0,204,204)', // Bright cyan - success: 'rgb(78,186,101)', // Bright green - error: 'rgb(255,107,128)', // Bright red + success: BRAND_GREEN, + error: BRAND_RED, warning: 'rgb(255,193,7)', // Bright amber merged: 'rgb(175,135,255)', // Electric violet (matches autoAccept) warningShimmer: 'rgb(255,223,57)', // Lighter amber for shimmer - diffAdded: 'rgb(34,92,43)', // Dark green - diffRemoved: 'rgb(122,41,54)', // Dark red - diffAddedDimmed: 'rgb(71,88,74)', // Very dark green - diffRemovedDimmed: 'rgb(105,72,77)', // Very dark red - diffAddedWord: 'rgb(56,166,96)', // Medium green - diffRemovedWord: 'rgb(179,89,107)', // Softer red (less intense than bright red) + diffAdded: 'rgb(20,54,20)', // Dark green (burgundy palette) + diffRemoved: 'rgb(74,0,31)', // Dark red + diffAddedDimmed: 'rgb(38,48,38)', // Very dark green + diffRemovedDimmed: 'rgb(57,38,46)', // Very dark red + diffAddedWord: BRAND_GREEN, + diffRemovedWord: BRAND_RED, // Agent colors red_FOR_SUBAGENTS_ONLY: 'rgb(220,38,38)', // Red 600 blue_FOR_SUBAGENTS_ONLY: 'rgb(37,99,235)', // Blue 600 @@ -483,21 +489,21 @@ const darkTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', - userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast - userMessageBackgroundHover: 'rgb(70, 70, 70)', + userMessageBackground: 'rgb(15, 15, 15)', + userMessageBackgroundHover: 'rgb(25, 25, 25)', messageActionsBackground: 'rgb(44, 50, 62)', // cool gray, slight blue selectionBg: 'rgb(38, 79, 120)', // classic dark-mode selection blue (VS Code dark default); light fgs stay readable bashMessageBackgroundColor: 'rgb(65, 60, 65)', memoryBackgroundColor: 'rgb(55, 65, 70)', - rate_limit_fill: 'rgb(177,185,249)', // Light blue-purple + rate_limit_fill: 'rgb(131,210,238)', // Light cyan-blue rate_limit_empty: 'rgb(80,83,112)', // Medium blue-purple fastMode: 'rgb(255,120,20)', // Electric orange for dark bg fastModeShimmer: 'rgb(255,165,70)', // Lighter orange for shimmer briefLabelYou: 'rgb(122,180,232)', // Light blue - briefLabelClaude: 'rgb(215,119,87)', // Brand orange + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', @@ -564,10 +570,10 @@ const darkDaltonizedTheme: Theme = { // Chrome colors chromeYellow: 'rgb(251,188,4)', // Chrome yellow // TUI V2 colors - clawd_body: 'rgb(215,119,87)', + clawd_body: BRAND_COLOR, clawd_background: 'rgb(0,0,0)', - userMessageBackground: 'rgb(55, 55, 55)', // Lighter grey for better visual contrast - userMessageBackgroundHover: 'rgb(70, 70, 70)', + userMessageBackground: 'rgb(15, 15, 15)', + userMessageBackgroundHover: 'rgb(25, 25, 25)', messageActionsBackground: 'rgb(44, 50, 62)', // cool gray, slight blue selectionBg: 'rgb(38, 79, 120)', // classic dark-mode selection blue (VS Code dark default); light fgs stay readable bashMessageBackgroundColor: 'rgb(65, 60, 65)', @@ -578,7 +584,7 @@ const darkDaltonizedTheme: Theme = { fastMode: 'rgb(255,120,20)', // Electric orange for dark bg (color-blind safe) fastModeShimmer: 'rgb(255,165,70)', // Lighter orange for shimmer briefLabelYou: 'rgb(122,180,232)', // Light blue - briefLabelClaude: 'rgb(255,153,51)', // Orange adjusted for deuteranopia (matches claude) + briefLabelClaude: BRAND_COLOR, rainbow_red: 'rgb(235,95,87)', rainbow_orange: 'rgb(245,139,87)', rainbow_yellow: 'rgb(250,195,95)', From a1788665a1d2150f5d947dbdfba728082eabc85c Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:32:18 +0000 Subject: [PATCH 09/18] docs: update contributors --- contributors.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contributors.svg b/contributors.svg index 2ce690c9b..7fcaf4444 100644 --- a/contributors.svg +++ b/contributors.svg @@ -12,10 +12,10 @@ - - - + + + From 9bbdd486d256655b7d08a613fa54e1615adac61a Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:17:09 +0000 Subject: [PATCH 10/18] docs: update contributors --- contributors.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contributors.svg b/contributors.svg index 7fcaf4444..2ce690c9b 100644 --- a/contributors.svg +++ b/contributors.svg @@ -12,10 +12,10 @@ - - - + + + From 0bd420b6aab47a43a4b121e1551ac1df61d8c2f0 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:29:14 +0800 Subject: [PATCH 11/18] fix: prevent XTVERSION/DA1 terminal responses from leaking into input box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-part fix for garbage text (">|iTerm2 3.6.9?64;...c") appearing pre-filled in the REPL input on every startup: 1. Export `setAppCallbacks` from @anthropic/ink so the business layer can wire in callbacks (was defined but never reachable externally). 2. Call `setAppCallbacks({ stopCapturingEarlyInput, ... })` in main.tsx before `createRoot()`. App.tsx calls this when Ink first enables raw mode — without the wiring it was a no-op, leaving both the earlyInput and Ink readable handlers active on stdin simultaneously. 3. Fix `processChunk()` escape-sequence skipping in earlyInput.ts. The old code treated the byte after ESC as the terminator if it was in 0x40–0x7E, so `\x1bP` consumed only the `P` (DCS introducer) and let `>|iTerm2 3.6.9` fall through as printable text; `\x1b[` similarly left `?64;1;2;4;6;17;18;21;22;52c` as text. New code handles DCS/OSC string sequences (skip until BEL or ESC+\) and CSI sequences (skip params+final-byte) separately from simple two-char escapes. Co-Authored-By: Claude Sonnet 4.6 --- packages/@ant/ink/src/index.ts | 1 + src/main.tsx | 11 ++++++- src/utils/earlyInput.ts | 57 +++++++++++++++++++++++++++++----- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/@ant/ink/src/index.ts b/packages/@ant/ink/src/index.ts index ac92daabc..3857b9ab5 100644 --- a/packages/@ant/ink/src/index.ts +++ b/packages/@ant/ink/src/index.ts @@ -77,6 +77,7 @@ export { AlternateScreen } from './components/AlternateScreen.js' // App types export type { Props as AppProps } from './components/AppContext.js' export type { Props as StdinProps } from './components/StdinContext.js' +export { setAppCallbacks } from './components/App.js' export { TerminalSizeContext, type TerminalSize } from './components/TerminalSizeContext.js' // ============================================================ diff --git a/src/main.tsx b/src/main.tsx index bf7b2a252..4f304edca 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -173,7 +173,7 @@ import { launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper, } from './dialogLaunchers.js' -import { SHOW_CURSOR } from '@anthropic/ink' +import { SHOW_CURSOR, setAppCallbacks } from '@anthropic/ink' import { exitWithError, exitWithMessage, @@ -3234,6 +3234,15 @@ async function run(): Promise { } const { createRoot } = await import('@anthropic/ink') + // Wire early-input capture teardown into Ink's raw-mode lifecycle. + // App.tsx calls stopCapturingEarlyInput() when it first enables raw mode + // so both handlers never coexist on stdin (which would let terminal query + // responses like XTVERSION leak into the early-input buffer as text). + setAppCallbacks({ + stopCapturingEarlyInput, + logForDebugging: (msg, opts) => logForDebugging(msg, opts), + logError: (err) => logError(err), + }) root = await createRoot(ctx.renderOptions) // Log startup time now, before any blocking dialog renders. Logging diff --git a/src/utils/earlyInput.ts b/src/utils/earlyInput.ts index 734dad4f4..c68f01b42 100644 --- a/src/utils/earlyInput.ts +++ b/src/utils/earlyInput.ts @@ -101,18 +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) and end with a byte in 0x40-0x7E + // Skip escape sequences (arrow keys, function keys, terminal responses, etc.) if (code === 27) { i++ // Skip the ESC character - // Skip until the terminating byte (@ to ~) or end of string - while ( - i < str.length && - !(str.charCodeAt(i) >= 64 && str.charCodeAt(i) <= 126) + if (i >= str.length) continue + + 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++ + i++ // Skip introducer + while (i < str.length) { + 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++ + } + } 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 } - if (i < str.length) i++ // Skip the terminating byte continue } From a2ebb282e46a4721f6c5392311b791818a08a4d1 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:29:50 +0000 Subject: [PATCH 12/18] docs: update contributors --- contributors.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contributors.svg b/contributors.svg index 2ce690c9b..c2ede6f35 100644 --- a/contributors.svg +++ b/contributors.svg @@ -8,10 +8,10 @@ - - - + + + From 7bcd70fdbbe871acdc935d4edbc05708537fcbc1 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:16:09 +0800 Subject: [PATCH 13/18] feat: improve macOS Computer Use with multi-display support and IME fixes - Add originX/originY to display geometry for multi-monitor coordinate mapping - Implement prepareDisplay() to hide non-allowlisted apps and activate target - Implement previewHideSet() to preview which apps will be hidden - Fix displayWidth/displayHeight propagation - prevents clicks landing at (0,0) - Use clipboard paste for all text on macOS to avoid IME composition issues Previously clicks could land at the Apple menu because scaleCoord() received NaN when display dimensions weren't propagated. Now coordinate scaling works correctly across all displays. Co-Authored-By: Claude Opus 4.6 --- .../computer-use-swift/src/backends/darwin.ts | 138 ++++++++++++++++-- src/utils/computerUse/executor.ts | 38 ++++- 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/packages/@ant/computer-use-swift/src/backends/darwin.ts b/packages/@ant/computer-use-swift/src/backends/darwin.ts index 0ddd087c3..d281fa2b7 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) { @@ -217,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. + } + } } }, } 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 } From 9a7606fe328fb2561a5aabc5e126e6818cdf5370 Mon Sep 17 00:00:00 2001 From: bonerush <96404351+bonerush@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:27:25 +0800 Subject: [PATCH 14/18] fix: reorder tool and user messages for OpenAI API compatibility (#168) (#177) Fixes #168 OpenAI requires that an assistant message with tool_calls be immediately followed by tool messages. Previously, convertInternalUserMessage output user content before tool results, causing 400 errors. Now tool messages are pushed first. --- src/services/api/openai/convertMessages.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 From c57a598fee67872e65a186bb0e4ea2a26cfc2e09 Mon Sep 17 00:00:00 2001 From: claude-code-best <272536312+claude-code-best@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:54:11 +0000 Subject: [PATCH 15/18] docs: update contributors --- contributors.svg | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/contributors.svg b/contributors.svg index c2ede6f35..1ac84b8af 100644 --- a/contributors.svg +++ b/contributors.svg @@ -8,29 +8,31 @@ - - - + - + - + + + - - - + + + + + - + - + - + - + \ No newline at end of file From f454bb008021c8ea99724320db855a56237a51dc Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Tue, 7 Apr 2026 19:15:29 +0800 Subject: [PATCH 16/18] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ant=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LogoV2/ExperimentEnrollmentNotice.tsx | 9 +++++++++ src/components/LogoV2/GateOverridesWarning.tsx | 10 ++++++++++ src/components/LogoV2/LogoV2.tsx | 2 ++ 3 files changed, 21 insertions(+) create mode 100644 src/components/LogoV2/ExperimentEnrollmentNotice.tsx create mode 100644 src/components/LogoV2/GateOverridesWarning.tsx 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 From 9b1c396dc657397bd73343c3b53fa85d54ad0ae5 Mon Sep 17 00:00:00 2001 From: claude-code-best <272536312+claude-code-best@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:41:24 +0000 Subject: [PATCH 17/18] docs: update contributors --- contributors.svg | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/contributors.svg b/contributors.svg index 1ac84b8af..813e8f155 100644 --- a/contributors.svg +++ b/contributors.svg @@ -26,13 +26,15 @@ + + - + - + - + - + \ No newline at end of file From 775afe48d120c9a41e46f3d5fda104655efc2f40 Mon Sep 17 00:00:00 2001 From: Icarus603 <177302395+Icarus603@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:03:11 +0800 Subject: [PATCH 18/18] fix(computer-use): add /System/Applications to installed app scan dirs Include Apple's system apps (Safari, Notes, Maps, etc.) in listInstalled() by scanning /System/Applications in addition to /Applications and ~/Applications. Co-Authored-By: Claude Sonnet 4.6 --- packages/@ant/computer-use-swift/src/backends/darwin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@ant/computer-use-swift/src/backends/darwin.ts b/packages/@ant/computer-use-swift/src/backends/darwin.ts index d281fa2b7..32ffef044 100644 --- a/packages/@ant/computer-use-swift/src/backends/darwin.ts +++ b/packages/@ant/computer-use-swift/src/backends/darwin.ts @@ -256,7 +256,7 @@ export const apps: AppsAPI = { ObjC.import("Foundation"); var fm = $.NSFileManager.defaultManager; var home = ObjC.unwrap($.NSHomeDirectory()); - var searchDirs = ["/Applications", home + "/Applications"]; + var searchDirs = ["/Applications", home + "/Applications", "/System/Applications"]; var result = []; for (var d = 0; d < searchDirs.length; d++) { var items = fm.contentsOfDirectoryAtPathError($(searchDirs[d]), null);